Commit Graph

25 Commits

Author SHA1 Message Date
Vasyka 2ca6ab58a1 feat(nav): FAQ shows all 8 sections + drag-drop reorder in Settings
FAQ sections were only 4 (service/crm/depozit/finante) even though
the sidebar has 8. Added the missing 4 to Hints::AREAS with proper
icons + accent colors:
  📊 Analiză (pink)   📣 Marketing (orange)
  🛒 Magazin (cyan)   ⚙️ Admin (slate)

Retagged 15 hints that were shoved into wrong areas as a workaround
(admin.*/marketing.*/shop.*/analytics.* now use the matching bucket).
FAQ blade reads accent color from the AREAS registry instead of a
hardcoded local map.

Also added per-tenant navigation group ordering:
- Settings → new "Ordine meniu lateral" section with a Filament
  Repeater that has reorderable buttons (drag/arrow buttons) but no
  add/delete (fixed list of 8 groups)
- Persisted to settings.nav_group_order as an array of keys
- New ConfigureNavGroups middleware (runs after ResolveTenant on
  the web stack) reads the current tenant's order and calls
  $panel->navigationGroups([...]) so the sidebar renders in that
  order per-request

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-09-01 11:09:24 +00:00
Vasyka 346fcf968f fix(faq): toggle worked server-side but click did nothing — nested forms
Filament pages wrap their content in an outer <form>. My inline
<form method="POST"> for the hint toggle + reset was nested inside
that outer form — invalid HTML, so browsers dropped my submit and
either did nothing or ran the outer Livewire form's handler.

Replaced both forms with plain <button type="button"> + onclick
fetch() POST + window.location.reload(). Controller now also returns
JSON when Accept: application/json (fetch sends that) instead of a
back() redirect.

The server-side flip was always correct — verified via a Feature
test that posts to /app/hints/toggle and asserts the user column
flipped from true → false.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-09-01 11:04:24 +00:00
Vasyka 06081159b6 feat(hints): contextual help system + FAQ page (MVP)
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>
2026-09-01 09:46:07 +00:00
Vasyka 3911012c65 feat(i18n): mega-wrap 494 raw RO strings + 269 lang entries + 161 human RU/163 EN
Broad sweep across the whole codebase:
- Blade views (39 files, 315 wraps): tag-text and title/placeholder/alt
  attributes wrapped with {{ __() }}. Excludes scripts, styles, @php,
  @verbatim, {{ }}, {!! !!}, comments to avoid touching interpolations.
- PHP (54 files, 179 wraps): array 'key' => 'RO value' patterns and
  list items with diacritics wrapped with __(). Reverted __() inside
  const arrays (PHP disallows non-constant expressions).
- Added 269 new keys to lang/{ru,en}.json (identity fallback for
  unknowns → 161 human RU + 163 EN translations added for the most
  common enums, stages, roles, statuses, payment methods, vehicle
  categories, warehouse, portal, form actions.

Missing translations fall back to RO so the UI never breaks. All 306
tests pass; view cache compiles cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-15 05:53:22 +00:00
Vasyka a03561eaaf fix: SetLocale reads app_locale cookie first (partial from previous commit)
The SetLocale::resolve() edit was dropped from the previous commit.
Cookie priority is essential for the switcher to actually take effect
— without this the /locale route was setting a cookie that nothing
read.

Now resolve order:
1. app_locale cookie (persistent, survives session regen)
2. session locale
3. auth user.locale
4. tenant.settings.language
5. hard-coded 'ro'

Suite: 306 unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-13 20:25:25 +00:00
Vasyka b9d096fa59 fix: language switcher works — GET route, simple links, forced RO fallback
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>
2026-07-13 20:17:12 +00:00
Vasyka 03e030d6d2 feat: tier 3 polish — M12/13/14/15 deep cleanup
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>
2026-06-05 05:31:50 +00:00
Vasyka 0e3119a6e2 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>
2026-06-05 04:56:06 +00:00
Vasyka d9180e16b3 feat: P2 RBAC defers — REST API + invitation workflow
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>
2026-06-04 22:36:44 +00:00
Vasyka 3da1f5412a feat: shop UX polish — password reset / order email / multi-image / customer admin
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>
2026-06-03 06:14:45 +00:00
Vasyka 75386c354a feat: shop customer accounts (register/login + order history)
Schema:
- shop_customers (company_id, name, phone unique-per-tenant, email, password,
  client_id auto-linked, last_login_at)
- online_orders.shop_customer_id nullable FK

Auth:
- New 'shop' guard (session driver, shop_customers provider) in config/auth.php
- ShopCustomer Authenticatable with hashed password cast and BelongsToTenant
  global scope — login attempts naturally scoped to current tenant subdomain

Flow:
- ShopAuthController: register / login / logout / account
- Register auto-links to existing Client by phone match
- /shop/account: order history (only the logged customer's orders) + profile
- Checkout prefills name/phone/email from logged customer + sets
  shop_customer_id (and client_id from auto-link) on the placed order
- Layout nav switches between Login/Register and "👤 Name + Ieșire"

Tests (8 new):
- register creates customer + auto-login
- register auto-links existing Client by phone
- duplicate phone rejected
- login validates credentials
- /account requires auth (redirects to /shop/login)
- /account lists only the logged customer's orders
- checkout attaches shop_customer_id
- customers tenant-isolated

Full suite: 117 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 19:43:39 +00:00
Vasyka 954ba8f059 Stage 12 — Online Store: public catalog + cart + orders
Schema:
- online_orders (token-tracked, status workflow, delivery method/fee)
- online_order_items (price snapshot, fulfilled flag)
- part_cross_refs (OEM/equivalent codes for search)
- parts.is_published (shop visibility)

Storefront (ShopController, tenant subdomain, /shop):
- Catalog with search across name/article/brand/cross-refs, category +
  in-stock filters, live stock, white-label themed layout
- Part detail page with cross-ref codes
- VIN search → VinDecoder → guided catalog search
- Session cart (per-tenant key), guest checkout, order confirmation page
- Respects settings.shop.enabled (404 when off); tenant-guarded

Part::searchPublished matches cross-ref articles via whereHas.

Order notifications (ShopOrderNotifier, best-effort):
- Staff: Web Push to active users
- Customer: Telegram if phone matches a linked client

Filament (tenant):
- OnlineOrderResource under "Magazin" nav group, status workflow,
  items relation, "Onorează" action issues stock via WarehouseService (FIFO)
- PartResource: is_published toggle + column + bulk publish/unpublish +
  CrossRefsRelationManager
- Settings: shop section (enable, delivery methods, fee, free-over)
- Landing page: shop button when enabled

Tests (6 new):
- catalog 404 when disabled; lists published only; cross-ref search;
  order placement (token + items + total); fulfill issues stock;
  cross-tenant token isolation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:27:51 +00:00
Vasyka c413004930 Stage 15 — PWA complete: install prompt + Web Push notifications
Dependency:
- minishlink/web-push v10 (VAPID JWT + aes128gcm payload encryption)
- Dockerfile: add curl, mbstring, gmp extensions (web-push needs ext-curl)

VAPID:
- config/webpush.php from env; `php artisan push:vapid` generates keypair
- Shared platform keypair; .env.example has empty placeholders

Schema:
- push_subscriptions (user/company, endpoint unique, p256dh, auth, encoding)

WebPushService:
- send / sendToUser / dispatch via WebPush::flush
- Auto-prunes subscriptions reported expired (404/410)

Subscribe flow:
- POST /push/subscribe + /push/unsubscribe (auth, tenant)
- Tenant panel JS subscribes after SW registration with VAPID public key

Service worker (/sw.js):
- Cache v2, push listener → showNotification, notificationclick → focus/open

Install prompt:
- Floating "Instalează aplicația" button wired to beforeinstallprompt

Staff push:
- WorkOrder master_id change → push to assigned mechanic
- Settings "Test notificare push" action

Tests (6 new):
- subscribe stores + upserts; requires auth (401); validation (422);
  service configured; sendToUser with no subs returns zero

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:11:18 +00:00
Vasyka e48ef1b755 Stage 7+14 — Mechanic Board + Scan Center
Mechanic Workflow (Stage 7):
- /app/mechanic Filament page filtered to master_id = auth user
- Kanban 4 columns (in_work / awaiting_parts / ready / recent), each card
  shows WO#, plate, client, complaint summary, photo presence
- 2 KPI tiles (active now / closed today)
- Mobile-responsive grid (auto-fit, minmax 260px)

WarehouseService:
- issueNow(WorkOrderPart) — consume reservations immediately scoped to one
  line, without closing the WO (mechanic physically takes part now)
- returnPart(WorkOrderPart, qty?, notes?) — refund to stock as new batch
  at original buy_price, writes `return` event, capped at consumed total

WO PartsRelationManager:
- "Eliberează" action — visible when active reservation exists
- "Restituire" action — visible when consumed reservation exists, with qty
  modal + notes

Scan Center (Stage 14):
- PartResource "QR" action — per-part SVG QR with payload PART:<article|id>
- BulkAction "Tipărește etichete QR" → /parts/labels?ids=N,M (HTML A4 sheet,
  3-col grid, print CSS hides toolbar)
- /app/scan Filament page using html5-qrcode 2.3.8 (CDN), auto-picks back
  camera, decodes → Livewire dispatches scanner-decoded → resolveAndRedirect
- Lookup matches PART:N prefix, parts.article, parts.barcode, or numeric id
- Manual input fallback for browsers without camera

Tests (6 new):
- WarehouseIssueReturnTest (3): issueNow consumes immediately; returnPart
  creates positive batch + return event; over-return is capped
- ScannerLookupTest (3): PART: prefix lookup, raw barcode lookup, unknown miss

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:39:39 +00:00
Vasyka 85ef2f6e00 Stage 13 — Notifications: Telegram bot + multi-channel + service reminders
Schema:
- clients.telegram_chat_id (linked via /start contact-share)
- clients.notify_prefs (per-client channel order override)
- service_reminders_sent (dedup ledger for the daily cron)

Telegram (per tenant):
- TelegramService (sendMessage, getMe, setWebhook with auto-generated secret)
- Bot token stored in companies.settings.telegram.bot_token
- Webhook /telegram/webhook/{slug} validates X-Telegram-Bot-Api-Secret-Token,
  matches client by last 9 digits of phone, persists chat_id, replies confirm
- /start prompts share-contact; /stop unlinks chat_id

NotificationDispatcher refactor:
- Multi-channel: telegram first if chat_id + bot configured, then email
- Backwards-compat with legacy boolean notify.{type} flags
- 4 HTML-formatted Telegram messages (wo_ready with tracking link, payment,
  appointment, reminder)

Service reminders:
- `reminders:send` artisan command with --slug / --dry-run
- Policy: vehicles whose last closed WO is older than reminder.after_days
  (default 365). Skips if sent within reminder.cooldown_days (default 30).
- Schedule daily 09:00

Filament UI:
- Settings page: Telegram bot token field + "Test bot" + "Set webhook" actions
- Settings page: reminder_after_days + reminder_cooldown_days inputs
- ClientResource: telegram_chat_id readonly badge

Tests (6 new, all pass):
- webhook links client via shared contact
- webhook rejects wrong secret → 401
- dispatcher uses telegram when chat_id present (Http::fake)
- dispatcher falls back to email otherwise
- dispatcher returns false when no channel available
- reminder cron respects 30-day cooldown

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:14:17 +00:00
Vasyka edcdba9d53 Stage 3 — WO photos + ETA + QR + public tracking page
- HasMedia (Spatie) on WorkOrder with `photos` collection
- eta_at + tracking_token columns; token auto-generated on create
- Public /t/{token} page — tenant-scoped via subdomain, white-label themed
- QR code SVG via chillerlan/php-qrcode (inline modal + download)
- Filament: SpatieMediaLibraryFileUpload + ETA picker + tracking section
- EditWorkOrder header action "Link client (QR)" modal
- Fix: Auditable::dontSubmitEmptyLogs() → dontLogEmptyChanges() (removed in activitylog)
- Tests: TrackingPageTest (4 pass) covering token gen + cross-tenant isolation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:21:23 +00:00
Vasyka 93a69dd826 Add Paynet (Moldova) payment gateway
PaymentSettings:
- New "🇲🇩 Paynet" section: enabled toggle, mode (test/live), merchant_code,
  service_id, user, password, secret (HMAC), webhook URL hint
- Webhook URL: https://service.mir.md/payments/paynet/webhook

PaymentController:
- startPaynet() — builds Paynet redirect (stub mode prints flow)
- paynetWebhook() — verifies HMAC-SHA256 signature canonical
  Merchant_Code|Order_ID|Amount|Status, marks subscription paid on Status=OK,
  matches by invoice_number = Order_ID
- availableMethods() includes paynet

Tenant /billing:
- 4th payment button "🇲🇩 Paynet" — visible only when configured.
  Description: Card MAIB / MICB / Victoriabank, MD Cash, e-money

Routes:
- POST /payments/paynet/webhook (CSRF excluded)
2026-05-08 06:20:11 +00:00
Vasyka 827bf12d89 Demo plan + Payment integrations (Stripe/PayPal/Bank)
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
2026-05-08 05:55:30 +00:00
Vasyka 0399262514 Deploy 3: Onboarding wizard + empty states + docs operationale
- 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
2026-05-07 20:16:03 +00:00
Vasyka eaa05d68c1 Deploy 2: 2FA (App + Email) + REST API + CSV import-export + auto backup
- Filament v5 multiFactorAuthentication enabled on both panels (App + Email)
- HasAppAuthentication + HasEmailAuthentication on User and SuperAdmin
- Migration: app_authentication_secret + recovery_codes + email_authentication_at
- Sanctum REST API: /api/v1/login, /me, clients, vehicles, work-orders
- EnsureTokenMatchesTenant middleware blocks cross-tenant token usage
- CsvImportExport service: clients + vehicles bulk via plain CSV
- Import/Export buttons on Client + Vehicle list pages
- ApiTokens page in tenant panel (generate/revoke + last-used)
- BackupAllTenantsCommand + scheduler (daily 03:00, retain 14 days)
- Background scheduler in entrypoint.sh
2026-05-07 19:25:27 +00:00
Vasyka ce4e21220f fix: SetLocale tolerant to early-pipeline missing session 2026-05-07 19:15:43 +00:00
Vasyka d1e0695930 Deploy 1: i18n + Notifications + Global Search + Tests
- SetLocale middleware (ro/ru/en, session-first, user-persisted)
- Lang switcher in topbar (Filament render hook USER_MENU_BEFORE)
- POST /locale/{lang} route persists to user.locale + session
- Database notifications enabled on tenant panel (30s polling)
- GlobalSearch (Cmd+K / Ctrl+K) on Client, Vehicle, WorkOrder, Lead, Part
- Tests: TenantIsolation (4), AuthFlow (2), WorkOrderCalc (3), MarkupRule (3)
2026-05-07 18:22:48 +00:00
Vasyka c9cb3560ef Faza 3.1: CRM core — Leads, Deals, Appointments, Settings, Widgets, Users
Spatie Permission cu teams (team_foreign_key=company_id, teams=true):
- migrations create_permission_tables (model_has_roles cu company_id scope)
- HasRoles trait pe User
- ResolveTenant middleware setează permissions team_id la tenant.id
- Seed: 7 roluri default per tenant (admin/manager/receptionist/mechanic/parts_manager/accountant/marketer)

Module noi:
- Leads (cereri): name, phone, car/model, source, UTM, status, budget, assigned_to,
  acțiune "Convertește" → creează automat Client + Deal
- Deals (pipeline): client/vehicle, stage (8 stage-uri), price, source, lost_reason
- Posts + Appointments: post_id (boxă), master_id, date+time_start+time_end, status, color
- UserResource (tenant): CRUD users cu role/status/locale; canViewAny doar pentru admin

Custom Filament page "Setări" (tenant):
- Brand & contact (display_name, city, phone, email)
- Localizare (limba RO/RU/EN, currency, theme color picker)
- Servicii & tarif (labor_rate)
- Liste configurabile (services, cars) — păstrate în companies.settings JSON

Widgets dashboard:
- Tenant: StatsOverview (Clienți, Mașini, Cereri noi, Deal-uri active, Programări azi)
- Central: PlatformStats (Companii total/active/trial, Expiră în 7 zile)

Seed extins demo PSauto:
- 3 posturi (Pod 1/2/3 cu culori)
- 2 lead-uri demo (Alex Grosu Telegram, Irina Cojocaru WhatsApp)
- 3 deal-uri demo (BMW done, Audi in_work, Porsche agree)
- 2 programări (azi + mâine)

Filament v5 fixes:
- $navigationGroup type → string|UnitEnum|null (parent stricter signature)
- Toate resources noi au tipurile corecte
2026-05-06 17:36:32 +00:00
Vasyka 4b1635d045 Faza 2: multi-tenancy + Filament dual panels + seed PSauto
Schema centrală:
- companies (slug unique, status, plan_id, settings JSON, trial/active dates)
- super_admins (operator platform)
- plans (free/basic/pro)

Schema tenant (toate cu company_id NOT NULL):
- users (UNIQUE company_id+email)
- clients
- vehicles

Tenancy core:
- App\Tenancy\TenantManager singleton
- App\Models\Concerns\BelongsToTenant trait + TenantScope
- ResolveTenant middleware (slug → Company, 404 pentru rezervate/missing)
- CheckTenantStatus middleware (suspended/expired/archived)
- Fail-safe: TenantScope returns 0 rows când tenant nu e rezolvat

Auth guards:
- 'central' guard cu super_admins provider (panou platform)
- 'web' guard cu users provider (per-tenant)

Filament panels:
- CentralPanelProvider la service.mir.md/admin
- TenantPanelProvider la <slug>.service.mir.md/app
- CompanyResource (central): CRUD companii cu status badge + filtre
- ClientResource (tenant): CRUD clienți cu status, sursă, sold
- VehicleResource (tenant): CRUD mașini cu marcă/model/VIN

Seed:
- 3 plans (free/basic/pro)
- super-admin: vasyka.moraru@gmail.com / admin123
- demo company 'psauto' cu admin user admin@psauto.md / admin123
- 3 clienți + 3 mașini preluate din AutoCRM.html

Bootstrap:
- TrustProxies (Cloudflare→Traefik HTTPS detection)
- forceScheme/forceRootUrl când APP_URL e HTTPS
- Helper global tenant() în app/helpers.php (autoload via composer)
- RUN_SEED env var în entrypoint pentru db:seed condiționat
2026-05-05 21:29:52 +00:00
Vasyka 5e32f82b3a Initial Laravel 12 + Filament 5 + Octane skeleton
- Laravel 12 base
- Filament 5 (default admin panel)
- Stancl/Tenancy v3 (config + migrations only)
- Spatie Permission
- Octane FrankenPHP runtime
- Sanctum
- Dockerfile multi-stage (composer + node + frankenphp:8.4)
- Entrypoint runs migrations + caches on boot
- .env.example pre-completat cu hosturi interne Coolify
- Health endpoint /up

Repo init pentru multi-tenant SaaS pe Coolify Hetzner.
2026-05-04 12:19:55 +00:00