Infrastructure for a portal-wide in-app help system:
* Users have hints_enabled (default true) and dismissed_hints (JSON
array) columns. User::shouldSeeHint(key) checks both.
* app/Support/Hints.php — single-source-of-truth registry with 14
pilot hints across Service (5), CRM (3), Depozit (3), Finanțe (3).
Each entry has RO/RU/EN title + body + optional next-step + links.
* <x-hint key="wo.dashboard.overview" /> Blade component renders a
small "?" icon with Alpine.js popover; the popover shows title,
body, next-step, related links and an "X" button that POSTs to
/app/hints/{key}/dismiss.
* HintController handles dismiss (per key), toggle (global on/off)
and reset (clear dismissed + re-enable). Routes are auth:web.
* /app/faq page (Filament Page under Admin group) renders the whole
registry grouped by area with a live search box and buttons to
toggle global hints or reset dismissed ones.
* Wired 3 pilot hints into the WO dashboard: title (overview), Docs
tab PDF preview, and the Chat client card.
Follow-ups: extend registry to cover more pages and add <x-hint>
tags where useful. Filament resource fields can also reuse the same
copy via ->hint()/->helperText().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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