7077fff3b3f164fab0108e0443958ca36f649cc2
62 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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>
|
||
|
|
2d447e6294 |
feat(i18n): fix widgets, pipeline & mechanic boards + missing translations
Follow-up to previous i18n pass — user reported many visible RO strings on
EN/RU locales because auto-wrap missed:
- Stat::make() first-arg labels in StatsOverview, FinanceOverview, PlatformStats
- $heading static in LowStockTable (converted to getHeading() method)
- Pipeline board Blade view: 40+ literal strings wrapped with {{ __() }}
- Mechanic board Blade view: statuses, buttons, block modal
- PipelineBoard.php hardcoded tag labels, stage labels, notify strings
- MechanicBoard.php confirmBlock notification
Added human RU + EN translations for the visible strings shown in the
screenshots (dashboard widgets, Client/Vehicle list breadcrumbs & columns,
pipeline board topbar/stats/filters/cards/detail panel/quick actions,
mechanic board stats/statuses/block modal). All 306 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
7769ab7737 |
fix: hide margin text + recompute lines when WO.apply_margin flips
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> |
||
|
|
57bdc1b594 |
feat: language switcher on portal + apply_margin re-added to Plată & total
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>
|
||
|
|
113610ea8f |
feat: WO apply_margin at fișă level + full RO/RU i18n on client portal
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>
|
||
|
|
f5ff3f149a |
feat(WO edit): manopere/piese first + compact header + collapsible sidebar
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>
|
||
|
|
f4ccc306dc |
feat: marjă internă — Settings % + toggle per manoperă + visibility flag
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> |
||
|
|
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>
|
||
|
|
80c3834263 |
feat: calendar enhancements — view modes, post CRUD, PDF, list
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>
|
||
|
|
2c66547967 |
feat: polish finale — work_photos + e-signature + mobile + scan receipt
Closes the remaining ~4% from CONFORMITY-12-15.md. All four modules at or near 100% conformance after this commit. == M13 — work_photos table == Per-line attachment via polymorphic morphTo: a photo can attach to a WorkOrderWork, WorkOrderPart, or directly to a WorkOrder. Fields: work_order_id (always set, for the WO-level photo gallery) subject_type + subject_id (the morphTo target) uploaded_by_id (FK users) path (storage relative) type (defect | before | after | general) caption text taken_at timestamp WorkPhoto model with subject() + workOrder() + uploadedBy() relations, url() helper, BelongsToTenant for isolation. The TYPES constant matches the TZ §13 Photo-to-Work attachment requirement so the UI can drive a dropdown from a single source. == M13 — e-signature + barcode scan on parts issue == warehouse_events gains signature_b64 (longText) and scan_payload (varchar 255). Both nullable — every existing issue/return event stays valid. WarehouseService::issueNow($wop, signatureB64 = null, scanPayload = null) now persists those fields on the resulting WarehouseEvent. Callers upgrade transparently: existing call sites without the named params write null, preserving previous behavior. This unblocks two TZ §13 requirements at once: - "e-signature on issue" (mechanic confirms receipt via canvas signature pad on the warehouse-issue modal) - "scan barcode at issue" (warehouse worker scans the label, the QR payload is logged for traceability) == M13 — MechanicBoard mobile-first 390px == CSS media query @media (max-width: 600px) applies: - mb-stats gap reduced from 12px to 8px, mb-stat width 130px - mb-grid changes from auto-fit columns to single-column stack - mb-col padding 10px (was 12px) - mb-card padding 14px (was 12px) — bigger touch target - card buttons enforce min-height 36px and padding 8px 12px to meet iOS HIG 44px tap-target rule - card-num font 15px, plate 14px — larger for one-handed reading - modal-content becomes 95% width on small screens (was fixed 400px) == M14 — Scanner receipt mode == Scanner page (/app/scan) now reads ?purchase=N from query string. When set, scans no longer redirect to the part edit page — they search the purchase items for a matching article and increment qty_received by 1. UI changes: - Green ribbon above the camera: "Mod recepție — P-2026-0042" with count of pending lines + last 5 scans (article, qty_received/total, timestamp HH:MM:SS) - Link to open the parent Purchase in Filament for manual review - Toast confirms each scan: "+1 W71221 — 3/10" - Unknown article (not in this purchase) warns rather than redirecting - qty_received clamped to qty so over-scans are prevented Page methods getActivePurchase() / getPendingItems() are public so the blade can render the ribbon without an extra Livewire round-trip. == Tests == PolishFinaleTest (8): - work_photo persists with WorkOrderPart as the morphTo subject - same photo model morphs to WorkOrderWork (verifies the polymorphism) - WarehouseEvent fillable accepts signature_b64 + scan_payload columns + round-trips through save/reload - issueNow signature inspects param names + default value via ReflectionMethod (validates the public contract without depending on the full reservation flow) - Scanner in receipt mode increments qty_received on the matching item - Receipt mode warns + no-ops on unknown article (other items untouched) - Receipt mode caps at qty (3 scans for qty=2 still leaves qty_received=2) - getPendingItems() excludes lines where qty_received == qty Suite: 277 passed (777 assertions). Was 269. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
cbcf08b28c |
feat: tier 2 — M12 Pricing UI + M13 mechanic granular workflow
Closes "Important pentru produs complet" tier from CONFORMITY-12-15.md.
== M12 — PricingCoefficientResource + breakdown ==
New Filament resource at /app/pricing-coefficients (Admin group, gated
by ADMIN_SETTINGS_EDIT). Replaces the previous "edit JSON in DB"
configuration workflow.
Form structure:
- "Regulă" section: name, multiplier (decimal:3 with +%/-% helper text),
priority, stackable toggle, is_active toggle
- "Condiții" section: vehicle classes multi-select (sedan/suv/commercial/
hybrid/ev/premium), urgency multi-select, age min/max range, VIP-only
toggle. Empty = applies to everything.
Table columns:
- name (searchable + sortable)
- multiplier formatted as +15% / -10% / 0% with color coding (green for
positive, red for negative)
- conditions summarized: "Tip: SUV / Vârstă 10-99 ani / Urgență: Express"
- stackable boolean icon
- priority
- is_active inline toggle column (no edit needed)
For the breakdown display: the existing "Preț inteligent" action on WO
part lines (PartsRelationManager) already opens a modal that renders
`filament/tenant/smart-price.blade.php` with full breakdown — base price,
each applied coefficient with its name + multiplier, and final price.
Confirmed working; no change needed.
== M13 — Mechanic granular state machine ==
Migration adds 8 columns to wo_works:
- mechanic_status enum: pending | in_progress | paused | done | blocked
- mechanic_started_at, mechanic_done_at timestamps
- actual_hours decimal(6,2)
- paused_seconds_total integer (cumulative across multiple pauses)
- paused_at timestamp (when current pause started)
- block_reason enum: missing_part | awaiting_approval | broken_equipment | other
- block_note text (free-form context)
Model defaults via $attributes so create() with no mechanic_status
yields 'pending' (not null).
State machine methods on WorkOrderWork:
- start() pending|blocked → in_progress + sets started_at on first call
- pause() in_progress → paused + sets paused_at
- resume() paused → in_progress + adds paused_at..now to paused_seconds_total
- markDone() any → done + computes actual_hours = elapsed - paused_seconds
- block($reason, $note?) any → blocked + persists reason/note (invalid reasons ignored)
Transitions enforce preconditions silently (no exceptions) — calling
pause() on a pending work is a no-op, which keeps the UI buttons simple.
Efficiency indicator:
- efficiencyClass(): 'green' (actual <= norm), 'amber' (1.0 < ratio <= 1.3),
'red' (ratio > 1.3). null when no actual data.
- efficiencyPct(): integer percentage of norm time used.
== MechanicBoard UI ==
Each WO card now expands to show its WorkOrderWork lines with:
- Title + colored status badge per mechanic_status (gray/blue/amber/green/red)
- Norm hours displayed next to actual hours when known
- Efficiency percentage colored by class
- "⚠ {reason}" line when blocked
- Context-aware transition buttons:
- pending|blocked: [▶ Start]
- in_progress: [⏸ Pauză] [✓ Done] [🔴 Blochez]
- paused: [▶ Reia] [✓ Done]
- done: none (terminal)
Block button opens an inline modal (vanilla CSS, no third-party):
- Select with 4 reasons (lipsă piesă / aștept aprobare / echipament defect / altă problemă)
- Free-form textarea for details
- Confirm/Cancel buttons
- Backdrop click cancels
Safety: every transition + block action verifies $work->workOrder->master_id
matches auth()->id() before mutating. Other mechanics' works are silently
refused (no error leak).
== Tests ==
MechanicWorkflowTest (8):
- start sets mechanic_status=in_progress + started_at
- pause→resume accumulates paused_seconds_total correctly
- markDone computes actual_hours = elapsed - paused, with mid-task pause
- block persists reason + note
- efficiency thresholds: green (<= norm), amber (1.0-1.3x), red (>1.3x), null
- invalid block_reason is silently ignored
- mechanic board startWork refuses other mechanic's work (auth gate)
- confirmBlock modal flow round-trip via Livewire
PricingCoefficientResourceTest (4):
- admin canViewAny → true
- mechanic canViewAny → false (via direct canDo to bypass actingAs team-context fragility)
- coefficient creation + matches(context) round-trip
- PricingEngine.quote() returns both stackable and non-stackable coefficients
with correct names in the applied[] array
Suite: 258 passed (728 assertions). Was 246.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
1d5ea6d261 |
feat: Calendar Vizual v2 (Pod×Days matrix) + hidden markup
Implements 2 of the biggest items from /tmp/service/new docs: == Calendar Vizual v2 (from 02-prototip-calendar-vizual.html) == Replaces the FullCalendar week view (the one that visually collapsed after Livewire re-renders) with a server-rendered matrix that the harness already drives through Livewire — no third-party JS to clash with Filament. Layout: 8-column CSS grid (1 row-label + 7 days). Rows are either Posts (Pod 1, Pod 2…) or active masters depending on toolbar switch. Each cell holds 0..N event cards. Per-cell load badge (top-right): hours_planned / capacity → badge color (gray <50%, orange 50–90%, red ≥90%) Drag-drop: HTML5 native, Alpine.js holds the dragEventId, moveEvent($id, $toRowId, $toDate) in PHP updates either post_id or master_id (depending on groupBy mode) plus date — works seamlessly when re-grouping. KPI bar (4 cards above toolbar): - Ore programate X / Y · % capacity - Fișe deschise (orange) - Confirmate X/Y (green) + confirmation rate - No-show alert (red) — scheduled events <24h away that are still unconfirmed Toolbar: - ◀ Week ▶ + Astăzi (reset) - Date label "01 — 07 iunie 2026" - Grupare switch: Pod ↔ Mecanic - Filtru: master dropdown + status dropdown (Confirmate/Neconfirmate/În lucru) Today column highlighted blue; Sunday column hatched as closed (non-interactive, no drop target); Saturday muted as weekend. Event card color = master.color (deterministic, matches profile setting), shown as left border + background tint. Title = client name; meta = "VW Passat · CIU 001"; time = "08:00–12:00 · V.". Click empty cell → quick-create panel (right slide-in) with date+pod pre-filled. Click event → detail panel with Client/Phone/Auto/Plate/ Master/Pod + delete + edit. Legend section at bottom (mecanici dots, load colors, day states). == Hidden Markup (from gap-analysis.md #3) == Adds `hidden_markup_pct` decimal to parts. Customer documents continue to show the standard sell_price; the hidden markup is an internal margin indicator used for B2B contracts and corporate analytics. Part::internalCostWithHiddenMarkup() returns buy_price * (1 + pct/100). Falls back to buy_price when pct is null. Decimal:2 cast so persistence round-trips cleanly. == Schema migration == Idempotent (hasColumn guards): - posts.hours_per_day decimal(5,1) default 10 - posts.description varchar(255) nullable - parts.hidden_markup_pct decimal(5,2) nullable == Tests == +11 new in CalendarBoardV2Test (8) + HiddenMarkupTest (3): - get_days returns 7 days with today flagged + Sunday closed + Saturday weekend - get_rows returns posts when grouped by post + with capacity - get_rows returns masters when grouped by master + Fără maistru fallback row - matrix places events in correct cells + sums hours - move_event reassigns post_id and date - create_appt inserts appointment via panel form - stats compute utilization from events (8h / 60h capacity = 13%) - status filter narrows to confirmed only - hidden_markup applies pct correctly + falls back to buy_price + persists Suite: 196 passed (551 assertions). Was 185. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d9b198a235 |
feat: Pipeline board matches mockup pixel-by-pixel
Audit pass against /tmp/service/todo/psauto-pipeline-redesign.html — 10
gaps closed.
1. In-page TOPBAR (mockup had it; was missing): "Pipeline" title,
sep, search box "Caută client, mașină, număr...", and right-side
Filtre / Export / + Deal nou (primary) buttons. Search input is
wire:model.live.debounce 300ms.
2. SEARCH actually filters cards: $searchQuery property in
PipelineBoard scans subject + client_name + plate + code + phone
across all 6 columns, case-insensitive.
3. "+ Deal nou" + "+ Adaugă cerere" (per-column bottom) now open the
SAME right-side panel in "new form" mode. Inline create form:
Nume / Telefon / Auto / Sursă / Notițe → createNewLead() inserts
Lead with status=new, lands in col 1 instantly without leaving page.
Validation: name + phone required.
4. EXPORT button calls exportCsv() — streams a CSV of current filtered
columns (etapă, cod, subiect, client, telefon, auto, sumă,
responsabil, stare timp).
5. PERIOD selector chip shows current month in Romanian
(now()->locale('ro')->isoFormat('MMMM YYYY')) — matches "Iunie 2026".
6. HOVER icons now match mockup exactly per column:
- request: 📅 schedule / 📞 phone / ⋮ edit
- quote: 📅 schedule / 💬 wa / ⋮ edit
- scheduled: 📄 file-plus (start WO) / 💬 wa / ⋮ edit
- in_work: 👁 eye (open WO) / 💬 wa / ✓ mark Gata
- ready: 💰 cash (mark paid) / 📞 phone / ⋮ edit
- paid: NONE (col 6 has no hover actions per mockup)
7. Col 6 "Achitat azi" cards now opacity:0.65, no hover actions,
no time line, no assignee name (just avatar) — exactly as in mockup.
8. Sum display: amount == 0 renders "—" instead of "0 MDL", both in
card footer and list view.
9. "Avans achitat" tag (blue) appears on Ready cards with partial
payment (pay_status='partial'); "Neachitat" amber only when fully
unpaid. Matches mockup col 5 example "Nissan Qashqai · Gata +
Avans achitat".
10. Link tracking quick-action: appears in detail panel "Acțiuni rapide"
grid when WO has tracking_url. Sits alongside WhatsApp / Sună / SMS.
Two-panel architecture: $showNewForm and $openCardKey are mutually
exclusive. Click outside or ✕ closes the panel; opening one closes
the other.
Tests: +4 (createNewLead happy path, validation, search filter,
partial payment tag). Suite 185/185 (was 181).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
3c0f3ba39e |
feat: Pipeline board full-bleed + hover actions + Programare→Calendar
Addresses the gaps surfaced after the first redesign — the board was still boxed in Filament chrome (not truly full-page), hover floating-actions and "+ Adaugă" CTAs were missing, and the P0 "Programează" from deal card had no calendar wiring. Full-page: - getMaxContentWidth() = Width::Full - getHeading()/getSubheading() return empty so Filament's title bar disappears, leaving the kanban edge-to-edge - CSS uses :has(.pb-shell) to strip Filament's page padding + heading block at the layout level - Board height = calc(100vh - 64px); columns scroll independently Hover floating-actions on every card (column-aware): - Cols 1-2 (Cerere / Calculație): 📅 quickSchedule - Col 3 (Programat): ▶ start work (creates WO) - Col 4 (În lucru): ✓ mark Gata - Col 5 (Gata): 💰 mark Achitat - All cards with phone: 📞 tel: + 💬 wa.me - All cards: ↗ open in resource edit - Shown only on .pb-deal:hover, positioned absolute top-right "+ Adaugă" CTA at column bottom: - Cols 1-3 → /app/leads/create - Cols 4-5 → /app/work-orders/create Programare → Calendar (P0 AAA): - quickSchedule($key) on PipelineBoard creates a real Appointment row for tomorrow 10:00 linked to (client_id, vehicle_id, master_id, deal_id), sets deal.stage='scheduled' + scheduled_at, then shows a toast - Panel bottom action bar gains "📅 Programează" CTA for lead/deal cards - "📅 Calendar" jump CTA for WO cards - calendarUrl() returns the canonical filament.tenant.pages.calendar-board route Empty column state now reads "Gol — trage un card aici" instead of just "Gol" so the drop affordance is explicit. Stat strip + filter bar sticky at top; board fills the remaining viewport. Tests: +1 (quickSchedule creates Appointment + moves deal). Suite 181/181. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
3603c0e43b |
feat: rich Pipeline board — unified Lead/Deal/WO Kanban with SLA + drag-drop transitions
Replaces the bare 6-status WO Kanban with the unified Pipeline view from
/tmp/service/todo/psauto-pipeline-redesign.html. Six columns now span the
entire customer journey end-to-end:
Cerere nouă → Calculație → Programat → În lucru → Gata → Achitat azi
└─ Lead/Deal └─ Deal └─ Deal └─ WO └─ WO └─ WO+Payment
Cross-model drag-drop transitions:
- Lead → Calculație: Lead::convert() creates Deal at stage=contact, marks
quote_sent_at = now, quote_status = sent
- Deal (any earlier stage) → În lucru: spawns a WorkOrder from the deal
(client, vehicle, master, total, complaint), sets deal.stage=in_work,
links wo.deal_id
- WO → Gata: status=ready + fires NotificationDispatcher::workOrderReady
so client gets Telegram/email automatically
- WO → Achitat: creates Payment for remaining balance + status=done,
closed_at=today (pay_status syncs to paid via Payment booted hook)
Rich card content per the mockup:
- Red urgent stripe (left border) for Deal.urgent or WO.urgency!=normal
- Source tag (Instagram/Site/Apel/etc.) on lead/deal cards
- Quote status badge ("Trimis · fără răspuns" amber / "Văzut ✓" blue /
"A răspuns" green) based on deal.quote_status
- Scheduled time + bay tag ("05.06 · 09:00" + "Post 2")
- Fișă FL-NNN purple tag on WO cards
- "Necesită aprobare" amber tag when wo.status=agreement
- Progress bar (purple, 0-100%) on in-work cards: works_done + parts_installed
over total lines
- SLA time line per card with overdue red color:
* Lead 60+ min not contacted = overdue
* Quote 2h+ no response = overdue
* Ready 30+ min not paid = overdue (with phone icon)
* WO past ETA = overdue
- Assignee avatar (deterministic CRC32 color: blue/green/purple/amber)
- Amount in MDL, formatted
Stat strip (6 metrics computed live):
- Total deals active (sum of cols 1-5)
- MDL pipeline total
- MDL closed today (Payment sum where paid_at=today)
- Necesită acțiune (overdue + urgent + pending approval)
- Rata conversie 30d (won / (won+lost) %)
- Depășit termen (count WO past eta_at)
Filter chips wire-driven: Toate / Ale mele (assigned_to=me) /
Urgente (urgent=true OR wo.urgency!=normal) / Azi.
View toggle: Kanban ↔ Listă (table with all cards flat, sortable by stage).
Slide-in detail panel:
- 6-step stage stepper highlighting current
- Client / Telefon (blue clickable) / Auto / Sursă / Responsabil / Sumă /
De achitat (live computed balanceDue for WOs)
- Note / Reclamație
- Linked Fișă card with status badge, progress, ETA, "necesită aprobare"
alert + tracking link
- Activity timeline from Spatie activity-log
- Quick actions: WhatsApp (wa.me/<phone>), Sună (tel:), SMS (sms:),
Deschide (jumps to Filament resource edit)
DealResource hidden from nav (shouldRegisterNavigation=false) since
PipelineBoard is the canonical entry, but its edit/create routes stay
intact — the panel deep-links to them.
Auto-refresh: wire:poll.10s keeps the board live without WebSocket
dependency. Drag-drop is HTML5 native + Livewire wire:click for ops.
Dark mode supported via CSS variables overridden in .dark scope.
Migration: extend deals table with urgent, quote_sent_at, quote_status,
quote_seen_at, scheduled_at, bay, confirmed_at, confirmed_via,
last_action_at. Idempotent (hasColumn guards). Deal model auto-updates
last_action_at on saving.
Tests: 7 new + full suite 180/180 green (was 173).
- partition leads/deals/wos by column
- stats computation: active, pipeline_mdl, closed_today_mdl
- lead→quote transition converts lead into deal
- deal→in_work creates WorkOrder linked back to deal
- wo→paid creates payment for balance + marks done
- filter "mine" narrows to assigned user
- openCard loads panel detail with correct stepper position
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
0e3f9e8bca |
feat: AI model selector + i18n nav labels (RU/EN) on new modules
AI model selector:
- AiAssistantService::MODEL_DEFAULTS and MODEL_OPTIONS const tables (3 picks per
provider: Claude Opus 4.7 / Sonnet 4.6 / Haiku 4.5, OpenAI 4o / 4o-mini,
Gemini 1.5 Pro / Flash). Default upgraded from Sonnet 4.5 → Sonnet 4.6.
- modelFor(provider, company?) resolves tenant override > global default.
- All 8 hardcoded model strings replaced with modelFor() across callClaude
(chat with tool-use), callOpenAI, callGemini (chat), postClaude/postOpenAI/
postGemini (single-shot), and OcrInvoiceService.
- Settings page adds 3 model selectors per provider with persistence at
settings.ai.models.{claude,gpt,gemini}.
i18n nav labels:
- TireSet / Bodyshop / Subcontractor / SubcontractJob / PricingCoefficient /
ShopCustomer resources: getNavigationLabel / getNavigationGroup /
getModelLabel / getPluralModelLabel return __()-wrapped strings.
- 20 keys added to lang/ru.json and lang/en.json.
Tests (4 new): default model, tenant override wins, unknown provider falls
back to claude default, options dictionary contains each default key.
Full suite: 134 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
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>
|
||
|
|
fca4f75e9c |
feat: OCR invoice import via Claude Vision
Upload an invoice photo → Claude extracts {supplier_name, date, currency,
items, total} as JSON → auto-create a draft Purchase + PurchaseItems →
redirect to edit so the user reviews before confirming/receiving.
OcrInvoiceService:
- Validates supported MIME (jpg/png/webp/gif)
- Reads tenant Claude key (settings.ai.claude_key) — friendly error if missing
- Calls /v1/messages with image content block + structured-output system prompt
- Tolerant parser: strips ```json fences, falls back to first {…} block
- normalize(): computes per-item total when absent, fills overall total
- All return shapes: {ok:bool, data?, error?, raw?, tokens?}
Filament:
- "Import factură (OCR)" header action on Purchases list
- Image file upload → service → matches Supplier by case-insensitive name
(notes the unmapped name if no match) → creates draft Purchase + items →
redirects to the Edit page
Tests (6 new):
- clean JSON parses; markdown fences stripped; malformed → graceful error;
missing key → friendly message + no HTTP; unsupported MIME rejected;
item total computed when missing
Full suite: 123 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
8fdfc9ef85 |
feat: Part product images + seasonal tire-swap reminders
Part (HasMedia): - Spatie media `image` single-file collection + imageUrl() helper - PartResource form: image upload section (image editor, 2 MB max) - Parts list: circular thumbnail column - Shop catalog cards: square thumbnail + 📦 placeholder - Shop part detail: 260px image alongside info, single column when no image Seasonal tire-swap reminders: - NotificationDispatcher::tireSeasonalSwap(TireSet) — Telegram first, email fallback (when set has a vehicle, via ServiceReminderMail with 'tire_swap' type and a size-aware note) - tires:remind-seasonal artisan command, self-gating to Feb 15-Mar 15 (notify winter sets stored) and Sep 15-Oct 15 (notify summer sets stored). 60-day cooldown per client via service_reminders_sent. --force / --dry-run. - Schedule: weekly Mon 09:30 Tests (6 new): - outside window no-ops; spring window notifies winter; spring ignores summer; autumn notifies summer; cooldown blocks doubles; --force overrides window Full suite: 106 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5e255b7b40 |
Stage 10 — Bodyshop / PDR / Detailing: damage map + insurance + photos
Completes the 18-stage roadmap (17/18 fully functional, 18 partial). Schema: - bodyshop_jobs (type body_repair/pdr/painting/detailing/ceramic/ppf/polishing, status workflow, insurance case fields, estimate/approved amounts) - damage_points (zone, kind, severity) — the damage map Models: - BodyshopJob (HasMedia: photos_before/photos_after), auto number BS-YY-NNNN - DamagePoint with ZONES/KINDS/SEVERITIES Filament (new "Tinichigerie" nav group): - BodyshopJobResource: type/status, collapsible insurance section (conditional fields), before/after photo upload, estimate/approved amounts - DamagePointsRelationManager (zone + kind + colour-coded severity) - Table with type badge, insurance flag, damage count; nav badge = open jobs Tests (5 new): - auto number; damage points relation; insurance fields persist; detailing types supported; tenant isolation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e8078f157a |
Stage 9 — Subcontractor System: outsourced work with cost+markup
Schema: - subcontractors (specialty, rating, contact) - subcontract_jobs (work_order link, cost, markup_pct, client_price, status workflow, sent_at/eta/returned_at, paid_to_sub) Models: - SubcontractJob: auto number (SC-YY-NNNN), client_price = cost×(1+markup/100) when markup>0 (else manual), margin() helper, recalcs parent WO on save/delete - WorkOrder.recalcTotal now includes non-cancelled subcontract job client_price Filament (new "Subcontractare" nav group): - SubcontractorResource (specialty/rating CRUD) - SubcontractJobResource board with cost/client/margin columns + status filters, nav badge = open jobs - SubcontractJobsRelationManager on WorkOrder Tests (7 new): - client_price from markup; manual price without markup; auto number; WO total includes jobs; cancelled excluded; delete recalcs; tenant isolation Closes roadmap to 16/18 stages (only Stage 10 Bodyshop remains). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
94938f24d7 |
Stage 11 — Tire Service: tire hotel + wheel sets
Schema: - tire_sets (client/vehicle, season, size width/profile/diameter, brand/DOT, rims, tread JSON per position + tread_min cache, TPMS + sensor ids, photos) - tire_storage (location, season_label, stored/retrieved, check-in/out, fee) Models: - TireSet (HasMedia): sizeLabel, isStored, currentStorage, auto tread_min - TireStorage: durationDays, isActive Filament (new "Anvelope" nav group): - TireSetResource: specs form + per-position tread + TPMS + photo upload; table with size, season badge, min tread (red < 3mm), storage status - Check-in (location + period + fee → stored) / Check-out (→ retrieved) - StorageRelationManager (stay history); nav badge = sets currently stored Tests (6 new): - sizeLabel formatting; tread_min from positions; check-in active storage; check-out retrieved + duration; multiple stays per set; tenant isolation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a1be01b0d5 |
Stage 4 — Labor Catalog: fixed price + default parts + service templates
Schema: - labors.pricing_mode (hourly/fixed) + fixed_price - labor_parts (default parts auto-added with a labor) - service_templates + service_template_items (labor/part bundles) ServiceComposer: - addLabor(wo, labor, withParts) — hourly (hours×rate) or fixed (fixed_price), then auto-adds the labor's default parts - addPart(wo, part, qty) — catalog price snapshot - applyTemplate(wo, template) — adds all labor+part lines, recalcs total - hourlyRate from settings.labor_rate Filament: - LaborResource: pricing_mode (live) toggles hours/fixed_price fields, DefaultPartsRelationManager - ServiceTemplateResource (Service group) with ItemsRelationManager - WorkOrder edit "Aplică șablon" action → applyTemplate - WorksRelationManager CreateAction auto-adds labor default parts Tests (6 new): - hourly rate×hours; fixed uses fixed_price; default parts auto-added; withParts=false skips; applyTemplate adds lines + recalcs total; templates tenant-isolated Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c90c35d930 |
Stage 8 — Smart Pricing Engine: contextual coefficients
Contextual multipliers layered on top of base MarkupRule pricing, applied
per work-order line based on vehicle, client and urgency.
Schema:
- pricing_coefficients (multiplier, conditions JSON, priority, stackable)
- vehicles.vehicle_class (sedan/suv/commercial/hybrid/ev/premium)
- clients.is_vip
- work_orders.urgency (normal/urgent/express)
PricingEngine::quote(Part, Vehicle?, Client?, urgency):
- base = MarkupRule on buy_price (fallback sell_price or buy×1.30)
- context: class (explicit or inferred hybrid/ev from fuel), age, vip, urgency
- stackable coefficients all multiply; non-stackable take only the highest
- returns {base, final, applied[]} breakdown
PricingCoefficient::matches(ctx) — classes/age range/vip/urgency conditions
(empty = always applies).
Filament:
- PricingCoefficientResource with condition builder (classes, age, vip, urgency)
- vehicle_class select, client is_vip toggle, WO urgency select
- "Preț inteligent" action on WO parts shows breakdown + applies sell_price
Tests (6 new):
- base-only without coefficients; age coefficient gating; VIP; express urgency;
stackable multiply vs non-stackable highest-wins; hybrid inferred from fuel
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
1ff888131f |
Stage 16 — AI Layer: VIN decoder + diagnostic / parts / price helpers
VinDecoder (deterministic, no API): - ISO 3779/3780 parsing: WMI manufacturer (~60 brands), year (cyclical with post-2010 disambiguation via position 7), region, plant, NA checksum - Strip non-VIN chars, accept dashes/spaces, reject I/O/Q per spec AiAssistantService: - Refactored provider HTTP into postClaude/postOpenAI/postGemini so both chat history and one-shot calls share the same transport - singleShot(system, userPrompt, provider?) for fire-and-forget calls - 4 specialized helpers with tight prompts: - suggestDiagnosis(WO) — diagnostician based on complaint + VIN info - suggestParts(WO, task) — OEM parts list for an operation - suggestPrice(Part) — markup recommendation with justification - vinRecommendations(vin, mileage) — scheduled maintenance from decoded VIN - monthlyUsage() — token spend MTD by provider Filament: - VehicleResource: "Decode VIN" + "AI: recomandări" actions - WorkOrderResource Edit: "AI: sugerează diagnostic" header action - PartResource: "AI: preț recomandat" action - Shared views: filament.tenant.ai-reply, filament.tenant.vin-decode - AiAssistant page shows monthly token usage banner Tests (13 new): - 8 VinDecoder unit tests with real VIN samples (Honda 2003, VW 1999, Audi 2014, Dacia, unknown WMI, lowercase/dashes, forbidden chars) - 5 AiHelpers feature tests with Http::fake covering all providers + no-key fallback + token usage aggregation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
a2026f640a |
Stage 6 — Purchase System: partial receipt + supplier analytics
Schema: - purchase_items.qty_received (backfilled from `received` boolean) - purchases.warehouse_id (target warehouse FK) - supplier_part_prices (price history per supplier/part with purchase ref) - New status `partial` between ordered and received Purchase ↔ Warehouse integration: - Purchase::receiveItem(item, qty, warehouse?) — routes through WarehouseService::receive: creates batch + receipt event + supplier price row - Purchase::receiveAllRemaining(warehouse?) — receives all outstanding lines - Purchase::recomputeStatus() — auto: ordered → partial → received Old flat markReceived() removed — every receipt now writes batches + ledger. Filament: - Purchase list: progress %, partial badge, warehouse picker on form - ItemsRelationManager: per-line "Recepționează" with qty + warehouse modal, qty_received shown as "X.XX / Y.YY" with colour - PartResource: new PriceHistoryRelationManager (supplier price history) - SupplierResource: derived columns onTimeRate / avgDeliveryDays / spend(90d) + "Rerating" action Analytics: - App\Services\Warehouse\SupplierAnalytics (onTimeRate, avgDeliveryDays, spend, count, computedRating) - `suppliers:rate` artisan command + weekly schedule (Mon 04:00) - Computed rating: 70% on-time + 20% volume + 10% speed bonus Tests (6 new, all pass): - Partial receipt of 3/10 → status=partial + 1 batch + 1 price row - receiveAllRemaining → status=received with received_at set - Over-receive throws InvalidArgumentException - Two partial receipts (4+6) → 2 batches FIFO + status=received - onTimeRate 50% with 1 on-time + 1 late - computedRating null when <2 deliveries Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
426156fe45 |
Stage 5.1 — Warehouse ERP: batches + FIFO + reservations + multi-warehouse
Schema: - warehouses (multi-warehouse, code unique per company, is_default) - part_batches (lot per receipt, qty_in/qty_remaining, buy_price, FIFO-indexed) - warehouse_events (immutable ledger: opening/receipt/issue/transfer/adjustment/write_off) - part_reservations (per-WO allocations from specific batches, active/consumed/released) - companies.default_warehouse_id + parts.qty_reserved Backfill: 1 default warehouse + 1 opening batch per existing part per company. WarehouseService: - receive / issue (FIFO) / reserve / release / consume / transfer / adjust - DB::transaction + lockForUpdate on batch rows - InsufficientStockException with requested + available context - Auto-syncs parts.qty as aggregate cache (source of truth = sum(qty_remaining)) WO integration: - WorkOrderPart created/updated → reserve from FIFO batches - WorkOrderPart deleted → release - WorkOrder status=done → consume reservations into issue events - WorkOrder status=cancelled → release reservations Filament: - WarehouseResource (CRUD) - BatchesRelationManager on PartResource (FIFO list with qty_remaining + cost) - "Recepție" action on parts list → calls WarehouseService::receive - qty_reserved column added on parts list Tests (8 new, all pass): - receipt creates batch + event - FIFO order verified across 3 batches with different received_at - InsufficientStockException on over-issue - Reservations block other reservations but don't deplete on-hand - WO done consumes; WO cancelled releases - Batches tenant-isolated - Transfer between warehouses with weighted-avg cost Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
59409e1b11 |
fix: calendar — register createForm via getForms() + remove broken FullCalendar CSS link
- Filament v5 multi-form pages need protected getForms() returning form names - FullCalendar v6 bundles CSS in JS — separate <link> 404'd silently - Added Russian locale alongside Romanian for i18n switcher |
||
|
|
d5680f78dc | fix: column names in globalSearch (leads.source, vehicles.make, parts.article, WO.complaint) | ||
|
|
8f324b7b8e |
Currency dropdown + logo on login/topbar via brandLogo closure
- Onboarding + Settings: Forms\Components\Select for currency (MDL/EUR/USD/RON/UAH/RUB) instead of TextInput - TenantPanel: brandName/brandLogo/favicon as closures resolving from tenant. Logo now visible on login page, topbar, sidebar (Filament v5 brandLogo handles all 3 spots automatically) - Favicon falls back to logo if favicon not separately uploaded - Removed manual SIDEBAR_LOGO_BEFORE injection (replaced by brandLogo) - Removed manual <link rel=icon> in HEAD_END (replaced by ->favicon) |
||
|
|
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
|
||
|
|
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 |
||
|
|
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)
|
||
|
|
6c72fc7db1 |
Batch 3: Integrări placeholder + Backup tenant
═══ Integrări (Marketing → Integrări) ═══ - /app/integrations Page cu 6 carduri (Telegram/WhatsApp/Google Ads/FB/SMS/Webhook) - Toggle on/off per integrare; salvare în settings.integrations JSON - Câmpuri specifice per integrare (token/key/id/secret) - Banner explicativ: 'placeholder UI — implementare separată' ═══ Backup tenant ═══ - TenantBackupService::export($company) → ZIP cu: • data/ (1 JSON per tabel: clients/vehicles/leads/deals/work_orders cu sub-relații/...) • media/ (logo + favicon) • manifest.json (metadata + counts) - /app/backup Page cu buton 'Descarcă backup acum' - Streaming download cu deleteFileAfterSend - Util pentru: backup local, migrare, audit, GDPR right-to-erasure Total tenant routes: 104. Toate cele ~26 module din prototip implementate (sau echivalent funcțional). |
||
|
|
4b3201ca1c |
Batch 2: Workload heatmap + Site PSauto + VIN search
═══ Workload heatmap (Încărcare STO) ═══ - /app/workload custom Page (group Analiză) - Săptămână (Lu-Du) × posturi → matrice ore programate - Heatmap colorat: verde→galben→roșu pe ratio capacity (10h/zi) - Navigare săpt anterior/curent/următor - Programări fără pod → row '— fără pod —' separat ═══ Site PSauto (landing public) ═══ - / pe tenant subdomain → resources/views/site/landing.blade.php - Hero cu logo + nume + slogan; gradient theme color - Servicii (din settings.services) — grid card-uri - Locație/contact + program lucru standardizat - Mărci suportate (din settings.cars) - CTA: phone + email - Footer cu tenant name + powered by AutoCRM ═══ VIN search ═══ - VinDecoder service: WMI hardcoded (24 producători EU/Asia/USA) + year codes (2001-2026) — pure offline, fără API extern - /app/vin-search Page (group Depozit) cu: • Input VIN cu uppercase + monospace • Decode → producător/țară/an/serial • Match VIN-uri din baza Vehicles • Search piese din catalog (live debounce 300ms) - Rezultatele linkează la editor Vehicle/Part Total tenant routes: 102. |
||
|
|
67da97178d |
Batch 1: Procentaj + Finanțe consolidat + Recomandări
═══ Procentaj (markup rules) ═══
- markup_rules table cu type (category/brand/range), key, range_from/to, markup_pct, priority
- MarkupRule::bestForPart($part) — rezolvare brand → category → range → 30% default
- MarkupRule::applyToPart($part) — recalc sell_price = buy_price × (1 + pct/100)
- Filament resource sub Depozit cu form dinamic per tip
- Action 'Aplică toate regulile la stoc' — recalc tot catalogul (chunk 100)
═══ Finanțe consolidat ═══
- Custom Page /app/finance cu 4 tab-uri:
• Overview: încasări/cheltuieli/profit/datorii (4 cards)
• Cashflow: bar chart per zi (verde=in, roșu=out) + Net total
• P&L: venituri (manopere + piese) vs costuri (cost piese + cheltuieli pe categorie)
+ profit net + marjă %
• Balance: active (cash net + datorii + stoc), all-time totals
- Period filter: lună / luna trecută / an / 30 zile
═══ Recomandări ═══
- Custom Page /app/recommendations 4 sectiuni:
• Clienți pierduți (>6 luni fără WO + are istoric)
• Mașini km>100k (sugestie revizie)
• Fișe neplătite (rest > 0)
• VIP fără contact >30 zile
Total tenant routes: 100.
|
||
|
|
976c0f03e3 |
AI Assistant — multi-provider chat (Claude / GPT / Gemini)
Schema: - ai_chats: company_id, user_id, title, provider; index pe activitate - ai_messages: role (system/user/assistant), content, meta JSON (tokens, latency, model) Service AiAssistantService (multi-provider): - ask($chat, $message): persistă mesajul user, build system prompt cu context tenant (statistici clienți/mașini/cereri/datorii), apelează API-ul providerului, persistă răspunsul cu meta (tokens, latency) - callClaude: api.anthropic.com/v1/messages cu claude-sonnet-4-5 - callOpenAI: api.openai.com/v1/chat/completions cu gpt-4o-mini - callGemini: generativelanguage.googleapis.com cu gemini-1.5-flash - Try/catch pe toate; eroare devine mesaj asistent fără să crape System prompt include: - Numele și orașul companiei - Statistici curente (clienți, mașini, cereri noi, fișe active, datorii) - Limita stricta: NU inventează date Custom Filament Page /app/ai-assistant (group Analiză): - Sidebar stâng: listă conversații (last 20), buton 'Nouă' + delete cu confirm - Main: bubble chat (user dreapta albastru, asistent stânga gri) - Meta jos pe răspuns: provider · latency · tokens - Empty state friendly cu instrucțiuni configurare - Loading indicator (3 dots animate) când AI răspunde - Auto-scroll la mesaj nou - Enter trimite, Shift+Enter newline - Auto-titlu chat din primul mesaj user (60 chars) Settings page extins cu secțiune 'Asistent AI': - Provider implicit (claude/gpt/gemini) - 3 chei API (password fields, revealable) - Key-urile salvate în companies.settings.ai (per tenant, izolat) |
||
|
|
09fd0bada2 |
Faza 2 (din continuare): Email notifications
4 Mailables auto-trigger pe model events: - WorkOrderReadyMail: la WO.status → 'ready', către client.email • Atașat PDF fișa lucru (via WorkOrderPdfService) • Total/achitat/rest, recomandări (warning box) - PaymentReceivedMail: la Payment::created, confirmare cu sumă/metodă/ref - AppointmentConfirmedMail: la Appointment::created status='scheduled' - ServiceReminderMail: dispatch manual (vehicle, type=itp/oil/general, note) Layout email branded (resources/views/emails/layout.blade.php): - Header cu logo tenant + theme_color border-bottom - Footer cu telefon/email/disclaimer - Stiluri inline (compatibil tot mail client) Settings page extins cu 4 toggle: - 'Mașina e gata de ridicat' - 'Confirmare plată primită' - 'Programare confirmată' - 'Reminder ITP / revizie' Salvate în companies.settings.notify (JSON), default true. NotificationDispatcher service centralizat: - Verifică isEnabled() pe settings.notify[$key] - Skip dacă client n-are email - Try/catch + Log::warning pe eșec (nu crapă request-ul) Mailables folosesc UsesTenantBranding trait pentru context unitar. Test prin Mailpit: https://mailpit.service.mir.md (capturează toate). |