- WorksRelationManager Name column: getStateUsing(labor?->label()) so
the row shows the labor's localized name (not the raw snapshot);
fallback to stored snapshot when labor is missing.
- 4 explicit empty states with helper description + icon on Works,
Parts, SubcontractJobs, Payments — replaces Filament's auto-generated
'Не найдено X / Создать X для старта.'
- 4 CreateAction titles / modal headings for all four relation managers.
- Notification 'Piesa returnată în stoc' / 'Nimic de restituit' wrapped.
- +11 translation keys.
All 306 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Migration: add nullable `labors.name_en` alongside existing name_ro/
name_ru.
- Labor model: label(?locale) accessor returns name_{locale} with
name_ro fallback.
- LaborResource form: expose 3rd 'Nume (EN)' field.
- Table + Select displays now go through label() so options show the
locale-appropriate name:
/app/labors table column,
WorkOrderResource works Select ('[category] name (Nh)'),
ServiceTemplate items Select,
ServiceComposer WorkOrderWork snapshot on create.
- Snapshot name saved on wo_works.name at creation reflects the
current locale; existing rows keep their stored snapshot untouched.
All 306 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Labor Select options now build '[__(category)] name_ro (hours __(h))'
so category chip and hour suffix translate on RU/EN. Labor name stays
as name_ro (user data — separate long-term concern).
- CreateAction gets explicit label + modalHeading (was 'Create Work
Order Work' auto-generated).
- Notification 'Piese implicite adăugate (N)' rewritten with __(':n')
placeholder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MechanicKpi getPeriodLabel() was hardcoded to ->locale('ro') which
forced Romanian month names ('iulie 2026') regardless of the app
locale. Same on PDF appointments day-label. Both now switch to
app()->getLocale() so RU shows «июль 2026», EN 'July 2026'.
SetLocale middleware already calls Carbon::setLocale on every
request, so translatedFormat() elsewhere already respects the app
locale — no other files needed patching.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prior 'Everything up-to-date' commit was a no-op because Edit failed.
Now the change lands. Splits specialization by '/' and __()-translates
each token, matching Reports.php + CalendarBoard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both the row-meta on the master matrix rows and the legend list at
the bottom split specialization by '/' and translate each token via
__(), matching the pattern already used on Reports.php.
'Suspensie / Frâne' → «Подвеска / Тормоза», 'Motor / Cutie viteze' →
«Двигатель / КПП» etc. (all tokens already in the dict).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OnlineOrder::DELIVERY values (Ridicare din service / Curier / Poștă)
weren't translated; now go through I18n::opts and have RU/EN entries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Currency Select ->options() had 4 raw entries (MDL/EUR/USD/UAH)
and 2 wrapped (RON/RUB); consolidated all 6 under I18n::opts()
- Push notification title 'Trimis pe N dispozitiv(e)' rebuilt with
__(':n')-placeholder; empty state + helper now go through __()
- Added 4 long helper description keys with the closing „…"
Romanian quotation marks (the previous keys were missing the
trailing quote and dot; now match Settings.php byte-for-byte)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Filament default rendered 'Не найдено user permission overrides /
Создать user permission override для старта.' from the model class
name. Overridden with translated heading + explanatory description
+ shield-exclamation icon.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prior 'fix' commit was a no-op because git diff was empty. This time
the Edit lands the change on disk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add Permissions::permissionLabels() mapping every slug to a
human-readable RO label (e.g. 'clients.view_all' → 'Vezi toți
clienții').
- RoleResource permission checkboxes now show translated labels
as titles + technical slug as description (so admins see both
the meaning and the key).
- PermissionOverridesRelationManager Select 'permission_id' now
shows 'Human label (technical.slug)' format, plus helper text
explaining GRANT/DENY. Same format on the display column.
- +57 translations (50 permission labels in RU/EN + role page
supporting keys + module labels).
All 306 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 'DENY — interzice dreptul' select option was missing __() wrap
- CreateAction/EditAction now have explicit label/modalHeading so
the modal shows 'Добавить исключение прав' instead of
'Создать User Permission Override' (auto-generated from class name)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Status column showed raw DB enum values (active/inactive/blocked) with
no formatStateUsing → readers saw 'active' instead of the translated
'Активный / Активно' visible in the Select. Added formatStateUsing with
a match() that returns the appropriate __() key.
Locale header re-translated to 'Язык' (RU) / 'Language' (EN) since
'Локаль' is unusual for end users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Both role selects (primary + additional roles multi) passed the raw
roleLabels() array without translation; wrap with I18n::opts() so
Proprietar/Administrator/… now render translated in RU/EN.
- 2FA status text ('✓ Activat (TOTP)' / '✗ Dezactivat') split so
the checkmark stays and label runs through __().
- +3 translations (Activat (TOTP), Dezactivat, Locale) — Locale is
Filament's auto-generated headline from the `locale` field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Reports.php periods() had 'this_year' hardcoded; now wrapped in __()
- masters tab specialization cell: split by '/' and __()-translate each
token so 'Motor / Cutie viteze' auto-renders as «Двигатель / КПП»
on RU (and 'Engine / Gearbox' on EN). Users can enter any free-text
specialisation; the common keywords now have RU/EN translations.
- +12 specialisation vocabulary entries (Motor, Frâne, Suspensie,
Anvelope, Cutie viteze, Electrică, Diagnosticare, Vopsitorie,
Tinichigerie, Aer condiționat, Roți, Ambreiaj).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously .fi-main was capped at width-7xl (~1280px). Tables with
many columns overflowed and needed inner scroll. Now the entire main
area uses 100% width so tables render without truncation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Notification 'Calculat salariul YYYY-MM pentru N utilizatori' was
built via string interpolation → couldn't translate. Now uses __()
with :period / :count placeholders. Adds 7 more translations for
section titles and column labels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Field 'method' auto-generated 'Method' as label. Since RO is the source
language, __('Method') fell back to English on RO locale. Now explicitly
labeled with 'Metodă' so all 3 langs resolve properly (Metodă / Метод /
Method).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- All 4 tabs (Overview/Cashflow/P&L/Balanță) built with __() around
the text portion, emoji stays in code
- 4 period presets wrapped with __()
- P&L table Marjă/Marjă piese footer wrapped
- Expense::CATEGORIES values look up through __() in the expensesByCat
loop so categories show translated (Salariu → Зарплата etc.)
- +25 translations covering all UI copy on /app/finance
306 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two CheckboxList options passed raw Vehicle::BODY_TYPES and
Vehicle::TRANSMISSION_TYPES arrays — now they run through I18n::opts()
so the RO values translate at display. +6 translations for the enum
values that weren't yet in dict (Crossover, Pickup, Minivan, DSG,
DCT, AMT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wrapped the dynamic Select 'key' label (Brand/Categorie) with __(),
switched the category options to I18n::opts(). Notification 'Recalculat
preț pentru N piese' now uses __(:n) placeholder.
+11 keys: regulă, reguli markup, Cheie, Mai mic = aplicat primul.,
'Va recalcula sell_price…' confirm text, notification, 'Interval preț'
enum, and missing Part.CATEGORIES (Ulei, Filtre, Lichide, Distribuție).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Migration: create `units` table + nullable unit_id FK on
parts / wo_parts / purchase_items / labor_parts. Seeds ~11 standard
units (buc/set/l/ml/kg/g/m/cm/m²/oră/pack) for every existing
tenant and backfills unit_id by matching the legacy string `unit`
column. Keeps `unit` string as fallback so old code paths keep
rendering.
- Unit model: label(locale), forSelect(locale), labelFor(id, code)
helpers. All 4 owner models get unitModel() relation + unitLabel()
accessor.
- UnitResource under Depozit group with Filament UI: code, sort,
is_active + separate name_ro/name_ru/name_en fields.
- Filament forms/tables updated: Part / PurchaseItem / LaborPart /
WorkOrderPart now use Select('unit_id')->options(Unit::forSelect())
for input and TextColumn->getStateUsing(unitLabel()) for display.
Selecting a part auto-fills unit_id when the source has one.
- +10 translations for the new resource + defaults; nav.label
'Unități de măsură' added to all 3 lang files.
All 306 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit added the translation entries but the Edit call
failed silently (file-not-read guard). Now the CreateAction has
->label(__('Adaugă articol')) + ->modalHeading(__('Adaugă articol
în comandă')) so RU/EN see translated modal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Filament's default empty state used the model class name ('service
template items') and default 'Create service template item' modal
heading. Both are now explicit __() calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit's translation entries were added but the source change
was silently rejected by the Edit tool's file-not-read guard. Now the
CreateAction/EditAction overrides are in place; RU shows 'Добавить
запчасть по умолчанию' / 'Изменить запчасть по умолчанию' instead of
the auto-generated 'Create Labor Part'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous sed pass only matched \$state; missed \$s and other arg-name
variants. This time the regex is arg-name-agnostic and touches 21
files across Filament resources & relation managers.
Also wraps two special cases: UserResource role-labels lookup and
LaborResource pricing_mode ternary ('Fix' | 'Pe oră').
+27 human translations for the enum values that were still identity
fallback: WorkOrderWork.STATUSES (De făcut), Purchase.STATUSES,
OnlineOrder.STATUSES, Call.DIRECTIONS/STATUSES, BodyshopJob.TYPES/
STATUSES, TireSet.SEASONS, MessageTemplate.CHANNELS,
DamagePoint.SEVERITIES, ServiceTemplateItem.KINDS.
All 306 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- getContentTabLabel: 'ⓘ Info & antet' now goes through __()
- Tracking-client modal heading translated ('Tracking client — WO #…')
- Both 'Închide' cancel labels wrapped in __()
- Tracking modal widened to 'lg' so the QR + link input fit without overlap
- Notification 'Șablon aplicat' template built with __() so RU/EN see
translated pieces
- +16 translations covering the QR modal, Info tab, Închide etc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit added the translation entries and commit message claimed
this, but the Edit tool call failed silently (file-not-read guard) so
the source change never landed. This time it's applied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
max(...[]) throws ArgumentCountError. Split the intermediate array
and only call max() when it has elements. Fixes /app/finance 500
on tenants with no payments in the current period.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Post/mechanic meta 'h/zi' → __('h/zi') so RU shows 'ч/день', EN 'h/day'
- Wrap the 3 load-legend labels (0–5h/10, 5–8.5h/10, ≥9h/10) so 'h' → 'ч' on RU
- Split the long howto sentence so 'Pod'/'Mecanic' + 'Zile' translate
- Default fallback 'Pod 1 (default)' now uses __('Pod') + __('implicit')
Adds 5 new translations. Post NAMES themselves are DB rows (user data)
and are shown as-is — they're not translated by the app.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Was created but never staged in the previous commit; deployment failed
with 'Class App\Support\I18n not found' on any page that uses the
I18n::opts() wrapper on Model::CONSTANT Select options.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of untranslated table headers: Filament's HasLabel::getLabel()
auto-generates a label from the field name (e.g. `name` → "Name") but only
calls __() on it if you also invoke ->translateLabel(). Without that, the
auto-generated label always renders in English regardless of app locale.
Fix: configureUsing() in AppServiceProvider makes translateLabel() the
default for every Column, BaseFilter, Field, Action and BulkAction.
Then added 175 human RU/EN translations for Filament's auto-generated
keys (Name, Phone, Email, Status, Balance, Created at, Plate, Make,
Model, Year, VIN, Mileage, Owner, Client, Vehicle, Amount, Total, Qty,
Actions, Search, Filter, Export, Import, plus enums/relationships).
All 306 tests pass. Deploy will fix table headers on
Clients / Vehicles / Work orders / Payments / Purchases / Suppliers etc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
- Convert static $modelLabel/$pluralModelLabel/$title to getter methods
- Wrap ->label()/->placeholder()/->helperText()/->description()/->title()/->body() args
- Wrap Section::make()/Fieldset::make()/Notification::make()->title() args
- Fix RelationManagers::getTitle() signature to match parent (Model, string)
- Fix Pages::getTitle() to instance method (BasePage::getTitle is non-static)
- Extend lang/ru.json + lang/en.json with 700+ common terms; identity fallback for the rest
- Remove duplicate getters in 5 resources that had manual getModelLabel already
All 306 tests pass. Missing translations fall back to the RO key so the UI never breaks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User screenshots showed the tenant admin panel (Filament) had sidebar
labels stuck in Romanian even when switching to Russian: 'Cereri',
'Calendar vizual', 'Atelierul meu', 'KPI mecanici', 'Fișe lucru',
'Norme-ore', 'Tehnicieni', 'Șabloane servicii', 'Depozite', 'Scaner',
'Depozit', 'VIN-căutare', 'Furnizori', 'Achiziții', 'Procentaj',
'Coeficienți preț', plus all group headers.
Root cause: every Filament Resource and Page had static properties
'protected static ?string $navigationLabel = "Fișe lucru"' — string
literals baked into class definitions. Static properties don't run
through the translation layer.
Fix in two parts:
1. New translation files with 52 label keys + 12 group keys:
- lang/ro/nav.php — Romanian (identity)
- lang/ru/nav.php — full Russian translations (Заказ-наряды,
Автомобили, Клиенты, Календарь, Моя мастерская, Механики KPI,
Настройки, etc.)
- lang/en/nav.php — English translations (Work orders, Vehicles,
Clients, Calendar, My workshop, Mechanic KPI, Settings, etc.)
Keyed by the Romanian original so lookups map 1:1 —
'nav.label.Fișe lucru' returns 'Заказ-наряды' in RU, 'Work orders'
in EN, 'Fișe lucru' in RO.
2. Python transformer converted 54 files:
- 33 Filament Tenant Resources
- 15 Filament Tenant Pages
- 4 Filament Central Resources
- 1 Filament Central Page
- 1 Widget
Each 'protected static ?string $navigationLabel = "X";' became
'public static function getNavigationLabel(): string { return
__("nav.label.X"); }'. Same treatment for $navigationGroup.
Cleanup: 6 resources already had manually-added getNavigationLabel
methods from an earlier partial effort — those used flat JSON keys
(__("Cereri")) that never resolved. Deduped so only the nav.label.*
version remains.
Untouched (intentional):
- $modelLabel / $pluralModelLabel (used in breadcrumbs and headings —
still hardcoded, next tier of work)
- Section titles, column headers, form field labels (medium priority)
- $navigationSort (numeric, no translation needed)
- $navigationIcon (icon reference)
Suite: 306 passed (853 assertions). Unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Two related fixes for the WO-level "Aplică marjă internă" toggle:
== 1. Hide description text when WO toggle is off ==
WorksRelationManager's Total column showed a gray subtitle line
"Bază salariu: 320 MDL · marjă 20%" that persisted even after
apply_margin was toggled OFF at the WO level. Confusing — the user
expected "off means invisible".
Fix: description callback now short-circuits to null when
$record->workOrder->apply_margin === false, hiding the entire text.
Also hides when applied_margin_pct is 0 (nothing meaningful to show).
Result: OFF at WO level → zero margin details anywhere in the
Manopere tab. ON → same as before.
== 2. Auto-recompute salary_base on all lines when toggle flips ==
Previously, salary_base was frozen at line save-time. Flipping
apply_margin from on→off left existing lines with the old
20%-reduced salary_base, so payroll still used the reduced amount
even though the user had visually decided "no margin".
Fix: WorkOrder::updated hook detects wasChanged(['apply_margin',
'override_margin_pct']) and iterates through works():
- apply_margin=false → salary_base = total, applied_margin_pct = 0
- apply_margin=true → resolver chain (WO override → mechanic → default)
saveQuietly() on each line so we don't retrigger the works() booted
hooks that would recompute again.
This is DIFFERENT semantic from user.internal_margin_pct changes —
those DON'T rewrite history (test still passes). The distinction:
- User margin change: personnel decision, must not touch closed WOs
- WO apply_margin change: explicit per-Fișă decision, must affect
every line on that same Fișă
InternalMarginRecomputeTest (3):
- Flipping WO.apply_margin off recomputes both existing lines to at-cost
- Flipping back on recomputes to margined
- Changing WO.override_margin_pct recomputes with new % (40 → 60% base)
Suite: 306 passed (853 assertions). Was 303. +3 recompute tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes:
== 1. Language switcher (RO/RU/EN) in portal headers ==
New partial resources/views/partials/lang-switcher.blade.php with two
visual styles:
- style='chip' (default) — white-tinted glass for colored headers
(shop nav bar, tracking page hero)
- style='light' — outlined buttons for pale backgrounds (invitation
accept card)
Each button is a POST form to the existing /locale/{lang} route which:
- puts locale in session
- persists to user.locale if authenticated
- redirects back to the same page
Included in:
- resources/views/shop/layout.blade.php (right side of top nav,
after login/register)
- resources/views/tracking/show.blade.php (top-right of hero header,
absolutely positioned)
- resources/views/invitations/accept.blade.php (top-right of card,
above welcome heading, light style)
Current locale button is highlighted (opaque white on colored bg /
blue on pale bg). Others are muted until hovered.
Two new translation keys:
- portal.common.language (RO: Limbă / RU: Язык)
== 2. apply_margin toggle back in "Plată & total" ==
Previous session's edit didn't persist to the file. Now confirmed in
place: WorkOrderResource form's "Plată & total" section shows a
"Aplică marjă internă" toggle between discount_pct and
override_margin_pct. Gated by FINANCE_VIEW_INTERNAL_MARGIN so only
owner / admin / manager / accountant see it. Default = true.
When toggled off on a Fișă, WorkOrderWork::saving hook writes
salary_base = total for every line on that WO (no reduction).
Backend logic already in place — this commit fixes the missing UI
control.
Suite: 303 passed (840 assertions). Unchanged — refactor + view only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes in one commit:
== 1. Moved apply_margin toggle from line-level to Fișă-level ==
The per-manoperă apply_margin toggle is gone from the Manopere tab.
In its place: a single "Aplică marjă internă" toggle in Fișa's
"Plată & total" section (next to override_margin_pct). One decision
per Fișă instead of per line — cleaner mental model, matches how the
shop actually thinks about at-cost vs. billable work.
Migration: work_orders.apply_margin boolean default true (idempotent).
WorkOrderWork::saving now reads WO.apply_margin from DB directly (not
via belongsTo cache) to determine salary_base:
- WO.apply_margin=false → every line gets salary_base=total, applied_margin_pct=0
- WO.apply_margin=true → resolver chain (WO.override → user margin → tenant default)
Old wo_works.apply_margin column stays untouched (backward-compat with
existing rows), but no longer exposed in UI. Tests updated to new
semantic. All existing tests green.
== 2. Full i18n audit on client-facing portal — RO/RU separated ==
Problem: user selecting Russian saw Romanian mixed into headings,
buttons, labels. Every client-facing Blade file was 100% hardcoded
Romanian — zero __() calls.
Fix: created lang/ro/portal.php + lang/ru/portal.php with 131 keys
across 3 namespaces:
- portal.common (email, phone, save, total, powered_by, ...)
- portal.invitation (welcome_name, activate_account, expired_body, ...)
- portal.tracking (title_fisa, approve, approval_needed_title,
ready_estimated, hours, unit_pcs, ...)
- portal.shop (catalog, cart, checkout_title, order_number, vin_title,
signin_title, add_to_cart, in_stock, ...)
Converted 15 Blade files to __() calls:
- resources/views/invitations/{accept,expired,invalid}.blade.php
- resources/views/tracking/show.blade.php
- resources/views/shop/{layout,catalog,cart,checkout,order,account,part,vin}.blade.php
- resources/views/shop/auth/{login,register,forgot,reset}.blade.php
Each view's <html lang="{{ app()->getLocale() }}"> now reflects the
resolved locale (was hardcoded lang="ro").
SetLocale middleware resolves locale in this order:
1. session locale (user picked via language switcher)
2. authenticated user.locale
3. tenant.settings.language
4. app.locale default (now 'ro')
Config change: config/app.php default locale + fallback both = 'ro'
(was 'en'). English falls back to Romanian for portal.* keys since
we don't ship English portal translations — a Romanian shop that
switches to English shows Romanian text, which is safer than showing
"portal.invitation.activate_account" literals.
phpunit.xml sets APP_LOCALE=ro so test assertSee() calls that look
for Romanian text pass.
Verified via portal.* grep: 131 __() calls across 15 files. Zero
hardcoded Romanian nouns/verbs left in any client-facing view.
Suite: 303 passed (840 assertions). Unchanged count — refactor,
not new tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three UX changes to /app/work-orders/{id}/edit per user request:
1. Manopere / Piese / Subcontract / Plăți now appear as top-level tabs
2. Header info (Antet, Diagnostic, Foto, Tracking, Plată) is compact
and collapsed by default
3. Left navigation column can be narrowed via Filament's built-in
sidebar toggle button
== Relation managers on top ==
EditWorkOrder now enables Filament's combined-tabs mode:
- hasCombinedRelationManagerTabsWithContent() → true
- getContentTabPosition() → ContentTabPosition::After
- getContentTabLabel() → "ⓘ Info & antet"
Result: the WO edit page opens on the Manopere tab (first tab).
Piese, Subcontract, Plăți follow. The full form (Antet, Diagnostic,
Foto, Tracking & ETA, Plată & total) is the last tab, opened only
when the user needs to change header info.
Rationale: mechanics and receptionists spend 90% of their WO edit
time in Manopere/Piese, not in the header. Putting those first cuts
one scroll on every WO open.
== Compact header ==
WorkOrderResource::form restructured:
- "Antet fișă" section: single compact section with 4-column dense
grid. Contains only the always-visible essentials: Nr., Deschis,
Status, Urgență, Client (span 2), Auto (span 2), Maistru (span 2),
Km intrare, Km ieșire. Uses ->compact() to reduce padding.
- Diagnostic / Foto / Tracking & ETA / Plată & total: ALL now
->collapsible()->collapsed()->compact(). They appear as closed
accordions — visible titles but zero screen space until clicked
open.
Result: opening the "Info & antet" tab shows a tight 4-column top
row + 4 closed accordion titles below. Fits in ~40% the vertical
space of the previous layout.
== Sidebar collapsible on desktop ==
TenantPanelProvider ->sidebarCollapsibleOnDesktop() — Filament adds
a chevron button that toggles the left nav between full-width labels
and icon-only strip. Persisted per user via localStorage.
Result: user can narrow the left column with one click when they
want more horizontal space for a wide Manopere table.
No schema changes. No test changes required (existing WO tests still
pass — 303/303).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds three usability improvements to the marja internă feature:
1. Settings UI section "Marjă internă (nu TVA)" — configurable at tenant level
2. Per-line toggle "Aplică marjă" on each manoperă
3. Global visibility flag to hide margin details in-session
== 1. Settings page ==
New section on /app/settings (gated by FINANCE_VIEW_INTERNAL_MARGIN):
- "% marjă implicit" numeric input with % suffix, 0–90 range
- "Afișează detalii marjă la procesele calculate" toggle (default on)
- Explicit label "Marjă internă (nu TVA)" plus helper text explaining
it's not the Moldovan tax — feeds into MarginResolver as the tenant
default, applied only when the mechanic has no per-user margin.
Persists as company.settings.default_internal_margin_pct and
company.settings.show_internal_margin_details.
== 2. Per-line "Aplică marjă" toggle ==
New wo_works.apply_margin boolean, default true. When false:
applied_margin_pct = 0
salary_base = total (mechanic gets salaried on the full amount)
Use case: oil change, tire mount, and similar "at-cost" services where
the shop doesn't want to hold back part of the labor rate. The owner
can flag those specific lines while keeping margin on diagnostic and
premium labor.
WorksRelationManager form gains a Toggle field (gated by
FINANCE_VIEW_INTERNAL_MARGIN); table gains a ToggleColumn for quick
inline flipping without opening the row.
Booted hook now recomputes salary_base when apply_margin is dirtied,
so toggling live in the table takes effect immediately.
== 3. Show internal margin details flag ==
Global tenant flag (default on): when off, the gray subtitle line
"Bază salariu: 200 · marjă 20%" under the Total column disappears for
everyone, even users with FINANCE_VIEW_INTERNAL_MARGIN.
Practical use: when reviewing a Fișă face-to-face with the client on
the manager's screen, flip the flag off from Settings for the day →
no risk of the client accidentally seeing internal numbers. Flip back
when done.
The flag lives in company.settings.show_internal_margin_details.
== Description text on the Total column ==
Now shows either:
- "Bază salariu: 200.00 MDL · marjă 20%" when apply_margin=true
- "Fără marjă · bază salariu = Total" when apply_margin=false
- nothing when show_internal_margin_details=false or role lacks permission
== Tests ==
InternalMarginToggleTest (5):
- apply_margin=false → salary_base equals total, applied_margin_pct=0
- apply_margin=true (default) still applies 20% margin
- Toggling apply_margin recomputes salary_base bidirectionally
- Company default margin resolves when mechanic has no per-user setting
- show_internal_margin_details flag persists correctly in Company.settings
Suite: 303 passed (845 assertions). Was 298.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Client sees only Total. Salary is calculated from salary_base = client_price
× (1 − margin/100). Margin never appears in customer-facing surfaces (PDF,
tracking JSON, portal).
Terminology: "marjă internă" — internal profit margin. NOT VAT/TVA. Never
called NDS/TVA anywhere in the code to avoid confusion with real Moldova
tax reporting (Doc 19/1C integration).
== Configuration ==
Fallback chain (in MarginResolver::resolve):
1. WorkOrder.override_margin_pct — per-Fișă for special contracts/VIP
2. User.internal_margin_pct — per-mechanic (main setting)
3. Company.settings.default_internal_margin_pct — tenant default
4. 0.0 — no margin
Example (mechanic Andrei with 20% margin):
User enters price_per_hour = 250 for 1h diagnosis
→ total = 250 (what client sees, goes into PDF)
→ salary_base = 250 × 0.80 = 200 (what mechanic gets salaried on)
→ applied_margin_pct = 20 (frozen)
If admin later changes Andrei's margin to 40%, the row's salary_base does
NOT change — history is immutable. Only new rows use the new margin.
Solves the retroactive-recompute problem for closed payroll periods.
== salary_base freeze semantics ==
wo_works gains 2 columns:
salary_base decimal(10,2) nullable
applied_margin_pct decimal(5,2) nullable
Frozen at save time by WorkOrderWork::saving hook. Recomputes only if
total OR master_id changes (i.e., someone actively edits the price or
reassigns the mechanic — in those cases we WANT the salary_base to
follow). Legacy rows (before this feature) have null salary_base;
PayrollCalculator falls back to total for them.
== PayrollCalculator uses salary_base ==
Previously: sum(wo_works.total) × works_pct → gave the mechanic a cut
of the price INCLUDING margin.
Now: sum(salary_base ?? total) × works_pct → the cut is from the
labor rate excluding margin.
Impact: for a 250 lei diagnosis at 20% margin with 50% payroll cut, the
mechanic gets 200 × 50% = 100 lei (was 250 × 50% = 125 lei). The shop
keeps the 50 lei margin regardless of the payroll %.
== RBAC gate ==
New permission FINANCE_VIEW_INTERNAL_MARGIN. Assigned to owner + admin +
manager + accountant in seed matrix. Not granted to mechanic,
receptionist, or viewer — those roles never see the "Bază salariu"
disclosure line or the margin % fields.
== UI surfaces ==
UserResource — new "Salariu & marjă" section (visible only with
FINANCE_VIEW_INTERNAL_MARGIN):
- Tarif orar (MDL)
- Marjă internă (%) with helper text explaining the -X% semantics
- Placeholder tells manager the exact formula
WorkOrderResource form — new override_margin_pct field in the "Plată &
total" section, gated by same permission. Helper text: "Doar pentru
cazuri speciale. Lasă gol pentru a folosi marja mecanicului."
WorksRelationManager (WO edit page) — Total column now shows a gray
subtitle line "Bază salariu: 200.00 MDL · marjă 20%" ONLY for users
with FINANCE_VIEW_INTERNAL_MARGIN. Everyone else sees just Total.
== Contract tests: NO leak ==
InternalMarginTest verifies with black-box grepping that:
- WorkOrderPdfService::generate output contains NONE of
{salary_base, internal_margin, applied_margin_pct, marja intern,
Bază salariu}
- /api/track/{token} JSON payload contains NONE of the same terms
- wo_parts table has no salary_base column (margin ONLY on labor)
- Changing mechanic.internal_margin_pct after work is saved does NOT
rewrite the historical salary_base (frozen)
- WO override wins over mechanic margin (contract-priced clients)
- Fallback chain: WO → mechanic → company default → 0
== Suite ==
298 passed (828 assertions). Was 285. +13 InternalMarginTest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes 5 user-requested features in /app/calendar-board:
1. View mode switcher: Zi / Săpt / Lună / Custom / Listă
2. Editable post names + assignable default master per bay
3. Quick-add bay (+ Pod nou) from calendar toolbar — supports yard
spaces without a lift ("Curte 1", "Atelier electric")
4. PDF export of programări for printing
5. Inline list view alongside the matrix view
== View modes ==
$viewMode: day | week | month | custom | list
- Day view: 1 column, just today (or navigated day). Shift moves day by day.
- Week view: current 7-column matrix (unchanged default).
- Month view: 30/31 columns shown smaller (70px each). Shift moves by month.
- Custom: 2 date pickers for arbitrary start..end range (max 31 days).
- List view: flat sortable table with Data/Ora/Subiect/Client/Telefon/
Auto/Pod/Maistru/Status columns. Click row → opens detail panel.
getDays() computes the right day count + start anchor for each mode.
setViewMode() snaps weekStart to the right anchor (startOfMonth, today,
startOfWeek). shiftWeek delta semantics adapt: day mode shifts 1 day,
month mode shifts 1 month, others shift 7 days.
== Editable posts + default master ==
New PostResource (/app/posts) in Admin group: full CRUD with name,
color, hours_per_day, default_master_id, description, is_active,
sort_order. Gated by ADMIN_SETTINGS_EDIT.
Migration: posts.default_master_id FK → users (nullOnDelete).
Inline rename from calendar: click any post's row label opens a modal
with name field + default master dropdown. Saved values propagate
immediately to next appointment creation.
Auto-fill in new appointment: when creating an appointment via the "+"
cell button on a post row, master_id is pre-filled from
post.default_master_id (if not already set by groupBy='master' row).
== Quick-add bay ==
"+ Pod nou" button in toolbar opens a small modal (no full page nav):
name, color picker, hours/day, description. createPost() saves and
refreshes the row list. Designed for "yard space" use-cases — names
like "Curte 1" or "Atelier electric" are first-class, not workarounds.
== PDF export ==
"🖨 PDF programări" button calls exportPdf() which uses the existing
dompdf integration (already installed). Renders pdf/appointments.blade.php
grouped by day with table per day showing time/title/client+vehicle/
post/master/status. Romanian date headers ("Marți, 10 Iunie 2026").
streamDownload with filename programari_YYYY-MM-DD_YYYY-MM-DD.pdf.
== List view ==
getListAppointments() returns flat array of all appointments in the
visible period (date-range respects current viewMode), with full
client/vehicle/post/master joined. Status filter respected. Row click
opens the existing event detail panel.
== Tests ==
CalendarEnhancementsTest (8):
- viewMode='day' returns 1 day
- viewMode='month' returns 30 days for June 2026
- viewMode='custom' uses customStart..customEnd range
- quick-add post via Livewire createPost persists with all fields
- rename post updates name + default_master_id
- new appointment auto-fills master_id from post's default_master_id
- list view returns flat array with phone + post name joined
- exportPdf returns StreamedResponse with .pdf filename
Suite: 285 passed (802 assertions). Was 277.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>