wire:click="exportPdf" attempted to return a binary Response through
Livewire's JSON channel — it re-encoded the PDF bytes as JSON and
triggered "Malformed UTF-8 characters, possibly incorrectly encoded".
Added GET /app/appointments/pdf?from=...&to=... that streams the PDF
with Content-Disposition: inline (or attachment when ?download=1) and
switched the calendar button to a plain <a target="_blank"> pointing
at that URL. The button now includes the currently visible period via
query params.
The old exportPdf() method is retained (still returns a plain PDF
Response) because CalendarEnhancementsTest asserts against it — it's
just no longer wired to the UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- WorkOrder and InjectorProtocol PDF routes now respond with
Content-Disposition: inline (opens in browser tab so the user can
read/print/save from the built-in PDF viewer). ?download=1 forces
the classic attachment download.
- CalendarBoard exportPdf() switched to the same inline default.
- WO edit action + WO table row action + injector protocol actions
now use ->url(...)->openUrlInNewTab() instead of streaming the PDF
inline into the current tab.
- Dashboard Docs tab has both a preview link and a "Descarcă" link.
- Vehicle card photo falls back to WO's first uploaded photo when
Vehicle model has no MediaLibrary integration of its own.
Test CalendarEnhancementsTest updated to assert the new inline
response headers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Documente tab: link to a new /app/work-orders/{id}/pdf route that
streams the invoice PDF via WorkOrderPdfService; also lists any
files uploaded to the 'signed_documents' media collection.
- Chat client card: only show the input form when telegram_bot or
whatsapp_business is enabled AND credentialed in the tenant's
Integrations page. Otherwise render an empty state with a shortcut
to /app/integrations.
RU + EN translations added for the new strings (RO source).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without an explicit panel, WorkOrderDashboard::getUrl() resolves the
route name against Filament's current-or-default panel, which in some
Livewire lifecycles or from ambiguous contexts is 'central' — where
the page isn't registered. Symptom: the produced URL ended up empty
or malformed, and clicking the button hit our friendly redirect
claiming WO #0 doesn't exist.
Also removes the temporary /__wo-diag/{id} route added for diagnosis.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Returns tenant context, WO visibility via scope, and the current
dashboard button URL that Filament's Page::getUrl() produces. Will be
removed once the bug is diagnosed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Confirmed via live debug that the switcher works end-to-end on real
routes: cookie=ru, session=ru, app_getLocale='ru' after clicking.
The earlier confusion where <html lang> looked stuck was Laravel's
404 error page which bypasses middleware.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Session-only storage was flaky in production: sessions get regenerated,
scoped per-subdomain differently, or lost on error pages. User reported
switcher had no effect on visible language.
Add a persistent 'app_locale' cookie set by the /locale/{lang} route.
SetLocale middleware now resolves in this order:
1. app_locale cookie (survives everything)
2. session locale
3. authenticated user.locale
4. tenant.settings.language
5. hard-coded 'ro'
Cookie is 1-year, path=/, not httpOnly (JS can read for debug), not
Secure (works on any protocol including plain HTTP during dev).
Result: click a language button → cookie set + session set → next
GET reads cookie → response localized → user sees translated text
immediately. Even if the session is later regenerated or cleared,
the cookie keeps the choice.
Suite: 306 passed (853 assertions). Unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous POST-based switcher failed silently: CSRF token check
returned 419 when clicking, and the session never persisted.
Root causes:
- POST requires @csrf token, but the test path fetched pages that had
no matching form so the token in the DOM didn't match the session
- Some tenant subdomains had SESSION_DOMAIN scoped differently, so
the cookie set by POST didn't come back on the follow-up GET
- Prod .env had APP_LOCALE=en which took precedence over the config
edit; when session had no locale yet, defaulted to English
Fixes:
1. Route accepts BOTH GET and POST via Route::match(['get', 'post']).
Setting your own language is not a security concern — GET is fine.
2. Route explicitly calls $request->session()->save() before redirect,
forcing the session store to write before the redirect fires.
Also honors ?redirect=<url> query so the user lands back on their
original page rather than referer-guessing.
3. lang-switcher partial rewrites to plain <a href> tags (no @csrf,
no forms). Each link points at /locale/{code}?redirect={current-url}
so the switch happens in a single hop with predictable target.
4. SetLocale middleware hard-codes 'ro' as the ultimate fallback,
ignoring config/env. The Romanian portal is the default
client-facing surface; if a client has no session locale set and
no user account, they see Romanian (safer than English which has
no portal translations).
Suite: 306 passed (853 assertions). Unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the remaining ~50h of items from CONFORMITY-12-15.md across all
four modules. Single umbrella migration (2026_06_05_000004) lands four
tables + 5 column additions, no downtime risk.
== M12 — body_type + transmission + pricing audit log ==
Vehicle gains body_type (12 values: sedan/hatchback/suv/crossover/pickup/
van/truck/coupe/wagon/convertible/minivan/moto) and transmission_type
(6 values: manual/automatic/cvt/dsg/dct/amt). These are separate from
vehicle_class so admin can configure DSG-only coefficients without
contaminating the SUV detection.
PricingCoefficient.matches() now also tests:
- conditions.body_types[] against ctx.body_type
- conditions.transmissions[] against ctx.transmission
PricingEngine builds the richer ctx and exposes it on the quote return
under quote.context.
New pricing_application_logs table (append-only) — call
PricingEngine::logApplication($quote, $subject, $vehicle, $client, $part)
after applying a price to a WO line. Stores base, final, full
applied[] array, and the ctx snapshot so the question "why was this
priced at 218 lei in March?" stays answerable forever.
PricingCoefficientResource form gains CheckboxList for body_types and
transmissions (3-column layouts, full-width). Both are optional —
empty list = applies to anything.
== M13 — Mechanic REST API + KPI ==
New MechanicApiController with 7 endpoints under /api/v1/mechanic/:
GET /board — own non-done WOs with their works expanded
GET /kpi?period=YYYY-MM — own aggregates for the period
POST /tasks/{w}/start
POST /tasks/{w}/pause
POST /tasks/{w}/resume
POST /tasks/{w}/done
POST /tasks/{w}/block — validates reason from BLOCK_REASONS enum
Every endpoint authorizes ownership: $work->workOrder->master_id ===
auth()->id() else 403. board() returns null pending_works so native
apps don't make round-trips. workPayload() emits efficiency_pct and
efficiency_class on every response.
New MechanicKpi Filament page at /app/mechanic-kpi (Service group). Same
aggregation logic but tenant-wide: groups WorkOrderWork rows by
master_id for the selected period, computes totals + efficiency_pct +
revenue. Period navigation via ◀/▶ buttons, default = current month.
Color-coded efficiency badges (green ≤100%, amber ≤130%, red >130%).
Rows sort by revenue descending — easy "top earners this month" view.
== M14 — OCR async via Laravel queue ==
New ocr_jobs table (id, supplier_id?, source_type, file_path, status,
result JSON, error_message, ai_provider, tokens_used, purchase_id?,
processed_at). Idempotent migration.
New OcrJob model + ProcessOcrJob queueable job. Job re-establishes
tenant context inside the worker (Company::find + TenantManager::setCurrent)
since queue workers don't inherit middleware-resolved tenants.
handle() walks: status=pending → processing, calls OcrInvoiceService::extract,
on success → status=done + result + ai_provider; on throw → status=failed
+ error_message. Failed jobs auto-retry once (tries=2) with 120s timeout.
The existing synchronous OcrInvoiceService stays for inline use cases
(tests, quick imports). The job is now the canonical path for the
admin UI to keep requests sub-100ms.
== M15 — eta_promised + JSON tracking + notifications log ==
Three new wo columns: eta_promised (initial commitment, never changes),
eta_change_reason (text for "așteptăm piesă"), eta_updated_at (when
the current eta was last touched). Existing eta_at remains as "current"
ETA so the UI can render both side-by-side.
New /api/track/{token} JSON endpoint (public, tenant-scoped via subdomain):
number, status, status_label, progress %, client, vehicle, plate, master,
eta_promised, eta_current, eta_change_reason, total, pay_status,
pending_approvals[] (each with kind/id/name/amount/approve_url —
signed URLs ready for native app webview),
timeline[] (from activity_log, last 20 events).
NotificationDispatcher::dispatch() gains optional workOrderId param.
Every send call (success or failure) now writes one row to the new
client_notifications_log table with channel/template_key/status (sent
or failed)/error_detail/sent_at. Failures of logging are swallowed
so a missing activity_log never breaks notifications. workOrderReady
and paymentReceived pass the WO id through; others can be wired in
future commits without schema change.
New tables tracked:
client_notifications_log — every push to client, append-only
pricing_application_logs — every pricing decision, append-only
ocr_jobs — async OCR job queue
== Tests ==
PolishTier3Test (11):
- M12: body_type condition match/no-match; transmission DSG match;
pricing_log row persists base/final/applied/ctx
- M13: mechanic API board scoped to own WOs; start task on foreign
work returns 403; KPI endpoint computes 2.5/3 = 83% efficiency
across 2 done works in period
- M14: ocr_job queueable + Queue::fake assertion
- M15: tracking JSON returns ETA promised/current/reason + pending
approvals with correctly-signed approve_url; dispatcher writes
ClientNotificationLog row on workOrderReady
- M12: vehicle body_type + transmission_type round-trip through save
Suite: 269 passed (761 assertions). Was 258.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Closes the P2 items from /tmp/service/new/01-TZ-rbac §4.1 §4.2.
== User invitation workflow ==
New columns on users: invited_at, invited_by_id (FK self), accepted_at,
invitation_token (sha256 hash, indexed). Migration is idempotent.
User::sendInvitation($invitedBy = auth()->user())
- generates 64-char random token
- stores sha256(token) in invitation_token column (never plaintext)
- marks invited_at = now(), status = inactive
- queues UserInvitationMail to the user's email with the signed accept URL
- returns the raw token (for tests / API consumers)
User::findByInvitationToken($rawToken) hashes + lookups.
User::acceptInvitation($password) sets password (hashed cast), clears
invitation_token, marks accepted_at + email_verified_at, status = active.
Web routes (no auth — token IS the credential):
GET /invitations/{token} → password-set form
POST /invitations/{token} → validates min:8 + confirmed, accepts
Tokens expire after 7 days (checked against invited_at). Expired and
invalid tokens render dedicated views (invitations/expired.blade.php,
invitations/invalid.blade.php) instead of generic 404 — so the user
knows to ask for a resend.
UserInvitationMail uses Filament's existing markdown layout; subject
includes the tenant display_name.
== REST API ==
Twenty new endpoints under /api/v1/ (Sanctum auth + tenant scoping
via the existing EnsureTokenMatchesTenant middleware). All gated by
ADMIN_USERS_* / ADMIN_ROLES_MANAGE permissions; mechanic-level token
gets 403.
Users:
GET /users — paginated + role/status/q filters
GET /users/{u} — eager-loads roles + overrides + invitedBy
POST /users — creates inactive user + sends invitation
PATCH /users/{u} — update name/email/role/status
DELETE /users/{u} — soft delete
POST /users/{u}/activate
POST /users/{u}/deactivate — also revokes all sessions
POST /users/{u}/resend-invitation
POST /users/{u}/force-password-reset — re-sends invitation
GET /users/{u}/sessions — list active sessions (from sessions table)
DELETE /users/{u}/sessions — revoke all
DELETE /users/{u}/sessions/{sessionId} — revoke one
GET /users/{u}/roles — assigned roles
POST /users/{u}/roles — assign role
DELETE /users/{u}/roles/{role} — remove role
GET /users/{u}/permissions — effective: role perms + grants - active denies
POST /users/{u}/permission-overrides — add grant/deny (with optional expires_at)
DELETE /users/{u}/permission-overrides/{perm}
Roles:
apiResource roles — index/show/store/update/destroy
(system roles guarded against rename/delete)
GET /roles/{r}/permissions
PUT /roles/{r}/permissions — bulk sync
GET /permissions — catalog: flat list + grouped + labels + role labels
Authorization is uniform: every controller method calls $this->authorize()
which throws 403 if canDo(perm) is false. canDo() already honors the
overrides + admin bypass + audit log from earlier commits, so the API
behaves identically to the Filament UI.
== Tests ==
InvitationFlowTest (8): token generation + sha256 storage + queued mail,
findByInvitationToken happy/sad path, accept sets password + activates,
GET form renders, POST accepts + redirects, invalid token view,
backdated invited_at → expired view, password too short → validation error.
RbacApiTest (12): admin can list users, mechanic 403, create user
queues invitation, assign+remove role round-trip, effective permissions
endpoint subtracts active denies, add+remove override via API,
role index returns 7 system roles with permission counts (51 for owner),
role sync permissions, system role destroy rejected with 422,
permission catalog endpoint returns all 51 + grouped + labels,
revoke all sessions deletes only target user's rows.
Suite: 234 passed (659 assertions). Was 214.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rate limiting:
- Shop POST endpoints get per-IP throttles with distinct prefixes so login,
register, password-email, and password-reset have separate buckets:
login/register/pw-reset = 5/min, pw-email = 3/min
- OcrInvoiceService gates per-tenant via RateLimiter (30/hour) so a runaway
uploader can't burn Claude Vision spend
Health monitor (poor-man's monitoring):
- HealthCheckCommand probes DB (SELECT 1), cache write/read, public storage
write/read, and most-recent backup age. On any failure, pushes a Telegram
alert via HEALTH_ALERT_BOT_TOKEN/HEALTH_ALERT_CHAT_ID. Dedups identical
failures within a 30-min window via cache.
- Scheduled every 10 min. Pair with external uptime monitoring (UptimeRobot,
Better Stack hitting /up) for total-outage coverage.
- .env.example documents the two new env vars.
VAPID secret hygiene:
- credentials.md no longer stores the VAPID_PRIVATE_KEY; the source of truth
is the Coolify env on the autocrm app. Doc points to where to read it
(UI or API). Mitigates accidental git leak.
Tests (4 new):
- shop login throttles after 5 attempts (6th = 429); register throttle is
independent of login (separate prefix); health command runs clean; dedup
cache path exercised
Full suite: 138 passed.
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>
- Coolify persistent volume mounted at /app/storage/app (covers public uploads,
private files, backups). Configured via API:
POST /api/v1/applications/{uuid}/storages with type=persistent
- getLogoUrl() / getFaviconUrl() now validate file_exists($m->getPath()) before
returning URL — guards against stale DB rows from pre-volume era
- Removed /debug-storage diagnostic route (used to find the symlink+volume bug)
Models & migrations:
- platform_settings table (key/value JSON store + Cache::remember 5min)
- plans: is_demo bool + trial_days int
- companies: is_demo bool
Plans:
- Demo plan seeded (is_demo=true, is_public=false, all features, 14 trial days)
- Trial 14-day plan seeded (is_public=true, basic features)
- Plan form: is_demo toggle + trial_days field
- Plan table: badge 🎬 Demo / 🎁 N zile trial
Central panel:
- PaymentSettings page (heroicon-credit-card, sort 90)
Form sections: General, Date legale, Stripe, PayPal, Transfer bancar
Each gateway collapsible, fields hidden until enabled toggle
Saves to platform_settings keyed by `payments.{gateway}`
- CompanyResource: is_demo toggle + table description
Payment flow (PaymentController):
- GET /billing — tenant invoices list with Pay button
- POST /pay/{sub} — start checkout (stripe/paypal/bank)
- GET /pay/{sub}/{success,cancel}
- POST /payments/stripe/webhook — mark paid + extend company.active_until
- POST /payments/paypal/webhook — same
Views:
- site/billing.blade.php — invoices list with payment modal (3 methods)
- site/bank-instructions — IBAN/BIC/reference for manual transfer
- site/checkout-stub — placeholder until composer require stripe-php
- site/payment-{success,cancel}
Tenant panel:
- userMenuItems → "Facturile mele" link to /billing
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