Completes the P1 items from /tmp/service/new/01-TZ-rbac §2.1 §4.1.
== user_permission_overrides table ==
Per-user grant/deny exceptions on top of role-based RBAC. Composite PK
(user_id, permission_id) so each user can have at most one override per
permission. Schema:
- mode: 'grant' | 'deny'
- reason: text (audit context: "lockdown audit period", etc.)
- granted_by_id + granted_at: who/when made the exception
- expires_at: optional auto-expiry
Eloquent model UserPermissionOverride with relations to user, permission,
grantedBy; isExpired() helper.
== Resolution order in User::canDo() ==
1. Active deny-override (not expired) → return false (and log if sensitive)
2. Active grant-override (not expired) → return true
3. Admin/owner bypass → return true
4. Standard role-based check via Spatie
Critically: deny overrides ALSO block admin/owner. This is intentional —
the TZ's "separation of duties" requirement (an admin who shouldn't be
able to delete payments). Without this, deny is useless against admins.
Override resolution uses a single query per check (cached by Eloquent
during the request). The override-check happens before the role check
so a deny is always authoritative.
== Audit log on sensitive denials ==
When canDo() returns false for one of these sensitive permissions, a
spatie/activitylog entry is written with event=permission_denied:
- admin.users.manage / admin.roles.manage / admin.settings.edit
/ admin.backup.download
- finance.delete_payment / finance.view_pl
- salaries.mark_paid / salaries.view_all
- work_orders.delete / work_orders.approve_discount_any
Non-sensitive denials (e.g., clients.create) don't log to avoid noise.
The activity payload includes the permission slug; causedBy is the user
who was denied. Failures of the logger are swallowed so a misconfigured
activitylog never breaks auth.
== UserResource UI ==
New PermissionOverridesRelationManager mounted on the edit page:
- Table: permission, mode (GRANT/DENY badge), reason, expires_at,
granted_by
- Create form: permission select, mode, expires_at, reason
- granted_at + granted_by_id auto-populated to now() / auth()->id()
- Default sort: granted_at desc
Two new actions on the user row:
- "Force logout" (warning color): visible only when active sessions
exist. Deletes every row in `sessions` with user_id=record→id.
Notification shows count revoked.
- "Resetează 2FA" stays (from previous commit)
Two new toggleable columns:
- Sesiuni active (count from sessions table)
- Excepții (count of permission overrides)
== Tests ==
PermissionOverridesTest covers:
- grant unlocks a permission the role doesn't have
- deny blocks a permission the role grants
- deny blocks even admin role (separation of duties)
- expired override is ignored
- future-expiry override stays active
- audit log writes on sensitive denial
- audit log silent on non-sensitive denial
- force_logout deletes all user sessions but not others'
Suite: 214 passed (591 assertions). Was 206.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the RBAC TZ in app/Auth/Permissions.php with a 51-permission
catalog spanning 9 modules (clients/vehicles/work_orders/finance/salaries/
inventory/suppliers/admin/ai_assistant+analytics). All slugs are constants,
not magic strings — refactors against renames stay safe.
== 7 system roles ==
owner / admin / manager / accountant / receptionist / mechanic / viewer
Each gets a curated role-permission matrix per the TZ section 2.4:
- owner + admin: all 51
- manager: 23 (operations + reporting, no destructive finance/users)
- accountant: 17 (full finance/salaries, view-only WOs, no admin)
- receptionist: 13 (front-desk: clients/vehicles/WOs/payment-create)
- mechanic: 4 (own WOs + inventory view + own salary)
- viewer: 6 (read-only everything except finance/salaries)
== Seeder ==
App\Services\RbacSeeder:
- seedPermissions() creates the 51 Permission rows globally (idempotent)
- seedTenantRoles($companyId) sets the team context, creates the 7 Role
rows scoped to that tenant, and syncPermissions per matrix
- syncUsersToRoles($companyId) maps legacy users.role string column to
the new Spatie role assignment (parts_manager→manager, master→mechanic,
marketer→manager, user→viewer)
== Migration ==
2026_06_04_000003 loops over all existing Companies and runs the seeder.
On a fresh prod deploy, every tenant gets the full RBAC catalog wired up
automatically. CompanyProvisioner::provision() also calls the seeder for
new tenants going forward.
== Resource gates ==
canViewAny / canCreate / canDelete on:
- PaymentResource (FINANCE_VIEW_OVERVIEW / FINANCE_CREATE_PAYMENT / FINANCE_DELETE_PAYMENT)
- ExpenseResource (FINANCE_VIEW_OVERVIEW / FINANCE_CREATE_EXPENSE / FINANCE_DELETE_PAYMENT)
- PayrollAdjustmentResource (SALARIES_VIEW_ALL / SALARIES_CALCULATE)
- PayrollRunResource (SALARIES_VIEW_ALL / SALARIES_CALCULATE)
- UserResource (ADMIN_USERS_VIEW / ADMIN_USERS_MANAGE)
- RoleResource (ADMIN_ROLES_MANAGE)
Mechanic sees only own WOs + inventory + own salary. Accountant sees all
finance but not admin. Receptionist sees clients/WOs but not finance
overview. Etc.
== User helpers ==
$user->canDo(Permissions::WORK_ORDERS_CREATE) — admin gets a bypass to
prevent lockouts from misconfigured permission grants.
$user->isOwner() / isAccountant() / isMechanic() — role shortcuts.
$user->hasTwoFactorEnabled() — true when app_authentication_secret is set.
== 2FA ==
Filament 5's native MultiFactorAuthentication (App + Email) is already
enabled in both TenantPanelProvider and CentralPanelProvider — confirmed.
The User model already implements HasAppAuthentication +
HasAppAuthenticationRecovery + HasEmailAuthentication.
This commit adds UX around it:
- UserResource list column: 2FA badge (green ✓ when enabled, amber ⚠ when off)
- UserResource form: "Securitate" section shows enabled/disabled + last_login_at
- New admin action "Resetează 2FA" with confirmation modal — clears
app_authentication_secret + recovery codes for locked-out users
== Roles management UI ==
New /app/roles RoleResource:
- List: role label + slug + permission count + user count
- Edit: 10 grouped checkbox lists (per module) for fine-grained
permission assignment + bulk-toggle per group
- System roles (owner/admin/etc.) have slug locked, can't be deleted
- Custom tenant-specific roles can be added on top
- Gated behind ADMIN_ROLES_MANAGE
== UserResource extension ==
- Role select now uses Permissions::roleLabels() (owner/admin/manager/...)
- New "Roluri suplimentare" multi-select for stacking roles on top of
the primary one (permissions cumulate)
- afterSave syncs the picked roles + ensures primary role is always
included
== Tests ==
RbacTest covers: 51 permissions seeded, 7 roles per tenant, owner has
all, mechanic has minimal, accountant has finance but not admin,
canDo returns true when role has permission, admin bypass, owner helper,
syncUsersToRoles legacy mapping (parts_manager→manager, master→mechanic,
user→viewer), 2FA helper round-trip.
Suite: 206 passed (576 assertions). Was 196.
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>
Shop password reset:
- Configured 'shop_customers' password broker on the existing
password_reset_tokens table
- ShopCustomer::sendPasswordResetNotification overrides Laravel default to
send a ShopPasswordResetMail with a tenant-subdomain reset URL
- Routes /shop/password/forgot, /shop/password/email, /shop/password/reset/{token}
+ ShopAuthController showForgotPassword/sendResetLink/showResetPassword/
resetPassword. Forgot view stays generic ("if it exists, we sent…") to avoid
email enumeration. Login view links to "Am uitat parola".
Order confirmation email:
- ShopOrderConfirmationMail + nicely formatted HTML email template
- ShopOrderNotifier::placed now also emails customer_email (best-effort,
warning-only logged on failure) alongside existing Telegram + staff push
Multiple images per Part:
- Part media collection switched from singleFile to multiple (max 8 in form)
- imageUrls() helper for galleries; imageUrl() still returns first for cards
- PartResource form: reorderable multi-upload
- Shop part detail: vertical thumbnails switch the main image via vanilla JS
ShopCustomerResource (tenant Filament, "Magazin" nav group):
- List with name/phone/email/client_id/orders_count/last_login_at
- Edit (no password field exposed)
- "Trimite reset parolă" action uses the new broker
- OrdersRelationManager shows the customer's orders read-only
Tests (7 new):
- forgot sends mail; forgot doesn't disclose unknown email; reset with valid
token changes password; bad token rejected; order email when customer_email
set; email skipped without it; Part has imageUrls() collection
Full suite: 130 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Upload an invoice photo → Claude extracts {supplier_name, date, currency,
items, total} as JSON → auto-create a draft Purchase + PurchaseItems →
redirect to edit so the user reviews before confirming/receiving.
OcrInvoiceService:
- Validates supported MIME (jpg/png/webp/gif)
- Reads tenant Claude key (settings.ai.claude_key) — friendly error if missing
- Calls /v1/messages with image content block + structured-output system prompt
- Tolerant parser: strips ```json fences, falls back to first {…} block
- normalize(): computes per-item total when absent, fills overall total
- All return shapes: {ok:bool, data?, error?, raw?, tokens?}
Filament:
- "Import factură (OCR)" header action on Purchases list
- Image file upload → service → matches Supplier by case-insensitive name
(notes the unmapped name if no match) → creates draft Purchase + items →
redirects to the Edit page
Tests (6 new):
- clean JSON parses; markdown fences stripped; malformed → graceful error;
missing key → friendly message + no HTTP; unsupported MIME rejected;
item total computed when missing
Full suite: 123 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema:
- subcontractors (specialty, rating, contact)
- subcontract_jobs (work_order link, cost, markup_pct, client_price, status
workflow, sent_at/eta/returned_at, paid_to_sub)
Models:
- SubcontractJob: auto number (SC-YY-NNNN), client_price = cost×(1+markup/100)
when markup>0 (else manual), margin() helper, recalcs parent WO on save/delete
- WorkOrder.recalcTotal now includes non-cancelled subcontract job client_price
Filament (new "Subcontractare" nav group):
- SubcontractorResource (specialty/rating CRUD)
- SubcontractJobResource board with cost/client/margin columns + status filters,
nav badge = open jobs
- SubcontractJobsRelationManager on WorkOrder
Tests (7 new):
- client_price from markup; manual price without markup; auto number;
WO total includes jobs; cancelled excluded; delete recalcs; tenant isolation
Closes roadmap to 16/18 stages (only Stage 10 Bodyshop remains).
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
- barryvdh/laravel-dompdf instalat
- WorkOrderPdfService: încarcă WO cu toate relațiile (works/parts/payments),
embed-ează logo ca data URI, foloseste theme_color din settings
- Blade template /resources/views/pdf/work-order.blade.php:
- Header cu logo + date companie + nr fișă + data
- Box-uri client + auto (kilometraj/VIN/plate)
- Plângere + diagnostic
- Tabel manopere (h, preț/h, total) cu maistru pe fiecare rând
- Tabel piese (cod, brand, qty, preț, total)
- Box total cu discount + plăți efectuate + rest de achitat
- Block recomandări cu fundal galben (warning)
- Linii semnătură client + maistru
- Footer cu timestamp generare
- Action 'PDF' (icon descărcare) pe rând în lista de WO
- Action 'Descarcă PDF' în header-ul paginii Edit WO
Schema:
- payments: client_id, work_order_id, user_id (operator), paid_at, amount,
method (cash/card/transfer/mobile), reference, notes
- expenses: supplier_id, purchase_id, paid_at, category (salary/purchase/rent/
utilities/advance/tax/fuel/tools/marketing/other), name, amount, method, ref
Logică auto:
- Payment::saved/deleted recalculează automat work_order.pay_status
(unpaid → partial → paid) based on suma totală vs work_order.total
- WO model are noi metode: payments(), paidAmount(), balanceDue()
Filament resources (group Finanțe):
- PaymentResource: form cu legare opțională la WO + client; tabel cu
Sum summary, filtre azi/luna_curentă/method
- ExpenseResource: 10 categorii preset, badge categ, total summary,
filtru luna curentă
- PaymentsRelationManager pe WO: "Plăți" tab cu auto-fill client_id +
user_id la creare
Widget FinanceOverview:
- Încasări (luna), Cheltuieli (luna), Profit (luna), Datorii clienți
- color coded: profit verde sau roșu, datorii galben/verde
Settings page fix (Filament v5):
- mount() folosește acum $this->form->fill([...]) în loc de $this->data direct
- Filament v5 cere fill explicit pentru a inițializa state-ul schemei
Seed:
- 1 plată parțială pe fișa BMW (200 din 750)
- 6 cheltuieli demo: 3 salarii, chirie, electricitate, achiziție piese
Total Filament tenant routes: 69.
Schema:
- suppliers: name, contact, phone/email/website, pay_terms, delivery_days,
rating (1-5), discount_pct, categories (JSON), is_active, notes
- parts: name, article (UNIQUE per tenant), brand, category, qty/unit/min_qty,
buy_price/sell_price, location (rack/bin), barcode, preferred_supplier_id,
is_active. Index pe (company_id, category) și (company_id, is_active).
- purchases: număr unique per tenant + an, supplier_id, status workflow
(draft/ordered/received/cancelled), order/expected/received/paid_at, total
- purchase_items: name, article, qty, unit, buy_price, total auto, received bool;
link opțional la part_id
- wo_parts + part_id: linkare opțională la catalog (alter migration)
Modele cu logică:
- Part::adjustStock($delta) — modifică qty cu validare ≥ 0
- Part::isLow() / isOut() helpers
- Purchase::markReceived() — atomic: marchează items ca received + creste qty
pe pieces din catalog (DB::transaction)
- WorkOrderPart::updating event — la trecerea status='installed' decrementează
stoc auto. La revenire (ex: storno) incrementează la loc.
- PurchaseItem::saving — total = qty * buy_price; recalc parent total
Filament resources (group Depozit):
- SupplierResource: form 3 secțiuni, rating ★★★★★, TagsInput pentru categorii
- PartResource: form 4 secțiuni, badge nav cu nr. piese sub stoc minim,
filtre low_stock + out_of_stock, coloană qty colorată după stoc
- PurchaseResource: form antet + RelationManager Items.
Action 'Recepționează' care apelează markReceived() — un click = stoc actualizat
WorkOrder PartsRelationManager updated:
- Selector din catalog (Part::active) cu stoc afișat
- Auto-fill name/article/brand/unit/buy_price/sell_price din piesa selectată
- Helper text: la status='installed' se scade din stoc
Widget low-stock:
- TableWidget pe dashboard tenant, listează piesele cu qty <= min_qty
- Span full, sortat după qty (cele mai critice sus)
Seed:
- 2 furnizori (AutoParts Moldova SRL ★5, Inter Cars Moldova ★4)
- 5 piese demo: Ulei Shell, Filtru Mann, Plăcuțe Brembo, Antigel (qty=0!), Bujii NGK
- 1 achiziție recepționată (P-26-0001) cu 2 articole linked la catalog
Total Filament tenant routes: 63 (de la 31).
Schema:
- users + specialization, color, hourly_rate (pentru maistri)
- labors: catalog manopere standard cu category/ore/preț (RO+RU)
- work_orders: nr unique per tenant, status workflow (9 stări),
pay_status (3 stări), client/vehicle/master/deal/appointment refs,
complaint/diagnosis/recommendations, total auto-calculat
- wo_works: manopere per fișă, recalc auto la save/delete
- wo_parts: piese per fișă (free-text deocamdată), discount/total auto
Filament resources (group Service):
- LaborResource: CRUD + grupare pe categorie + filter active
- WorkOrderResource: form complex în 4 secțiuni (antet, diagnostic, plată)
+ 2 RelationManagers (Works, Parts)
- MasterResource: vedere User filtrată role=mechanic, edit specializare/
culoare calendar/tarif oră
Conversie auto: la adaugare manoperă din catalog Labor,
form populează numele + ore + preț/oră derivat (price/hours).
Number generator pentru WO: format WO-{YY}-{NNNN} per tenant per an,
calculat în CreateWorkOrder via WorkOrder::generateNumber().
Seed extins:
- 3 mecanici (Vasile/Andrei/Nicolae) cu culori + specializări
- 10 manopere standard din prototipul AutoCRM.html
- 1 fișă demo (BMW X5 plăcuțe Brembo) cu 1 manoperă + 1 piesă, total auto
Layout components (Section, Grid, Tabs, etc.) au fost mutate din
Filament\Forms\Components în Filament\Schemas\Components.
Forms\Components păstrează doar field-urile (TextInput, Select, etc.).
Forms\Get s-a mutat în Schemas\Components\Utilities\Get.
Filament v5 a unificat actions într-un singur namespace Filament\Actions.
Tables\Actions\EditAction et al. nu mai există → fix global pe toate resources.