Commit Graph

12 Commits

Author SHA1 Message Date
Vasyka 58f168dd97 i18n(users): wrap role Select options with I18n::opts + 2FA status + Locale
- 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>
2026-07-18 11:04:30 +00:00
Vasyka e969bb9c7d i18n: wrap all formatStateUsing(fn(\$s)=>X::CONST[\$s]??\$s) with __() (21 files)
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>
2026-07-16 07:41:35 +00:00
Vasyka 7077fff3b3 feat(i18n): convert 38 hardcoded static labels + safe blade wrap + 226 keys
- Convert all remaining static \$modelLabel/\$pluralModelLabel to getter
  methods with __() (AppointmentResource, WorkOrderResource, MasterResource,
  ServiceTemplateResource, LaborResource, and 33 others)
- Safe blade wrap v2 (37 files, 201 wraps): fixed the regex to not match
  `->`, `=>`, `!<` — previous aggressive pass broke @if directives
- Bulk-translate lowercase model-label keys (programări, fișe lucru,
  tehnicieni, șabloane servicii, norme-ore, etc.) — these were the RO
  strings appearing in page titles/breadcrumbs
- Add human RU/EN for mechanic-kpi (Mecanic/Lucrări/Norma ore/etc.)
  and calendar-board words

Tests 306 green. Blade compiles cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-15 06:17:10 +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 f7fc69077b feat(i18n): wrap 1103 hardcoded UI strings across Filament with __()
- 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>
2026-07-15 05:04:24 +00:00
Vasyka 78ff8d4b43 feat: i18n on Filament admin sidebar — 54 resources/pages translated
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>
2026-07-13 20:41:55 +00:00
Vasyka 70ca2fa74a feat: marjă internă per mechanic — hidden margin on labor
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>
2026-07-07 09:44:46 +00:00
Vasyka 1d4ac3db38 feat: P1 RBAC defers — overrides + sessions + audit log
Completes the P1 items from /tmp/service/new/01-TZ-rbac §2.1 §4.1.

== user_permission_overrides table ==
Per-user grant/deny exceptions on top of role-based RBAC. Composite PK
(user_id, permission_id) so each user can have at most one override per
permission. Schema:
- mode: 'grant' | 'deny'
- reason: text (audit context: "lockdown audit period", etc.)
- granted_by_id + granted_at: who/when made the exception
- expires_at: optional auto-expiry

Eloquent model UserPermissionOverride with relations to user, permission,
grantedBy; isExpired() helper.

== Resolution order in User::canDo() ==
1. Active deny-override (not expired) → return false (and log if sensitive)
2. Active grant-override (not expired) → return true
3. Admin/owner bypass → return true
4. Standard role-based check via Spatie

Critically: deny overrides ALSO block admin/owner. This is intentional —
the TZ's "separation of duties" requirement (an admin who shouldn't be
able to delete payments). Without this, deny is useless against admins.

Override resolution uses a single query per check (cached by Eloquent
during the request). The override-check happens before the role check
so a deny is always authoritative.

== Audit log on sensitive denials ==
When canDo() returns false for one of these sensitive permissions, a
spatie/activitylog entry is written with event=permission_denied:
- admin.users.manage / admin.roles.manage / admin.settings.edit
  / admin.backup.download
- finance.delete_payment / finance.view_pl
- salaries.mark_paid / salaries.view_all
- work_orders.delete / work_orders.approve_discount_any

Non-sensitive denials (e.g., clients.create) don't log to avoid noise.
The activity payload includes the permission slug; causedBy is the user
who was denied. Failures of the logger are swallowed so a misconfigured
activitylog never breaks auth.

== UserResource UI ==
New PermissionOverridesRelationManager mounted on the edit page:
- Table: permission, mode (GRANT/DENY badge), reason, expires_at,
  granted_by
- Create form: permission select, mode, expires_at, reason
- granted_at + granted_by_id auto-populated to now() / auth()->id()
- Default sort: granted_at desc

Two new actions on the user row:
- "Force logout" (warning color): visible only when active sessions
  exist. Deletes every row in `sessions` with user_id=record→id.
  Notification shows count revoked.
- "Resetează 2FA" stays (from previous commit)

Two new toggleable columns:
- Sesiuni active (count from sessions table)
- Excepții (count of permission overrides)

== Tests ==
PermissionOverridesTest covers:
- grant unlocks a permission the role doesn't have
- deny blocks a permission the role grants
- deny blocks even admin role (separation of duties)
- expired override is ignored
- future-expiry override stays active
- audit log writes on sensitive denial
- audit log silent on non-sensitive denial
- force_logout deletes all user sessions but not others'

Suite: 214 passed (591 assertions). Was 206.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 22:27:20 +00:00
Vasyka 58004b65c4 feat: RBAC catalog + 2FA UX (P0 blocker from /tmp/service/new/01-TZ)
Implements the RBAC TZ in app/Auth/Permissions.php with a 51-permission
catalog spanning 9 modules (clients/vehicles/work_orders/finance/salaries/
inventory/suppliers/admin/ai_assistant+analytics). All slugs are constants,
not magic strings — refactors against renames stay safe.

== 7 system roles ==
owner / admin / manager / accountant / receptionist / mechanic / viewer
Each gets a curated role-permission matrix per the TZ section 2.4:
- owner + admin: all 51
- manager: 23 (operations + reporting, no destructive finance/users)
- accountant: 17 (full finance/salaries, view-only WOs, no admin)
- receptionist: 13 (front-desk: clients/vehicles/WOs/payment-create)
- mechanic: 4 (own WOs + inventory view + own salary)
- viewer: 6 (read-only everything except finance/salaries)

== Seeder ==
App\Services\RbacSeeder:
- seedPermissions() creates the 51 Permission rows globally (idempotent)
- seedTenantRoles($companyId) sets the team context, creates the 7 Role
  rows scoped to that tenant, and syncPermissions per matrix
- syncUsersToRoles($companyId) maps legacy users.role string column to
  the new Spatie role assignment (parts_manager→manager, master→mechanic,
  marketer→manager, user→viewer)

== Migration ==
2026_06_04_000003 loops over all existing Companies and runs the seeder.
On a fresh prod deploy, every tenant gets the full RBAC catalog wired up
automatically. CompanyProvisioner::provision() also calls the seeder for
new tenants going forward.

== Resource gates ==
canViewAny / canCreate / canDelete on:
- PaymentResource (FINANCE_VIEW_OVERVIEW / FINANCE_CREATE_PAYMENT / FINANCE_DELETE_PAYMENT)
- ExpenseResource (FINANCE_VIEW_OVERVIEW / FINANCE_CREATE_EXPENSE / FINANCE_DELETE_PAYMENT)
- PayrollAdjustmentResource (SALARIES_VIEW_ALL / SALARIES_CALCULATE)
- PayrollRunResource (SALARIES_VIEW_ALL / SALARIES_CALCULATE)
- UserResource (ADMIN_USERS_VIEW / ADMIN_USERS_MANAGE)
- RoleResource (ADMIN_ROLES_MANAGE)

Mechanic sees only own WOs + inventory + own salary. Accountant sees all
finance but not admin. Receptionist sees clients/WOs but not finance
overview. Etc.

== User helpers ==
$user->canDo(Permissions::WORK_ORDERS_CREATE) — admin gets a bypass to
prevent lockouts from misconfigured permission grants.
$user->isOwner() / isAccountant() / isMechanic() — role shortcuts.
$user->hasTwoFactorEnabled() — true when app_authentication_secret is set.

== 2FA ==
Filament 5's native MultiFactorAuthentication (App + Email) is already
enabled in both TenantPanelProvider and CentralPanelProvider — confirmed.
The User model already implements HasAppAuthentication +
HasAppAuthenticationRecovery + HasEmailAuthentication.

This commit adds UX around it:
- UserResource list column: 2FA badge (green ✓ when enabled, amber ⚠ when off)
- UserResource form: "Securitate" section shows enabled/disabled + last_login_at
- New admin action "Resetează 2FA" with confirmation modal — clears
  app_authentication_secret + recovery codes for locked-out users

== Roles management UI ==
New /app/roles RoleResource:
- List: role label + slug + permission count + user count
- Edit: 10 grouped checkbox lists (per module) for fine-grained
  permission assignment + bulk-toggle per group
- System roles (owner/admin/etc.) have slug locked, can't be deleted
- Custom tenant-specific roles can be added on top
- Gated behind ADMIN_ROLES_MANAGE

== UserResource extension ==
- Role select now uses Permissions::roleLabels() (owner/admin/manager/...)
- New "Roluri suplimentare" multi-select for stacking roles on top of
  the primary one (permissions cumulate)
- afterSave syncs the picked roles + ensures primary role is always
  included

== Tests ==
RbacTest covers: 51 permissions seeded, 7 roles per tenant, owner has
all, mechanic has minimal, accountant has finance but not admin,
canDo returns true when role has permission, admin bypass, owner helper,
syncUsersToRoles legacy mapping (parts_manager→manager, master→mechanic,
user→viewer), 2FA helper round-trip.

Suite: 206 passed (576 assertions). Was 196.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 22:03:03 +00:00
Vasyka 721c57ff97 Filament v5: Forms\Components\Section → Schemas\Components\Section, Forms\Get → Schemas\Components\Utilities\Get
Layout components (Section, Grid, Tabs, etc.) au fost mutate din
Filament\Forms\Components în Filament\Schemas\Components.
Forms\Components păstrează doar field-urile (TextInput, Select, etc.).

Forms\Get s-a mutat în Schemas\Components\Utilities\Get.
2026-05-06 18:07:10 +00:00
Vasyka b1d4926cae Filament v5: move Tables\Actions\* → Actions\* namespace
Filament v5 a unificat actions într-un singur namespace Filament\Actions.
Tables\Actions\EditAction et al. nu mai există → fix global pe toate resources.
2026-05-06 17:52:33 +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