Compare commits

26 Commits

Author SHA1 Message Date
Claude Agent
115666155b pack_def 2026-03-31 14:09:01 +00:00
Claude Agent
217fd1af3c Merge feat/operator-shield into main 2026-03-31 13:01:15 +00:00
Claude Agent
6acb73b9ce fix(dashboard): cache ROA price status and align nefacturate counts
- Add price_match column to SQLite, cached on order detail view
- Background backfill on startup checks all unchecked imported orders
- Extract _enrich_items_with_codmat() helper to deduplicate SKU enrichment
- Attention card now shows same nefacturate count as filter pill

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:47:03 +00:00
Claude Agent
b2745a9a64 fix(flow): replace duplicate period dropdown with preset buttons
Period selector had both preset buttons (3/7/30 zile) and a dropdown
with overlapping options. Per plan Commit 6, preset buttons are the
single control: Azi / 3 zile / 7 zile / 30 zile / Custom. Visible
on all screen sizes with horizontal scroll on mobile.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-27 13:54:04 +00:00
Claude Agent
f516bb5756 fix(test): update mobile navbar test for bottom-nav design
The test expected .top-navbar visible on mobile, but the simplified
design hides it and shows .bottom-nav instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:38:53 +00:00
Claude Agent
efb055c2be fix(qa): ISSUE-002 — ROA prices not showing in order detail modal
The JS read pret_roa from order.price_check.items[idx] which doesn't
exist. The backend puts pret_roa and price_match directly on each item
in the items array. Fixed both desktop table and mobile view to read
from item.pret_roa and item.price_match instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:25:07 +00:00
Claude Agent
7e4bbabcae fix(qa): ISSUE-001 — SKU prices 404 for SKUs containing slashes
SKUs with forward slashes (e.g. 0V2071/250159/250158) caused a 404
on /api/mappings/{sku}/prices because path separators in the SKU
were interpreted as URL path segments. Changed to query parameter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:16:20 +00:00
Claude Agent
5a5ca63f92 feat(simplify): first-time guidance welcome card
Show a welcome card on the dashboard when no sync runs exist yet.
Guides new users: 1. Check Settings → 2. Start Sync → 3. Map SKUs.
Card auto-hides after first successful sync.

Cache-bust: dashboard.js?v=31, style.css?v=24

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:42:40 +00:00
Claude Agent
35709cdc6e feat(simplify): mappings toolbar dropdown
Consolidate Template CSV, Export CSV, Import CSV into a single
"Import/Export" dropdown. Keep "+ Adauga Mapare" and "Formular complet"
as prominent action buttons.

Cache-bust: mappings.js?v=13

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:42:33 +00:00
Claude Agent
d3d1905b18 feat(simplify): simplified logs view
Default to showing problem orders (ERROR/SKIPPED) first. Imported
orders collapsed behind "X comenzi importate cu succes" toggle.
Reduces noise for operators scanning for issues.

Cache-bust: logs.js?v=14, style.css?v=23

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:42:29 +00:00
Claude Agent
bd4524707e feat(simplify): hide advanced settings
Move Kit Pricing, Price Sync, and Dashboard Polling sections under a
collapsed "Setari avansate" toggle. Basic settings remain visible.
Tooltip warns: "Modificati doar la indicatia echipei tehnice."

Cache-bust: settings.js?v=9

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:42:24 +00:00
Claude Agent
4a589aafeb feat(flow): smart default view (3 days + preset buttons)
Change default period from 7 to 3 days. Add quick-select preset
buttons (3 zile / 7 zile / 30 zile) that sync with the dropdown.
Reduces noise for daily operators who only need recent orders.

Cache-bust: style.css?v=22, dashboard.js?v=30

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:38:24 +00:00
Claude Agent
b52313faf6 feat(flow): map SKU + auto-retry consolidated banner
After saving a SKU mapping, check for SKIPPED orders containing that
SKU and show a floating banner with count + "Importa" button. Batch
retries up to 20 orders and shows result feedback.

Backend:
- get_skipped_orders_with_sku() in sqlite_service.py
- GET /api/orders/by-sku/{sku}/pending endpoint
- POST /api/orders/batch-retry endpoint (max 20, sequential)

Frontend:
- Auto-retry banner after quickMap save with batch import button
- Success/error feedback, auto-dismiss after 15s

Cache-bust: shared.js?v=19

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:38:10 +00:00
Claude Agent
7a789b4fe7 feat(flow): retry failed orders
Add ability to re-import individual ERROR/SKIPPED orders directly from
the order detail modal. Downloads narrow date range from GoMag API,
finds the specific order, and re-runs import_single_order().

Backend:
- New retry_service.py with retry_single_order() — downloads order_date
  ±1 day from GoMag, finds order by number, imports via import_service
- Guard: blocks retry during active sync (_sync_lock check)
- POST /api/orders/{order_number}/retry endpoint

Frontend:
- "Reimporta" button in modal footer (visible only for ERROR/SKIPPED)
- Spinner during retry, success/error feedback with auto-refresh

Cache-bust: shared.js?v=18

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:34:51 +00:00
Claude Agent
1b2b1d8b24 feat(safety): invoice reconciliation on order detail
Add invoice total comparison in the order detail modal. When an order
has been invoiced, shows whether the invoice total matches the order
total — green badge if OK, red badge with difference amount if not.

Backend: compute reconciliation (difference, match) from existing
invoice.total_cu_tva vs order_total in order_detail endpoint.

Frontend: reconciliation badge below invoice info in modal, hidden
when no invoice exists.

Cache-bust: shared.js?v=17

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:30:21 +00:00
Claude Agent
a10a00aa4d feat(safety): needs attention card on dashboard
Add a "Needs Attention" card above the orders table that surfaces:
- Import errors count (click → ERROR filter)
- Unmapped SKUs count (click → Missing SKUs page)
- Uninvoiced orders >3 days (click → UNINVOICED filter)
Shows green "Totul in ordine" when all metrics are zero.

Backend: add uninvoiced_old count to get_orders() and unresolved_skus
from get_dashboard_stats() to dashboard/orders API response.

Cache-bust: style.css?v=21, dashboard.js?v=29

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:28:18 +00:00
Claude Agent
3bd0556f73 feat(safety): price comparison on order detail
Add ROA price comparison to order detail modal — operators can now see
if GoMag prices match Oracle before invoicing. Eliminates the #1 risk
of invoicing with wrong prices.

Backend:
- New get_prices_for_order() in validation_service.py — batch Oracle
  query with dual-policy routing (sales/production by cont 341/345),
  PRETURI_CU_TVA handling, kit total calculation
- Extend GET /api/sync/order/{orderNumber} with per-item pret_roa and
  order-level price_check summary
- GET /api/dashboard/orders returns price_match=null (lightweight)

Frontend:
- Modal: price check badge (green/red/grey), "Pret GoMag" + "Pret ROA"
  columns, match dot per row, mismatch rows highlighted
- Dashboard: price dot column (₽) in orders table
- Mobile: inline mismatch indicator

Cache-bust: shared.js?v=16, dashboard.js?v=28

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:25:02 +00:00
Claude Agent
f6b6b863bd fix(dark): mobile order values invisible — Bootstrap card color override
Bootstrap's .card sets color: var(--bs-body-color) which stays #212529 in
dark mode since we use data-theme="dark" not data-bs-theme. Added explicit
card dark override and color: var(--text-primary) on .flat-row.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:14:44 +00:00
Claude Agent
ef996a45b2 style(design): FINDING-004 — fix mobile pills horizontal overflow
Mobile segmented control (7 buttons, 431px) overflowed 375px viewport.
Added overflow-x:auto on segmented containers and min-width:0 on
flex-grow child. Body scroll width now matches viewport.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:00:50 +00:00
Claude Agent
2fabce7c5b style(design): FINDING-003 — fix dark mode zebra striping
Bootstrap --bs-table-bg defaults to white, not overridden in dark mode.
Added --bs-table-bg: var(--surface) and explicit td background for odd
rows. Zebra now alternates #1E1E1E / #2A2A2A in dark mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:00:30 +00:00
Claude Agent
60704d22c0 style(design): FINDING-002 — add glow to SKIPPED/yellow dots
DESIGN.md specifies box-shadow glow for SKIPPED status dots:
0 0 6px 2px rgba(202,138,4,0.3). Was missing from .dot-yellow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:52:37 +00:00
Claude Agent
aacca13b85 style(design): FINDING-001 — mobile segmented control uses amber accent
Mobile segmented filter buttons used Bootstrap btn-primary (blue) for
active state. Per DESIGN.md two-accent system, state indicators use
amber. Added .seg-active class with --accent color.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:52:15 +00:00
Claude Agent
a8292c2ef2 style(design): migrate to DESIGN.md system
Full visual migration: Space Grotesk + DM Sans + JetBrains Mono fonts,
warm amber two-accent system (amber=state, blue=action), dark mode with
CSS vars + localStorage + FOUC prevention, mobile bottom nav (5 tabs),
full-width tables, error/skipped dot glow, ~13 hardcoded hex replaced
with CSS vars in 4 JS files, 5 new E2E tests.

Files: style.css (full rewrite), base.html (fonts, theme script, dark
toggle, bottom nav), settings.html (dark toggle card), dashboard.js,
logs.js, mappings.js, settings.js (color vars), 5 templates (bnav
active blocks), test_design_system_e2e.py (NEW).

Cache-bust: style.css?v=18, shared.js?v=14, dashboard.js?v=27,
logs.js?v=13, mappings.js?v=12, settings.js?v=8

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:36:07 +00:00
Claude Agent
c5757b8322 refactor(modal): extract order detail to shared.js
Move duplicated order detail modal logic from dashboard.js and logs.js
into a shared renderOrderDetailModal() function in shared.js. Move
modal HTML from dashboard.html and logs.html into base.html.

Shared functions: renderCodmatCell, orderStatusBadge, fmtCost, fmtNum,
computeDiscountSplit, renderReceipt. Both pages now call the shared
modal with page-specific quick map callbacks.

Net -152 lines. Logs page gains invoice info, TVA column, and receipt
footer that were previously dashboard-only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:19:42 +00:00
Claude Agent
c6d69ac0e0 docs(design): add two-accent system, selective mono, and dark mode decisions
Decisions from plan-design-review and plan-eng-review:
- Two-accent system: amber = state (nav, pills), blue = action (buttons)
- JetBrains Mono selective: codes/numbers only, text uses DM Sans
- Dark mode now in scope for Commit 0.5
- Add TODOS.md with deferred P2 items

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:09:42 +00:00
Claude Agent
9f2fd24d93 docs(design): add design system with typography, colors, and mobile specs
Industrial/utilitarian aesthetic with amber accent, Space Grotesk + DM Sans +
JetBrains Mono stack, full dark mode, and dedicated mobile design including
bottom nav and card-based order views. Updates CLAUDE.md to enforce DESIGN.md
compliance on all visual work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:29:13 +00:00
27 changed files with 5287 additions and 926 deletions

View File

@@ -106,6 +106,13 @@ python3 scripts/sync_vending_to_mariusm.py --apply --yes
Sincronizează via SSH din VENDING (prod Windows) în MARIUSM_AUTO (dev ROA_CENTRAL):
nom_articole (noi by codmat, codmat updatat) + articole_terti (noi, modificate, soft-delete).
## Design System
Always read DESIGN.md before making any visual or UI decisions.
All font choices, colors, spacing, and aesthetic direction are defined there.
Do not deviate without explicit user approval.
In QA mode, flag any code that doesn't match DESIGN.md.
## Deploy Windows
Vezi [README.md](README.md#deploy-windows)

324
DESIGN.md Normal file
View File

@@ -0,0 +1,324 @@
# Design System — GoMag Vending
## Product Context
- **What this is:** Internal admin dashboard for importing web orders from GoMag e-commerce into ROA Oracle ERP
- **Who it's for:** Ops/admin team who monitor order sync daily, fix SKU mappings, check import errors
- **Space/industry:** Internal tools, B2B operations, ERP integration
- **Project type:** Data-heavy admin dashboard (tables, status indicators, sync controls)
## Aesthetic Direction
- **Direction:** Industrial/Utilitarian — function-first, data-dense, quietly confident
- **Decoration level:** Minimal — typography and color do the work. No illustrations, gradients, or decorative elements. The data IS the decoration.
- **Mood:** Command console. This tool says "built by someone who respects the operator." Serious, efficient, warm.
- **Anti-patterns:** No purple gradients, no 3-column icon grids, no centered-everything layouts, no decorative blobs, no stock-photo heroes
## Typography
### Font Stack
- **Display/Headings:** Space Grotesk — geometric, slightly techy, distinctive `a` and `g`. Says "engineered."
- **Body/UI:** DM Sans — clean, excellent readability, good tabular-nums for inline numbers
- **Data/Tables:** JetBrains Mono — order IDs, CODMATs, status codes align perfectly. Tables become scannable.
- **Code:** JetBrains Mono
### Loading
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
```
### CSS Variables
```css
--font-display: 'Space Grotesk', sans-serif;
--font-body: 'DM Sans', sans-serif;
--font-data: 'JetBrains Mono', monospace;
```
### Type Scale
| Level | Size | Weight | Font | Usage |
|-------|------|--------|------|-------|
| Page title | 18px | 600 | Display | "Panou de Comanda" |
| Section title | 16px | 600 | Display | Card headers |
| Label/uppercase | 12px | 500 | Display | Column headers, section labels (letter-spacing: 0.04em) |
| Body | 14px | 400 | Body | Paragraphs, descriptions |
| UI/Button | 13px | 500 | Body | Buttons, nav links, form labels |
| Data cell | 13px | 400 | Data | Codes, IDs, numbers, sums, dates (NOT text names — those use Body font) |
| Data small | 12px | 400 | Data | Timestamps, secondary data |
| Code/mono | 11px | 400 | Data | Inline code, debug info |
## Color
### Approach: Two-accent system (amber state + blue action)
Every admin tool is blue. This one uses amber — reads as "operational" and "attention-worthy."
- **Amber (--accent):** Navigation active state, filter pill active, accent backgrounds. "Where you are."
- **Blue (--info):** Primary buttons, CTAs, actionable links. "What you can do."
- Primary buttons (`btn-primary`) stay blue for clear action hierarchy.
### Light Mode (default)
```css
:root {
/* Surfaces */
--bg: #F8F7F5; /* warm off-white, not clinical gray */
--surface: #FFFFFF;
--surface-raised: #F3F2EF; /* hover states, table headers */
--card-shadow: 0 1px 3px rgba(28,25,23,0.1), 0 1px 2px rgba(28,25,23,0.06);
/* Text */
--text-primary: #1C1917; /* warm black */
--text-secondary: #57534E; /* warm gray */
--text-muted: #78716C; /* labels, timestamps */
/* Borders */
--border: #E7E5E4;
--border-subtle: #F0EFED;
/* Accent — amber */
--accent: #D97706;
--accent-hover: #B45309;
--accent-light: #FEF3C7; /* amber backgrounds */
--accent-text: #92400E; /* text on amber bg */
/* Semantic */
--success: #16A34A;
--success-light: #DCFCE7;
--success-text: #166534;
--warning: #CA8A04;
--warning-light: #FEF9C3;
--warning-text: #854D0E;
--error: #DC2626;
--error-light: #FEE2E2;
--error-text: #991B1B;
--info: #2563EB;
--info-light: #DBEAFE;
--info-text: #1E40AF;
--cancelled: #78716C;
--cancelled-light: #F5F5F4;
}
```
### Dark Mode
Strategy: invert surfaces, reduce accent saturation ~15%, keep semantic colors recognizable.
```css
[data-theme="dark"] {
--bg: #121212;
--surface: #1E1E1E;
--surface-raised: #2A2A2A;
--card-shadow: 0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3);
--text-primary: #E8E4DD; /* warm bone white */
--text-secondary: #A8A29E;
--text-muted: #78716C;
--border: #333333;
--border-subtle: #262626;
--accent: #F59E0B;
--accent-hover: #D97706;
--accent-light: rgba(245,158,11,0.12);
--accent-text: #FCD34D;
--success: #16A34A;
--success-light: rgba(22,163,74,0.15);
--success-text: #4ADE80;
--warning: #CA8A04;
--warning-light: rgba(202,138,4,0.15);
--warning-text: #FACC15;
--error: #DC2626;
--error-light: rgba(220,38,38,0.15);
--error-text: #FCA5A5;
--info: #2563EB;
--info-light: rgba(37,99,235,0.15);
--info-text: #93C5FD;
--cancelled: #78716C;
--cancelled-light: rgba(120,113,108,0.15);
}
```
### Status Color Mapping
| Status | Dot Color | Badge BG | Glow |
|--------|-----------|----------|------|
| IMPORTED | `--success` | `--success-light` | none (quiet when healthy) |
| ERROR | `--error` | `--error-light` | `0 0 8px 2px rgba(220,38,38,0.35)` |
| SKIPPED | `--warning` | `--warning-light` | `0 0 6px 2px rgba(202,138,4,0.3)` |
| ALREADY_IMPORTED | `--info` | `--info-light` | none |
| CANCELLED | `--cancelled` | `--cancelled-light` | none |
| DELETED_IN_ROA | `--cancelled` | `--cancelled-light` | none |
**Design rule:** Problems glow, success is calm. The operator's eye is pulled to rows that need action.
## Spacing
- **Base unit:** 4px
- **Density:** Comfortable — not cramped, not wasteful
- **Scale:**
| Token | Value | Usage |
|-------|-------|-------|
| 2xs | 2px | Tight internal gaps |
| xs | 4px | Icon-text gap, badge padding |
| sm | 8px | Compact card padding, table cell padding |
| md | 16px | Standard card padding, section gaps |
| lg | 24px | Section spacing |
| xl | 32px | Major section gaps |
| 2xl | 48px | Page-level spacing |
| 3xl | 64px | Hero spacing (rarely used) |
## Layout
### Approach: Grid-disciplined, full-width
Tables with 8+ columns and hundreds of rows need every pixel of width.
- **Nav:** Horizontal top bar, fixed, 48px height. Active tab has amber underline (2px).
- **Content max-width:** None on desktop (full-width for tables), 1200px for non-table content
- **Grid:** Single-column layout, cards stack vertically
- **Breakpoints:**
| Name | Width | Columns | Behavior |
|------|-------|---------|----------|
| Desktop | >= 1024px | Full width | All features visible |
| Tablet | 768-1023px | Full width | Nav labels abbreviated, tables scroll horizontally |
| Mobile | < 768px | Single column | Bottom nav, cards stack, condensed views |
### Border Radius
| Token | Value | Usage |
|-------|-------|-------|
| sm | 4px | Buttons, inputs, badges, status dots |
| md | 8px | Cards, dropdowns, modals |
| lg | 12px | Large containers, mockup frames |
| full | 9999px | Pills, avatar circles |
## Motion
- **Approach:** Minimal-functional only transitions that aid comprehension
- **Easing:** enter(ease-out) exit(ease-in) move(ease-in-out)
- **Duration:**
| Token | Value | Usage |
|-------|-------|-------|
| micro | 50-100ms | Button hover, focus ring |
| short | 150-250ms | Dropdown open, tab switch, color transitions |
| medium | 250-400ms | Modal open/close, page transitions |
| long | 400-700ms | Only for sync pulse animation |
- **Sync pulse:** The live sync dot uses a 2s infinite pulse (opacity 1 0.4 1)
- **No:** entrance animations, scroll effects, decorative motion
## Mobile Design
### Navigation
- **Bottom tab bar** replaces top horizontal nav on screens < 768px
- 5 tabs: Dashboard, Mapari, Lipsa, Jurnale, Setari
- Each tab: icon (Bootstrap Icons) + short label below
- Active tab: amber accent color, inactive: `--text-muted`
- Height: 56px, safe-area padding for notched devices
- Fixed position bottom, with `padding-bottom: env(safe-area-inset-bottom)`
```css
@media (max-width: 767px) {
.top-navbar { display: none; }
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 56px;
padding-bottom: env(safe-area-inset-bottom);
background: var(--surface);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-around;
align-items: center;
z-index: 1000;
}
.main-content {
padding-bottom: 72px; /* clear bottom nav */
padding-top: 8px; /* no top navbar */
}
}
```
### Dashboard — Mobile
- **Sync card:** Full width, stacked vertically
- Status + controls row wraps to 2 lines
- Sync button full-width at bottom of card
- Last sync info wraps naturally
- **Orders table:** Condensed card view instead of horizontal table
- Each order = a compact card showing: status dot + ID + client name + total
- Tap to expand: shows date, factura, full details
- Swipe left on card: quick action (view error details)
- **Filter bar:** Horizontal scrollable chips instead of dropdowns
- Period selector: pill chips (1zi, 7zi, 30zi, Toate)
- Status filter: colored chips matching status colors
- **Touch targets:** Minimum 44x44px for all interactive elements
### Orders Mobile Card Layout
```
┌────────────────────────────────┐
│ ● CMD-47832 2,450.00 RON│
│ SC Automate Express SRL │
│ 27.03.2026 · FCT-2026-1847 │
└────────────────────────────────┘
```
- Status dot (8px, left-aligned with glow for errors)
- Order ID in JetBrains Mono, amount right-aligned
- Client name in DM Sans
- Date + factura in muted data font
### SKU Mappings — Mobile
- Each mapping = expandable card
- Collapsed: SKU + product name + type badge (KIT/SIMPLU)
- Expanded: Full CODMAT list with quantities
- Search: Full-width sticky search bar at top
- Filter: Horizontal scrollable type chips
### Logs — Mobile
- Timeline view instead of table
- Each log entry = timestamp + status icon + summary
- Tap to expand full log details
- Infinite scroll with date separators
### Settings — Mobile
- Standard stacked form layout
- Full-width inputs
- Toggle switches for boolean settings (min 44px touch target)
- Save button sticky at bottom
### Gestures
- **Pull to refresh** on Dashboard: triggers sync status check
- **Swipe left** on order card: reveal quick actions
- **Long press** on SKU mapping: copy CODMAT to clipboard
- **No swipe navigation** between pages (use bottom tabs)
### Mobile Typography Adjustments
| Level | Desktop | Mobile |
|-------|---------|--------|
| Page title | 18px | 16px |
| Body | 14px | 14px (no change) |
| Data cell | 13px | 13px (no change) |
| Data small | 12px | 12px (no change) |
| Table header | 12px | 11px |
### Responsive Images & Icons
- Use Bootstrap Icons throughout (already loaded via CDN)
- Icon size: 16px desktop, 20px mobile (larger touch targets)
- No images in the admin interface (data-only)
## Decisions Log
| Date | Decision | Rationale |
|------|----------|-----------|
| 2026-03-27 | Initial design system created | Created by /design-consultation. Industrial/utilitarian aesthetic with amber accent, Space Grotesk + DM Sans + JetBrains Mono. |
| 2026-03-27 | Amber accent over blue | Every admin tool is blue. Amber reads as "operational" and gives the tool its own identity. Confirmed by Claude subagent ("Control Room Noir" also converged on amber). |
| 2026-03-27 | JetBrains Mono for data tables | Both primary analysis and subagent independently recommended monospace for data tables. Scannability win outweighs the ~15% wider columns. |
| 2026-03-27 | Warm tones throughout | Off-white (#F8F7F5) instead of clinical gray. Warm black text instead of blue-gray. Makes the tool feel handcrafted. |
| 2026-03-27 | Glowing status dots for errors | Problems glow (box-shadow), success is calm. Operator's eye is pulled to rows that need action. Inspired by subagent's "LED indicator" concept. |
| 2026-03-27 | Full mobile design | Bottom nav, card-based order views, touch-optimized gestures. Supports quick-glance usage from phone. |
| 2026-03-27 | Two-accent system | Blue = action (buttons, CTAs), amber = state (nav active, filter active). Clear hierarchy. |
| 2026-03-27 | JetBrains Mono selective | Mono font only for codes, IDs, numbers, sums, dates. Text names use DM Sans for readability. |
| 2026-03-27 | Dark mode in scope | CSS variables + toggle + localStorage. All DESIGN.md dark tokens implemented in Commit 0.5. |

15
TODOS.md Normal file
View File

@@ -0,0 +1,15 @@
# TODOS
## P2: Refactor sync_service.py in module separate
**What:** Split sync_service.py (870 linii) in: download_service, parse_service, sync_orchestrator.
**Why:** Faciliteza debugging si testare. Un bug in price sync nu ar trebui sa afecteze import flow.
**Effort:** M (human: ~1 sapt / CC: ~1-2h)
**Context:** Dupa implementarea planului Command Center (retry_service deja extras). sync_service face download + parse + validate + import + price sync + invoice check — prea multe responsabilitati.
**Depends on:** Finalizarea planului Command Center.
## P2: Email/webhook alert pe sync esuat
**What:** Cand sync-ul gaseste >5 erori sau esueaza complet, trimite un email/webhook.
**Why:** Post-lansare, cand app-ul ruleaza automat, nimeni nu sta sa verifice constant.
**Effort:** M (human: ~1 sapt / CC: ~1h)
**Context:** Depinde de infrastructura email/webhook disponibila la client. Implementare: SMTP simplu sau webhook URL configurabil in Settings.
**Depends on:** Lansare in productie + infrastructura email la client.

View File

@@ -332,6 +332,7 @@ def init_sqlite():
("discount_total", "REAL"),
("web_status", "TEXT"),
("discount_split", "TEXT"),
("price_match", "INTEGER"),
]:
if col not in order_cols:
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")

View File

@@ -1,3 +1,4 @@
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime
from fastapi import FastAPI
@@ -8,6 +9,7 @@ import os
from .config import settings
from .database import init_oracle, close_oracle, init_sqlite
from .routers.sync import backfill_price_match
# Configure logging with both stream and file handlers
_log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
@@ -56,6 +58,8 @@ async def lifespan(app: FastAPI):
except Exception:
pass
asyncio.create_task(backfill_price_match())
logger.info("GoMag Import Manager started")
yield

View File

@@ -146,8 +146,8 @@ async def create_batch_mapping(data: MappingBatchCreate):
return {"success": False, "error": str(e)}
@router.get("/api/mappings/{sku}/prices")
async def get_mapping_prices(sku: str):
@router.get("/api/mappings/prices")
async def get_mapping_prices(sku: str = Query(...)):
"""Get component prices from crm_politici_pret_art for a kit SKU."""
app_settings = await sqlite_service.get_app_settings()
id_pol = int(app_settings.get("id_pol") or 0) or None

View File

@@ -12,13 +12,81 @@ from pydantic import BaseModel
from pathlib import Path
from typing import Optional
from ..services import sync_service, scheduler_service, sqlite_service, invoice_service
from ..services import sync_service, scheduler_service, sqlite_service, invoice_service, validation_service
from .. import database
router = APIRouter(tags=["sync"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
async def _enrich_items_with_codmat(items: list) -> None:
"""Enrich order items with codmat_details from ARTICOLE_TERTI + NOM_ARTICOLE fallback."""
skus = {item["sku"] for item in items if item.get("sku")}
if not skus:
return
codmat_map = await asyncio.to_thread(_get_articole_terti_for_skus, skus)
for item in items:
sku = item.get("sku")
if sku and sku in codmat_map:
item["codmat_details"] = codmat_map[sku]
remaining_skus = {item["sku"] for item in items
if item.get("sku") and not item.get("codmat_details")}
if remaining_skus:
nom_map = await asyncio.to_thread(_get_nom_articole_for_direct_skus, remaining_skus)
for item in items:
sku = item.get("sku")
if sku and sku in nom_map and not item.get("codmat_details"):
item["codmat_details"] = [{"codmat": sku, "cantitate_roa": 1,
"denumire": nom_map[sku], "direct": True}]
async def backfill_price_match():
"""Background task: check prices for all imported orders without cached price_match."""
try:
from ..database import get_sqlite
db = await get_sqlite()
try:
cursor = await db.execute("""
SELECT order_number FROM orders
WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED')
AND price_match IS NULL
ORDER BY order_date DESC
""")
rows = [r["order_number"] for r in await cursor.fetchall()]
finally:
await db.close()
if not rows:
logger.info("backfill_price_match: no unchecked orders")
return
logger.info(f"backfill_price_match: checking {len(rows)} orders...")
app_settings = await sqlite_service.get_app_settings()
checked = 0
for order_number in rows:
try:
detail = await sqlite_service.get_order_detail(order_number)
if not detail:
continue
items = detail.get("items", [])
await _enrich_items_with_codmat(items)
price_data = await asyncio.to_thread(
validation_service.get_prices_for_order, items, app_settings
)
summary = price_data.get("summary", {})
if summary.get("oracle_available") is not False:
pm = summary.get("mismatches", 0) == 0
await sqlite_service.update_order_price_match(order_number, pm)
checked += 1
except Exception as e:
logger.debug(f"backfill_price_match: order {order_number} failed: {e}")
logger.info(f"backfill_price_match: done, {checked}/{len(rows)} updated")
except Exception as e:
logger.error(f"backfill_price_match failed: {e}")
class ScheduleConfig(BaseModel):
enabled: bool
interval_minutes: int = 5
@@ -380,33 +448,36 @@ async def order_detail(order_number: str):
if not detail:
return {"error": "Order not found"}
# Enrich items with ARTICOLE_TERTI mappings from Oracle
items = detail.get("items", [])
skus = {item["sku"] for item in items if item.get("sku")}
if skus:
codmat_map = await asyncio.to_thread(_get_articole_terti_for_skus, skus)
for item in items:
sku = item.get("sku")
if sku and sku in codmat_map:
item["codmat_details"] = codmat_map[sku]
await _enrich_items_with_codmat(items)
# Enrich remaining SKUs via NOM_ARTICOLE (fallback for stale mapping_status)
remaining_skus = {item["sku"] for item in items
if item.get("sku") and not item.get("codmat_details")}
if remaining_skus:
nom_map = await asyncio.to_thread(_get_nom_articole_for_direct_skus, remaining_skus)
for item in items:
sku = item.get("sku")
if sku and sku in nom_map and not item.get("codmat_details"):
item["codmat_details"] = [{
"codmat": sku,
"cantitate_roa": 1,
"denumire": nom_map[sku],
"direct": True
}]
# Price comparison against ROA Oracle
app_settings = await sqlite_service.get_app_settings()
try:
price_data = await asyncio.to_thread(
validation_service.get_prices_for_order, items, app_settings
)
price_items = price_data.get("items", {})
for idx, item in enumerate(items):
pi = price_items.get(idx)
if pi:
item["pret_roa"] = pi.get("pret_roa")
item["price_match"] = pi.get("match")
order_price_check = price_data.get("summary", {})
# Cache price_match in SQLite if changed
if order_price_check.get("oracle_available") is not False:
pm = order_price_check.get("mismatches", 0) == 0
cached = detail.get("order", {}).get("price_match")
cached_bool = True if cached == 1 else (False if cached == 0 else None)
if cached_bool != pm:
await sqlite_service.update_order_price_match(order_number, pm)
except Exception as e:
logger.warning(f"Price comparison failed for order {order_number}: {e}")
order_price_check = {"mismatches": 0, "checked": 0, "oracle_available": False}
# Enrich with invoice data
order = detail.get("order", {})
order["price_check"] = order_price_check
if order.get("factura_numar") and order.get("factura_data"):
order["invoice"] = {
"facturat": True,
@@ -438,6 +509,19 @@ async def order_detail(order_number: str):
except Exception:
pass
# Invoice reconciliation
inv = order.get("invoice")
if inv and inv.get("facturat") and inv.get("total_cu_tva") is not None:
order_total = float(order.get("order_total") or 0)
inv_total = float(inv["total_cu_tva"])
difference = round(inv_total - order_total, 2)
inv["reconciliation"] = {
"order_total": order_total,
"invoice_total": inv_total,
"difference": difference,
"match": abs(difference) < 0.01,
}
# Parse discount_split JSON string
if order.get("discount_split"):
try:
@@ -445,8 +529,7 @@ async def order_detail(order_number: str):
except (json.JSONDecodeError, TypeError):
pass
# Add settings for receipt display
app_settings = await sqlite_service.get_app_settings()
# Add settings for receipt display (app_settings already fetched above)
order["transport_vat"] = app_settings.get("transport_vat") or "21"
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
order["discount_codmat"] = app_settings.get("discount_codmat") or ""
@@ -454,6 +537,52 @@ async def order_detail(order_number: str):
return detail
@router.post("/api/orders/{order_number}/retry")
async def retry_order(order_number: str):
"""Retry importing a failed/skipped order."""
from ..services import retry_service
app_settings = await sqlite_service.get_app_settings()
result = await retry_service.retry_single_order(order_number, app_settings)
return result
@router.get("/api/orders/by-sku/{sku}/pending")
async def get_pending_orders_for_sku(sku: str):
"""Get SKIPPED orders that contain the given SKU."""
order_numbers = await sqlite_service.get_skipped_orders_with_sku(sku)
return {"sku": sku, "order_numbers": order_numbers, "count": len(order_numbers)}
@router.post("/api/orders/batch-retry")
async def batch_retry_orders(request: Request):
"""Batch retry multiple orders."""
from ..services import retry_service
body = await request.json()
order_numbers = body.get("order_numbers", [])
if not order_numbers:
return {"success": False, "message": "No orders specified"}
app_settings = await sqlite_service.get_app_settings()
results = {"imported": 0, "errors": 0, "messages": []}
for on in order_numbers[:20]: # Limit to 20 to avoid timeout
result = await retry_service.retry_single_order(str(on), app_settings)
if result.get("success"):
results["imported"] += 1
else:
results["errors"] += 1
results["messages"].append(f"{on}: {result.get('message', 'Error')}")
return {
"success": results["imported"] > 0,
"imported": results["imported"],
"errors": results["errors"],
"message": f"{results['imported']} importate, {results['errors']} erori" if results["errors"] else f"{results['imported']} importate cu succes",
"details": results["messages"][:5],
}
@router.get("/api/dashboard/orders")
async def dashboard_orders(page: int = 1, per_page: int = 50,
search: str = "", status: str = "all",
@@ -484,6 +613,9 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
# Enrich orders with invoice data — prefer SQLite cache, fallback to Oracle
all_orders = result["orders"]
for o in all_orders:
# price_match: 1=OK, 0=mismatch, NULL=not checked yet
pm = o.get("price_match")
o["price_match"] = True if pm == 1 else (False if pm == 0 else None)
if o.get("factura_numar") and o.get("factura_data"):
# Use cached invoice data from SQLite (only if complete)
o["invoice"] = {
@@ -534,9 +666,8 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
# Use counts from sqlite_service (already period-scoped)
counts = result.get("counts", {})
# Count newly-cached invoices found during this request
# Adjust uninvoiced count for invoices discovered via Oracle during this request
newly_invoiced = sum(1 for o in uncached_orders if o.get("invoice") and o["invoice"].get("facturat"))
# Adjust uninvoiced count: start from SQLite count, subtract newly-found invoices
uninvoiced_base = counts.get("uninvoiced_sqlite", sum(
1 for o in all_orders
if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")
@@ -546,6 +677,13 @@ async def dashboard_orders(page: int = 1, per_page: int = 50,
counts["facturate"] = max(0, imported_total - counts["nefacturate"])
counts.setdefault("total", counts.get("imported", 0) + counts.get("skipped", 0) + counts.get("error", 0))
# Attention metrics: add unresolved SKUs count
try:
stats = await sqlite_service.get_dashboard_stats()
counts["unresolved_skus"] = stats.get("unresolved_skus", 0)
except Exception:
counts["unresolved_skus"] = 0
# For UNINVOICED filter: apply server-side filtering + pagination
if is_uninvoiced_filter:
filtered = [o for o in all_orders if o.get("status") in ("IMPORTED", "ALREADY_IMPORTED") and not o.get("invoice")]

View File

@@ -0,0 +1,131 @@
"""Retry service — re-import individual failed/skipped orders."""
import asyncio
import logging
import tempfile
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
async def retry_single_order(order_number: str, app_settings: dict) -> dict:
"""Re-download and re-import a single order from GoMag.
Steps:
1. Read order from SQLite to get order_date / customer_name
2. Check sync lock (no retry during active sync)
3. Download narrow date range from GoMag (order_date ± 1 day)
4. Find the specific order in downloaded data
5. Run import_single_order()
6. Update status in SQLite
Returns: {"success": bool, "message": str, "status": str|None}
"""
from . import sqlite_service, sync_service, gomag_client, import_service, order_reader
# Check sync lock
if sync_service._sync_lock.locked():
return {"success": False, "message": "Sync in curs — asteapta finalizarea"}
# Get order from SQLite
detail = await sqlite_service.get_order_detail(order_number)
if not detail:
return {"success": False, "message": "Comanda nu a fost gasita"}
order_data = detail["order"]
status = order_data.get("status", "")
if status not in ("ERROR", "SKIPPED"):
return {"success": False, "message": f"Retry permis doar pentru ERROR/SKIPPED (status actual: {status})"}
order_date_str = order_data.get("order_date", "")
customer_name = order_data.get("customer_name", "")
# Parse order date for narrow download window
try:
order_date = datetime.fromisoformat(order_date_str.replace("Z", "+00:00")).date()
except (ValueError, AttributeError):
order_date = datetime.now().date() - timedelta(days=1)
gomag_key = app_settings.get("gomag_api_key") or None
gomag_shop = app_settings.get("gomag_api_shop") or None
with tempfile.TemporaryDirectory() as tmp_dir:
try:
today = datetime.now().date()
days_back = (today - order_date).days + 1
if days_back < 2:
days_back = 2
await gomag_client.download_orders(
tmp_dir, days_back=days_back,
api_key=gomag_key, api_shop=gomag_shop,
limit=200,
)
except Exception as e:
logger.error(f"Retry download failed for {order_number}: {e}")
return {"success": False, "message": f"Eroare download GoMag: {e}"}
# Find the specific order in downloaded data
target_order = None
orders, _ = order_reader.read_json_orders(json_dir=tmp_dir)
for o in orders:
if str(o.number) == str(order_number):
target_order = o
break
if not target_order:
return {"success": False, "message": f"Comanda {order_number} nu a fost gasita in GoMag API"}
# Import the order
id_pol = int(app_settings.get("id_pol") or 0)
id_sectie = int(app_settings.get("id_sectie") or 0)
id_gestiune = app_settings.get("id_gestiune", "")
id_gestiuni = [int(g.strip()) for g in id_gestiune.split(",") if g.strip()] if id_gestiune else None
try:
result = await asyncio.to_thread(
import_service.import_single_order,
target_order, id_pol=id_pol, id_sectie=id_sectie,
app_settings=app_settings, id_gestiuni=id_gestiuni
)
except Exception as e:
logger.error(f"Retry import failed for {order_number}: {e}")
await sqlite_service.upsert_order(
sync_run_id="retry",
order_number=order_number,
order_date=order_date_str,
customer_name=customer_name,
status="ERROR",
error_message=f"Retry failed: {e}",
)
return {"success": False, "message": f"Eroare import: {e}"}
if result.get("success"):
await sqlite_service.upsert_order(
sync_run_id="retry",
order_number=order_number,
order_date=order_date_str,
customer_name=customer_name,
status="IMPORTED",
id_comanda=result.get("id_comanda"),
id_partener=result.get("id_partener"),
error_message=None,
)
if result.get("id_adresa_facturare") or result.get("id_adresa_livrare"):
await sqlite_service.update_import_order_addresses(
order_number=order_number,
id_adresa_facturare=result.get("id_adresa_facturare"),
id_adresa_livrare=result.get("id_adresa_livrare"),
)
logger.info(f"Retry successful for order {order_number} → IMPORTED")
return {"success": True, "message": "Comanda reimportata cu succes", "status": "IMPORTED"}
else:
error = result.get("error", "Unknown error")
await sqlite_service.upsert_order(
sync_run_id="retry",
order_number=order_number,
order_date=order_date_str,
customer_name=customer_name,
status="ERROR",
error_message=f"Retry: {error}",
)
return {"success": False, "message": f"Import esuat: {error}", "status": "ERROR"}

View File

@@ -739,6 +739,16 @@ async def get_orders(page: int = 1, per_page: int = 50,
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_where}", base_params)
uninvoiced_sqlite = (await cursor.fetchone())[0]
# Uninvoiced > 3 days old
uninv_old_clauses = list(base_clauses) + [
"UPPER(status) IN ('IMPORTED', 'ALREADY_IMPORTED')",
"(factura_numar IS NULL OR factura_numar = '')",
"order_date < datetime('now', '-3 days')",
]
uninv_old_where = "WHERE " + " AND ".join(uninv_old_clauses)
cursor = await db.execute(f"SELECT COUNT(*) FROM orders {uninv_old_where}", base_params)
uninvoiced_old = (await cursor.fetchone())[0]
return {
"orders": [dict(r) for r in rows],
"total": total,
@@ -754,6 +764,7 @@ async def get_orders(page: int = 1, per_page: int = 50,
"cancelled": status_counts.get("CANCELLED", 0),
"total": sum(status_counts.values()),
"uninvoiced_sqlite": uninvoiced_sqlite,
"uninvoiced_old": uninvoiced_old,
}
}
finally:
@@ -820,6 +831,20 @@ async def update_order_invoice(order_number: str, serie: str = None,
await db.close()
async def update_order_price_match(order_number: str, match: bool | None):
"""Cache price_match result (True=OK, False=mismatch, None=unavailable)."""
db = await get_sqlite()
try:
val = None if match is None else (1 if match else 0)
await db.execute(
"UPDATE orders SET price_match = ?, updated_at = datetime('now') WHERE order_number = ?",
(val, order_number),
)
await db.commit()
finally:
await db.close()
async def get_invoiced_imported_orders() -> list:
"""Get imported orders that HAVE cached invoice data (for re-verification)."""
db = await get_sqlite()
@@ -949,6 +974,24 @@ async def set_app_setting(key: str, value: str):
await db.close()
# ── SKU-based order lookup ────────────────────────
async def get_skipped_orders_with_sku(sku: str) -> list[str]:
"""Get order_numbers of SKIPPED orders that contain the given SKU."""
db = await get_sqlite()
try:
cursor = await db.execute("""
SELECT DISTINCT oi.order_number
FROM order_items oi
JOIN orders o ON o.order_number = oi.order_number
WHERE oi.sku = ? AND o.status = 'SKIPPED'
""", (sku,))
rows = await cursor.fetchall()
return [row[0] for row in rows]
finally:
await db.close()
# ── Price Sync Runs ───────────────────────────────
async def get_price_sync_runs(page: int = 1, per_page: int = 20):

View File

@@ -586,3 +586,189 @@ def sync_prices_from_order(orders, mapped_codmat_data: dict, direct_id_map: dict
database.pool.release(conn)
return updated
def get_prices_for_order(items: list[dict], app_settings: dict, conn=None) -> dict:
"""Compare GoMag prices with ROA prices for order items.
Args:
items: list of order items, each with 'sku', 'price', 'quantity', 'codmat_details'
(codmat_details = [{"codmat", "cantitate_roa", "id_articol"?, "cont"?, "direct"?}])
app_settings: dict with 'id_pol', 'id_pol_productie'
conn: Oracle connection (optional, will acquire if None)
Returns: {
"items": {idx: {"pret_roa": float|None, "match": bool|None, "pret_gomag": float}},
"summary": {"mismatches": int, "checked": int, "oracle_available": bool}
}
"""
try:
id_pol = int(app_settings.get("id_pol", 0) or 0)
id_pol_productie = int(app_settings.get("id_pol_productie", 0) or 0)
except (ValueError, TypeError):
id_pol = 0
id_pol_productie = 0
def _empty_result(oracle_available: bool) -> dict:
return {
"items": {
idx: {"pret_roa": None, "match": None, "pret_gomag": float(item.get("price") or 0)}
for idx, item in enumerate(items)
},
"summary": {"mismatches": 0, "checked": 0, "oracle_available": oracle_available}
}
if not items or not id_pol:
return _empty_result(oracle_available=False)
own_conn = conn is None
try:
if own_conn:
conn = database.get_oracle_connection()
# Step 1: Collect codmats; use id_articol/cont from codmat_details when already known
pre_resolved = {} # {codmat: {"id_articol": int, "cont": str}}
all_codmats = set()
for item in items:
for cd in (item.get("codmat_details") or []):
codmat = cd.get("codmat")
if not codmat:
continue
all_codmats.add(codmat)
if cd.get("id_articol") and codmat not in pre_resolved:
pre_resolved[codmat] = {
"id_articol": cd["id_articol"],
"cont": cd.get("cont") or "",
}
# Step 2: Resolve missing id_articols via nom_articole
need_resolve = all_codmats - set(pre_resolved.keys())
if need_resolve:
db_resolved = resolve_codmat_ids(need_resolve, conn=conn)
pre_resolved.update(db_resolved)
codmat_info = pre_resolved # {codmat: {"id_articol": int, "cont": str}}
# Step 3: Get PRETURI_CU_TVA flag once per policy
policies = {id_pol}
if id_pol_productie and id_pol_productie != id_pol:
policies.add(id_pol_productie)
pol_cu_tva = {} # {id_pol: bool}
with conn.cursor() as cur:
for pol in policies:
cur.execute(
"SELECT PRETURI_CU_TVA FROM CRM_POLITICI_PRETURI WHERE ID_POL = :pol",
{"pol": pol},
)
row = cur.fetchone()
pol_cu_tva[pol] = (int(row[0] or 0) == 1) if row else False
# Step 4: Batch query PRET + PROC_TVAV for all id_articols across both policies
all_id_articols = list({
info["id_articol"]
for info in codmat_info.values()
if info.get("id_articol")
})
price_map = {} # {(id_pol, id_articol): (pret, proc_tvav)}
if all_id_articols:
pol_list = list(policies)
pol_placeholders = ",".join([f":p{k}" for k in range(len(pol_list))])
with conn.cursor() as cur:
for i in range(0, len(all_id_articols), 500):
batch = all_id_articols[i:i + 500]
art_placeholders = ",".join([f":a{j}" for j in range(len(batch))])
params = {f"a{j}": aid for j, aid in enumerate(batch)}
for k, pol in enumerate(pol_list):
params[f"p{k}"] = pol
cur.execute(f"""
SELECT ID_POL, ID_ARTICOL, PRET, PROC_TVAV
FROM CRM_POLITICI_PRET_ART
WHERE ID_POL IN ({pol_placeholders}) AND ID_ARTICOL IN ({art_placeholders})
""", params)
for row in cur:
price_map[(row[0], row[1])] = (row[2], row[3])
# Step 5: Compute pret_roa per item and compare with GoMag price
result_items = {}
mismatches = 0
checked = 0
for idx, item in enumerate(items):
pret_gomag = float(item.get("price") or 0)
result_items[idx] = {"pret_gomag": pret_gomag, "pret_roa": None, "match": None}
codmat_details = item.get("codmat_details") or []
if not codmat_details:
continue
is_kit = len(codmat_details) > 1 or (
len(codmat_details) == 1
and float(codmat_details[0].get("cantitate_roa") or 1) > 1
)
pret_roa_total = 0.0
all_resolved = True
for cd in codmat_details:
codmat = cd.get("codmat")
if not codmat:
all_resolved = False
break
info = codmat_info.get(codmat, {})
id_articol = info.get("id_articol")
if not id_articol:
all_resolved = False
break
# Dual-policy routing: cont 341/345 → production, else → sales
cont = str(info.get("cont") or cd.get("cont") or "").strip()
if cont in ("341", "345") and id_pol_productie:
pol = id_pol_productie
else:
pol = id_pol
price_entry = price_map.get((pol, id_articol))
if price_entry is None:
all_resolved = False
break
pret, proc_tvav = price_entry
proc_tvav = float(proc_tvav or 1.19)
if pol_cu_tva.get(pol):
pret_cu_tva = float(pret or 0)
else:
pret_cu_tva = float(pret or 0) * proc_tvav
cantitate_roa = float(cd.get("cantitate_roa") or 1)
if is_kit:
pret_roa_total += pret_cu_tva * cantitate_roa
else:
pret_roa_total = pret_cu_tva # cantitate_roa==1 for simple items
if not all_resolved:
continue
pret_roa = round(pret_roa_total, 4)
match = abs(pret_gomag - pret_roa) < 0.01
result_items[idx]["pret_roa"] = pret_roa
result_items[idx]["match"] = match
checked += 1
if not match:
mismatches += 1
logger.info(f"get_prices_for_order: {checked}/{len(items)} checked, {mismatches} mismatches")
return {
"items": result_items,
"summary": {"mismatches": mismatches, "checked": checked, "oracle_available": True},
}
except Exception as e:
logger.error(f"get_prices_for_order failed: {e}")
return _empty_result(oracle_available=False)
finally:
if own_conn and conn:
database.pool.release(conn)

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@ document.addEventListener('DOMContentLoaded', async () => {
loadDashOrders();
startSyncPolling();
wireFilterBar();
checkFirstTime();
});
async function initPollInterval() {
@@ -119,11 +120,33 @@ function updateSyncPanel(data) {
}
if (st) {
st.textContent = lr.status === 'completed' ? '\u2713' : '\u2715';
st.style.color = lr.status === 'completed' ? '#10b981' : '#ef4444';
st.style.color = lr.status === 'completed' ? 'var(--success)' : 'var(--error)';
}
}
}
async function checkFirstTime() {
const welcomeEl = document.getElementById('welcomeCard');
if (!welcomeEl) return;
try {
const data = await fetchJSON('/api/sync/status');
if (!data.last_run) {
welcomeEl.innerHTML = `<div class="welcome-card">
<h5 style="font-family:var(--font-display);margin:0 0 8px">Bine ai venit!</h5>
<p class="text-muted mb-2" style="font-size:0.875rem">Configureaza si ruleaza primul sync:</p>
<div class="welcome-steps">
<span class="welcome-step"><b>1.</b> <a href="${window.ROOT_PATH||''}/settings">Verifica Settings</a></span>
<span class="welcome-step"><b>2.</b> Apasa "Start Sync"</span>
<span class="welcome-step"><b>3.</b> <a href="${window.ROOT_PATH||''}/missing-skus">Mapeaza SKU-urile lipsa</a></span>
</div>
</div>`;
welcomeEl.style.display = '';
} else {
welcomeEl.style.display = 'none';
}
} catch(e) { welcomeEl.style.display = 'none'; }
}
// Wire last-sync-row click → journal (use current running sync if active)
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('lastSyncRow')?.addEventListener('click', () => {
@@ -201,10 +224,14 @@ async function loadSchedulerStatus() {
// ── Filter Bar wiring ─────────────────────────────
function wireFilterBar() {
// Period dropdown
document.getElementById('periodSelect')?.addEventListener('change', function () {
// Period preset buttons
document.querySelectorAll('.preset-btn[data-days]').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
const days = this.dataset.days;
const cr = document.getElementById('customRangeInputs');
if (this.value === 'custom') {
if (days === 'custom') {
cr?.classList.add('visible');
} else {
cr?.classList.remove('visible');
@@ -212,6 +239,7 @@ function wireFilterBar() {
loadDashOrders();
}
});
});
// Custom range inputs
['periodStart', 'periodEnd'].forEach(id => {
@@ -260,7 +288,8 @@ function dashSortBy(col) {
}
async function loadDashOrders() {
const periodVal = document.getElementById('periodSelect')?.value || '7';
const activePreset = document.querySelector('.preset-btn.active');
const periodVal = activePreset?.dataset.days || '3';
const params = new URLSearchParams();
if (periodVal === 'custom') {
@@ -301,11 +330,29 @@ async function loadDashOrders() {
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0;
// Attention card
const attnEl = document.getElementById('attentionCard');
if (attnEl) {
const errors = c.error || 0;
const unmapped = c.unresolved_skus || 0;
const nefact = c.nefacturate || 0;
if (errors === 0 && unmapped === 0 && nefact === 0) {
attnEl.innerHTML = '<div class="attention-card attention-ok"><i class="bi bi-check-circle"></i> Totul in ordine</div>';
} else {
let items = [];
if (errors > 0) items.push(`<span class="attention-item attention-error" onclick="document.querySelector('.filter-pill[data-status=ERROR]')?.click()"><i class="bi bi-exclamation-triangle"></i> ${errors} erori import</span>`);
if (unmapped > 0) items.push(`<span class="attention-item attention-warning" onclick="window.location='${window.ROOT_PATH||''}/missing-skus'"><i class="bi bi-puzzle"></i> ${unmapped} SKU-uri nemapate</span>`);
if (nefact > 0) items.push(`<span class="attention-item attention-warning" onclick="document.querySelector('.filter-pill[data-status=UNINVOICED]')?.click()"><i class="bi bi-receipt"></i> ${nefact} nefacturate</span>`);
attnEl.innerHTML = '<div class="attention-card attention-alert">' + items.join('') + '</div>';
}
}
const tbody = document.getElementById('dashOrdersBody');
const orders = data.orders || [];
if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted py-3">Nicio comanda</td></tr>';
} else {
tbody.innerHTML = orders.map(o => {
const dateStr = fmtDate(o.order_date);
@@ -321,6 +368,7 @@ async function loadDashOrders() {
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
<td class="text-end fw-bold">${orderTotal}</td>
<td class="text-center">${invoiceDot(o)}</td>
<td class="text-center">${priceDot(o)}</td>
</tr>`;
}).join('');
}
@@ -340,11 +388,12 @@ async function loadDashOrders() {
}
const name = o.customer_name || o.shipping_name || o.billing_name || '\u2014';
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
const priceMismatch = o.price_match === false ? '<span class="dot dot-red" style="width:6px;height:6px" title="Pret!="></span> ' : '';
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 style="color:var(--text-muted)" class="text-nowrap">${dateFmt}</span>
<span class="grow truncate fw-bold">${esc(name)}</span>
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span>
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · ' + priceMismatch + '<strong>' + totalStr + '</strong>' : ''}</span>
</div>`;
}).join('');
}
@@ -432,14 +481,6 @@ function escHtml(s) {
.replace(/'/g, '&#39;');
}
// Alias kept for backward compat with inline handlers in modal
function esc(s) { return escHtml(s); }
function fmtCost(v) {
return v > 0 ? Number(v).toFixed(2) : '';
}
function statusLabelText(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return 'Importat';
@@ -450,16 +491,10 @@ function statusLabelText(status) {
}
}
function orderStatusBadge(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
case 'CANCELLED': return '<span class="badge bg-secondary">Anulat</span>';
case 'DELETED_IN_ROA': return '<span class="badge bg-dark">Sters din ROA</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
}
function priceDot(order) {
if (order.price_match === true) return '<span class="dot dot-green" title="Preturi OK"></span>';
if (order.price_match === false) return '<span class="dot dot-red" title="Diferenta de pret"></span>';
return '<span class="dot dot-gray" title="Neverificat"></span>';
}
function invoiceDot(order) {
@@ -468,22 +503,6 @@ function invoiceDot(order) {
return '<span class="dot dot-red" title="Nefacturat"></span>';
}
function renderCodmatCell(item) {
if (!item.codmat_details || item.codmat_details.length === 0) {
return `<code>${esc(item.codmat || '-')}</code>`;
}
if (item.codmat_details.length === 1) {
const d = item.codmat_details[0];
if (d.direct) {
return `<code>${esc(d.codmat)}</code> <span class="badge bg-secondary" style="font-size:0.6rem;vertical-align:middle">direct</span>`;
}
return `<code>${esc(d.codmat)}</code>`;
}
return item.codmat_details.map(d =>
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa}</span></div>`
).join('');
}
// ── Refresh Invoices ──────────────────────────────
async function refreshInvoices() {
@@ -509,262 +528,12 @@ async function refreshInvoices() {
// ── Order Detail Modal ────────────────────────────
async function openDashOrderDetail(orderNumber) {
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
document.getElementById('detailCustomer').textContent = '...';
document.getElementById('detailDate').textContent = '';
document.getElementById('detailStatus').innerHTML = '';
document.getElementById('detailIdComanda').textContent = '-';
document.getElementById('detailIdPartener').textContent = '-';
document.getElementById('detailIdAdresaFact').textContent = '-';
document.getElementById('detailIdAdresaLivr').textContent = '-';
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="7" class="text-center">Se incarca...</td></tr>';
document.getElementById('detailError').style.display = 'none';
document.getElementById('detailReceipt').innerHTML = '';
document.getElementById('detailReceiptMobile').innerHTML = '';
const invInfo = document.getElementById('detailInvoiceInfo');
if (invInfo) invInfo.style.display = 'none';
const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) mobileContainer.innerHTML = '';
const modalEl = document.getElementById('orderDetailModal');
const existing = bootstrap.Modal.getInstance(modalEl);
if (existing) { existing.show(); } else { new bootstrap.Modal(modalEl).show(); }
try {
const res = await fetch(`/api/sync/order/${encodeURIComponent(orderNumber)}`);
const data = await res.json();
if (data.error) {
document.getElementById('detailError').textContent = data.error;
document.getElementById('detailError').style.display = '';
return;
}
const order = data.order || {};
document.getElementById('detailCustomer').textContent = order.customer_name || '-';
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
// Invoice info
const invInfo = document.getElementById('detailInvoiceInfo');
const inv = order.invoice;
if (inv && inv.facturat) {
const serie = inv.serie_act || '';
const numar = inv.numar_act || '';
document.getElementById('detailInvoiceNumber').textContent = serie ? `${serie} ${numar}` : numar;
document.getElementById('detailInvoiceDate').textContent = inv.data_act ? fmtDate(inv.data_act) : '-';
if (invInfo) invInfo.style.display = '';
} else {
if (invInfo) invInfo.style.display = 'none';
}
if (order.error_message) {
document.getElementById('detailError').textContent = order.error_message;
document.getElementById('detailError').style.display = '';
}
const items = data.items || [];
if (items.length === 0) {
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="7" class="text-center text-muted">Niciun articol</td></tr>';
return;
}
// Store items for quick map pre-population
window._detailItems = items;
// Mobile article flat list
const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) {
let mobileHtml = items.map((item, idx) => {
const codmatText = item.codmat_details?.length
? item.codmat_details.map(d => `<code>${esc(d.codmat)}</code>${d.direct ? ' <span class="badge bg-secondary" style="font-size:0.55rem">direct</span>' : ''}`).join(' ')
: `<code>${esc(item.codmat || '')}</code>`;
const valoare = (Number(item.price || 0) * Number(item.quantity || 0));
return `<div class="dif-item">
<div class="dif-row">
<span class="dif-sku dif-codmat-link" onclick="openDashQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}', ${idx})">${esc(item.sku)}</span>
${codmatText}
</div>
<div class="dif-row">
<span class="dif-name">${esc(item.product_name || '')}</span>
<span class="dif-qty">x${item.quantity || 0}</span>
<span class="dif-val">${fmtNum(valoare)} lei</span>
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
</div>
</div>`;
}).join('');
// Transport row (mobile)
if (order.delivery_cost > 0) {
const tVat = order.transport_vat || '21';
mobileHtml += `<div class="dif-item" style="opacity:0.7">
<div class="dif-row">
<span class="dif-name text-muted">Transport</span>
<span class="dif-qty">x1</span>
<span class="dif-val">${fmtNum(order.delivery_cost)} lei</span>
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${tVat}</span>
</div>
</div>`;
}
// Discount rows (mobile)
if (order.discount_total > 0) {
const discSplit = computeDiscountSplit(items, order);
if (discSplit) {
Object.entries(discSplit)
.sort(([a], [b]) => Number(a) - Number(b))
.forEach(([rate, amt]) => {
if (amt > 0) mobileHtml += `<div class="dif-item" style="opacity:0.7">
<div class="dif-row">
<span class="dif-name text-muted">Discount</span>
<span class="dif-qty">x\u20131</span>
<span class="dif-val">${fmtNum(amt)} lei</span>
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${Number(rate)}</span>
</div>
</div>`;
function openDashOrderDetail(orderNumber) {
_sharedModalQuickMapFn = openDashQuickMap;
renderOrderDetailModal(orderNumber, {
onQuickMap: openDashQuickMap,
onAfterRender: function() { /* nothing extra needed */ }
});
} else {
mobileHtml += `<div class="dif-item" style="opacity:0.7">
<div class="dif-row">
<span class="dif-name text-muted">Discount</span>
<span class="dif-qty">x\u20131</span>
<span class="dif-val">${fmtNum(order.discount_total)} lei</span>
</div>
</div>`;
}
}
mobileContainer.innerHTML = '<div class="detail-item-flat">' + mobileHtml + '</div>';
}
let tableHtml = items.map((item, idx) => {
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
return `<tr>
<td><code class="codmat-link" onclick="openDashQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare">${esc(item.sku)}</code></td>
<td>${esc(item.product_name || '-')}</td>
<td>${renderCodmatCell(item)}</td>
<td class="text-end">${item.quantity || 0}</td>
<td class="text-end">${item.price != null ? fmtNum(item.price) : '-'}</td>
<td class="text-end">${item.vat != null ? Number(item.vat) : '-'}</td>
<td class="text-end">${fmtNum(valoare)}</td>
</tr>`;
}).join('');
// Transport row
if (order.delivery_cost > 0) {
const tVat = order.transport_vat || '21';
const tCodmat = order.transport_codmat || '';
tableHtml += `<tr class="table-light">
<td></td><td class="text-muted">Transport</td>
<td>${tCodmat ? '<code>' + esc(tCodmat) + '</code>' : ''}</td>
<td class="text-end">1</td><td class="text-end">${fmtNum(order.delivery_cost)}</td>
<td class="text-end">${tVat}</td><td class="text-end">${fmtNum(order.delivery_cost)}</td>
</tr>`;
}
// Discount rows (split by VAT rate)
if (order.discount_total > 0) {
const dCodmat = order.discount_codmat || '';
const discSplit = computeDiscountSplit(items, order);
if (discSplit) {
Object.entries(discSplit)
.sort(([a], [b]) => Number(a) - Number(b))
.forEach(([rate, amt]) => {
if (amt > 0) tableHtml += `<tr class="table-light">
<td></td><td class="text-muted">Discount</td>
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
<td class="text-end">\u20131</td><td class="text-end">${fmtNum(amt)}</td>
<td class="text-end">${Number(rate)}</td><td class="text-end">\u2013${fmtNum(amt)}</td>
</tr>`;
});
} else {
tableHtml += `<tr class="table-light">
<td></td><td class="text-muted">Discount</td>
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
<td class="text-end">\u20131</td><td class="text-end">${fmtNum(order.discount_total)}</td>
<td class="text-end">-</td><td class="text-end">\u2013${fmtNum(order.discount_total)}</td>
</tr>`;
}
}
document.getElementById('detailItemsBody').innerHTML = tableHtml;
// Receipt footer (just total)
renderReceipt(items, order);
} catch (err) {
document.getElementById('detailError').textContent = err.message;
document.getElementById('detailError').style.display = '';
}
}
function fmtNum(v) {
return Number(v).toLocaleString('ro-RO', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function computeDiscountSplit(items, order) {
if (order.discount_split && typeof order.discount_split === 'object')
return order.discount_split;
// Compute proportionally from items by VAT rate
const byRate = {};
items.forEach(item => {
const rate = item.vat != null ? Number(item.vat) : null;
if (rate === null) return;
if (!byRate[rate]) byRate[rate] = 0;
byRate[rate] += Number(item.price || 0) * Number(item.quantity || 0);
});
const rates = Object.keys(byRate).sort((a, b) => Number(a) - Number(b));
if (rates.length === 0) return null;
const grandTotal = rates.reduce((s, r) => s + byRate[r], 0);
if (grandTotal <= 0) return null;
const split = {};
let remaining = order.discount_total;
rates.forEach((rate, i) => {
if (i === rates.length - 1) {
split[rate] = Math.round(remaining * 100) / 100;
} else {
const amt = Math.round(order.discount_total * byRate[rate] / grandTotal * 100) / 100;
split[rate] = amt;
remaining -= amt;
}
});
return split;
}
function renderReceipt(items, order) {
const desktop = document.getElementById('detailReceipt');
const mobile = document.getElementById('detailReceiptMobile');
if (!items.length) {
desktop.innerHTML = '';
mobile.innerHTML = '';
return;
}
const articole = items.reduce((s, i) => s + Number(i.price || 0) * Number(i.quantity || 0), 0);
const discount = Number(order.discount_total || 0);
const transport = Number(order.delivery_cost || 0);
const total = order.order_total != null ? fmtNum(order.order_total) : '-';
// Desktop: full labels
let dHtml = `<span class="text-muted">Articole: <strong class="text-body">${fmtNum(articole)}</strong></span>`;
if (discount > 0) dHtml += `<span class="text-muted">Discount: <strong class="text-danger">\u2013${fmtNum(discount)}</strong></span>`;
if (transport > 0) dHtml += `<span class="text-muted">Transport: <strong class="text-body">${fmtNum(transport)}</strong></span>`;
dHtml += `<span>Total: <strong>${total} lei</strong></span>`;
desktop.innerHTML = dHtml;
// Mobile: shorter labels
let mHtml = `<span class="text-muted">Art: <strong class="text-body">${fmtNum(articole)}</strong></span>`;
if (discount > 0) mHtml += `<span class="text-muted">Disc: <strong class="text-danger">\u2013${fmtNum(discount)}</strong></span>`;
if (transport > 0) mHtml += `<span class="text-muted">Transp: <strong class="text-body">${fmtNum(transport)}</strong></span>`;
mHtml += `<span>Total: <strong>${total} lei</strong></span>`;
mobile.innerHTML = mHtml;
}
// ── Quick Map Modal (uses shared openQuickMap) ───

View File

@@ -8,10 +8,6 @@ let ordersPage = 1;
let ordersSortColumn = 'order_date';
let ordersSortDirection = 'desc';
function fmtCost(v) {
return v > 0 ? Number(v).toFixed(2) : '';
}
function fmtDuration(startedAt, finishedAt) {
if (!startedAt || !finishedAt) return '-';
const diffMs = new Date(finishedAt) - new Date(startedAt);
@@ -23,24 +19,13 @@ function fmtDuration(startedAt, finishedAt) {
function runStatusBadge(status) {
switch ((status || '').toLowerCase()) {
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>';
case 'completed': return '<span style="color:var(--success);font-weight:600">completed</span>';
case 'running': return '<span style="color:var(--info);font-weight:600">running</span>';
case 'failed': return '<span style="color:var(--error);font-weight:600">failed</span>';
default: return `<span style="font-weight:600">${esc(status)}</span>`;
}
}
function orderStatusBadge(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
case 'DELETED_IN_ROA': return '<span class="badge bg-dark">Sters din ROA</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
}
}
function logStatusText(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return 'Importat';
@@ -156,7 +141,11 @@ async function loadRunOrders(runId, statusFilter, page) {
if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-3">Nicio comanda</td></tr>';
} else {
tbody.innerHTML = orders.map((o, i) => {
const problemOrders = orders.filter(o => ['ERROR', 'SKIPPED'].includes(o.status));
const okOrders = orders.filter(o => ['IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
const otherOrders = orders.filter(o => !['ERROR', 'SKIPPED', 'IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
function orderRow(o, i) {
const dateStr = fmtDate(o.order_date);
const orderTotal = o.order_total != null ? Number(o.order_total).toFixed(2) : '-';
return `<tr style="cursor:pointer" onclick="openOrderDetail('${esc(o.order_number)}')">
@@ -170,7 +159,31 @@ async function loadRunOrders(runId, statusFilter, page) {
<td class="text-end text-muted">${fmtCost(o.discount_total)}</td>
<td class="text-end fw-bold">${orderTotal}</td>
</tr>`;
}).join('');
}
let html = '';
// Show problem orders first (always visible)
problemOrders.forEach((o, i) => { html += orderRow(o, i); });
otherOrders.forEach((o, i) => { html += orderRow(o, problemOrders.length + i); });
// Collapsible OK orders
if (okOrders.length > 0) {
const toggleId = 'okOrdersCollapse_' + Date.now();
html += `<tr><td colspan="9" class="p-0">
<div class="log-ok-toggle" onclick="this.nextElementSibling.classList.toggle('d-none')">
${okOrders.length} comenzi importate cu succes
</div>
<div class="d-none">
<table class="table mb-0">
<tbody>
${okOrders.map((o, i) => orderRow(o, problemOrders.length + otherOrders.length + i)).join('')}
</tbody>
</table>
</div>
</td></tr>`;
}
tbody.innerHTML = html;
}
// Mobile flat rows
@@ -179,7 +192,11 @@ async function loadRunOrders(runId, statusFilter, page) {
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 problemOrders = orders.filter(o => ['ERROR', 'SKIPPED'].includes(o.status));
const okOrders = orders.filter(o => ['IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
const otherOrders = orders.filter(o => !['ERROR', 'SKIPPED', 'IMPORTED', 'ALREADY_IMPORTED'].includes(o.status));
function mobileRow(o) {
const d = o.order_date || '';
let dateFmt = '-';
if (d.length >= 10) {
@@ -189,11 +206,26 @@ async function loadRunOrders(runId, statusFilter, page) {
const totalStr = o.order_total ? Number(o.order_total).toFixed(2) : '';
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 style="color:var(--text-muted)" class="text-nowrap">${dateFmt}</span>
<span class="grow truncate fw-bold">${esc(o.customer_name || '—')}</span>
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · <strong>' + totalStr + '</strong>' : ''}</span>
</div>`;
}).join('');
}
let mobileHtml = '';
problemOrders.forEach(o => { mobileHtml += mobileRow(o); });
otherOrders.forEach(o => { mobileHtml += mobileRow(o); });
if (okOrders.length > 0) {
mobileHtml += `<div class="log-ok-toggle" onclick="this.nextElementSibling.classList.toggle('d-none')">
${okOrders.length} comenzi importate cu succes
</div>
<div class="d-none">
${okOrders.map(o => mobileRow(o)).join('')}
</div>`;
}
mobileList.innerHTML = mobileHtml;
}
}
@@ -296,125 +328,17 @@ async function fetchTextLog(runId) {
}
}
// ── Multi-CODMAT helper (D1) ─────────────────────
function renderCodmatCell(item) {
if (!item.codmat_details || item.codmat_details.length === 0) {
return `<code>${esc(item.codmat || '-')}</code>`;
}
if (item.codmat_details.length === 1) {
const d = item.codmat_details[0];
return `<code>${esc(d.codmat)}</code>`;
}
// Multi-CODMAT: compact list
return item.codmat_details.map(d =>
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa}</span></div>`
).join('');
}
// ── Order Detail Modal (R9) ─────────────────────
async function openOrderDetail(orderNumber) {
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
document.getElementById('detailCustomer').textContent = '...';
document.getElementById('detailDate').textContent = '';
document.getElementById('detailStatus').innerHTML = '';
document.getElementById('detailIdComanda').textContent = '-';
document.getElementById('detailIdPartener').textContent = '-';
document.getElementById('detailIdAdresaFact').textContent = '-';
document.getElementById('detailIdAdresaLivr').textContent = '-';
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center">Se incarca...</td></tr>';
document.getElementById('detailError').style.display = 'none';
const detailItemsTotal = document.getElementById('detailItemsTotal');
if (detailItemsTotal) detailItemsTotal.textContent = '-';
const detailOrderTotal = document.getElementById('detailOrderTotal');
if (detailOrderTotal) detailOrderTotal.textContent = '-';
const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) mobileContainer.innerHTML = '';
const modalEl = document.getElementById('orderDetailModal');
const existing = bootstrap.Modal.getInstance(modalEl);
if (existing) { existing.show(); } else { new bootstrap.Modal(modalEl).show(); }
try {
const res = await fetch(`/api/sync/order/${encodeURIComponent(orderNumber)}`);
const data = await res.json();
if (data.error) {
document.getElementById('detailError').textContent = data.error;
document.getElementById('detailError').style.display = '';
return;
}
const order = data.order || {};
document.getElementById('detailCustomer').textContent = order.customer_name || '-';
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
if (order.error_message) {
document.getElementById('detailError').textContent = order.error_message;
document.getElementById('detailError').style.display = '';
}
const dlvEl = document.getElementById('detailDeliveryCost');
if (dlvEl) dlvEl.textContent = order.delivery_cost > 0 ? Number(order.delivery_cost).toFixed(2) + ' lei' : '';
const dscEl = document.getElementById('detailDiscount');
if (dscEl) dscEl.textContent = order.discount_total > 0 ? '' + Number(order.discount_total).toFixed(2) + ' lei' : '';
const items = data.items || [];
if (items.length === 0) {
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center text-muted">Niciun articol</td></tr>';
return;
}
// Update totals row
const itemsTotal = items.reduce((sum, item) => sum + (Number(item.price || 0) * Number(item.quantity || 0)), 0);
document.getElementById('detailItemsTotal').textContent = itemsTotal.toFixed(2) + ' lei';
document.getElementById('detailOrderTotal').textContent = order.order_total != null ? Number(order.order_total).toFixed(2) + ' lei' : '-';
// Mobile article flat list
const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) {
mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => {
const codmatList = item.codmat_details?.length
? item.codmat_details.map(d => `<span class="dif-codmat-link" onclick="openLogsQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(d.codmat)}</span>`).join(' ')
: `<span class="dif-codmat-link" onclick="openLogsQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}')">${esc(item.codmat || '')}</span>`;
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
return `<div class="dif-item">
<div class="dif-row">
<span class="dif-sku">${esc(item.sku)}</span>
${codmatList}
</div>
<div class="dif-row">
<span class="dif-name">${esc(item.product_name || '')}</span>
<span class="dif-qty">x${item.quantity || 0}</span>
<span class="dif-val">${valoare} lei</span>
</div>
</div>`;
}).join('') + '</div>';
}
document.getElementById('detailItemsBody').innerHTML = items.map(item => {
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
const codmatCell = `<span class="codmat-link" onclick="openLogsQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}')" title="Click pentru mapare">${renderCodmatCell(item)}</span>`;
return `<tr>
<td><code>${esc(item.sku)}</code></td>
<td>${esc(item.product_name || '-')}</td>
<td>${codmatCell}</td>
<td>${item.quantity || 0}</td>
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
<td class="text-end">${valoare}</td>
</tr>`;
}).join('');
} catch (err) {
document.getElementById('detailError').textContent = err.message;
document.getElementById('detailError').style.display = '';
function openOrderDetail(orderNumber) {
_sharedModalQuickMapFn = function(sku, productName, orderNum, itemIdx) {
openLogsQuickMap(sku, productName, orderNum);
};
renderOrderDetailModal(orderNumber, {
onQuickMap: function(sku, productName, orderNum, itemIdx) {
openLogsQuickMap(sku, productName, orderNum);
}
});
}
// ── Quick Map Modal (uses shared openQuickMap) ───

View File

@@ -107,7 +107,7 @@ function renderTable(mappings, showDeleted) {
? ` <span class="text-muted small">Kit · ${skuCodmatCount[m.sku]}</span><span class="kit-price-loading" data-sku="${esc(m.sku)}" style="display:none"><span class="spinner-border spinner-border-sm ms-1" style="width:0.8rem;height:0.8rem"></span></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}">
html += `<div class="flat-row" style="background:var(--surface-raised);font-weight:600;border-top:1px solid var(--border);${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>
@@ -135,7 +135,7 @@ function renderTable(mappings, showDeleted) {
// After last CODMAT of a kit, add total row
const isLastOfKit = isKitRow && (i === mappings.length - 1 || mappings[i + 1].sku !== m.sku);
if (isLastOfKit) {
html += `<div class="flat-row kit-total-slot text-muted small" data-sku="${esc(m.sku)}" style="padding-left:1.5rem;display:none;border-top:1px dashed #e5e7eb"></div>`;
html += `<div class="flat-row kit-total-slot text-muted small" data-sku="${esc(m.sku)}" style="padding-left:1.5rem;display:none;border-top:1px dashed var(--border)"></div>`;
}
prevSku = m.sku;
@@ -176,7 +176,7 @@ async function loadKitPrices(sku, container) {
if (spinner) spinner.style.display = '';
try {
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/prices`);
const res = await fetch(`/api/mappings/prices?sku=${encodeURIComponent(sku)}`);
const data = await res.json();
if (data.error) {
if (spinner) spinner.innerHTML = `<small class="text-danger">${esc(data.error)}</small>`;
@@ -523,7 +523,7 @@ function showInlineAddRow() {
const row = document.createElement('div');
row.id = 'inlineAddRow';
row.className = 'flat-row';
row.style.background = '#eff6ff';
row.style.background = 'var(--info-light)';
row.style.gap = '0.5rem';
row.innerHTML = `
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">

View File

@@ -15,6 +15,15 @@ document.addEventListener('DOMContentLoaded', async () => {
});
});
// Dark mode toggle
const darkToggle = document.getElementById('settDarkMode');
if (darkToggle) {
darkToggle.checked = document.documentElement.getAttribute('data-theme') === 'dark';
darkToggle.addEventListener('change', () => {
if (typeof toggleDarkMode === 'function') toggleDarkMode();
});
}
// Catalog sync toggle
const catChk = document.getElementById('settCatalogSyncEnabled');
if (catChk) catChk.addEventListener('change', () => {
@@ -191,14 +200,14 @@ async function saveSettings() {
const data = await res.json();
const resultEl = document.getElementById('settSaveResult');
if (data.success) {
if (resultEl) { resultEl.textContent = 'Salvat!'; resultEl.style.color = '#16a34a'; }
if (resultEl) { resultEl.textContent = 'Salvat!'; resultEl.style.color = 'var(--success)'; }
setTimeout(() => { if (resultEl) resultEl.textContent = ''; }, 3000);
} else {
if (resultEl) { resultEl.textContent = 'Eroare: ' + JSON.stringify(data); resultEl.style.color = '#dc2626'; }
if (resultEl) { resultEl.textContent = 'Eroare: ' + JSON.stringify(data); resultEl.style.color = 'var(--error)'; }
}
} catch (err) {
const resultEl = document.getElementById('settSaveResult');
if (resultEl) { resultEl.textContent = 'Eroare: ' + err.message; resultEl.style.color = '#dc2626'; }
if (resultEl) { resultEl.textContent = 'Eroare: ' + err.message; resultEl.style.color = 'var(--error)'; }
}
}

View File

@@ -194,7 +194,7 @@ function renderMobileSegmented(containerId, pills, onSelect) {
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 cls = p.active ? 'btn seg-active' : '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>`;
@@ -344,6 +344,40 @@ async function saveQuickMapping() {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('quickMapModal')).hide();
if (_qmOnSave) _qmOnSave(sku, mappings);
// Check for SKIPPED orders that can now be imported
try {
const pendingRes = await fetch(`/api/orders/by-sku/${encodeURIComponent(sku)}/pending`);
const pendingData = await pendingRes.json();
if (pendingData.count > 0) {
const banner = document.createElement('div');
banner.className = 'alert alert-info d-flex align-items-center gap-2 mt-2';
banner.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);z-index:1060;min-width:300px;max-width:500px;box-shadow:var(--card-shadow)';
banner.innerHTML = `<i class="bi bi-arrow-clockwise"></i> <span>${pendingData.count} comenzi SKIPPED pot fi importate acum</span> <button class="btn btn-sm btn-primary ms-auto" id="batchRetryBtn">Importa</button> <button class="btn btn-sm btn-outline-secondary" onclick="this.parentElement.remove()">✕</button>`;
document.body.appendChild(banner);
document.getElementById('batchRetryBtn').onclick = async function() {
this.disabled = true;
this.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const retryRes = await fetch('/api/orders/batch-retry', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({order_numbers: pendingData.order_numbers})
});
const retryData = await retryRes.json();
banner.className = retryData.errors > 0 ? 'alert alert-warning d-flex align-items-center gap-2 mt-2' : 'alert alert-success d-flex align-items-center gap-2 mt-2';
banner.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);z-index:1060;min-width:300px;max-width:500px;box-shadow:var(--card-shadow)';
banner.innerHTML = `<i class="bi bi-check-circle"></i> ${esc(retryData.message)} <button class="btn btn-sm btn-outline-secondary ms-auto" onclick="this.parentElement.remove()">✕</button>`;
setTimeout(() => banner.remove(), 5000);
if (typeof loadDashOrders === 'function') loadDashOrders();
} catch(e) {
banner.innerHTML = `Eroare: ${esc(e.message)} <button class="btn btn-sm btn-outline-secondary ms-auto" onclick="this.parentElement.remove()">✕</button>`;
}
};
setTimeout(() => { if (banner.parentElement) banner.remove(); }, 15000);
}
} catch(e) { /* ignore */ }
} else {
alert('Eroare: ' + (data.error || 'Unknown'));
}
@@ -352,6 +386,415 @@ async function saveQuickMapping() {
}
}
// ── Shared helpers (moved from dashboard.js/logs.js) ─
function fmtCost(v) {
return v > 0 ? Number(v).toFixed(2) : '';
}
function fmtNum(v) {
return Number(v).toLocaleString('ro-RO', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function orderStatusBadge(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return '<span class="badge bg-success">Importat</span>';
case 'ALREADY_IMPORTED': return '<span class="badge bg-info">Deja importat</span>';
case 'SKIPPED': return '<span class="badge bg-warning">Omis</span>';
case 'ERROR': return '<span class="badge bg-danger">Eroare</span>';
case 'CANCELLED': return '<span class="badge bg-secondary">Anulat</span>';
case 'DELETED_IN_ROA': return '<span class="badge bg-dark">Sters din ROA</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
}
}
function renderCodmatCell(item) {
if (!item.codmat_details || item.codmat_details.length === 0) {
return `<code>${esc(item.codmat || '-')}</code>`;
}
if (item.codmat_details.length === 1) {
const d = item.codmat_details[0];
if (d.direct) {
return `<code>${esc(d.codmat)}</code> <span class="badge bg-secondary" style="font-size:0.6rem;vertical-align:middle">direct</span>`;
}
return `<code>${esc(d.codmat)}</code>`;
}
return item.codmat_details.map(d =>
`<div class="small"><code>${esc(d.codmat)}</code> <span class="text-muted">\xd7${d.cantitate_roa}</span></div>`
).join('');
}
function computeDiscountSplit(items, order) {
if (order.discount_split && typeof order.discount_split === 'object')
return order.discount_split;
const byRate = {};
items.forEach(item => {
const rate = item.vat != null ? Number(item.vat) : null;
if (rate === null) return;
if (!byRate[rate]) byRate[rate] = 0;
byRate[rate] += Number(item.price || 0) * Number(item.quantity || 0);
});
const rates = Object.keys(byRate).sort((a, b) => Number(a) - Number(b));
if (rates.length === 0) return null;
const grandTotal = rates.reduce((s, r) => s + byRate[r], 0);
if (grandTotal <= 0) return null;
const split = {};
let remaining = order.discount_total;
rates.forEach((rate, i) => {
if (i === rates.length - 1) {
split[rate] = Math.round(remaining * 100) / 100;
} else {
const amt = Math.round(order.discount_total * byRate[rate] / grandTotal * 100) / 100;
split[rate] = amt;
remaining -= amt;
}
});
return split;
}
function _renderReceipt(items, order) {
const desktop = document.getElementById('detailReceipt');
const mobile = document.getElementById('detailReceiptMobile');
if (!desktop && !mobile) return;
if (!items.length) {
if (desktop) desktop.innerHTML = '';
if (mobile) mobile.innerHTML = '';
return;
}
const articole = items.reduce((s, i) => s + Number(i.price || 0) * Number(i.quantity || 0), 0);
const discount = Number(order.discount_total || 0);
const transport = Number(order.delivery_cost || 0);
const total = order.order_total != null ? fmtNum(order.order_total) : '-';
let dHtml = `<span class="text-muted">Articole: <strong class="text-body">${fmtNum(articole)}</strong></span>`;
if (discount > 0) dHtml += `<span class="text-muted">Discount: <strong class="text-danger">\u2013${fmtNum(discount)}</strong></span>`;
if (transport > 0) dHtml += `<span class="text-muted">Transport: <strong class="text-body">${fmtNum(transport)}</strong></span>`;
dHtml += `<span>Total: <strong>${total} lei</strong></span>`;
if (desktop) desktop.innerHTML = dHtml;
let mHtml = `<span class="text-muted">Art: <strong class="text-body">${fmtNum(articole)}</strong></span>`;
if (discount > 0) mHtml += `<span class="text-muted">Disc: <strong class="text-danger">\u2013${fmtNum(discount)}</strong></span>`;
if (transport > 0) mHtml += `<span class="text-muted">Transp: <strong class="text-body">${fmtNum(transport)}</strong></span>`;
mHtml += `<span>Total: <strong>${total} lei</strong></span>`;
if (mobile) mobile.innerHTML = mHtml;
}
// ── Order Detail Modal (shared) ──────────────────
/**
* Render and show the order detail modal.
* @param {string} orderNumber
* @param {object} opts
* @param {function} opts.onQuickMap - (sku, productName, orderNumber, itemIdx) => void
* @param {function} [opts.onAfterRender] - (order, items) => void
*/
async function renderOrderDetailModal(orderNumber, opts) {
opts = opts || {};
// Reset modal state
document.getElementById('detailOrderNumber').textContent = '#' + orderNumber;
document.getElementById('detailCustomer').textContent = '...';
document.getElementById('detailDate').textContent = '';
document.getElementById('detailStatus').innerHTML = '';
document.getElementById('detailIdComanda').textContent = '-';
document.getElementById('detailIdPartener').textContent = '-';
document.getElementById('detailIdAdresaFact').textContent = '-';
document.getElementById('detailIdAdresaLivr').textContent = '-';
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="9" class="text-center">Se incarca...</td></tr>';
document.getElementById('detailError').style.display = 'none';
const retryBtn = document.getElementById('detailRetryBtn');
if (retryBtn) { retryBtn.style.display = 'none'; retryBtn.disabled = false; retryBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Reimporta'; retryBtn.className = 'btn btn-sm btn-outline-primary'; }
const receiptEl = document.getElementById('detailReceipt');
if (receiptEl) receiptEl.innerHTML = '';
const receiptMEl = document.getElementById('detailReceiptMobile');
if (receiptMEl) receiptMEl.innerHTML = '';
const invInfo = document.getElementById('detailInvoiceInfo');
if (invInfo) invInfo.style.display = 'none';
const mobileContainer = document.getElementById('detailItemsMobile');
if (mobileContainer) mobileContainer.innerHTML = '';
const priceCheckEl = document.getElementById('detailPriceCheck');
if (priceCheckEl) priceCheckEl.innerHTML = '';
const reconEl = document.getElementById('detailInvoiceRecon');
if (reconEl) { reconEl.innerHTML = ''; reconEl.style.display = 'none'; }
const modalEl = document.getElementById('orderDetailModal');
const existing = bootstrap.Modal.getInstance(modalEl);
if (existing) { existing.show(); } else { new bootstrap.Modal(modalEl).show(); }
try {
const res = await fetch(`/api/sync/order/${encodeURIComponent(orderNumber)}`);
const data = await res.json();
if (data.error) {
document.getElementById('detailError').textContent = data.error;
document.getElementById('detailError').style.display = '';
return;
}
const order = data.order || {};
document.getElementById('detailCustomer').textContent = order.customer_name || '-';
document.getElementById('detailDate').textContent = fmtDate(order.order_date);
document.getElementById('detailStatus').innerHTML = orderStatusBadge(order.status);
// Price check badge
const priceCheckEl = document.getElementById('detailPriceCheck');
if (priceCheckEl) {
const pc = order.price_check;
if (!pc || pc.oracle_available === false) {
priceCheckEl.innerHTML = '<span class="badge" style="background:var(--cancelled-light);color:var(--text-muted)">Preturi ROA indisponibile</span>';
} else if (pc.mismatches === 0) {
priceCheckEl.innerHTML = '<span class="badge" style="background:var(--success-light);color:var(--success-text)">✓ Preturi OK</span>';
} else {
priceCheckEl.innerHTML = `<span class="badge" style="background:var(--error-light);color:var(--error-text)">${pc.mismatches} diferente de pret</span>`;
}
}
document.getElementById('detailIdComanda').textContent = order.id_comanda || '-';
document.getElementById('detailIdPartener').textContent = order.id_partener || '-';
document.getElementById('detailIdAdresaFact').textContent = order.id_adresa_facturare || '-';
document.getElementById('detailIdAdresaLivr').textContent = order.id_adresa_livrare || '-';
// Invoice info
const inv = order.invoice;
if (inv && inv.facturat) {
const serie = inv.serie_act || '';
const numar = inv.numar_act || '';
document.getElementById('detailInvoiceNumber').textContent = serie ? `${serie} ${numar}` : numar;
document.getElementById('detailInvoiceDate').textContent = inv.data_act ? fmtDate(inv.data_act) : '-';
if (invInfo) invInfo.style.display = '';
}
// Invoice reconciliation
const reconEl = document.getElementById('detailInvoiceRecon');
if (reconEl && inv && inv.reconciliation) {
const r = inv.reconciliation;
if (r.match) {
reconEl.innerHTML = `<span class="badge" style="background:var(--success-light);color:var(--success-text)">✓ Total factura OK (${fmtNum(r.invoice_total)} lei)</span>`;
} else {
const sign = r.difference > 0 ? '+' : '';
reconEl.innerHTML = `<span class="badge" style="background:var(--error-light);color:var(--error-text)">Diferenta: ${sign}${fmtNum(r.difference)} lei</span>
<small class="text-muted ms-2">Factura: ${fmtNum(r.invoice_total)} | Comanda: ${fmtNum(r.order_total)}</small>`;
}
reconEl.style.display = '';
} else if (reconEl) {
reconEl.style.display = 'none';
}
if (order.error_message) {
document.getElementById('detailError').textContent = order.error_message;
document.getElementById('detailError').style.display = '';
}
const items = data.items || [];
if (items.length === 0) {
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="9" class="text-center text-muted">Niciun articol</td></tr>';
return;
}
// Store items for quick map pre-population
window._detailItems = items;
const qmFn = opts.onQuickMap ? opts.onQuickMap.name || '_sharedQuickMap' : null;
// Mobile article flat list
if (mobileContainer) {
let mobileHtml = items.map((item, idx) => {
const codmatText = item.codmat_details?.length
? item.codmat_details.map(d => `<code>${esc(d.codmat)}</code>${d.direct ? ' <span class="badge bg-secondary" style="font-size:0.55rem">direct</span>' : ''}`).join(' ')
: `<code>${esc(item.codmat || '')}</code>`;
const valoare = (Number(item.price || 0) * Number(item.quantity || 0));
const clickAttr = opts.onQuickMap ? `onclick="_sharedModalQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}',${idx})"` : '';
const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
const priceMismatchHtml = priceInfo.match === false
? `<div class="text-danger" style="font-size:0.7rem">ROA: ${fmtNum(priceInfo.pret_roa)} lei</div>`
: '';
return `<div class="dif-item">
<div class="dif-row">
<span class="dif-sku${opts.onQuickMap ? ' dif-codmat-link' : ''}" ${clickAttr}>${esc(item.sku)}</span>
${codmatText}
</div>
<div class="dif-row">
<span class="dif-name">${esc(item.product_name || '')}</span>
<span class="dif-qty">x${item.quantity || 0}</span>
<span class="dif-val">${fmtNum(valoare)} lei</span>
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
</div>
${priceMismatchHtml}
</div>`;
}).join('');
// Transport row (mobile)
if (order.delivery_cost > 0) {
const tVat = order.transport_vat || '21';
mobileHtml += `<div class="dif-item" style="opacity:0.7">
<div class="dif-row">
<span class="dif-name text-muted">Transport</span>
<span class="dif-qty">x1</span>
<span class="dif-val">${fmtNum(order.delivery_cost)} lei</span>
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${tVat}</span>
</div>
</div>`;
}
// Discount rows (mobile)
if (order.discount_total > 0) {
const discSplit = computeDiscountSplit(items, order);
if (discSplit) {
Object.entries(discSplit)
.sort(([a], [b]) => Number(a) - Number(b))
.forEach(([rate, amt]) => {
if (amt > 0) mobileHtml += `<div class="dif-item" style="opacity:0.7">
<div class="dif-row">
<span class="dif-name text-muted">Discount</span>
<span class="dif-qty">x\u20131</span>
<span class="dif-val">${fmtNum(amt)} lei</span>
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${Number(rate)}</span>
</div>
</div>`;
});
} else {
mobileHtml += `<div class="dif-item" style="opacity:0.7">
<div class="dif-row">
<span class="dif-name text-muted">Discount</span>
<span class="dif-qty">x\u20131</span>
<span class="dif-val">${fmtNum(order.discount_total)} lei</span>
</div>
</div>`;
}
}
mobileContainer.innerHTML = '<div class="detail-item-flat">' + mobileHtml + '</div>';
}
// Desktop items table
const clickAttrFn = (item, idx) => opts.onQuickMap
? `onclick="_sharedModalQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare"`
: '';
let tableHtml = items.map((item, idx) => {
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
const priceInfo = { pret_roa: item.pret_roa, match: item.price_match };
const pretRoaHtml = priceInfo.pret_roa != null ? fmtNum(priceInfo.pret_roa) : '';
let matchDot, rowStyle;
if (priceInfo.pret_roa == null && priceInfo.match == null) {
matchDot = '<span class="dot dot-gray"></span>';
rowStyle = '';
} else if (priceInfo.match === false) {
matchDot = '<span class="dot dot-red"></span>';
rowStyle = ' style="background:var(--error-light)"';
} else {
matchDot = '<span class="dot dot-green"></span>';
rowStyle = '';
}
return `<tr${rowStyle}>
<td><code class="${opts.onQuickMap ? 'codmat-link' : ''}" ${clickAttrFn(item, idx)}>${esc(item.sku)}</code></td>
<td>${esc(item.product_name || '-')}</td>
<td>${renderCodmatCell(item)}</td>
<td class="text-end">${item.quantity || 0}</td>
<td class="text-end font-data">${item.price != null ? fmtNum(item.price) : '-'}</td>
<td class="text-end font-data">${pretRoaHtml}</td>
<td class="text-end">${item.vat != null ? Number(item.vat) : '-'}</td>
<td class="text-end font-data">${fmtNum(valoare)}</td>
<td class="text-center">${matchDot}</td>
</tr>`;
}).join('');
// Transport row
if (order.delivery_cost > 0) {
const tVat = order.transport_vat || '21';
const tCodmat = order.transport_codmat || '';
tableHtml += `<tr class="table-light">
<td></td><td class="text-muted">Transport</td>
<td>${tCodmat ? '<code>' + esc(tCodmat) + '</code>' : ''}</td>
<td class="text-end">1</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
<td></td>
<td class="text-end">${tVat}</td><td class="text-end font-data">${fmtNum(order.delivery_cost)}</td>
<td></td>
</tr>`;
}
// Discount rows (split by VAT rate)
if (order.discount_total > 0) {
const dCodmat = order.discount_codmat || '';
const discSplit = computeDiscountSplit(items, order);
if (discSplit) {
Object.entries(discSplit)
.sort(([a], [b]) => Number(a) - Number(b))
.forEach(([rate, amt]) => {
if (amt > 0) tableHtml += `<tr class="table-light">
<td></td><td class="text-muted">Discount</td>
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
<td class="text-end">\u20131</td><td class="text-end font-data">${fmtNum(amt)}</td>
<td></td>
<td class="text-end">${Number(rate)}</td><td class="text-end font-data">\u2013${fmtNum(amt)}</td>
<td></td>
</tr>`;
});
} else {
tableHtml += `<tr class="table-light">
<td></td><td class="text-muted">Discount</td>
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
<td class="text-end">\u20131</td><td class="text-end font-data">${fmtNum(order.discount_total)}</td>
<td></td>
<td class="text-end">-</td><td class="text-end font-data">\u2013${fmtNum(order.discount_total)}</td>
<td></td>
</tr>`;
}
}
document.getElementById('detailItemsBody').innerHTML = tableHtml;
_renderReceipt(items, order);
// Retry button (only for ERROR/SKIPPED orders)
const retryBtn = document.getElementById('detailRetryBtn');
if (retryBtn) {
const canRetry = ['ERROR', 'SKIPPED'].includes((order.status || '').toUpperCase());
retryBtn.style.display = canRetry ? '' : 'none';
if (canRetry) {
retryBtn.onclick = async () => {
retryBtn.disabled = true;
retryBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Reimportare...';
try {
const res = await fetch(`/api/orders/${encodeURIComponent(orderNumber)}/retry`, { method: 'POST' });
const data = await res.json();
if (data.success) {
retryBtn.innerHTML = '<i class="bi bi-check-circle"></i> ' + (data.message || 'Reimportat');
retryBtn.className = 'btn btn-sm btn-success';
// Refresh modal after short delay
setTimeout(() => renderOrderDetailModal(orderNumber, opts), 1500);
} else {
retryBtn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> ' + (data.message || 'Eroare');
retryBtn.className = 'btn btn-sm btn-danger';
setTimeout(() => {
retryBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Reimporta';
retryBtn.className = 'btn btn-sm btn-outline-primary';
retryBtn.disabled = false;
}, 3000);
}
} catch (err) {
retryBtn.innerHTML = 'Eroare: ' + err.message;
retryBtn.disabled = false;
}
};
}
}
if (opts.onAfterRender) opts.onAfterRender(order, items);
} catch (err) {
document.getElementById('detailError').textContent = err.message;
document.getElementById('detailError').style.display = '';
}
}
// Global quick map dispatcher — set by each page
let _sharedModalQuickMapFn = null;
function _sharedModalQuickMap(sku, productName, orderNumber, itemIdx) {
if (_sharedModalQuickMapFn) _sharedModalQuickMapFn(sku, productName, orderNumber, itemIdx);
}
// ── Dot helper ────────────────────────────────────
function statusDot(status) {
switch ((status || '').toUpperCase()) {

View File

@@ -1,16 +1,28 @@
<!DOCTYPE html>
<html lang="ro" style="color-scheme: light">
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}GoMag Import Manager{% endblock %}</title>
<!-- FOUC prevention: apply saved theme before any rendering -->
<script>
try {
var t = localStorage.getItem('theme');
if (!t) t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
} catch(e) {}
</script>
<!-- Fonts (DESIGN.md) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
{% set rp = request.scope.get('root_path', '') %}
<link href="{{ rp }}/static/css/style.css?v=17" rel="stylesheet">
<link href="{{ rp }}/static/css/style.css?v=25" rel="stylesheet">
</head>
<body>
<!-- Top Navbar -->
<!-- Top Navbar (hidden on mobile via CSS) -->
<nav class="top-navbar">
<div class="navbar-brand">GoMag Import</div>
<div class="navbar-links">
@@ -20,10 +32,22 @@
<a href="{{ rp }}/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>
<a href="{{ rp }}/settings" class="nav-tab {% block nav_settings %}{% endblock %}"><span class="d-none d-md-inline">Setari</span><span class="d-md-none">Setari</span></a>
</div>
<button class="dark-toggle" onclick="toggleDarkMode()" title="Comuta tema" aria-label="Comuta tema intunecata">
<i class="bi bi-sun-fill"></i>
</button>
</nav>
<!-- Bottom Nav (mobile only, shown via CSS) -->
<nav class="bottom-nav">
<a href="{{ rp }}/" class="bottom-nav-item {% block bnav_dashboard %}{% endblock %}"><i class="bi bi-speedometer2"></i><span>Dashboard</span></a>
<a href="{{ rp }}/mappings" class="bottom-nav-item {% block bnav_mappings %}{% endblock %}"><i class="bi bi-arrow-left-right"></i><span>Mapari</span></a>
<a href="{{ rp }}/missing-skus" class="bottom-nav-item {% block bnav_missing %}{% endblock %}"><i class="bi bi-exclamation-triangle"></i><span>Lipsa</span></a>
<a href="{{ rp }}/logs" class="bottom-nav-item {% block bnav_logs %}{% endblock %}"><i class="bi bi-journal-text"></i><span>Jurnale</span></a>
<a href="{{ rp }}/settings" class="bottom-nav-item {% block bnav_settings %}{% endblock %}"><i class="bi bi-gear"></i><span>Setari</span></a>
</nav>
<!-- Main content -->
<main class="main-content">
<main class="main-content {% block main_class %}{% endblock %}">
{% block content %}{% endblock %}
</main>
@@ -39,7 +63,7 @@
<div style="margin-bottom:8px; font-size:0.85rem">
<small class="text-muted">Produs:</small> <strong id="qmProductName"></strong>
</div>
<div class="qm-row" style="font-size:0.7rem; color:#9ca3af; padding:0 0 2px">
<div class="qm-row" style="font-size:0.7rem; color:var(--text-muted); padding:0 0 2px">
<span style="flex:1">CODMAT</span>
<span style="width:70px">Cant.</span>
<span style="width:30px"></span>
@@ -59,9 +83,88 @@
</div>
</div>
<!-- Shared Order Detail Modal -->
<div class="modal fade" id="orderDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Comanda <code id="detailOrderNumber"></code></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
<small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
<small class="text-muted">Status:</small> <span id="detailStatus"></span><span id="detailPriceCheck" class="ms-2"></span>
</div>
<div class="col-md-6">
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
<small class="text-muted">ID Partener:</small> <span id="detailIdPartener">-</span><br>
<small class="text-muted">ID Adr. Facturare:</small> <span id="detailIdAdresaFact">-</span><br>
<small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
<div id="detailInvoiceInfo" style="display:none; margin-top:4px;">
<small class="text-muted">Factura:</small> <span id="detailInvoiceNumber"></span>
<span class="ms-2"><small class="text-muted">din</small> <span id="detailInvoiceDate"></span></span>
<div id="detailInvoiceRecon" class="mt-1" style="display:none"></div>
</div>
</div>
</div>
<div class="table-responsive d-none d-md-block">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>SKU</th>
<th>Produs</th>
<th>CODMAT</th>
<th class="text-end">Cant.</th>
<th class="text-end">Pret GoMag</th>
<th class="text-end">Pret ROA</th>
<th class="text-end">TVA%</th>
<th class="text-end">Valoare</th>
<th class="text-center"></th>
</tr>
</thead>
<tbody id="detailItemsBody">
</tbody>
</table>
<div id="detailReceipt" class="d-flex flex-wrap gap-2 mt-1 justify-content-end"></div>
</div>
<div class="d-md-none" id="detailItemsMobile"></div>
<div id="detailReceiptMobile" class="d-flex flex-wrap gap-2 mt-1 d-md-none justify-content-end"></div>
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
</div>
<div class="modal-footer">
<button type="button" id="detailRetryBtn" class="btn btn-sm btn-outline-primary" style="display:none"><i class="bi bi-arrow-clockwise"></i> Reimporta</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
</div>
</div>
</div>
</div>
<script>window.ROOT_PATH = "{{ rp }}";</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ rp }}/static/js/shared.js?v=12"></script>
<script src="{{ rp }}/static/js/shared.js?v=20"></script>
<script>
// Dark mode toggle
function toggleDarkMode() {
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
var newTheme = isDark ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
try { localStorage.setItem('theme', newTheme); } catch(e) {}
updateDarkToggleIcon();
// Sync settings page toggle if present
var settToggle = document.getElementById('settDarkMode');
if (settToggle) settToggle.checked = (newTheme === 'dark');
}
function updateDarkToggleIcon() {
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
document.querySelectorAll('.dark-toggle i').forEach(function(el) {
el.className = isDark ? 'bi bi-moon-fill' : 'bi bi-sun-fill';
});
}
updateDarkToggleIcon();
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,10 +1,13 @@
{% extends "base.html" %}
{% block title %}Dashboard - GoMag Import{% endblock %}
{% block nav_dashboard %}active{% endblock %}
{% block bnav_dashboard %}active{% endblock %}
{% block content %}
<h4 class="mb-4">Panou de Comanda</h4>
<div id="welcomeCard" style="display:none"></div>
<!-- Sync Card (unified two-row panel) -->
<div class="sync-card">
<!-- TOP ROW: Status + Controls -->
@@ -48,19 +51,17 @@
<span>Comenzi</span>
</div>
<div class="card-body py-2 px-3">
<div id="attentionCard"></div>
<div class="filter-bar" id="ordersFilterBar">
<!-- Period dropdown -->
<select id="periodSelect" class="select-compact">
<option value="1">1 zi</option>
<option value="2">2 zile</option>
<option value="3">3 zile</option>
<option value="7" selected>7 zile</option>
<option value="30">30 zile</option>
<option value="90">3 luni</option>
<option value="0">Toate</option>
<option value="custom">Perioada personalizata...</option>
</select>
<!-- Custom date range (hidden until 'custom' selected) -->
<!-- Period preset buttons -->
<div class="period-presets">
<button class="preset-btn" data-days="1">Azi</button>
<button class="preset-btn active" data-days="3">3 zile</button>
<button class="preset-btn" data-days="7">7 zile</button>
<button class="preset-btn" data-days="30">30 zile</button>
<button class="preset-btn" data-days="custom">Custom</button>
</div>
<!-- Custom date range (hidden until 'Custom' clicked) -->
<div class="period-custom-range" id="customRangeInputs">
<input type="date" id="periodStart" class="select-compact">
<span>&#8212;</span>
@@ -77,8 +78,8 @@
<button class="filter-pill d-none d-md-inline-flex" data-status="CANCELLED">Anulate <span class="filter-count fc-dark" id="cntCanc">0</span></button>
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" id="btnRefreshInvoices" onclick="refreshInvoices()" title="Actualizeaza status facturi din Oracle">&#8635;</button>
</div>
<div class="d-md-none mb-2 d-flex align-items-center gap-2">
<div class="flex-grow-1" id="dashMobileSeg"></div>
<div class="d-md-none mb-2 d-flex align-items-center gap-2" style="max-width:100%;overflow:hidden">
<div class="flex-grow-1" id="dashMobileSeg" style="min-width:0;overflow-x:auto"></div>
<button class="btn btn-sm btn-outline-secondary" id="btnRefreshInvoicesMobile" onclick="refreshInvoices()" title="Actualizeaza facturi" style="padding:4px 8px; font-size:1rem; line-height:1">&#8635;</button>
</div>
</div>
@@ -98,10 +99,11 @@
<th class="text-end">Discount</th>
<th class="text-end">Total</th>
<th style="width:28px" title="Facturat">F</th>
<th class="text-center" style="width:30px" title="Preturi ROA"></th>
</tr>
</thead>
<tbody id="dashOrdersBody">
<tr><td colspan="9" class="text-center text-muted py-3">Se incarca...</td></tr>
<tr><td colspan="10" class="text-center text-muted py-3">Se incarca...</td></tr>
</tbody>
</table>
</div>
@@ -109,64 +111,8 @@
<div id="dashPagination" class="pag-strip pag-strip-bottom"></div>
</div>
<!-- Order Detail Modal -->
<div class="modal fade" id="orderDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Comanda <code id="detailOrderNumber"></code></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
<small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
<small class="text-muted">Status:</small> <span id="detailStatus"></span>
</div>
<div class="col-md-6">
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
<small class="text-muted">ID Partener:</small> <span id="detailIdPartener">-</span><br>
<small class="text-muted">ID Adr. Facturare:</small> <span id="detailIdAdresaFact">-</span><br>
<small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
<div id="detailInvoiceInfo" style="display:none; margin-top:4px;">
<small class="text-muted">Factura:</small> <span id="detailInvoiceNumber"></span>
<span class="ms-2"><small class="text-muted">din</small> <span id="detailInvoiceDate"></span></span>
</div>
</div>
</div>
<div class="table-responsive d-none d-md-block">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>SKU</th>
<th>Produs</th>
<th>CODMAT</th>
<th class="text-end">Cant.</th>
<th class="text-end">Pret</th>
<th class="text-end">TVA%</th>
<th class="text-end">Valoare</th>
</tr>
</thead>
<tbody id="detailItemsBody">
</tbody>
</table>
<div id="detailReceipt" class="d-flex flex-wrap gap-2 mt-1 justify-content-end"></div>
</div>
<div class="d-md-none" id="detailItemsMobile"></div>
<div id="detailReceiptMobile" class="d-flex flex-wrap gap-2 mt-1 d-md-none justify-content-end"></div>
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
</div>
</div>
</div>
</div>
<!-- Quick Map Modal (used from order detail) -->
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=25"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=32"></script>
{% endblock %}

View File

@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block title %}Jurnale Import - GoMag Import{% endblock %}
{% block nav_logs %}active{% endblock %}
{% block bnav_logs %}active{% endblock %}
{% block content %}
<h4 class="mb-4">Jurnale Import</h4>
@@ -56,7 +57,7 @@
<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>
<div class="d-md-none mb-2" id="logsMobileSeg" style="overflow-x:auto"></div>
<!-- Orders table -->
<div class="card mb-3">
@@ -96,65 +97,10 @@
</div>
</div>
<!-- Order Detail Modal -->
<div class="modal fade" id="orderDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Comanda <code id="detailOrderNumber"></code></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<small class="text-muted">Client:</small> <strong id="detailCustomer"></strong><br>
<small class="text-muted">Data comanda:</small> <span id="detailDate"></span><br>
<small class="text-muted">Status:</small> <span id="detailStatus"></span>
</div>
<div class="col-md-6">
<small class="text-muted">ID Comanda ROA:</small> <span id="detailIdComanda">-</span><br>
<small class="text-muted">ID Partener:</small> <span id="detailIdPartener">-</span><br>
<small class="text-muted">ID Adr. Facturare:</small> <span id="detailIdAdresaFact">-</span><br>
<small class="text-muted">ID Adr. Livrare:</small> <span id="detailIdAdresaLivr">-</span>
</div>
</div>
<div id="detailTotals" class="d-flex gap-3 mb-2 flex-wrap" style="font-size:0.875rem">
<span><small class="text-muted">Valoare:</small> <strong id="detailItemsTotal">-</strong></span>
<span id="detailDiscountWrap"><small class="text-muted">Discount:</small> <strong id="detailDiscount">-</strong></span>
<span id="detailDeliveryWrap"><small class="text-muted">Transport:</small> <strong id="detailDeliveryCost">-</strong></span>
<span><small class="text-muted">Total:</small> <strong id="detailOrderTotal">-</strong></span>
</div>
<div class="table-responsive d-none d-md-block">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>SKU</th>
<th>Produs</th>
<th>CODMAT</th>
<th>Cant.</th>
<th>Pret</th>
<th class="text-end">Valoare</th>
</tr>
</thead>
<tbody id="detailItemsBody">
</tbody>
</table>
</div>
<div class="d-md-none" id="detailItemsMobile"></div>
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Inchide</button>
</div>
</div>
</div>
</div>
<!-- Quick Map Modal (used from order detail) -->
<!-- Hidden field for pre-selected run from URL/server -->
<input type="hidden" id="preselectedRun" value="{{ selected_run }}">
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=11"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=14"></script>
{% endblock %}

View File

@@ -1,15 +1,24 @@
{% extends "base.html" %}
{% block title %}Mapari SKU - GoMag Import{% endblock %}
{% block nav_mappings %}active{% endblock %}
{% block bnav_mappings %}active{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Mapari SKU</h4>
<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>
<!-- Desktop Import/Export dropdown -->
<div class="dropdown d-none d-md-inline-block">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-file-earmark-spreadsheet"></i> Import/Export
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" onclick="downloadTemplate(); return false"><i class="bi bi-file-earmark-arrow-down me-1"></i> Download 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><hr class="dropdown-divider"></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>
</ul>
</div>
<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 -->
@@ -150,5 +159,5 @@
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=11"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=14"></script>
{% endblock %}

View File

@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block title %}SKU-uri Lipsa - GoMag Import{% endblock %}
{% block nav_missing %}active{% endblock %}
{% block bnav_missing %}active{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">

View File

@@ -1,10 +1,23 @@
{% extends "base.html" %}
{% block title %}Setari - GoMag Import{% endblock %}
{% block nav_settings %}active{% endblock %}
{% block bnav_settings %}active{% endblock %}
{% block main_class %}constrained{% endblock %}
{% block content %}
<h4 class="mb-3">Setari</h4>
<!-- Dark mode toggle -->
<div class="theme-toggle-card">
<div>
<i class="bi bi-moon-fill me-2"></i>
<label for="settDarkMode">Mod intunecat</label>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch" id="settDarkMode" style="width:2.5rem;height:1.25rem">
</div>
</div>
<div class="row g-3 mb-3">
<!-- GoMag API card -->
<div class="col-md-6">
@@ -144,6 +157,11 @@
</div>
</div>
<div class="mt-4">
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#advancedSettings" aria-expanded="false" title="Modificati doar la indicatia echipei tehnice">
<i class="bi bi-gear"></i> Setari avansate
</button>
<div class="collapse mt-2" id="advancedSettings">
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="card h-100">
@@ -224,6 +242,8 @@
</div>
</div>
</div>
</div>
</div>
<div class="mb-3">
<button class="btn btn-primary btn-sm" onclick="saveSettings()">Salvează Setările</button>
@@ -233,5 +253,5 @@
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=7"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/settings.js?v=9"></script>
{% endblock %}

View File

@@ -1,74 +1,3 @@
-- ====================================================================
-- PACK_IMPORT_COMENZI
-- Package pentru importul comenzilor din platforme web (GoMag, etc.)
-- in sistemul ROA Oracle.
--
-- Dependinte:
-- Packages: PACK_COMENZI (adauga_comanda, adauga_articol_comanda)
-- pljson (pljson_list, pljson) - instalat in CONTAFIN_ORACLE,
-- accesat prin PUBLIC SYNONYM
-- Tabele: ARTICOLE_TERTI (mapari SKU -> CODMAT)
-- NOM_ARTICOLE (nomenclator articole ROA)
-- COMENZI (verificare duplicat comanda_externa)
-- CRM_POLITICI_PRETURI (flag PRETURI_CU_TVA per politica)
-- CRM_POLITICI_PRET_ART (preturi componente kituri)
--
-- Proceduri publice:
--
-- importa_comanda(...)
-- Importa o comanda completa: creeaza comanda + adauga articolele.
-- p_json_articole accepta:
-- - array JSON: [{"sku":"X","quantity":"1","price":"10","vat":"19"}, ...]
-- - obiect JSON: {"sku":"X","quantity":"1","price":"10","vat":"19"}
-- Optional per articol: "id_pol":"5" — politica de pret specifica
-- (pentru transport/discount cu politica separata de cea a comenzii)
-- Valorile sku, quantity, price, vat sunt extrase ca STRING si convertite.
-- Daca comanda exista deja (comanda_externa), nu se dubleaza.
-- La eroare ridica RAISE_APPLICATION_ERROR(-20001, mesaj).
-- Returneaza v_id_comanda (OUT) = ID-ul comenzii create.
--
-- Parametri kit pricing:
-- p_kit_mode — 'distributed' | 'separate_line' | NULL
-- distributed: discountul fata de suma componentelor se distribuie
-- proportional in pretul fiecarei componente
-- separate_line: componentele se insereaza la pret plin +
-- linii discount separate per-kit sub componente, grupate pe cota TVA
-- p_id_pol_productie — politica de pret pentru articole de productie
-- (cont in 341/345); NULL = nu se foloseste
-- p_kit_discount_codmat — CODMAT-ul articolului discount (Mode separate_line)
-- p_kit_discount_id_pol — id_pol pentru liniile discount (Mode separate_line)
--
-- Logica cautare articol per SKU:
-- 1. Mapari speciale din ARTICOLE_TERTI (reimpachetare, seturi compuse)
-- - daca SKU are >1 rand si p_kit_mode IS NOT NULL: kit pricing logic
-- - altfel (1 rand sau kit_mode NULL): pret web / cantitate_roa direct
-- 2. Fallback: cautare directa in NOM_ARTICOLE dupa CODMAT = SKU
--
-- get_last_error / clear_error
-- Management erori pentru orchestratorul VFP.
--
-- Exemplu utilizare:
-- DECLARE
-- v_id NUMBER;
-- BEGIN
-- PACK_IMPORT_COMENZI.importa_comanda(
-- p_nr_comanda_ext => '479317993',
-- p_data_comanda => SYSDATE,
-- p_id_partener => 1424,
-- p_json_articole => '[{"sku":"5941623003366","quantity":"1.00","price":"40.99","vat":"21"}]',
-- p_id_pol => 39,
-- v_id_comanda => v_id);
-- DBMS_OUTPUT.PUT_LINE('ID comanda: ' || v_id);
-- END;
-- 20.03.2026 - dual policy vanzare/productie, kit pricing distributed/separate_line, SKU→CODMAT via ARTICOLE_TERTI
-- 20.03.2026 - kit discount deferred cross-kit (separate_line, merge-on-collision)
-- 20.03.2026 - merge_or_insert_articol: merge cantitati cand kit+individual au acelasi articol/pret
-- 20.03.2026 - kit pricing extins pt reambalari single-component (cantitate_roa > 1)
-- 21.03.2026 - diagnostic detaliat discount kit (id_pol, id_art, codmat in eroare)
-- 21.03.2026 - fix discount amount: v_disc_amt e per-kit, nu se imparte la v_cantitate_web
-- 25.03.2026 - skip negative kit discount (markup), ROUND prices to nzecimale_pretv
-- 25.03.2026 - kit discount inserat per-kit sub componente (nu deferred cross-kit)
-- ====================================================================
CREATE OR REPLACE PACKAGE PACK_IMPORT_COMENZI AS
-- Variabila package pentru ultima eroare (pentru orchestrator VFP)
@@ -98,6 +27,31 @@ END PACK_IMPORT_COMENZI;
/
CREATE OR REPLACE PACKAGE BODY PACK_IMPORT_COMENZI AS
-- ====================================================================
-- PACK_IMPORT_COMENZI
-- Package pentru importul comenzilor din platforme web (GoMag, etc.)
-- in sistemul ROA Oracle.
--
-- Dependinte:
-- Packages: PACK_COMENZI (adauga_comanda, adauga_articol_comanda)
-- pljson (pljson_list, pljson) - instalat in CONTAFIN_ORACLE,
-- accesat prin PUBLIC SYNONYM
-- Tabele: ARTICOLE_TERTI (mapari SKU -> CODMAT)
-- NOM_ARTICOLE (nomenclator articole ROA)
-- COMENZI (verificare duplicat comanda_externa)
-- CRM_POLITICI_PRETURI (flag PRETURI_CU_TVA per politica)
-- CRM_POLITICI_PRET_ART (preturi componente kituri)
-- 20.03.2026 - dual policy vanzare/productie, kit pricing distributed/separate_line, SKU→CODMAT via ARTICOLE_TERTI
-- 20.03.2026 - kit discount deferred cross-kit (separate_line, merge-on-collision)
-- 20.03.2026 - merge_or_insert_articol: merge cantitati cand kit+individual au acelasi articol/pret
-- 20.03.2026 - kit pricing extins pt reambalari single-component (cantitate_roa > 1)
-- 21.03.2026 - diagnostic detaliat discount kit (id_pol, id_art, codmat in eroare)
-- 21.03.2026 - fix discount amount: v_disc_amt e per-kit, nu se imparte la v_cantitate_web
-- 25.03.2026 - skip negative kit discount (markup), ROUND prices to nzecimale_pretv
-- 25.03.2026 - kit discount inserat per-kit sub componente (nu deferred cross-kit)
-- ====================================================================
-- Constante pentru configurare
c_id_util CONSTANT NUMBER := -3; -- Sistem
c_interna CONSTANT NUMBER := 2; -- Comenzi de la client (web)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
"""
E2E tests for DESIGN.md migration (Commit 0.5).
Tests: dark toggle, FOUC prevention, bottom nav, active tab amber, dark contrast.
"""
import pytest
pytestmark = [pytest.mark.e2e]
def test_dark_mode_toggle(page, app_url):
"""Dark toggle switches theme and persists in localStorage."""
page.goto(f"{app_url}/settings")
page.wait_for_load_state("networkidle")
# Settings page has the dark mode toggle
toggle = page.locator("#settDarkMode")
assert toggle.is_visible()
# Start in light mode
theme = page.evaluate("document.documentElement.getAttribute('data-theme')")
if theme == "dark":
toggle.click()
page.wait_for_timeout(200)
# Toggle to dark
toggle.click()
page.wait_for_timeout(200)
assert page.evaluate("document.documentElement.getAttribute('data-theme')") == "dark"
assert page.evaluate("localStorage.getItem('theme')") == "dark"
# Toggle back to light
toggle.click()
page.wait_for_timeout(200)
assert page.evaluate("document.documentElement.getAttribute('data-theme')") != "dark"
assert page.evaluate("localStorage.getItem('theme')") == "light"
def test_fouc_prevention(page, app_url):
"""Theme is applied before CSS loads (inline script in <head>)."""
# Set dark theme in localStorage before navigation
page.goto(f"{app_url}/")
page.evaluate("localStorage.setItem('theme', 'dark')")
# Navigate fresh — the inline script should apply dark before paint
page.goto(f"{app_url}/")
# Check immediately (before networkidle) that data-theme is set
theme = page.evaluate("document.documentElement.getAttribute('data-theme')")
assert theme == "dark", "FOUC: dark theme not applied before first paint"
# Cleanup
page.evaluate("localStorage.removeItem('theme')")
def test_bottom_nav_visible_on_mobile(page, app_url):
"""Bottom nav is visible on mobile viewport, top navbar is hidden."""
page.set_viewport_size({"width": 375, "height": 812})
page.goto(f"{app_url}/")
page.wait_for_load_state("networkidle")
bottom_nav = page.locator(".bottom-nav")
top_navbar = page.locator(".top-navbar")
assert bottom_nav.is_visible(), "Bottom nav should be visible on mobile"
assert not top_navbar.is_visible(), "Top navbar should be hidden on mobile"
# Check 5 tabs exist
tabs = page.locator(".bottom-nav-item")
assert tabs.count() == 5
def test_active_tab_amber_accent(page, app_url):
"""Active nav tab uses amber accent color, not blue."""
page.goto(f"{app_url}/")
page.wait_for_load_state("networkidle")
active_tab = page.locator(".nav-tab.active")
assert active_tab.count() >= 1
# Get computed color of active tab
color = page.evaluate("""
() => getComputedStyle(document.querySelector('.nav-tab.active')).color
""")
# Amber #D97706 = rgb(217, 119, 6)
assert "217" in color and "119" in color, f"Active tab color should be amber, got: {color}"
def test_dark_mode_contrast(page, app_url):
"""Dark mode has proper contrast — bg is dark, text is light."""
page.goto(f"{app_url}/")
page.wait_for_load_state("networkidle")
# Enable dark mode
page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')")
page.wait_for_timeout(100)
bg = page.evaluate("getComputedStyle(document.body).backgroundColor")
color = page.evaluate("getComputedStyle(document.body).color")
# bg should be dark (#121212 = rgb(18, 18, 18))
assert "18" in bg, f"Dark mode bg should be dark, got: {bg}"
# text should be light (#E8E4DD = rgb(232, 228, 221))
assert "232" in color or "228" in color, f"Dark mode text should be light, got: {color}"
# Cleanup
page.evaluate("document.documentElement.removeAttribute('data-theme')")

View File

@@ -29,7 +29,7 @@ def test_order_detail_items_table_columns(page: Page, app_url: str):
texts = headers.all_text_contents()
# Current columns (may evolve — check dashboard.html for source of truth)
required_columns = ["SKU", "Produs", "CODMAT", "Cant.", "Pret", "Valoare"]
required_columns = ["SKU", "Produs", "CODMAT", "Cant.", "Pret GoMag", "Pret ROA", "Valoare"]
for col in required_columns:
assert col in texts, f"Column '{col}' missing from order detail items table. Found: {texts}"
@@ -51,5 +51,5 @@ def test_dashboard_navigates_to_logs(page: Page, app_url: str):
page.goto(f"{app_url}/")
page.wait_for_load_state("networkidle")
logs_link = page.locator("a[href='/logs']")
expect(logs_link).to_be_visible()
logs_link = page.locator(".top-navbar a[href='/logs'], .bottom-nav a[href='/logs']")
expect(logs_link.first).to_be_visible()

View File

@@ -89,14 +89,14 @@ def test_responsive_page(
# ---------------------------------------------------------------------------
def test_mobile_navbar_visible(pw_browser, base_url: str):
"""Mobile viewport: navbar should still be visible and functional."""
"""Mobile viewport: bottom nav should be visible (top navbar hidden on mobile)."""
context = pw_browser.new_context(viewport=VIEWPORTS["mobile"])
page = context.new_page()
try:
page.goto(base_url, wait_until="networkidle", timeout=15_000)
# Custom navbar: .top-navbar with .navbar-brand
navbar = page.locator(".top-navbar")
expect(navbar).to_be_visible()
# On mobile, top-navbar is hidden and bottom-nav is shown
bottom_nav = page.locator(".bottom-nav")
expect(bottom_nav).to_be_visible()
finally:
context.close()