- Replaced broken ->rules(fn (\$record)) with proper Filament ->unique()
using ignoreRecord:true + modifyRuleUsing scoped to company_id,
applied to both phone and email in MasterResource and UserResource
- Added migration 2026_09_26_000001 to add unique(company_id, phone)
constraint on users table (nulls out duplicate phones before adding,
MySQL allows multiple NULLs in unique index so blank phones are fine)
- Exception catch in Create/EditMaster and Create/EditUser now also
handles UniqueConstraintViolationException on phone
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two-layer protection on MasterResource and UserResource:
1. Form validation via ->rules() with Rule::unique() scoped to company_id
(prevents DB hit in normal flow, shows inline field error)
2. handleRecordCreation/handleRecordUpdate catches
UniqueConstraintViolationException as fallback — shows a Filament
danger notification and halts without showing an error page
Also adds phone uniqueness validation (scoped per tenant).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Added ->unique(ignoreRecord: true) scoped to company_id on the email
field in both MasterResource and UserResource. Previously a DB-level
UniqueConstraintViolationException crashed the page; now Filament shows
"Acest email este deja folosit de un alt utilizator." inline on the field.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previous approach wrapped {{ $this->table }} in x-show which caused
Livewire/Alpine conflicts and broken toggling. New approach:
- viewMode is a Livewire #[Url] property (persisted in URL as ?view=)
- wire:click="setViewMode('list/tree')" triggers a clean Livewire render
- Only one view is rendered at a time (@if, not x-show)
- Tree: Alpine manages per-category open/close state client-side via
indexed array; Open/Close all buttons use setAll() helper
- Visual overhaul: tighter cards, chevron arrows, proper hover states
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ListLabors now has a toggle button (persistat în localStorage) care
comută între:
- Listă: tabelul Filament grupat pe categorii (existent)
- Arbore: accordion cu toate categoriile ca noduri colapsabile,
fiecare cu tabelul manoperelor sale (Manoperă, Mod, Ore, Preț,
Cost, link Editează); butoane Deschide tot / Închide tot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DATE columns lose time information, causing timeline to show 00:00 for
car reception and WO closure. Migrated both columns to DATETIME and
updated: model casts (date → datetime), form pickers (DatePicker →
DateTimePicker with seconds:false), default to now() instead of today(),
and cleaned up defensive Carbon::parse() in getTimeline().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Parts (wo_parts) already had discount_pct; now also added to wo_works.
Labor catalog gets a cost_price field as the hard minimum sell floor.
- WorkOrderWork: discount_pct + cost_price fields; total calculation
now includes discount: hours × price_per_hour × (1 − disc/100)
- Labor model: cost_price field (used to pre-fill work rows and validate)
- WorksRelationManager: discount_pct field with live helper text showing
effective price + margin %; validation blocks save if below buy cost
or below company min_discount_margin_pct; "Reducere globală" header
action applies % to all work rows (skips rows that would breach limits)
- PartsRelationManager: same validation on existing discount_pct field;
helper text (🟢/🟠/🔴) shows effective price + margin in real time;
"Reducere globală" header action for all parts
- Settings page: new "Reduceri & limite preț" section with
min_discount_margin_pct — 0 = no limit, >0 = hard block below that %
- LaborResource form: cost_price field
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The read-only Achitat (MDL) display is unnecessary — the Total field
already shows the amount. Only show the editable Sumă achitată field
when partial is selected; rest de achitat remains always visible.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WorkOrderResource: pay_status is now live(); added _partial_amount
TextInput (visible only when pay_status=partial) that shows current
paid amount and accepts a new total — dehydrated so it does not save
directly; _achitat hidden when partial is selected to avoid confusion
- EditWorkOrder.afterSave: creates a Payment record for the difference
when pay_status=partial and entered amount > existing paidAmount
- WorkOrderDashboard: WithFileUploads + uploadPhotos / deletePhoto
methods using Spatie MediaLibrary
- Dashboard blade: Foto tab replaced with upload form + gallery with
per-photo delete buttons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows the financial breakdown inline in the Plată & total section:
- Total (MDL) — bold, computed
- Achitat (MDL) — green, from paidAmount()
- Rest de achitat (MDL) — red if unpaid, green if fully paid
All three fields are readOnly/dehydrated (display only). To register
payments, use the Plăți relation manager tab.
Translations added for RO/RU/EN.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- pay_status: add label Statut plată (was showing English auto-label)
- discount_pct: rename label to Reducere % (standard RO terminology)
- total: disabled() → readOnly() so field looks active (not grayed out)
while still preventing manual editing; $set() from discount live works
- Add ru/en translations: Statut plată, Reducere %
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PaymentsRelationManager: remove broken default() closure on amount;
use fillForm() on CreateAction instead (correct Filament API) so the
modal pre-fills with remaining balance (total - paid) but field stays
fully editable; add minValue/step for numeric UX
- WorkOrderResource: discount_pct is now live(onBlur) with afterStateUpdated
that recomputes works+parts+subcontract minus discount and sets the
total field immediately — no Save required to see the effect
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PDF parts table: removed Cod/article column, widened Denumire column,
fixed colspan in tfoot from 4 to 3
- PaymentsRelationManager: amount field now defaults to (total - paidAmount)
so it pre-fills with the remaining balance; always editable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
afterSave() calls recalcTotal() so discount_pct changes are applied
to the total immediately on save, then fillForm() refreshes the
displayed total field.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
With collapsedGroupsByDefault() the 386 rows are fetched but not
rendered in DOM until expanded — no perf issue, all 17 group headers
now visible without needing to navigate pages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Migration: replace 45 leaf categories (diag.scan, engine.mech…) with
17 root categories (diag, engine…); update labors.category by taking
the prefix before the first dot; delete the leaf LaborCategory rows
- ImportLaborCatalogCommand: rewritten to use root categories directly
so future imports go to the right level from the start
- LaborResource: add ->collapsible() to groups so user can collapse/
expand each category section to see only headers or only one group
- entrypoint.sh: remove one-time catalog import (was already done)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- LaborResource: hide category column from data rows by default
(toggleable) so group headers span all columns cleanly; the group
header already shows the localized category name
- ImportLaborCatalogCommand: add --all flag to iterate every company,
refactor into importForCompany() helper, keep idempotent (skips
existing codes, updates only with --force)
- entrypoint.sh: run catalog:import-labors --all after each deploy so
all 386 operations + 45 categories are seeded automatically
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- LaborResource: add titlePrefixedWithLabel(false) so group headers show
just the localized category name (not "Categorie: Шины"); remove static
cache from closure to avoid Octane cross-request locale bleed
- WorkOrderDashboard: add changeStatus() Livewire method + Alpine.js
dropdown on the status badge so status can be changed directly from
the dashboard without going to the full edit form
- New artisan command catalog:import-labors {company} that reads
database/data/labor-catalog.json (386 operations, 45 leaf categories)
and upserts labor_categories + labors for the given tenant; supports
--force to update existing records by code
- Add "Status actualizat" translation to ru/en JSON
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Defines a proper Tables\Grouping\Group with getTitleFromRecordUsing so
group headers use the locale-aware label from LaborCategory::optionsForSelect()
instead of the raw DB value with Filament's hardcoded "Category:" prefix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
formatStateUsing looks up localized category name from LaborCategory
so 'Anvelope' shows as 'Шины' (ru) / 'Tires' (en). Filter label
uses __('Categorie') to avoid the auto-derived 'Category' (English).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Section was Filament\Schemas\Components\Section not Forms\Components\Section
- Added ru/en translations for page title, form labels, nav label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Migration: adds name_ro, name_ru, name_en columns; copies name→name_ro;
seeds RU and EN translations for all 11 default categories
- LaborCategory: label() returns localized name, optionsForSelect() keyed
by canonical name (stored in labors.category) with localized label
- LaborCategoryResource: 3-column form with RO/RU/EN inputs; table shows
all three columns side by side
- LaborResource: category select and filter use optionsForSelect()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Creates labor_categories table (company-scoped) seeded with 11 defaults
- LaborCategory model with BelongsToTenant
- Full Filament CRUD resource with drag-to-reorder (sort_order)
- LaborResource category Select now reads from DB instead of hardcoded const
- Navigation: 'Categorii manopere' in Service group, sort 33
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getPluralModelLabel now uses nav.label.Norme-ore (already translated
to 'Catalog manopere'/'Каталог работ') so breadcrumb and page heading
match the sidebar navigation label.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Migration: make clients.phone nullable (was NOT NULL, broke inline create)
- Vehicle createOptionUsing: use Get callback instead of $livewire->data
to reliably get client_id in the vehicle inline form
- Remove plate->required() on inline vehicle form (not required in DB)
- Rename 'Norme-ore' → 'Catalog manopere'/'Каталог работ' in navigation
so users can find the labor catalog easily
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Queries now run only when modal is opened — no risk of page-load
exception hiding the button. Button is always visible for admin/owner.
Adds restore action inside modal footer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Helps identify cross-tenant WO leakage — shows each company's slug,
active count, deleted count, and whether it's the current tenant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Soft-deleted WOs (WO-26-0005..0008) are missing from admin view.
A new restore action lets the admin recover them without CLI access.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace isAdmin() (Spatie-dependent) with direct role check and add
TenantManager fallback using hostname subdomain so the button appears
even if TenantManager was not resolved for the current request.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows total WO count broken down by tenant/other-company/deleted across
all records without global scopes, helping debug missing WO visibility.
Also adds a recover action when orphaned WOs are found in wrong company.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Instead of relying on terminal/artisan commands, admins see a warning
button on the WO list when orphaned WOs exist (wrong company_id due to
Octane singleton bug). One click moves them all to the current tenant.
Button only shows if there are orphaned WOs and user is admin/owner.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
FrankenPHP/Octane reuses the same PHP process for concurrent requests.
TenantManager registered as singleton() was shared between requests —
a concurrent request from another tenant could overwrite the current
tenant context mid-flight, causing records to be saved with the wrong
company_id.
Fix 1: Change singleton() to scoped() so Octane flushes TenantManager
at the end of each request (fresh instance per request lifecycle).
Fix 2: Explicitly set company_id in mutateFormDataBeforeCreate() as an
extra guard — even if the creating event fires in the wrong context,
the value from the request's own TenantManager call takes precedence.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
isMechanic() calls hasAnyRole(['mechanic']) via Spatie which checks the
model_has_roles table with team_id scoping. If roles are not correctly
seeded for a tenant, admins/owners could accidentally pass the mechanic
filter and see only WOs where master_id = their own id (i.e. nothing).
Now uses $user->role === 'mechanic' directly — no Spatie involved —
so admin/owner/manager always see all WOs in their tenant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previous implementation relied on Spatie canDo() for all roles, which
caused admins/managers to see nothing if permissions weren't seeded.
Now: mechanics see only their assigned WOs (master_id = self); every
other role (admin, manager, receptionist, etc.) sees all WOs in the
tenant — TenantScope already prevents cross-tenant access.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Admin/owner and users with work_orders.view_all see all work orders.
Users with only work_orders.view_own_assigned (mechanics) see only
work orders where master_id = their own user ID.
Roles without either permission see nothing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- client_id field: switched to relationship() with createOptionForm so new clients
can be created inline (name, phone, email); search also matches by phone
- vehicle_id field: added createOptionForm (make, model, plate, year, VIN); newly
created vehicle is automatically linked to the currently selected client
- master_id field: now shows only active users with role mechanic/master
- WorksRelationManager: labor catalog select marked optional; added createOptionForm
so new catalog entries can be added without leaving the WO form
- SubcontractJobsRelationManager: category table column now translates value via __()
and shows explicit label(__('Categorie'))
- PDF work-order: replaced 📞 / ✉ / ⚠ emojis with plain-text equivalents so
DomPDF/DejaVu Sans renders them correctly instead of showing "????"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously nav had 12 groups, several with just 1 item. Now 8 clean
groups, no orphans:
- Anvelope (1 item) → merged into Service (Seturi anvelope)
- Stoc & Finanțe (1 item) → merged into Depozit (Import factură Excel)
- Tinichigerie (1 item) → merged into Service (Tinichigerie / Detailing)
- Subcontractare (2 items) → merged into Service (Lucrări terți,
Subcontractori)
- Integrări moved from Marketing → Admin (it's a config concern, not
a marketing feature)
- ClientResource + VehicleResource were ungrouped → added to CRM
Final groups:
Admin(8) · Analiză(4) · CRM(5) · Depozit(10) · Finanțe(6) ·
Magazin(2) · Marketing(3) · Service(11)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- WO list row click now opens /work-orders/{id}/dashboard instead of
/edit (via ->recordUrl on the table). Edit remains reachable from
the per-row Filament EditAction.
- CreateWorkOrder redirects to the dashboard after save instead of
the edit page.
- Dashboard top bar exposes: "Listă" (back to WO list), "+ Nou"
(create), and "Editare completă" (full edit form) so users don't
have to hop through the sidebar to move between related WO screens.
The old /work-orders, /create, and /{id}/edit routes stay intact —
just the default navigation flow now converges on the dashboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AppointmentResource::\$shouldRegisterNavigation = false so only
CalendarBoard shows in the sidebar as "Calendar". The board already
has a "Listă" view-mode button alongside Zi/Săpt/Lună/Custom, so both
use cases are covered from a single entry. CRUD routes
(/app/appointments/create, /{id}/edit) stay accessible from the
board's click handlers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All "Vizualizare PDF" actions now open a wide (7xl) Filament modal
containing an iframe pointing at the inline PDF endpoint, with a
top-right "Descarcă" and "Deschide în tab nou" fallback.
- WorkOrder edit action + WO table row action
- InjectorProtocol edit action + protocols table row action
- Dashboard Docs tab preview button (Alpine.js overlay since it's
a plain Blade view, not a Filament action)
The underlying /pdf routes stay unchanged (Content-Disposition: inline,
?download=1 for attachment) — the iframe just loads the same URL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vehicle model now implements HasMedia + InteractsWithMedia and
registers a 'photos' collection. VehicleResource form gets a
SpatieMediaLibraryFileUpload section (collapsible, max 6 photos,
imageEditor enabled).
Dashboard already checks vehicle->getFirstMediaUrl('photos') first
and falls back to the WO's photos, so uploading here immediately
populates the vehicle card on the dashboard without any view changes.
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>
Root cause of the "Fișa #0" bug: the page had
public ?WorkOrder \$record = null;
and the route slug was work-orders/{record}/dashboard. Livewire/Laravel
saw the {record} param and the typed \$record property with the same
name and attempted route model binding via BelongsToTenant scope. When
that resolution didn't return a Model instance in a Livewire hydration
context, mount() ended up being called with 0, so the button worked
but the destination page redirected saying "Fișa #0".
Fix: rename the route parameter to {wo} so it no longer collides with
the property name, receive it as int|string in mount(), and do the
find() ourselves. The property stays as \$record for the Blade view
but is now always populated by our own code.
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>
Filament's static ::getUrl(['record' => id]) is the canonical way to
build a page URL with parameters, and it uses the actual route resolver
(handling tenant panel prefix, etc). This is cleaner than hand-building
the path and avoids any \$this-binding ambiguity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Under some Livewire lifecycles \$this->record isn't hydrated when
getHeaderActions() runs, so \$this->record->id was null and the URL
came out as /app/work-orders//dashboard. Fall back to the URL segment
(always present because we're at /app/work-orders/{id}/edit) and log
which path was taken to help diagnose.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>