feat: M14 Excel import wizard + M15 client approval via tracking link

Top-ROI items from CONFORMITY-12-15.md. Together: ~40h of TZ work
delivered in one pass.

== M14 — Excel/CSV invoice import wizard ==

phpoffice/phpspreadsheet ^5.7 added as composer dep — parses both XLSX
and CSV cleanly.

ExcelInvoiceImportService (app/Services/ExcelInvoiceImportService.php):
- headersPreview($path)          → first 5 rows + detected column letters
- preview($path, $mapping)       → all rows classified as found/new/no_article
- import($supplier, $rows, $createNew=true) → creates Purchase + items,
                                    auto-creates Parts for "new" rows
- rememberMapping / rememberedMappingFor($supplier) — round-trips JSON
  config (article_col / name_col / qty_col / price_col / brand_col? /
  header_row / sheet_name?) per supplier so the second import is
  instant

Decimal parser tolerates European formats: "1 234,56", "1,234.56",
non-breaking spaces (U+00A0 NBSP common in copy-pastes from PDF).
Article matching uses single batch query (Part::whereIn) — O(1) for
the whole sheet, not O(rows).

ExcelImportWizard Filament page (/app/excel-import-wizard) — 4-step
Livewire wizard:
  1. Upload + supplier select (saved mapping auto-loads if exists)
  2. Column mapping with first-3-rows preview table + per-column
     dropdowns
  3. Preview with status badges per row ( Found / ⚠️ New /  Missing)
     + summary counts
  4. Confirmation → "Open Purchase" CTA

Stored in nav group "Stoc & Finanțe", sort 65. Width Full.

Migration: supplier_invoice_mappings (id, company_id, supplier_id UNIQUE,
mapping_config JSON, sample_file_name, last_used_at, timestamps).
Per-tenant scope via BelongsToTenant.

== M15 — Client approval via tracking link (the P0 from TZ §15) ==

Migration: adds 4 columns to wo_works AND wo_parts:
- requires_approval boolean default false
- approved_at timestamp nullable
- approval_token varchar(32) nullable (indexed for fast lookup)
- declined_at timestamp nullable

Both model booted hooks: when a row is saved with requires_approval=true
and no token yet, auto-generate Str::random(24). Models gain
isPendingApproval() helper returning true only while not yet approved
nor declined.

Public route: POST /t/{token}/approve/{kind}/{lineToken}
  kind = 'work' | 'part'
  body: decision = 'approve' | 'decline'
The line's approval_token IS the credential — anyone with the URL can
act. No CSRF token required since this is the unauthed public tracking
flow (the tracking_token + line approval_token combo functions as
shared-secret). Form-encoded POST with csrf_field() on the public form
keeps Laravel happy.

TrackingController::show() now eager-loads works + parts, computes
pendingWorks and pendingParts collections, passes them to the view.
TrackingController::approve() validates kind, locates the line by
(work_order_id, approval_token), idempotently marks approved_at or
declined_at, redirects back to /t/{token} with a flash status.

UI banner (tracking/show.blade.php) at the top of the page:
- Amber warning "⚠ Necesită aprobarea ta"
- Per-line card: title + amount (ore/qty + total MDL) + two buttons
  (green Aprob / outline-red Nu aprob)
- Disappears as soon as approved/declined
- Success/error flash above the banner after each action

== Tests ==

ExcelInvoiceImportTest (5):
- headers_preview returns first 5 rows + column letters
- preview classifies rows as found/new/no_article based on Part DB
- import creates Purchase with items + auto-creates parts for "new"
- remember_mapping upserts, no duplicate per supplier
- decimal parser tolerates "1 234,56" European format with NBSP

TrackingApprovalTest (7):
- creating a work with requires_approval auto-generates 24-char token
- POST /t/{token}/approve/work/{lineToken} marks approved_at
- POST with decision=decline marks declined_at instead
- wrong line token redirects with error flash (no leak)
- already-approved line cannot be approved again (idempotent)
- tracking page renders "Necesită aprobarea ta" banner when pending
- approved line vanishes from banner on next page load

Suite: 246 passed (700 assertions). Was 234.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 04:56:06 +00:00
parent d9180e16b3
commit 0e3119a6e2
15 changed files with 1440 additions and 4 deletions
@@ -0,0 +1,228 @@
<x-filament-panels::page>
<style>
.wiz { max-width: 1000px; margin: 0 auto; }
.wiz-steps { display: flex; gap: 8px; margin-bottom: 24px; align-items: center; }
.wiz-step { display: flex; align-items: center; gap: 8px; padding: 6px 14px; border-radius: 20px; font-size: 13px; font-weight: 500; color: #94a3b8; }
.wiz-step.active { background: #ebf5ff; color: #1d4ed8; }
.wiz-step.done { background: #dcfce7; color: #15803d; }
.wiz-step-sep { width: 24px; height: 2px; background: #e2e8f0; }
.wiz-card { background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 24px; }
.dark .wiz-card { background: #1f2937; border-color: #374151; }
.wiz-field { margin-bottom: 16px; }
.wiz-field label { display: block; font-size: 11px; font-weight: 600; color: #718096; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
.wiz-field input, .wiz-field select { width: 100%; padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 14px; }
.dark .wiz-field input, .dark .wiz-field select { background: #111827; color: #f1f5f9; border-color: #374151; }
.wiz-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
.wiz-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 24px; }
.wiz-btn { padding: 8px 16px; border-radius: 6px; font-weight: 500; cursor: pointer; border: 1px solid #cbd5e1; background: white; color: #1a202c; }
.wiz-btn:hover { background: #f7fafc; }
.wiz-btn-primary { background: #3b82f6; color: white; border-color: #3b82f6; }
.wiz-btn-primary:hover { background: #2563eb; }
.wiz-preview-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.wiz-preview-table th, .wiz-preview-table td { padding: 6px 8px; border: 1px solid #e2e8f0; text-align: left; }
.wiz-preview-table th { background: #f7fafc; font-weight: 600; font-size: 11px; color: #4a5568; }
.dark .wiz-preview-table th { background: #111827; color: #cbd5e1; }
.wiz-badge { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; }
.wiz-badge.found { background: #dcfce7; color: #15803d; }
.wiz-badge.new { background: #fef3c7; color: #92400e; }
.wiz-badge.no_article { background: #fee2e2; color: #991b1b; }
.wiz-summary { display: flex; gap: 16px; margin-bottom: 16px; padding: 12px; background: #f7fafc; border-radius: 6px; }
.dark .wiz-summary { background: #111827; }
.wiz-summary-item { font-size: 13px; }
.wiz-summary-item strong { font-size: 20px; display: block; font-weight: 700; }
.wiz-upload { border: 2px dashed #cbd5e1; border-radius: 8px; padding: 32px; text-align: center; cursor: pointer; }
.wiz-upload:hover { border-color: #3b82f6; background: #ebf5ff; }
.dark .wiz-upload:hover { background: #1e293b; }
</style>
<div class="wiz">
<div class="wiz-steps">
<div class="wiz-step {{ $step >= 1 ? 'active' : '' }} {{ $step > 1 ? 'done' : '' }}">1. Upload</div>
<div class="wiz-step-sep"></div>
<div class="wiz-step {{ $step == 2 ? 'active' : '' }} {{ $step > 2 ? 'done' : '' }}">2. Mapare coloane</div>
<div class="wiz-step-sep"></div>
<div class="wiz-step {{ $step == 3 ? 'active' : '' }} {{ $step > 3 ? 'done' : '' }}">3. Previzualizare</div>
<div class="wiz-step-sep"></div>
<div class="wiz-step {{ $step == 4 ? 'active' : '' }}">4. Confirmare</div>
</div>
<div class="wiz-card">
{{-- STEP 1: Upload --}}
@if ($step === 1)
<h2 style="font-size:18px;font-weight:600;margin-bottom:16px;">Pasul 1: Încarcă fișierul</h2>
<div class="wiz-field">
<label>Furnizor *</label>
<select wire:model="supplierId">
<option value=""> alege </option>
@foreach ($this->getSupplierOptions() as $id => $name)
<option value="{{ $id }}">{{ $name }}</option>
@endforeach
</select>
</div>
<div class="wiz-field">
<label>Fișier Excel (.xlsx) sau CSV *</label>
<input type="file" wire:model="upload" accept=".xlsx,.xls,.csv" />
@if ($upload)
<div style="margin-top:6px;font-size:12px;color:#4a5568;">Selectat: {{ $upload->getClientOriginalName() }}</div>
@endif
</div>
<div class="wiz-actions">
<button class="wiz-btn wiz-btn-primary" wire:click="goToStep2" wire:loading.attr="disabled">Următorul </button>
</div>
@endif
{{-- STEP 2: Mapping --}}
@if ($step === 2)
<h2 style="font-size:18px;font-weight:600;margin-bottom:16px;">Pasul 2: Mapează coloanele</h2>
<p style="color:#4a5568;font-size:13px;margin-bottom:16px;">Spune-i sistemului ce reprezintă fiecare coloană din fișier. Vom salva configurația pentru imporțările viitoare.</p>
<div style="overflow-x:auto;margin-bottom:20px;">
<table class="wiz-preview-table">
<thead>
<tr>
@foreach ($headersPreview['columns'] as $col)
<th>{{ $col }}</th>
@endforeach
</tr>
</thead>
<tbody>
@foreach ($headersPreview['rows'] as $row)
<tr>
@foreach ($headersPreview['columns'] as $col)
<td>{{ $row[$col] ?? '' }}</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="wiz-grid">
<div class="wiz-field">
<label>Coloana Articol *</label>
<select wire:model="mapping.article_col">
<option value=""></option>
@foreach ($headersPreview['columns'] as $c)<option value="{{ $c }}">{{ $c }}</option>@endforeach
</select>
</div>
<div class="wiz-field">
<label>Coloana Denumire *</label>
<select wire:model="mapping.name_col">
<option value=""></option>
@foreach ($headersPreview['columns'] as $c)<option value="{{ $c }}">{{ $c }}</option>@endforeach
</select>
</div>
<div class="wiz-field">
<label>Coloana Cantitate *</label>
<select wire:model="mapping.qty_col">
<option value=""></option>
@foreach ($headersPreview['columns'] as $c)<option value="{{ $c }}">{{ $c }}</option>@endforeach
</select>
</div>
<div class="wiz-field">
<label>Coloana Preț *</label>
<select wire:model="mapping.price_col">
<option value=""></option>
@foreach ($headersPreview['columns'] as $c)<option value="{{ $c }}">{{ $c }}</option>@endforeach
</select>
</div>
<div class="wiz-field">
<label>Coloana Brand (opțional)</label>
<select wire:model="mapping.brand_col">
<option value=""></option>
@foreach ($headersPreview['columns'] as $c)<option value="{{ $c }}">{{ $c }}</option>@endforeach
</select>
</div>
<div class="wiz-field">
<label>De la rândul (sărim header) *</label>
<input type="number" min="1" wire:model="mapping.header_row" />
</div>
</div>
<div class="wiz-field">
<label style="display:flex;align-items:center;gap:8px;text-transform:none;">
<input type="checkbox" wire:model="rememberMapping" />
<span>Salvează configurația pentru acest furnizor</span>
</label>
</div>
<div class="wiz-actions">
<button class="wiz-btn" wire:click="$set('step', 1)"> Înapoi</button>
<button class="wiz-btn wiz-btn-primary" wire:click="goToStep3">Previzualizare </button>
</div>
@endif
{{-- STEP 3: Preview --}}
@if ($step === 3)
<h2 style="font-size:18px;font-weight:600;margin-bottom:16px;">Pasul 3: Previzualizare {{ $previewSummary['total'] }} poziții</h2>
<div class="wiz-summary">
<div class="wiz-summary-item">
<strong style="color:#15803d;">{{ $previewSummary['found'] }}</strong>
Găsite (cu articol existent)
</div>
<div class="wiz-summary-item">
<strong style="color:#92400e;">{{ $previewSummary['new'] }}</strong>
Articole noi (se vor crea)
</div>
<div class="wiz-summary-item">
<strong style="color:#991b1b;">{{ $previewSummary['no_article'] }}</strong>
Fără articol (manual)
</div>
</div>
<div style="max-height:400px;overflow-y:auto;margin-bottom:16px;">
<table class="wiz-preview-table">
<thead>
<tr>
<th>Articol</th><th>Denumire</th><th>Brand</th><th>Cant.</th><th>Preț</th><th>Status</th>
</tr>
</thead>
<tbody>
@foreach ($previewRows as $row)
<tr>
<td style="font-family:monospace;">{{ $row['article'] }}</td>
<td>{{ $row['name'] }}</td>
<td>{{ $row['brand'] }}</td>
<td>{{ rtrim(rtrim(number_format($row['qty'], 2), '0'), '.') }}</td>
<td>{{ number_format($row['price'], 2) }}</td>
<td><span class="wiz-badge {{ $row['status'] }}">{{ ['found' => '✅ Găsit', 'new' => '⚠️ Nou', 'no_article' => '❓ Nu găsit'][$row['status']] ?? $row['status'] }}</span></td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="wiz-field">
<label style="display:flex;align-items:center;gap:8px;text-transform:none;">
<input type="checkbox" wire:model="createNew" />
<span>Creează automat articolele noi în nomenclatură</span>
</label>
</div>
<div class="wiz-actions">
<button class="wiz-btn" wire:click="$set('step', 2)"> Înapoi</button>
<button class="wiz-btn wiz-btn-primary" wire:click="confirmImport">Confirmă și importă</button>
</div>
@endif
{{-- STEP 4: Done --}}
@if ($step === 4)
<div style="text-align:center;padding:32px;">
<div style="font-size:48px;margin-bottom:16px;"></div>
<h2 style="font-size:20px;font-weight:600;margin-bottom:8px;">Import finalizat cu succes</h2>
<p style="color:#4a5568;">{{ $previewSummary['total'] }} poziții importate în Purchase nouă</p>
<div style="margin-top:24px;">
@if (session('purchase_id'))
<a class="wiz-btn wiz-btn-primary" href="{{ route('filament.tenant.resources.purchases.edit', ['record' => session('purchase_id')]) }}"> Deschide Purchase</a>
@endif
<button class="wiz-btn" wire:click="reset_">Import nou</button>
</div>
</div>
@endif
</div>
</div>
</x-filament-panels::page>