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>
Implements 2 of the biggest items from /tmp/service/new docs:
== Calendar Vizual v2 (from 02-prototip-calendar-vizual.html) ==
Replaces the FullCalendar week view (the one that visually collapsed after
Livewire re-renders) with a server-rendered matrix that the harness
already drives through Livewire — no third-party JS to clash with Filament.
Layout: 8-column CSS grid (1 row-label + 7 days). Rows are either Posts
(Pod 1, Pod 2…) or active masters depending on toolbar switch. Each
cell holds 0..N event cards.
Per-cell load badge (top-right):
hours_planned / capacity → badge color (gray <50%, orange 50–90%, red ≥90%)
Drag-drop: HTML5 native, Alpine.js holds the dragEventId, moveEvent($id,
$toRowId, $toDate) in PHP updates either post_id or master_id (depending
on groupBy mode) plus date — works seamlessly when re-grouping.
KPI bar (4 cards above toolbar):
- Ore programate X / Y · % capacity
- Fișe deschise (orange)
- Confirmate X/Y (green) + confirmation rate
- No-show alert (red) — scheduled events <24h away that are still unconfirmed
Toolbar:
- ◀ Week ▶ + Astăzi (reset)
- Date label "01 — 07 iunie 2026"
- Grupare switch: Pod ↔ Mecanic
- Filtru: master dropdown + status dropdown (Confirmate/Neconfirmate/În lucru)
Today column highlighted blue; Sunday column hatched as closed
(non-interactive, no drop target); Saturday muted as weekend.
Event card color = master.color (deterministic, matches profile setting),
shown as left border + background tint. Title = client name; meta =
"VW Passat · CIU 001"; time = "08:00–12:00 · V.".
Click empty cell → quick-create panel (right slide-in) with date+pod
pre-filled. Click event → detail panel with Client/Phone/Auto/Plate/
Master/Pod + delete + edit.
Legend section at bottom (mecanici dots, load colors, day states).
== Hidden Markup (from gap-analysis.md #3) ==
Adds `hidden_markup_pct` decimal to parts. Customer documents continue
to show the standard sell_price; the hidden markup is an internal margin
indicator used for B2B contracts and corporate analytics.
Part::internalCostWithHiddenMarkup() returns buy_price * (1 + pct/100).
Falls back to buy_price when pct is null. Decimal:2 cast so persistence
round-trips cleanly.
== Schema migration ==
Idempotent (hasColumn guards):
- posts.hours_per_day decimal(5,1) default 10
- posts.description varchar(255) nullable
- parts.hidden_markup_pct decimal(5,2) nullable
== Tests ==
+11 new in CalendarBoardV2Test (8) + HiddenMarkupTest (3):
- get_days returns 7 days with today flagged + Sunday closed + Saturday weekend
- get_rows returns posts when grouped by post + with capacity
- get_rows returns masters when grouped by master + Fără maistru fallback row
- matrix places events in correct cells + sums hours
- move_event reassigns post_id and date
- create_appt inserts appointment via panel form
- stats compute utilization from events (8h / 60h capacity = 13%)
- status filter narrows to confirmed only
- hidden_markup applies pct correctly + falls back to buy_price + persists
Suite: 196 passed (551 assertions). Was 185.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit pass against /tmp/service/todo/psauto-pipeline-redesign.html — 10
gaps closed.
1. In-page TOPBAR (mockup had it; was missing): "Pipeline" title,
sep, search box "Caută client, mașină, număr...", and right-side
Filtre / Export / + Deal nou (primary) buttons. Search input is
wire:model.live.debounce 300ms.
2. SEARCH actually filters cards: $searchQuery property in
PipelineBoard scans subject + client_name + plate + code + phone
across all 6 columns, case-insensitive.
3. "+ Deal nou" + "+ Adaugă cerere" (per-column bottom) now open the
SAME right-side panel in "new form" mode. Inline create form:
Nume / Telefon / Auto / Sursă / Notițe → createNewLead() inserts
Lead with status=new, lands in col 1 instantly without leaving page.
Validation: name + phone required.
4. EXPORT button calls exportCsv() — streams a CSV of current filtered
columns (etapă, cod, subiect, client, telefon, auto, sumă,
responsabil, stare timp).
5. PERIOD selector chip shows current month in Romanian
(now()->locale('ro')->isoFormat('MMMM YYYY')) — matches "Iunie 2026".
6. HOVER icons now match mockup exactly per column:
- request: 📅 schedule / 📞 phone / ⋮ edit
- quote: 📅 schedule / 💬 wa / ⋮ edit
- scheduled: 📄 file-plus (start WO) / 💬 wa / ⋮ edit
- in_work: 👁 eye (open WO) / 💬 wa / ✓ mark Gata
- ready: 💰 cash (mark paid) / 📞 phone / ⋮ edit
- paid: NONE (col 6 has no hover actions per mockup)
7. Col 6 "Achitat azi" cards now opacity:0.65, no hover actions,
no time line, no assignee name (just avatar) — exactly as in mockup.
8. Sum display: amount == 0 renders "—" instead of "0 MDL", both in
card footer and list view.
9. "Avans achitat" tag (blue) appears on Ready cards with partial
payment (pay_status='partial'); "Neachitat" amber only when fully
unpaid. Matches mockup col 5 example "Nissan Qashqai · Gata +
Avans achitat".
10. Link tracking quick-action: appears in detail panel "Acțiuni rapide"
grid when WO has tracking_url. Sits alongside WhatsApp / Sună / SMS.
Two-panel architecture: $showNewForm and $openCardKey are mutually
exclusive. Click outside or ✕ closes the panel; opening one closes
the other.
Tests: +4 (createNewLead happy path, validation, search filter,
partial payment tag). Suite 185/185 (was 181).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses the gaps surfaced after the first redesign — the board was still
boxed in Filament chrome (not truly full-page), hover floating-actions and
"+ Adaugă" CTAs were missing, and the P0 "Programează" from deal card had
no calendar wiring.
Full-page:
- getMaxContentWidth() = Width::Full
- getHeading()/getSubheading() return empty so Filament's title bar
disappears, leaving the kanban edge-to-edge
- CSS uses :has(.pb-shell) to strip Filament's page padding + heading
block at the layout level
- Board height = calc(100vh - 64px); columns scroll independently
Hover floating-actions on every card (column-aware):
- Cols 1-2 (Cerere / Calculație): 📅 quickSchedule
- Col 3 (Programat): ▶ start work (creates WO)
- Col 4 (În lucru): ✓ mark Gata
- Col 5 (Gata): 💰 mark Achitat
- All cards with phone: 📞 tel: + 💬 wa.me
- All cards: ↗ open in resource edit
- Shown only on .pb-deal:hover, positioned absolute top-right
"+ Adaugă" CTA at column bottom:
- Cols 1-3 → /app/leads/create
- Cols 4-5 → /app/work-orders/create
Programare → Calendar (P0 AAA):
- quickSchedule($key) on PipelineBoard creates a real Appointment row for
tomorrow 10:00 linked to (client_id, vehicle_id, master_id, deal_id),
sets deal.stage='scheduled' + scheduled_at, then shows a toast
- Panel bottom action bar gains "📅 Programează" CTA for lead/deal cards
- "📅 Calendar" jump CTA for WO cards
- calendarUrl() returns the canonical filament.tenant.pages.calendar-board
route
Empty column state now reads "Gol — trage un card aici" instead of just
"Gol" so the drop affordance is explicit.
Stat strip + filter bar sticky at top; board fills the remaining viewport.
Tests: +1 (quickSchedule creates Appointment + moves deal). Suite 181/181.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the bare 6-status WO Kanban with the unified Pipeline view from
/tmp/service/todo/psauto-pipeline-redesign.html. Six columns now span the
entire customer journey end-to-end:
Cerere nouă → Calculație → Programat → În lucru → Gata → Achitat azi
└─ Lead/Deal └─ Deal └─ Deal └─ WO └─ WO └─ WO+Payment
Cross-model drag-drop transitions:
- Lead → Calculație: Lead::convert() creates Deal at stage=contact, marks
quote_sent_at = now, quote_status = sent
- Deal (any earlier stage) → În lucru: spawns a WorkOrder from the deal
(client, vehicle, master, total, complaint), sets deal.stage=in_work,
links wo.deal_id
- WO → Gata: status=ready + fires NotificationDispatcher::workOrderReady
so client gets Telegram/email automatically
- WO → Achitat: creates Payment for remaining balance + status=done,
closed_at=today (pay_status syncs to paid via Payment booted hook)
Rich card content per the mockup:
- Red urgent stripe (left border) for Deal.urgent or WO.urgency!=normal
- Source tag (Instagram/Site/Apel/etc.) on lead/deal cards
- Quote status badge ("Trimis · fără răspuns" amber / "Văzut ✓" blue /
"A răspuns" green) based on deal.quote_status
- Scheduled time + bay tag ("05.06 · 09:00" + "Post 2")
- Fișă FL-NNN purple tag on WO cards
- "Necesită aprobare" amber tag when wo.status=agreement
- Progress bar (purple, 0-100%) on in-work cards: works_done + parts_installed
over total lines
- SLA time line per card with overdue red color:
* Lead 60+ min not contacted = overdue
* Quote 2h+ no response = overdue
* Ready 30+ min not paid = overdue (with phone icon)
* WO past ETA = overdue
- Assignee avatar (deterministic CRC32 color: blue/green/purple/amber)
- Amount in MDL, formatted
Stat strip (6 metrics computed live):
- Total deals active (sum of cols 1-5)
- MDL pipeline total
- MDL closed today (Payment sum where paid_at=today)
- Necesită acțiune (overdue + urgent + pending approval)
- Rata conversie 30d (won / (won+lost) %)
- Depășit termen (count WO past eta_at)
Filter chips wire-driven: Toate / Ale mele (assigned_to=me) /
Urgente (urgent=true OR wo.urgency!=normal) / Azi.
View toggle: Kanban ↔ Listă (table with all cards flat, sortable by stage).
Slide-in detail panel:
- 6-step stage stepper highlighting current
- Client / Telefon (blue clickable) / Auto / Sursă / Responsabil / Sumă /
De achitat (live computed balanceDue for WOs)
- Note / Reclamație
- Linked Fișă card with status badge, progress, ETA, "necesită aprobare"
alert + tracking link
- Activity timeline from Spatie activity-log
- Quick actions: WhatsApp (wa.me/<phone>), Sună (tel:), SMS (sms:),
Deschide (jumps to Filament resource edit)
DealResource hidden from nav (shouldRegisterNavigation=false) since
PipelineBoard is the canonical entry, but its edit/create routes stay
intact — the panel deep-links to them.
Auto-refresh: wire:poll.10s keeps the board live without WebSocket
dependency. Drag-drop is HTML5 native + Livewire wire:click for ops.
Dark mode supported via CSS variables overridden in .dark scope.
Migration: extend deals table with urgent, quote_sent_at, quote_status,
quote_seen_at, scheduled_at, bay, confirmed_at, confirmed_via,
last_action_at. Idempotent (hasColumn guards). Deal model auto-updates
last_action_at on saving.
Tests: 7 new + full suite 180/180 green (was 173).
- partition leads/deals/wos by column
- stats computation: active, pipeline_mdl, closed_today_mdl
- lead→quote transition converts lead into deal
- deal→in_work creates WorkOrder linked back to deal
- wo→paid creates payment for balance + marks done
- filter "mine" narrows to assigned user
- openCard loads panel detail with correct stepper position
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Scanner page: wrap the html5-qrcode camera container (#reader) in
wire:ignore so a Livewire DOM morph can't tear down the live camera
stream (same class of bug as the calendar).
- Company::getCustomColumns(): add `is_demo` and `default_warehouse_id`.
Stancl Tenant treats columns absent from this list as virtual `data` JSON
attributes, so editing a company could move default_warehouse_id into data
and null the real column — breaking WarehouseService::defaultWarehouse.
Full suite: 100 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FullCalendar mounts into a Livewire-managed subtree. The first
$wire.getEvents() response triggered a Livewire DOM morph that reverted
#autocrm-calendar to its empty server HTML, destroying the rendered grid
(~1s after load it became unstyled text).
Wrap the calendar container in wire:ignore so Livewire's morphdom skips it.
The quick-create modal stays outside wire:ignore to keep its form reactive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contextual multipliers layered on top of base MarkupRule pricing, applied
per work-order line based on vehicle, client and urgency.
Schema:
- pricing_coefficients (multiplier, conditions JSON, priority, stackable)
- vehicles.vehicle_class (sedan/suv/commercial/hybrid/ev/premium)
- clients.is_vip
- work_orders.urgency (normal/urgent/express)
PricingEngine::quote(Part, Vehicle?, Client?, urgency):
- base = MarkupRule on buy_price (fallback sell_price or buy×1.30)
- context: class (explicit or inferred hybrid/ev from fuel), age, vip, urgency
- stackable coefficients all multiply; non-stackable take only the highest
- returns {base, final, applied[]} breakdown
PricingCoefficient::matches(ctx) — classes/age range/vip/urgency conditions
(empty = always applies).
Filament:
- PricingCoefficientResource with condition builder (classes, age, vip, urgency)
- vehicle_class select, client is_vip toggle, WO urgency select
- "Preț inteligent" action on WO parts shows breakdown + applies sell_price
Tests (6 new):
- base-only without coefficients; age coefficient gating; VIP; express urgency;
stackable multiply vs non-stackable highest-wins; hybrid inferred from fuel
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 3-step onboarding wizard at /app/onboarding (auto-redirected via
RequireOnboarding middleware on first login per tenant)
- Empty states with icon + heading + description on Client, Vehicle,
WorkOrder, Lead, Part lists
- Docs: operations/{api,i18n,2fa,monitoring}.md, stack/reverb.md
- Updated 00-index.md and journal.md with status of all 15 items
Schema:
- ai_chats: company_id, user_id, title, provider; index pe activitate
- ai_messages: role (system/user/assistant), content, meta JSON (tokens, latency, model)
Service AiAssistantService (multi-provider):
- ask($chat, $message): persistă mesajul user, build system prompt cu context
tenant (statistici clienți/mașini/cereri/datorii), apelează API-ul providerului,
persistă răspunsul cu meta (tokens, latency)
- callClaude: api.anthropic.com/v1/messages cu claude-sonnet-4-5
- callOpenAI: api.openai.com/v1/chat/completions cu gpt-4o-mini
- callGemini: generativelanguage.googleapis.com cu gemini-1.5-flash
- Try/catch pe toate; eroare devine mesaj asistent fără să crape
System prompt include:
- Numele și orașul companiei
- Statistici curente (clienți, mașini, cereri noi, fișe active, datorii)
- Limita stricta: NU inventează date
Custom Filament Page /app/ai-assistant (group Analiză):
- Sidebar stâng: listă conversații (last 20), buton 'Nouă' + delete cu confirm
- Main: bubble chat (user dreapta albastru, asistent stânga gri)
- Meta jos pe răspuns: provider · latency · tokens
- Empty state friendly cu instrucțiuni configurare
- Loading indicator (3 dots animate) când AI răspunde
- Auto-scroll la mesaj nou
- Enter trimite, Shift+Enter newline
- Auto-titlu chat din primul mesaj user (60 chars)
Settings page extins cu secțiune 'Asistent AI':
- Provider implicit (claude/gpt/gemini)
- 3 chei API (password fields, revealable)
- Key-urile salvate în companies.settings.ai (per tenant, izolat)