Compare commits

..

25 Commits

Author SHA1 Message Date
Vasyka 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>
2026-06-06 07:34:27 +00:00
Vasyka 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>
2026-06-06 07:24:15 +00:00
Vasyka 03e030d6d2 feat: tier 3 polish — M12/13/14/15 deep cleanup
Closes the remaining ~50h of items from CONFORMITY-12-15.md across all
four modules. Single umbrella migration (2026_06_05_000004) lands four
tables + 5 column additions, no downtime risk.

== M12 — body_type + transmission + pricing audit log ==

Vehicle gains body_type (12 values: sedan/hatchback/suv/crossover/pickup/
van/truck/coupe/wagon/convertible/minivan/moto) and transmission_type
(6 values: manual/automatic/cvt/dsg/dct/amt). These are separate from
vehicle_class so admin can configure DSG-only coefficients without
contaminating the SUV detection.

PricingCoefficient.matches() now also tests:
  - conditions.body_types[] against ctx.body_type
  - conditions.transmissions[] against ctx.transmission

PricingEngine builds the richer ctx and exposes it on the quote return
under quote.context.

New pricing_application_logs table (append-only) — call
PricingEngine::logApplication($quote, $subject, $vehicle, $client, $part)
after applying a price to a WO line. Stores base, final, full
applied[] array, and the ctx snapshot so the question "why was this
priced at 218 lei in March?" stays answerable forever.

PricingCoefficientResource form gains CheckboxList for body_types and
transmissions (3-column layouts, full-width). Both are optional —
empty list = applies to anything.

== M13 — Mechanic REST API + KPI ==

New MechanicApiController with 7 endpoints under /api/v1/mechanic/:
  GET    /board               — own non-done WOs with their works expanded
  GET    /kpi?period=YYYY-MM  — own aggregates for the period
  POST   /tasks/{w}/start
  POST   /tasks/{w}/pause
  POST   /tasks/{w}/resume
  POST   /tasks/{w}/done
  POST   /tasks/{w}/block     — validates reason from BLOCK_REASONS enum

Every endpoint authorizes ownership: $work->workOrder->master_id ===
auth()->id() else 403. board() returns null pending_works so native
apps don't make round-trips. workPayload() emits efficiency_pct and
efficiency_class on every response.

New MechanicKpi Filament page at /app/mechanic-kpi (Service group). Same
aggregation logic but tenant-wide: groups WorkOrderWork rows by
master_id for the selected period, computes totals + efficiency_pct +
revenue. Period navigation via ◀/▶ buttons, default = current month.
Color-coded efficiency badges (green ≤100%, amber ≤130%, red >130%).
Rows sort by revenue descending — easy "top earners this month" view.

== M14 — OCR async via Laravel queue ==

New ocr_jobs table (id, supplier_id?, source_type, file_path, status,
result JSON, error_message, ai_provider, tokens_used, purchase_id?,
processed_at). Idempotent migration.

New OcrJob model + ProcessOcrJob queueable job. Job re-establishes
tenant context inside the worker (Company::find + TenantManager::setCurrent)
since queue workers don't inherit middleware-resolved tenants.

handle() walks: status=pending → processing, calls OcrInvoiceService::extract,
on success → status=done + result + ai_provider; on throw → status=failed
+ error_message. Failed jobs auto-retry once (tries=2) with 120s timeout.

The existing synchronous OcrInvoiceService stays for inline use cases
(tests, quick imports). The job is now the canonical path for the
admin UI to keep requests sub-100ms.

== M15 — eta_promised + JSON tracking + notifications log ==

Three new wo columns: eta_promised (initial commitment, never changes),
eta_change_reason (text for "așteptăm piesă"), eta_updated_at (when
the current eta was last touched). Existing eta_at remains as "current"
ETA so the UI can render both side-by-side.

New /api/track/{token} JSON endpoint (public, tenant-scoped via subdomain):
  number, status, status_label, progress %, client, vehicle, plate, master,
  eta_promised, eta_current, eta_change_reason, total, pay_status,
  pending_approvals[] (each with kind/id/name/amount/approve_url —
  signed URLs ready for native app webview),
  timeline[] (from activity_log, last 20 events).

NotificationDispatcher::dispatch() gains optional workOrderId param.
Every send call (success or failure) now writes one row to the new
client_notifications_log table with channel/template_key/status (sent
or failed)/error_detail/sent_at. Failures of logging are swallowed
so a missing activity_log never breaks notifications. workOrderReady
and paymentReceived pass the WO id through; others can be wired in
future commits without schema change.

New tables tracked:
  client_notifications_log — every push to client, append-only
  pricing_application_logs — every pricing decision, append-only
  ocr_jobs — async OCR job queue

== Tests ==

PolishTier3Test (11):
- M12: body_type condition match/no-match; transmission DSG match;
  pricing_log row persists base/final/applied/ctx
- M13: mechanic API board scoped to own WOs; start task on foreign
  work returns 403; KPI endpoint computes 2.5/3 = 83% efficiency
  across 2 done works in period
- M14: ocr_job queueable + Queue::fake assertion
- M15: tracking JSON returns ETA promised/current/reason + pending
  approvals with correctly-signed approve_url; dispatcher writes
  ClientNotificationLog row on workOrderReady
- M12: vehicle body_type + transmission_type round-trip through save

Suite: 269 passed (761 assertions). Was 258.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 05:31:50 +00:00
Vasyka 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>
2026-06-05 05:15:01 +00:00
Vasyka 0e3119a6e2 feat: M14 Excel import wizard + M15 client approval via tracking link
Top-ROI items from CONFORMITY-12-15.md. Together: ~40h of TZ work
delivered in one pass.

== M14 — Excel/CSV invoice import wizard ==

phpoffice/phpspreadsheet ^5.7 added as composer dep — parses both XLSX
and CSV cleanly.

ExcelInvoiceImportService (app/Services/ExcelInvoiceImportService.php):
- headersPreview($path)          → first 5 rows + detected column letters
- preview($path, $mapping)       → all rows classified as found/new/no_article
- import($supplier, $rows, $createNew=true) → creates Purchase + items,
                                    auto-creates Parts for "new" rows
- rememberMapping / rememberedMappingFor($supplier) — round-trips JSON
  config (article_col / name_col / qty_col / price_col / brand_col? /
  header_row / sheet_name?) per supplier so the second import is
  instant

Decimal parser tolerates European formats: "1 234,56", "1,234.56",
non-breaking spaces (U+00A0 NBSP common in copy-pastes from PDF).
Article matching uses single batch query (Part::whereIn) — O(1) for
the whole sheet, not O(rows).

ExcelImportWizard Filament page (/app/excel-import-wizard) — 4-step
Livewire wizard:
  1. Upload + supplier select (saved mapping auto-loads if exists)
  2. Column mapping with first-3-rows preview table + per-column
     dropdowns
  3. Preview with status badges per row ( Found / ⚠️ New /  Missing)
     + summary counts
  4. Confirmation → "Open Purchase" CTA

Stored in nav group "Stoc & Finanțe", sort 65. Width Full.

Migration: supplier_invoice_mappings (id, company_id, supplier_id UNIQUE,
mapping_config JSON, sample_file_name, last_used_at, timestamps).
Per-tenant scope via BelongsToTenant.

== M15 — Client approval via tracking link (the P0 from TZ §15) ==

Migration: adds 4 columns to wo_works AND wo_parts:
- requires_approval boolean default false
- approved_at timestamp nullable
- approval_token varchar(32) nullable (indexed for fast lookup)
- declined_at timestamp nullable

Both model booted hooks: when a row is saved with requires_approval=true
and no token yet, auto-generate Str::random(24). Models gain
isPendingApproval() helper returning true only while not yet approved
nor declined.

Public route: POST /t/{token}/approve/{kind}/{lineToken}
  kind = 'work' | 'part'
  body: decision = 'approve' | 'decline'
The line's approval_token IS the credential — anyone with the URL can
act. No CSRF token required since this is the unauthed public tracking
flow (the tracking_token + line approval_token combo functions as
shared-secret). Form-encoded POST with csrf_field() on the public form
keeps Laravel happy.

TrackingController::show() now eager-loads works + parts, computes
pendingWorks and pendingParts collections, passes them to the view.
TrackingController::approve() validates kind, locates the line by
(work_order_id, approval_token), idempotently marks approved_at or
declined_at, redirects back to /t/{token} with a flash status.

UI banner (tracking/show.blade.php) at the top of the page:
- Amber warning "⚠ Necesită aprobarea ta"
- Per-line card: title + amount (ore/qty + total MDL) + two buttons
  (green Aprob / outline-red Nu aprob)
- Disappears as soon as approved/declined
- Success/error flash above the banner after each action

== Tests ==

ExcelInvoiceImportTest (5):
- headers_preview returns first 5 rows + column letters
- preview classifies rows as found/new/no_article based on Part DB
- import creates Purchase with items + auto-creates parts for "new"
- remember_mapping upserts, no duplicate per supplier
- decimal parser tolerates "1 234,56" European format with NBSP

TrackingApprovalTest (7):
- creating a work with requires_approval auto-generates 24-char token
- POST /t/{token}/approve/work/{lineToken} marks approved_at
- POST with decision=decline marks declined_at instead
- wrong line token redirects with error flash (no leak)
- already-approved line cannot be approved again (idempotent)
- tracking page renders "Necesită aprobarea ta" banner when pending
- approved line vanishes from banner on next page load

Suite: 246 passed (700 assertions). Was 234.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 04:56:06 +00:00
Vasyka d9180e16b3 feat: P2 RBAC defers — REST API + invitation workflow
Closes the P2 items from /tmp/service/new/01-TZ-rbac §4.1 §4.2.

== User invitation workflow ==

New columns on users: invited_at, invited_by_id (FK self), accepted_at,
invitation_token (sha256 hash, indexed). Migration is idempotent.

User::sendInvitation($invitedBy = auth()->user())
  - generates 64-char random token
  - stores sha256(token) in invitation_token column (never plaintext)
  - marks invited_at = now(), status = inactive
  - queues UserInvitationMail to the user's email with the signed accept URL
  - returns the raw token (for tests / API consumers)

User::findByInvitationToken($rawToken) hashes + lookups.
User::acceptInvitation($password) sets password (hashed cast), clears
invitation_token, marks accepted_at + email_verified_at, status = active.

Web routes (no auth — token IS the credential):
  GET  /invitations/{token}  → password-set form
  POST /invitations/{token}  → validates min:8 + confirmed, accepts

Tokens expire after 7 days (checked against invited_at). Expired and
invalid tokens render dedicated views (invitations/expired.blade.php,
invitations/invalid.blade.php) instead of generic 404 — so the user
knows to ask for a resend.

UserInvitationMail uses Filament's existing markdown layout; subject
includes the tenant display_name.

== REST API ==

Twenty new endpoints under /api/v1/ (Sanctum auth + tenant scoping
via the existing EnsureTokenMatchesTenant middleware). All gated by
ADMIN_USERS_* / ADMIN_ROLES_MANAGE permissions; mechanic-level token
gets 403.

Users:
  GET    /users                                  — paginated + role/status/q filters
  GET    /users/{u}                              — eager-loads roles + overrides + invitedBy
  POST   /users                                  — creates inactive user + sends invitation
  PATCH  /users/{u}                              — update name/email/role/status
  DELETE /users/{u}                              — soft delete
  POST   /users/{u}/activate
  POST   /users/{u}/deactivate                   — also revokes all sessions
  POST   /users/{u}/resend-invitation
  POST   /users/{u}/force-password-reset         — re-sends invitation
  GET    /users/{u}/sessions                     — list active sessions (from sessions table)
  DELETE /users/{u}/sessions                     — revoke all
  DELETE /users/{u}/sessions/{sessionId}         — revoke one
  GET    /users/{u}/roles                        — assigned roles
  POST   /users/{u}/roles                        — assign role
  DELETE /users/{u}/roles/{role}                 — remove role
  GET    /users/{u}/permissions                  — effective: role perms + grants - active denies
  POST   /users/{u}/permission-overrides         — add grant/deny (with optional expires_at)
  DELETE /users/{u}/permission-overrides/{perm}

Roles:
  apiResource roles                              — index/show/store/update/destroy
                                                   (system roles guarded against rename/delete)
  GET    /roles/{r}/permissions
  PUT    /roles/{r}/permissions                  — bulk sync
  GET    /permissions                            — catalog: flat list + grouped + labels + role labels

Authorization is uniform: every controller method calls $this->authorize()
which throws 403 if canDo(perm) is false. canDo() already honors the
overrides + admin bypass + audit log from earlier commits, so the API
behaves identically to the Filament UI.

== Tests ==

InvitationFlowTest (8): token generation + sha256 storage + queued mail,
findByInvitationToken happy/sad path, accept sets password + activates,
GET form renders, POST accepts + redirects, invalid token view,
backdated invited_at → expired view, password too short → validation error.

RbacApiTest (12): admin can list users, mechanic 403, create user
queues invitation, assign+remove role round-trip, effective permissions
endpoint subtracts active denies, add+remove override via API,
role index returns 7 system roles with permission counts (51 for owner),
role sync permissions, system role destroy rejected with 422,
permission catalog endpoint returns all 51 + grouped + labels,
revoke all sessions deletes only target user's rows.

Suite: 234 passed (659 assertions). Was 214.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 22:36:44 +00:00
Vasyka 1d4ac3db38 feat: P1 RBAC defers — overrides + sessions + audit log
Completes the P1 items from /tmp/service/new/01-TZ-rbac §2.1 §4.1.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 22:03:03 +00:00
Vasyka 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>
2026-06-04 21:50:22 +00:00
Vasyka 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>
2026-06-04 21:18:54 +00:00
Vasyka 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>
2026-06-04 20:14:20 +00:00
Vasyka 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>
2026-06-04 20:02:44 +00:00
Vasyka 0620635abb test: full E2E audit + fix CsvImportExport vehicle.brand → make
Audit pass (33 new tests in tests/Feature/Audit/):
- CrmFunnelE2ETest: full Lead→Deal→Appointment→WO→Payment journey,
  covering 5 previously-untested models. Verifies WO.balanceDue updates
  correctly after payments, including refunds (negative amount → balance
  increases).
- WorkOrderTotalsTest: works+parts+subcontract+discount sum correctly,
  cancelled subcontract excluded, deleting lines triggers recalc, status=done
  consumes part reservations into issues, cancelled releases reservations.
- ShopJourneyE2ETest: register→cart→checkout→email confirmation→tracking
  page reachable→admin fulfills→stock drops→warehouse event recorded.
  Also guest checkout still works without account.
- CsvImportExportTest: round-trip, dedup-by-phone, **caught real bug** —
  Vehicle export wrote $row->brand (no such property) and import set
  'brand' => row['brand'] in Vehicle::create (column is `make`). Fix
  applied to both paths.
- TenantBackupServiceTest: zip contains valid manifest with counts +
  data/*.json per model + works embedded with WorkOrder.
- WorkOrderPdfServiceTest: generated PDF starts with %PDF, includes WO data,
  non-trivial size, handles empty WO.
- PayrollCalculatorTest: base + works_pct + parts_pct + bonus - fine -
  advance, scoped to user + period.
- NotificationFallbackTest: Telegram wins when chat_id present, falls back
  to email when not, returns false when neither, tenant disable flag stops
  both.
- AiProvidersCrossCheckTest: OpenAI request shape, Gemini URL with model,
  no-key friendly message, tenant model override propagates into HTTP body.
- SettingsPersistenceTest: 25-key settings JSON round-trips, partial update
  via array_replace_recursive preserves other keys.
- CompanyProvisionerTest: suspend / reactivate / archive behavior.

Bug fixed: CsvImportExport used `brand` on Vehicle which has column `make`.
The export silently emitted empty values, the import silently dropped the
brand. Now both paths use `make`.

Full suite: 173 passed (468 assertions). 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 07:05:46 +00:00
Vasyka 439ef605a1 feat: production email (Resend) + offsite backup (B2)
Resend mail transport:
- composer require resend/resend-laravel (v1.4)
- Laravel 11 ships the 'resend' mailer config in config/mail.php + services
- To switch to production email: set MAIL_MAILER=resend + RESEND_API_KEY,
  register the domain at resend.com/domains, and add the TXT + DKIM CNAME
  records in Cloudflare. .env.example documents the required steps.

Backblaze B2 offsite backup:
- New filesystems 'b2' disk (S3-compatible, env: B2_KEY/SECRET/BUCKET/REGION/ENDPOINT)
- BackupAllTenantsCommand: after writing each tenant's zip to local disk, it
  uploads the same file to the b2 disk under {YYYY-MM-DD}/{slug}.zip — only
  when both B2_KEY and B2_BUCKET are set, so unconfigured installs are no-op
- Without offsite, backups live on the same VPS as production: a single
  hardware failure loses everything. B2 + Resend together make the install
  genuinely production-ready (people get email + offsite backup).

Tests (2 new):
- backup uploads to b2 (fake disk) when configured
- backup skips offsite when env vars not present

Full suite: 140 passed. Force-rebuild deploy required so composer install
picks up resend/resend-php.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 06:43:39 +00:00
Vasyka 51917bcbaf feat: rate limiting + internal health monitor + secure VAPID note
Rate limiting:
- Shop POST endpoints get per-IP throttles with distinct prefixes so login,
  register, password-email, and password-reset have separate buckets:
  login/register/pw-reset = 5/min, pw-email = 3/min
- OcrInvoiceService gates per-tenant via RateLimiter (30/hour) so a runaway
  uploader can't burn Claude Vision spend

Health monitor (poor-man's monitoring):
- HealthCheckCommand probes DB (SELECT 1), cache write/read, public storage
  write/read, and most-recent backup age. On any failure, pushes a Telegram
  alert via HEALTH_ALERT_BOT_TOKEN/HEALTH_ALERT_CHAT_ID. Dedups identical
  failures within a 30-min window via cache.
- Scheduled every 10 min. Pair with external uptime monitoring (UptimeRobot,
  Better Stack hitting /up) for total-outage coverage.
- .env.example documents the two new env vars.

VAPID secret hygiene:
- credentials.md no longer stores the VAPID_PRIVATE_KEY; the source of truth
  is the Coolify env on the autocrm app. Doc points to where to read it
  (UI or API). Mitigates accidental git leak.

Tests (4 new):
- shop login throttles after 5 attempts (6th = 429); register throttle is
  independent of login (separate prefix); health command runs clean; dedup
  cache path exercised

Full suite: 138 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 06:37:53 +00:00
Vasyka 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>
2026-06-03 06:23:21 +00:00
Vasyka 3da1f5412a feat: shop UX polish — password reset / order email / multi-image / customer admin
Shop password reset:
- Configured 'shop_customers' password broker on the existing
  password_reset_tokens table
- ShopCustomer::sendPasswordResetNotification overrides Laravel default to
  send a ShopPasswordResetMail with a tenant-subdomain reset URL
- Routes /shop/password/forgot, /shop/password/email, /shop/password/reset/{token}
  + ShopAuthController showForgotPassword/sendResetLink/showResetPassword/
  resetPassword. Forgot view stays generic ("if it exists, we sent…") to avoid
  email enumeration. Login view links to "Am uitat parola".

Order confirmation email:
- ShopOrderConfirmationMail + nicely formatted HTML email template
- ShopOrderNotifier::placed now also emails customer_email (best-effort,
  warning-only logged on failure) alongside existing Telegram + staff push

Multiple images per Part:
- Part media collection switched from singleFile to multiple (max 8 in form)
- imageUrls() helper for galleries; imageUrl() still returns first for cards
- PartResource form: reorderable multi-upload
- Shop part detail: vertical thumbnails switch the main image via vanilla JS

ShopCustomerResource (tenant Filament, "Magazin" nav group):
- List with name/phone/email/client_id/orders_count/last_login_at
- Edit (no password field exposed)
- "Trimite reset parolă" action uses the new broker
- OrdersRelationManager shows the customer's orders read-only

Tests (7 new):
- forgot sends mail; forgot doesn't disclose unknown email; reset with valid
  token changes password; bad token rejected; order email when customer_email
  set; email skipped without it; Part has imageUrls() collection

Full suite: 130 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 06:14:45 +00:00
Vasyka 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>
2026-06-03 06:00:45 +00:00
Vasyka 75386c354a feat: shop customer accounts (register/login + order history)
Schema:
- shop_customers (company_id, name, phone unique-per-tenant, email, password,
  client_id auto-linked, last_login_at)
- online_orders.shop_customer_id nullable FK

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

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

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

Full suite: 117 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 19:43:39 +00:00
Vasyka dfb92bf5e2 feat: AI chat tool-use (Claude function calling)
The Asistent AI chat can now query the tenant DB directly through Claude
tool use. AiToolExecutor exposes 5 read-only tools (search_clients,
get_vehicle, find_parts, recent_workorders, low_stock_parts) all scoped
to the current tenant via BelongsToTenant.

AiAssistantService::callClaude loops on stop_reason=tool_use up to 5 rounds:
- normalize message history to content blocks
- send `tools` definitions + messages to Anthropic API
- on tool_use: execute each tool, append tool_results as user turn, recall
- on end_turn: collect text + cumulative token counts + tool-call audit in
  AiMessage.meta.tools

Single-shot helpers (suggestDiagnosis, suggestPrice, vinRecommendations,
suggestParts) are unchanged — only the conversational chat gets tool-use.

Tests (3 new):
- two-round tool_use → execute → final text; verify 5 tools sent both rounds;
  cumulative tokens
- executor finds part by brand
- unknown tool name returns error blob

Full suite: 109 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 19:36:07 +00:00
Vasyka 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>
2026-06-02 19:31:24 +00:00
Vasyka b9ff9c6583 fix: scanner wire:ignore + Company custom columns
- Scanner page: wrap the html5-qrcode camera container (#reader) in
  wire:ignore so a Livewire DOM morph can't tear down the live camera
  stream (same class of bug as the calendar).
- Company::getCustomColumns(): add `is_demo` and `default_warehouse_id`.
  Stancl Tenant treats columns absent from this list as virtual `data` JSON
  attributes, so editing a company could move default_warehouse_id into data
  and null the real column — breaking WarehouseService::defaultWarehouse.

Full suite: 100 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:14:09 +00:00
Vasyka c84ef5d9bd fix: calendar collapses to plain text after first render
FullCalendar mounts into a Livewire-managed subtree. The first
$wire.getEvents() response triggered a Livewire DOM morph that reverted
#autocrm-calendar to its empty server HTML, destroying the rendered grid
(~1s after load it became unstyled text).

Wrap the calendar container in wire:ignore so Livewire's morphdom skips it.
The quick-create modal stays outside wire:ignore to keep its form reactive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:58:55 +00:00
Vasyka 40478dd2aa fix: make purchases partial-receipt migration idempotent
On MariaDB (no transactional DDL) a half-applied prior run left
purchases.warehouse_id committed without recording the migration. On retry it
failed with "Duplicate column name 'warehouse_id'", which aborted the migrate
run and blocked every later migration (notifications, push, online store,
pricing, labor templates, tire, subcontractor, bodyshop) — so those tables were
never created (e.g. bodyshop_jobs missing → 500 on tenant pages).

Guard each step with Schema::hasColumn / hasTable so the migration completes on
re-run and unblocks the rest of the batch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:39:34 +00:00
Vasyka ac7d5b4733 fix: central company view page 404 + broken stats query
The /admin/companies/{id} view page (ViewCompany extends Page) 404'd because
the {record} route param could arrive as a JSON-encoded model (Livewire typed-
property hydration), so findOrFail() received a non-id and threw
ModelNotFoundException. Added resolveRecordKey() to normalize scalar id / model
/ JSON-string down to the integer key.

Also fixed getStats() referencing non-existent parts columns `stock` /
`low_stock_threshold` (real columns are `qty` / `min_qty`), which would 500 the
page once mount resolved.

Added CentralCompanyViewTest as regression (asserts 200 + company name). Full
suite: 100 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:24:08 +00:00
162 changed files with 13487 additions and 509 deletions
+22 -1
View File
@@ -48,7 +48,10 @@ REDIS_DB=0
# Broadcasting (Reverb — adăugăm la nevoie)
BROADCAST_CONNECTION=log
# Mail — Mailpit intern
# Mail — Mailpit intern (dev) sau Resend (prod)
# Dev: lasă smtp + Mailpit. Prod: setează MAIL_MAILER=resend + RESEND_API_KEY,
# înregistrează domeniul în https://resend.com/domains și adaugă DNS-urile
# (TXT + DKIM CNAME-uri) în Cloudflare. Verifică în dashboard înainte de trafic.
MAIL_MAILER=smtp
MAIL_HOST=autocrm-mailpit
MAIL_PORT=1025
@@ -58,11 +61,29 @@ MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="noreply@service.mir.md"
MAIL_FROM_NAME="${APP_NAME}"
# Resend API — necesar dacă MAIL_MAILER=resend
RESEND_API_KEY=
# Web Push (VAPID) — generate with: php artisan push:vapid
VAPID_SUBJECT=mailto:admin@service.mir.md
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# Internal health monitor → Telegram alerts every 10 min on DB/cache/storage/backup failure.
# Create a separate bot at @BotFather and a private group; put the bot in it
# and use the group's chat_id (negative number).
HEALTH_ALERT_BOT_TOKEN=
HEALTH_ALERT_CHAT_ID=
# Backblaze B2 (S3-compatible) — offsite backup target for backup:tenants.
# Creează un bucket privat + Application Key cu acces la el. Fără aceste env
# vars, backup-urile rămân doar pe VPS (single point of failure).
B2_KEY=
B2_SECRET=
B2_BUCKET=
B2_REGION=us-west-002
B2_ENDPOINT=https://s3.us-west-002.backblazeb2.com
# Storage — local pentru MVP, S3-compatible mai târziu
FILESYSTEM_DISK=local
+224
View File
@@ -0,0 +1,224 @@
<?php
namespace App\Auth;
/**
* Central permission catalog 50 fine-grained permissions across 9 modules.
* Mirrors the catalog in /tmp/service/new/01-TZ-rbac-utilizator-tehnician.md §2.3.
*
* Use these constants instead of magic strings:
* $user->can(Permissions::WORK_ORDERS_CREATE)
*/
class Permissions
{
// Clients
public const CLIENTS_VIEW_ALL = 'clients.view_all';
public const CLIENTS_VIEW_OWN = 'clients.view_own';
public const CLIENTS_CREATE = 'clients.create';
public const CLIENTS_EDIT = 'clients.edit';
public const CLIENTS_DELETE = 'clients.delete';
public const CLIENTS_EXPORT = 'clients.export';
// Vehicles
public const VEHICLES_VIEW_ALL = 'vehicles.view_all';
public const VEHICLES_CREATE = 'vehicles.create';
public const VEHICLES_EDIT = 'vehicles.edit';
public const VEHICLES_DELETE = 'vehicles.delete';
// Work orders (Fișe)
public const WORK_ORDERS_VIEW_ALL = 'work_orders.view_all';
public const WORK_ORDERS_VIEW_OWN_ASSIGNED = 'work_orders.view_own_assigned';
public const WORK_ORDERS_CREATE = 'work_orders.create';
public const WORK_ORDERS_EDIT = 'work_orders.edit';
public const WORK_ORDERS_DELETE = 'work_orders.delete';
public const WORK_ORDERS_CHANGE_STATUS = 'work_orders.change_status';
public const WORK_ORDERS_APPROVE_DISCOUNT_5 = 'work_orders.approve_discount_5';
public const WORK_ORDERS_APPROVE_DISCOUNT_20 = 'work_orders.approve_discount_20';
public const WORK_ORDERS_APPROVE_DISCOUNT_ANY = 'work_orders.approve_discount_any';
public const WORK_ORDERS_PRINT = 'work_orders.print';
// Finance
public const FINANCE_VIEW_OVERVIEW = 'finance.view_overview';
public const FINANCE_VIEW_PL = 'finance.view_pl';
public const FINANCE_CREATE_PAYMENT = 'finance.create_payment';
public const FINANCE_DELETE_PAYMENT = 'finance.delete_payment';
public const FINANCE_CREATE_EXPENSE = 'finance.create_expense';
public const FINANCE_EXPORT = 'finance.export';
// Salaries
public const SALARIES_VIEW_OWN = 'salaries.view_own';
public const SALARIES_VIEW_ALL = 'salaries.view_all';
public const SALARIES_CALCULATE = 'salaries.calculate';
public const SALARIES_MARK_PAID = 'salaries.mark_paid';
// Inventory
public const INVENTORY_VIEW = 'inventory.view';
public const INVENTORY_CREATE_PART = 'inventory.create_part';
public const INVENTORY_EDIT_PART = 'inventory.edit_part';
public const INVENTORY_DELETE_PART = 'inventory.delete_part';
public const INVENTORY_ADJUST_STOCK = 'inventory.adjust_stock';
public const INVENTORY_CREATE_PURCHASE = 'inventory.create_purchase';
public const INVENTORY_RECEIVE_GOODS = 'inventory.receive_goods';
// Suppliers
public const SUPPLIERS_VIEW = 'suppliers.view';
public const SUPPLIERS_EDIT = 'suppliers.edit';
public const SUPPLIERS_DELETE = 'suppliers.delete';
// Admin
public const ADMIN_USERS_VIEW = 'admin.users.view';
public const ADMIN_USERS_MANAGE = 'admin.users.manage';
public const ADMIN_ROLES_MANAGE = 'admin.roles.manage';
public const ADMIN_SETTINGS_EDIT = 'admin.settings.edit';
public const ADMIN_INTEGRATIONS = 'admin.settings.integrations';
public const ADMIN_API_TOKENS_MANAGE = 'admin.api_tokens.manage';
public const ADMIN_AUDIT_LOG_VIEW = 'admin.audit_log.view';
public const ADMIN_BACKUP_DOWNLOAD = 'admin.backup.download';
// AI & Analytics
public const AI_ASSISTANT_USE = 'ai_assistant.use';
public const AI_ASSISTANT_CONFIGURE_KEYS = 'ai_assistant.configure_keys';
public const ANALYTICS_VIEW = 'analytics.view';
/** Full list — used by seeder. */
public static function all(): array
{
return [
self::CLIENTS_VIEW_ALL, self::CLIENTS_VIEW_OWN, self::CLIENTS_CREATE, self::CLIENTS_EDIT,
self::CLIENTS_DELETE, self::CLIENTS_EXPORT,
self::VEHICLES_VIEW_ALL, self::VEHICLES_CREATE, self::VEHICLES_EDIT, self::VEHICLES_DELETE,
self::WORK_ORDERS_VIEW_ALL, self::WORK_ORDERS_VIEW_OWN_ASSIGNED, self::WORK_ORDERS_CREATE,
self::WORK_ORDERS_EDIT, self::WORK_ORDERS_DELETE, self::WORK_ORDERS_CHANGE_STATUS,
self::WORK_ORDERS_APPROVE_DISCOUNT_5, self::WORK_ORDERS_APPROVE_DISCOUNT_20,
self::WORK_ORDERS_APPROVE_DISCOUNT_ANY, self::WORK_ORDERS_PRINT,
self::FINANCE_VIEW_OVERVIEW, self::FINANCE_VIEW_PL, self::FINANCE_CREATE_PAYMENT,
self::FINANCE_DELETE_PAYMENT, self::FINANCE_CREATE_EXPENSE, self::FINANCE_EXPORT,
self::SALARIES_VIEW_OWN, self::SALARIES_VIEW_ALL, self::SALARIES_CALCULATE, self::SALARIES_MARK_PAID,
self::INVENTORY_VIEW, self::INVENTORY_CREATE_PART, self::INVENTORY_EDIT_PART, self::INVENTORY_DELETE_PART,
self::INVENTORY_ADJUST_STOCK, self::INVENTORY_CREATE_PURCHASE, self::INVENTORY_RECEIVE_GOODS,
self::SUPPLIERS_VIEW, self::SUPPLIERS_EDIT, self::SUPPLIERS_DELETE,
self::ADMIN_USERS_VIEW, self::ADMIN_USERS_MANAGE, self::ADMIN_ROLES_MANAGE,
self::ADMIN_SETTINGS_EDIT, self::ADMIN_INTEGRATIONS, self::ADMIN_API_TOKENS_MANAGE,
self::ADMIN_AUDIT_LOG_VIEW, self::ADMIN_BACKUP_DOWNLOAD,
self::AI_ASSISTANT_USE, self::AI_ASSISTANT_CONFIGURE_KEYS, self::ANALYTICS_VIEW,
];
}
/** Groups for UI rendering: module → list of permissions. */
public static function grouped(): array
{
$groups = [];
foreach (self::all() as $slug) {
[$module] = explode('.', $slug, 2);
$groups[$module][] = $slug;
}
return $groups;
}
public static function labels(): array
{
return [
'clients' => 'Clienți',
'vehicles' => 'Mașini',
'work_orders' => 'Fișe lucru',
'finance' => 'Finanțe',
'salaries' => 'Salarii',
'inventory' => 'Stoc',
'suppliers' => 'Furnizori',
'admin' => 'Administrare',
'ai_assistant' => 'AI Assistant',
'analytics' => 'Analitică',
];
}
/**
* Role-permission matrix per TZ §2.4.
* Returns: ['owner' => [perm1, perm2, ...], ...]
*/
public static function roleMatrix(): array
{
$all = self::all();
$matrix = [];
// owner = everything
$matrix['owner'] = $all;
// admin = everything except billing-only stuff (we don't have that yet, so = all)
$matrix['admin'] = $all;
// manager: operations + reporting, no destructive
$matrix['manager'] = [
self::CLIENTS_VIEW_ALL, self::CLIENTS_CREATE, self::CLIENTS_EDIT, self::CLIENTS_EXPORT,
self::VEHICLES_VIEW_ALL, self::VEHICLES_CREATE, self::VEHICLES_EDIT,
self::WORK_ORDERS_VIEW_ALL, self::WORK_ORDERS_VIEW_OWN_ASSIGNED, self::WORK_ORDERS_CREATE,
self::WORK_ORDERS_EDIT, self::WORK_ORDERS_CHANGE_STATUS,
self::WORK_ORDERS_APPROVE_DISCOUNT_5, self::WORK_ORDERS_APPROVE_DISCOUNT_20,
self::WORK_ORDERS_PRINT,
self::FINANCE_VIEW_OVERVIEW, self::FINANCE_CREATE_PAYMENT, self::FINANCE_CREATE_EXPENSE,
self::SALARIES_VIEW_OWN,
self::INVENTORY_VIEW, self::INVENTORY_CREATE_PART, self::INVENTORY_EDIT_PART,
self::INVENTORY_ADJUST_STOCK, self::INVENTORY_CREATE_PURCHASE, self::INVENTORY_RECEIVE_GOODS,
self::SUPPLIERS_VIEW, self::SUPPLIERS_EDIT,
self::AI_ASSISTANT_USE,
self::ANALYTICS_VIEW,
];
// accountant: finance + reporting only
$matrix['accountant'] = [
self::CLIENTS_VIEW_ALL, self::CLIENTS_EXPORT,
self::VEHICLES_VIEW_ALL,
self::WORK_ORDERS_VIEW_ALL,
self::FINANCE_VIEW_OVERVIEW, self::FINANCE_VIEW_PL, self::FINANCE_CREATE_PAYMENT,
self::FINANCE_DELETE_PAYMENT, self::FINANCE_CREATE_EXPENSE, self::FINANCE_EXPORT,
self::SALARIES_VIEW_OWN, self::SALARIES_VIEW_ALL, self::SALARIES_CALCULATE, self::SALARIES_MARK_PAID,
self::INVENTORY_VIEW,
self::SUPPLIERS_VIEW,
self::ANALYTICS_VIEW,
self::AI_ASSISTANT_USE,
];
// receptionist: front-desk operations
$matrix['receptionist'] = [
self::CLIENTS_VIEW_ALL, self::CLIENTS_CREATE, self::CLIENTS_EDIT,
self::VEHICLES_VIEW_ALL, self::VEHICLES_CREATE, self::VEHICLES_EDIT,
self::WORK_ORDERS_VIEW_ALL, self::WORK_ORDERS_CREATE, self::WORK_ORDERS_EDIT,
self::WORK_ORDERS_CHANGE_STATUS, self::WORK_ORDERS_APPROVE_DISCOUNT_5, self::WORK_ORDERS_PRINT,
self::FINANCE_CREATE_PAYMENT,
self::SALARIES_VIEW_OWN,
self::INVENTORY_VIEW,
self::AI_ASSISTANT_USE,
];
// mechanic: only own WOs + inventory view
$matrix['mechanic'] = [
self::WORK_ORDERS_VIEW_OWN_ASSIGNED, self::WORK_ORDERS_CHANGE_STATUS,
self::INVENTORY_VIEW,
self::SALARIES_VIEW_OWN,
];
// viewer: read-only
$matrix['viewer'] = [
self::CLIENTS_VIEW_ALL,
self::VEHICLES_VIEW_ALL,
self::WORK_ORDERS_VIEW_ALL,
self::INVENTORY_VIEW,
self::SUPPLIERS_VIEW,
self::AI_ASSISTANT_USE,
];
return $matrix;
}
public static function roleLabels(): array
{
return [
'owner' => 'Proprietar',
'admin' => 'Administrator',
'manager' => 'Manager',
'accountant' => 'Contabil',
'receptionist' => 'Recepție',
'mechanic' => 'Mecanic',
'viewer' => 'Vizitator',
];
}
}
@@ -49,6 +49,9 @@ class BackupAllTenantsCommand extends Command
$size = round(filesize($dest) / 1024, 1);
$this->info("{$company->slug}{$size}KB");
// Offsite copy to B2 (if configured) — disk lazily resolved.
$this->uploadOffsite($dest, "{$date}/{$company->slug}.zip");
$ok++;
} catch (\Throwable $e) {
$this->error("{$company->slug}: {$e->getMessage()}");
@@ -65,6 +68,20 @@ class BackupAllTenantsCommand extends Command
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/** Upload one backup zip to the offsite B2 disk if env is configured. */
private function uploadOffsite(string $localPath, string $remoteKey): void
{
if (! env('B2_KEY') || ! env('B2_BUCKET')) return;
try {
$stream = fopen($localPath, 'rb');
\Illuminate\Support\Facades\Storage::disk('b2')->put($remoteKey, $stream);
if (is_resource($stream)) fclose($stream);
$this->line(" ↑ offsite: {$remoteKey}");
} catch (\Throwable $e) {
$this->warn(" ✗ offsite upload failed: " . substr($e->getMessage(), 0, 120));
}
}
private function cleanupOld(int $keep): void
{
$backupsDir = storage_path('app/backups');
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/**
* Lightweight self-health probe. Runs on the box, so it can't catch total
* outages pair with external uptime monitoring (UptimeRobot, Better Stack)
* pointing at /up.
*
* Tests:
* - DB connectivity (central connection: SELECT 1)
* - Cache write/read (Redis if configured)
* - Public storage disk write/read
* - Most recent tenant backup age (warn if > 30h)
*
* On any failure, pushes a short Telegram alert to HEALTH_ALERT_CHAT_ID via
* HEALTH_ALERT_BOT_TOKEN (env). Dedups identical failures within 30 minutes
* via cache to avoid spamming on each cron tick.
*/
class HealthCheckCommand extends Command
{
protected $signature = 'health:check
{--silent : Do not echo OK output (for cron)}';
protected $description = 'Probe DB / cache / storage / backup freshness; alert via Telegram on failure.';
public function handle(): int
{
$issues = [];
try {
DB::connection()->select('SELECT 1');
} catch (\Throwable $e) {
$issues[] = 'DB: ' . substr($e->getMessage(), 0, 120);
}
try {
$stamp = 'hc:' . (string) microtime(true);
Cache::put('health:probe', $stamp, 30);
if (Cache::get('health:probe') !== $stamp) {
$issues[] = 'Cache: write/read mismatch';
}
} catch (\Throwable $e) {
$issues[] = 'Cache: ' . substr($e->getMessage(), 0, 120);
}
try {
$f = 'health/' . md5((string) microtime(true)) . '.txt';
Storage::disk('public')->put($f, 'ok');
if (Storage::disk('public')->get($f) !== 'ok') {
$issues[] = 'Storage: write/read mismatch';
}
Storage::disk('public')->delete($f);
} catch (\Throwable $e) {
$issues[] = 'Storage: ' . substr($e->getMessage(), 0, 120);
}
try {
$newest = collect(Storage::disk('local')->allFiles('backups'))
->map(fn ($f) => Storage::disk('local')->lastModified($f))
->max();
if ($newest && (time() - $newest) > 30 * 3600) {
$age = round((time() - $newest) / 3600, 1);
$issues[] = "Backup: cel mai recent are {$age}h (expectat <30h).";
}
} catch (\Throwable $e) {
// Backup folder might be empty on a fresh install — not an alert.
}
if (empty($issues)) {
if (! $this->option('silent')) {
$this->info('Health OK · ' . now()->toIso8601String());
}
return self::SUCCESS;
}
$signature = md5(implode('|', $issues));
$dedupKey = "health:alert:{$signature}";
if (! Cache::has($dedupKey)) {
$this->pushTelegramAlert($issues);
Cache::put($dedupKey, 1, 30 * 60); // 30-min cooldown per fingerprint
}
foreach ($issues as $i) $this->error($i);
return self::FAILURE;
}
private function pushTelegramAlert(array $issues): void
{
$bot = env('HEALTH_ALERT_BOT_TOKEN');
$chat = env('HEALTH_ALERT_CHAT_ID');
if (! $bot || ! $chat) {
Log::warning('health:check failed but HEALTH_ALERT_BOT_TOKEN/CHAT_ID not set', [
'issues' => $issues,
]);
return;
}
$body = "🚨 <b>AutoCRM health alert</b>\n"
. implode("\n", array_map(fn ($i) => '• ' . htmlspecialchars($i), $issues))
. "\n\n" . config('app.url', 'service.mir.md');
try {
Http::asJson()
->timeout(10)
->post("https://api.telegram.org/bot{$bot}/sendMessage", [
'chat_id' => $chat,
'text' => $body,
'parse_mode' => 'HTML',
]);
} catch (\Throwable $e) {
Log::warning('health:check telegram alert failed', ['err' => $e->getMessage()]);
}
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Console\Commands;
use App\Models\Central\Company;
use App\Models\Tenant\ServiceReminderSent;
use App\Models\Tenant\TireSet;
use App\Services\NotificationDispatcher;
use App\Tenancy\TenantManager;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Twice-a-year window reminder for clients to swap seasonal tires.
* - Around March 1 (Feb 15 Mar 15): notify clients with WINTER sets still
* in storage (time to swap to summer).
* - Around October 1 (Sep 15 Oct 15): notify clients with SUMMER sets still
* in storage (time to swap to winter).
*
* Dedup via service_reminders_sent (type='tire_swap', per client+set, 60-day
* cooldown effectively once per window).
*/
class SendTireSeasonalRemindersCommand extends Command
{
protected $signature = 'tires:remind-seasonal
{--slug= : Only one tenant by slug}
{--force : Send even outside the swap window}
{--dry-run : Show candidates without sending}';
protected $description = 'Send seasonal tire-swap reminders during Feb-Mar / Sep-Oct windows.';
public function handle(NotificationDispatcher $dispatcher): int
{
$window = $this->windowFor(today());
$force = (bool) $this->option('force');
$dry = (bool) $this->option('dry-run');
if (! $window && ! $force) {
$this->info('Outside swap window. Use --force to run anyway. Today: ' . today()->toDateString());
return self::SUCCESS;
}
$targetSeason = $window['season'] ?? 'winter'; // season of stored sets we want to notify
$query = Company::query()->where('status', '!=', 'archived');
if ($slug = $this->option('slug')) $query->where('slug', $slug);
$companies = $query->get();
$totalSent = 0;
$cooldown = today()->subDays(60);
foreach ($companies as $company) {
app(TenantManager::class)->setCurrent($company);
// Sets currently in storage whose season matches the window target.
$sets = TireSet::with(['client', 'vehicle', 'storage'])
->where('season', $targetSeason)
->whereHas('storage', fn ($s) => $s->where('status', 'stored'))
->get()
->filter(fn (TireSet $s) => $s->client && $s->client->status === 'active');
$sentThisTenant = 0;
foreach ($sets as $set) {
$recent = ServiceReminderSent::where('type', 'tire_swap')
->where('client_id', $set->client_id)
->where('sent_at', '>=', $cooldown)
->exists();
if ($recent) continue;
if ($dry) {
$this->line(sprintf(' - [%s] set #%d %s · client %s · loc %s',
$company->slug, $set->id, $set->sizeLabel(),
$set->client?->name ?? '—',
$set->currentStorage()?->location ?? '—'));
continue;
}
$ok = $dispatcher->tireSeasonalSwap($set);
if ($ok) {
ServiceReminderSent::create([
'company_id' => $company->id,
'vehicle_id' => $set->vehicle_id,
'client_id' => $set->client_id,
'channel' => $set->client?->telegram_chat_id ? 'telegram' : 'email',
'type' => 'tire_swap',
'sent_at' => now(),
]);
$sentThisTenant++;
}
}
$this->info(sprintf('[%s] tire-swap reminders sent: %d', $company->slug, $sentThisTenant));
$totalSent += $sentThisTenant;
}
$this->info("Total tire-swap reminders sent: {$totalSent}" . ($dry ? ' (dry run)' : ''));
return self::SUCCESS;
}
/** Returns ['season' => 'winter'|'summer'] if today is in a swap window, else null. */
private function windowFor(Carbon $today): ?array
{
// Feb 15 Mar 15 → notify WINTER sets (swap to summer).
$springStart = Carbon::create($today->year, 2, 15);
$springEnd = Carbon::create($today->year, 3, 15);
if ($today->between($springStart, $springEnd)) return ['season' => 'winter'];
// Sep 15 Oct 15 → notify SUMMER sets (swap to winter).
$autumnStart = Carbon::create($today->year, 9, 15);
$autumnEnd = Carbon::create($today->year, 10, 15);
if ($today->between($autumnStart, $autumnEnd)) return ['season' => 'summer'];
return null;
}
}
@@ -27,7 +27,27 @@ class ViewCompany extends Page
public function mount(int|string $record): void
{
$this->record = Company::with(['plan', 'subscriptions' => fn ($q) => $q->latest('period_end')->limit(10)])->findOrFail($record);
// The {record} route param may arrive as a scalar id, an Eloquent model,
// or (via Livewire's typed-property hydration) a JSON-encoded model.
// Normalize all of these down to the integer primary key.
$key = $this->resolveRecordKey($record);
$this->record = Company::with(['plan', 'subscriptions' => fn ($q) => $q->latest('period_end')->limit(10)])
->findOrFail($key);
}
private function resolveRecordKey(mixed $record): int|string
{
if ($record instanceof Company) {
return $record->getKey();
}
if (is_string($record) && str_starts_with(ltrim($record), '{')) {
$decoded = json_decode($record, true);
if (is_array($decoded) && isset($decoded['id'])) {
return $decoded['id'];
}
}
return $record;
}
public function getTitle(): string
@@ -50,8 +70,8 @@ class ViewCompany extends Page
'work_orders_open' => WorkOrder::whereNotIn('status', ['done', 'cancelled'])->count(),
'parts' => Part::count(),
'parts_low_stock' => Part::where('is_active', true)
->whereColumn('stock', '<=', 'low_stock_threshold')
->where('stock', '>', 0)
->whereColumn('qty', '<=', 'min_qty')
->where('qty', '>', 0)
->count(),
'revenue_this_month' => (float) Payment::whereYear('paid_at', date('Y'))
->whereMonth('paid_at', date('m'))->sum('amount'),
+466 -113
View File
@@ -8,11 +8,8 @@ use App\Models\Tenant\Post;
use App\Models\Tenant\User;
use App\Models\Tenant\Vehicle;
use Carbon\Carbon;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas;
use Filament\Schemas\Schema;
class CalendarBoard extends Page
{
@@ -24,147 +21,503 @@ class CalendarBoard extends Page
protected static ?int $navigationSort = 8;
protected static ?string $title = 'Calendar';
protected static ?string $title = 'Calendar vizual';
protected string $view = 'filament.tenant.pages.calendar';
public ?array $createData = [];
public string $weekStart; // 'Y-m-d' (Monday)
public string $groupBy = 'post'; // 'post' | 'master'
public string $viewMode = 'week'; // day | week | month | list
public string $customStart = ''; // when viewMode='custom'
public string $customEnd = '';
public ?int $masterFilter = null;
public string $statusFilter = 'all'; // all | confirmed | unconfirmed | in_work
public bool $showNewForm = false;
public bool $showNewPostForm = false;
public ?int $openEventId = null;
public ?int $renamingPostId = null;
public string $renamingPostName = '';
public ?int $renamingPostMasterId = null;
public ?array $editData = [];
public array $newAppt = [];
public array $newPost = ['name' => '', 'color' => '#3b82f6', 'hours_per_day' => 10, 'description' => ''];
public ?int $editId = null;
/** Register all forms used by this page (Filament v5 multi-form pattern). */
protected function getForms(): array
public function getMaxContentWidth(): \Filament\Support\Enums\Width
{
return ['createForm'];
return \Filament\Support\Enums\Width::Full;
}
public function getEvents(string $start, string $end): array
public function getHeading(): string { return ''; }
public function getSubheading(): ?string { return null; }
public function mount(): void
{
return Appointment::with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name,color', 'post:id,name,color'])
->whereBetween('date', [$start, $end])
->get()
->map(fn (Appointment $a) => [
'id' => $a->id,
'title' => trim($a->title ?: ($a->client?->name ?? '—')),
'start' => $a->date->format('Y-m-d') . 'T' . ($a->time_start ?? '08:00:00'),
'end' => $a->date->format('Y-m-d') . 'T' . ($a->time_end ?? '09:00:00'),
'backgroundColor' => $a->color ?: ($a->master?->color ?? '#3b82f6'),
'borderColor' => $a->color ?: ($a->master?->color ?? '#3b82f6'),
'extendedProps' => [
'client' => $a->client?->name,
'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')),
'plate' => $a->vehicle?->plate,
'master' => $a->master?->name,
'post' => $a->post?->name,
'status' => $a->status,
'notes' => $a->notes,
],
$this->weekStart = Carbon::now()->startOfWeek()->toDateString();
}
public function shiftWeek(int $deltaWeeks): void
{
// delta semantic depends on view mode
$current = Carbon::parse($this->weekStart);
$this->weekStart = match ($this->viewMode) {
'day' => $current->addDays($deltaWeeks)->toDateString(),
'month' => $current->addMonths($deltaWeeks)->startOfMonth()->toDateString(),
default => $current->addWeeks($deltaWeeks)->toDateString(),
};
}
public function setWeekToday(): void
{
$this->weekStart = match ($this->viewMode) {
'day' => Carbon::today()->toDateString(),
'month' => Carbon::now()->startOfMonth()->toDateString(),
default => Carbon::now()->startOfWeek()->toDateString(),
};
}
public function setGroupBy(string $g): void
{
$this->groupBy = in_array($g, ['post', 'master'], true) ? $g : 'post';
}
public function setViewMode(string $m): void
{
if (! in_array($m, ['day', 'week', 'month', 'list', 'custom'], true)) return;
$this->viewMode = $m;
// Snap weekStart to a sensible anchor for the new view
$this->weekStart = match ($m) {
'day' => Carbon::today()->toDateString(),
'month' => Carbon::parse($this->weekStart)->startOfMonth()->toDateString(),
'custom' => $this->customStart ?: Carbon::today()->toDateString(),
default => Carbon::parse($this->weekStart)->startOfWeek()->toDateString(),
};
}
public function setStatusFilter(string $s): void
{
$this->statusFilter = in_array($s, ['all', 'confirmed', 'unconfirmed', 'in_work'], true) ? $s : 'all';
}
public function setMasterFilter($id): void
{
$this->masterFilter = $id ? (int) $id : null;
}
/** Build day headers — count varies by view mode. */
public function getDays(): array
{
$today = Carbon::today()->toDateString();
$names = ['Luni', 'Marți', 'Miercuri', 'Joi', 'Vineri', 'Sâmbătă', 'Duminică'];
$start = Carbon::parse($this->weekStart);
$count = match ($this->viewMode) {
'day' => 1,
'month' => $start->daysInMonth,
'custom' => $this->customStart && $this->customEnd
? max(1, min(31, Carbon::parse($this->customStart)->diffInDays(Carbon::parse($this->customEnd)) + 1))
: 7,
default => 7,
};
if ($this->viewMode === 'month') {
$start = Carbon::parse($this->weekStart)->startOfMonth();
}
if ($this->viewMode === 'custom' && $this->customStart) {
$start = Carbon::parse($this->customStart);
}
$days = [];
for ($i = 0; $i < $count; $i++) {
$d = $start->copy()->addDays($i);
$dow = (int) $d->dayOfWeek; // 0=Sunday, 6=Saturday in Carbon
$isoDow = (int) $d->isoWeekday(); // 1=Mon..7=Sun
$days[] = [
'date' => $d->toDateString(),
'label' => $d->format('d.m'),
'name' => $names[($isoDow - 1) % 7],
'is_today' => $d->toDateString() === $today,
'is_weekend' => $isoDow >= 6,
'is_closed' => $isoDow === 7, // Sunday default
];
}
return $days;
}
/** Rows: either Posts or active Masters depending on $groupBy. */
public function getRows(): array
{
if ($this->groupBy === 'master') {
$rows = User::query()
->where('status', 'active')
->whereNotNull('role')
->where(function ($q) { $q->where('role', 'master')->orWhere('role', 'mecanic')->orWhereNull('role'); })
->orderBy('name')
->get(['id', 'name', 'color', 'specialization'])
->map(fn ($u) => [
'kind' => 'master',
'id' => $u->id,
'name' => $u->name,
'color' => $u->color ?: '#3b82f6',
'meta' => $u->specialization ?: '8h/zi',
'capacity_hours' => 8.0,
])->all();
// Always include "Fără maistru" row at the bottom
$rows[] = ['kind' => 'master', 'id' => 0, 'name' => 'Fără maistru', 'color' => '#94a3b8', 'meta' => '—', 'capacity_hours' => 0];
return $rows;
}
$posts = Post::where('is_active', true)->orderBy('sort_order')->orderBy('name')->get();
// Fallback: synthesize a default post if none configured yet
if ($posts->isEmpty()) {
return [
['kind' => 'post', 'id' => 0, 'name' => 'Pod 1 (default)', 'color' => '#3b82f6', 'meta' => '10h/zi', 'capacity_hours' => 10.0],
];
}
return $posts->map(fn ($p) => [
'kind' => 'post',
'id' => $p->id,
'name' => $p->name,
'color' => $p->color ?: '#3b82f6',
'meta' => ($p->hours_per_day ? $p->hours_per_day . 'h/zi' : '10h/zi') . ($p->description ? ' · ' . $p->description : ''),
'capacity_hours' => (float) ($p->hours_per_day ?: 10),
])->all();
}
/** Drag-drop reschedule. */
public function moveEvent(int $id, string $start, string $end): void
/** Returns map [rowId][date] => ['events'=>[], 'load_hours'=>float, 'capacity'=>float] */
public function getMatrix(): array
{
$start = $this->weekStart;
$end = Carbon::parse($this->weekStart)->addDays(6)->toDateString();
$q = Appointment::with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name,color', 'post:id,name,color'])
->whereBetween('date', [$start, $end]);
if ($this->masterFilter) {
$q->where('master_id', $this->masterFilter);
}
if ($this->statusFilter === 'confirmed') {
$q->where('status', 'arrived');
} elseif ($this->statusFilter === 'unconfirmed') {
$q->where('status', 'scheduled');
} elseif ($this->statusFilter === 'in_work') {
$q->where('status', 'in_work');
}
$events = $q->get();
$rows = $this->getRows();
$days = $this->getDays();
$matrix = [];
foreach ($rows as $row) {
foreach ($days as $day) {
$matrix[$row['id']][$day['date']] = ['events' => [], 'load_hours' => 0, 'capacity' => $row['capacity_hours']];
}
}
foreach ($events as $a) {
$rowId = $this->groupBy === 'post' ? ($a->post_id ?: ($rows[0]['id'] ?? 0)) : ($a->master_id ?: 0);
if (! isset($matrix[$rowId])) continue;
$date = $a->date->toDateString();
if (! isset($matrix[$rowId][$date])) continue;
$hours = $this->calcHours($a->time_start, $a->time_end);
$matrix[$rowId][$date]['load_hours'] += $hours;
$matrix[$rowId][$date]['events'][] = [
'id' => $a->id,
'title' => $a->title ?: ($a->client?->name ?? '—'),
'client_name' => $a->client?->name,
'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')),
'plate' => $a->vehicle?->plate,
'master_name' => $a->master?->name,
'master_initial' => $a->master ? strtoupper(mb_substr($a->master->name, 0, 1)) . '.' : '',
'time' => substr($a->time_start ?? '', 0, 5) . '' . substr($a->time_end ?? '', 0, 5),
'color' => $a->color ?: ($a->master?->color ?? '#3b82f6'),
'status' => $a->status,
];
}
return $matrix;
}
public function getStats(): array
{
$start = $this->weekStart;
$end = Carbon::parse($this->weekStart)->addDays(6)->toDateString();
$events = Appointment::whereBetween('date', [$start, $end])->get();
$rows = $this->getRows();
// Capacity = sum(rows.capacity_hours) * 6 working days
$capacity = 0;
foreach ($rows as $r) { $capacity += $r['capacity_hours'] * 6; }
$scheduled = 0;
foreach ($events as $a) {
$scheduled += $this->calcHours($a->time_start, $a->time_end);
}
$open = $events->whereNotIn('status', ['done', 'cancelled', 'no_show'])->count();
$confirmed = $events->whereIn('status', ['arrived', 'in_work', 'done'])->count();
$noShowAlert = $events
->where('status', 'scheduled')
->filter(fn ($a) => Carbon::parse($a->date->toDateString() . ' ' . ($a->time_start ?? '08:00'))->diffInHours(now(), false) > -24)
->count();
return [
'scheduled_hours' => round($scheduled, 1),
'capacity_hours' => round($capacity, 1),
'utilization_pct' => $capacity > 0 ? (int) round(100 * $scheduled / $capacity) : 0,
'open_count' => $open,
'confirmed_count' => $confirmed,
'total_count' => $events->count(),
'confirmation_rate_pct' => $events->count() > 0 ? (int) round(100 * $confirmed / $events->count()) : 0,
'no_show_alert' => $noShowAlert,
];
}
// ============== mutations ==============
public function moveEvent(int $id, int $toRowId, string $toDate): void
{
$a = Appointment::find($id);
if (! $a) return;
[$startDate, $startTime] = $this->splitIso($start);
[, $endTime] = $this->splitIso($end);
$a->update([
'date' => $startDate,
'time_start' => $startTime,
'time_end' => $endTime,
]);
Notification::make()
->title('Programare mutată')
->body($a->title . ' → ' . $startDate . ' ' . substr($startTime, 0, 5))
->success()->send();
$this->dispatch('events-changed');
if ($this->groupBy === 'post') {
$a->post_id = $toRowId ?: null;
} else {
$a->master_id = $toRowId ?: null;
}
$a->date = $toDate;
$a->save();
Notification::make()->title('Programare mutată')->body($a->title . ' → ' . $toDate)->success()->send();
}
public function quickCreate(string $start, string $end): void
public function openEvent(int $id): void
{
$this->createData = [
'date' => substr($start, 0, 10),
'time_start' => substr($start, 11, 5),
'time_end' => substr($end, 11, 5),
$this->openEventId = $id;
$this->showNewForm = false;
}
public function getOpenEvent(): ?array
{
if (! $this->openEventId) return null;
$a = Appointment::with(['client', 'vehicle', 'master', 'post'])->find($this->openEventId);
if (! $a) return null;
return [
'id' => $a->id,
'title' => $a->title,
'status' => $a->status,
'date' => $a->date->format('d.m.Y'),
'time' => substr($a->time_start ?? '', 0, 5) . '' . substr($a->time_end ?? '', 0, 5),
'client_name' => $a->client?->name,
'client_phone' => $a->client?->phone,
'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')),
'plate' => $a->vehicle?->plate,
'master_name' => $a->master?->name,
'post_name' => $a->post?->name,
'notes' => $a->notes,
'deal_id' => $a->deal_id,
];
$this->createForm->fill($this->createData);
$this->dispatch('open-create-modal');
}
public function createForm(Schema $schema): Schema
public function closeEvent(): void
{
return $schema->components([
Forms\Components\Hidden::make('date'),
Forms\Components\TextInput::make('title')->label('Subiect')->required(),
Schemas\Components\Section::make('Când')
->columns(2)
->schema([
Forms\Components\TimePicker::make('time_start')->label('De la')->seconds(false)->required(),
Forms\Components\TimePicker::make('time_end')->label('Până la')->seconds(false)->required(),
]),
Schemas\Components\Section::make('Cine')
->columns(2)
->schema([
Forms\Components\Select::make('client_id')->label('Client')
->options(fn () => Client::pluck('name', 'id'))
->searchable()
->live(),
Forms\Components\Select::make('vehicle_id')->label('Auto')
->options(fn (\Filament\Schemas\Components\Utilities\Get $get) => $get('client_id')
? Vehicle::where('client_id', $get('client_id'))->pluck('plate', 'id')
: []),
Forms\Components\Select::make('master_id')->label('Maistru')
->options(fn () => User::where('status', 'active')->pluck('name', 'id'))
->searchable(),
Forms\Components\Select::make('post_id')->label('Pod')
->options(fn () => Post::where('is_active', true)->pluck('name', 'id'))
->searchable(),
]),
Forms\Components\Textarea::make('notes')->rows(2),
])->statePath('createData');
}
public function saveCreate(): void
{
$data = $this->createForm->getState();
Appointment::create([
'date' => $data['date'],
'time_start' => $data['time_start'],
'time_end' => $data['time_end'],
'title' => $data['title'],
'client_id' => $data['client_id'] ?? null,
'vehicle_id' => $data['vehicle_id'] ?? null,
'master_id' => $data['master_id'] ?? null,
'post_id' => $data['post_id'] ?? null,
'notes' => $data['notes'] ?? null,
'status' => 'scheduled',
]);
$this->createData = [];
Notification::make()->title('Programare adăugată')->success()->send();
$this->dispatch('close-create-modal');
$this->dispatch('events-changed');
$this->openEventId = null;
}
public function deleteEvent(int $id): void
{
Appointment::where('id', $id)->delete();
$this->openEventId = null;
Notification::make()->title('Programare ștearsă')->success()->send();
$this->dispatch('events-changed');
}
protected function splitIso(string $iso): array
public function openNewForm(int $rowId = 0, string $date = ''): void
{
// "2026-05-07T10:30:00" → ["2026-05-07", "10:30:00"]
if (str_contains($iso, 'T')) {
return explode('T', $iso);
$masterId = $this->groupBy === 'master' && $rowId ? $rowId : null;
$postId = $this->groupBy === 'post' && $rowId ? $rowId : null;
// Auto-fill default master from post if one is set
if ($postId && ! $masterId) {
$post = Post::find($postId);
if ($post && $post->default_master_id) {
$masterId = $post->default_master_id;
}
return [substr($iso, 0, 10), substr($iso, 11) ?: '08:00:00'];
}
$this->newAppt = [
'date' => $date ?: today()->toDateString(),
'time_start' => '09:00',
'time_end' => '10:00',
'title' => '',
'client_id' => null,
'vehicle_id' => null,
'master_id' => $masterId,
'post_id' => $postId,
'notes' => '',
];
$this->showNewForm = true;
$this->openEventId = null;
}
/** Quick-add post from calendar toolbar. */
public function openNewPostForm(): void
{
$this->showNewPostForm = true;
$this->newPost = ['name' => '', 'color' => '#3b82f6', 'hours_per_day' => 10, 'description' => ''];
}
public function createPost(): void
{
$name = trim($this->newPost['name'] ?? '');
if ($name === '') {
Notification::make()->title('Numele este obligatoriu')->danger()->send();
return;
}
Post::create([
'name' => $name,
'color' => $this->newPost['color'] ?? '#3b82f6',
'hours_per_day' => (float) ($this->newPost['hours_per_day'] ?? 10),
'description' => trim($this->newPost['description'] ?? '') ?: null,
'is_active' => true,
'sort_order' => 100,
]);
$this->showNewPostForm = false;
Notification::make()->title('Spațiu de lucru adăugat')->success()->send();
}
/** Inline rename + reassign default master from row label click. */
public function openRenamePost(int $postId): void
{
$post = Post::find($postId);
if (! $post) return;
$this->renamingPostId = $postId;
$this->renamingPostName = $post->name;
$this->renamingPostMasterId = $post->default_master_id;
}
public function saveRenamePost(): void
{
if (! $this->renamingPostId) return;
$post = Post::find($this->renamingPostId);
if (! $post) return;
$name = trim($this->renamingPostName);
if ($name === '') return;
$post->update([
'name' => $name,
'default_master_id' => $this->renamingPostMasterId ?: null,
]);
$this->renamingPostId = null;
Notification::make()->title('Post actualizat')->success()->send();
}
/** Generate PDF for all appointments in the visible period. */
public function exportPdf()
{
$days = $this->getDays();
$firstDate = $days[0]['date'] ?? today()->toDateString();
$lastDate = end($days)['date'] ?? today()->toDateString();
$appointments = \App\Models\Tenant\Appointment::with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name', 'post:id,name'])
->whereBetween('date', [$firstDate, $lastDate])
->orderBy('date')
->orderBy('time_start')
->get();
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('pdf.appointments', [
'appointments' => $appointments->groupBy(fn ($a) => $a->date->toDateString()),
'periodLabel' => Carbon::parse($firstDate)->format('d.m.Y') . ' — ' . Carbon::parse($lastDate)->format('d.m.Y'),
'generatedAt' => now()->format('d.m.Y H:i'),
])->setPaper('a4', 'portrait');
return response()->streamDownload(
fn () => print $pdf->output(),
'programari_' . $firstDate . '_' . $lastDate . '.pdf',
['Content-Type' => 'application/pdf']
);
}
/** Flat list of appointments for the visible period — used by list view. */
public function getListAppointments(): array
{
$days = $this->getDays();
$firstDate = $days[0]['date'] ?? today()->toDateString();
$lastDate = end($days)['date'] ?? today()->toDateString();
return \App\Models\Tenant\Appointment::with(['client:id,name,phone', 'vehicle:id,plate,make,model', 'master:id,name', 'post:id,name'])
->whereBetween('date', [$firstDate, $lastDate])
->when($this->masterFilter, fn ($q) => $q->where('master_id', $this->masterFilter))
->orderBy('date')
->orderBy('time_start')
->get()
->map(fn ($a) => [
'id' => $a->id,
'date' => $a->date->format('d.m.Y'),
'time' => substr($a->time_start ?? '', 0, 5) . '' . substr($a->time_end ?? '', 0, 5),
'title' => $a->title,
'client_name' => $a->client?->name,
'client_phone' => $a->client?->phone,
'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')) . ' · ' . ($a->vehicle?->plate ?? '—'),
'master_name' => $a->master?->name ?? '—',
'post_name' => $a->post?->name ?? '—',
'status' => $a->status,
])->all();
}
public function createAppt(): void
{
$d = $this->newAppt;
if (empty($d['title']) || empty($d['date'])) {
Notification::make()->title('Subiect și data sunt obligatorii')->danger()->send();
return;
}
Appointment::create([
'date' => $d['date'],
'time_start' => $d['time_start'] ?: '09:00',
'time_end' => $d['time_end'] ?: '10:00',
'title' => $d['title'],
'client_id' => $d['client_id'] ?: null,
'vehicle_id' => $d['vehicle_id'] ?: null,
'master_id' => $d['master_id'] ?: null,
'post_id' => $d['post_id'] ?: null,
'notes' => $d['notes'] ?: null,
'status' => 'scheduled',
]);
$this->showNewForm = false;
Notification::make()->title('Programare adăugată')->success()->send();
}
public function getMasterOptions(): array
{
return User::where('status', 'active')->pluck('name', 'id')->toArray();
}
public function getPostOptions(): array
{
return Post::where('is_active', true)->pluck('name', 'id')->toArray();
}
public function getClientOptions(): array
{
return Client::orderBy('name')->limit(50)->pluck('name', 'id')->toArray();
}
public function getVehicleOptions(?int $clientId): array
{
if (! $clientId) return [];
return Vehicle::where('client_id', $clientId)->pluck('plate', 'id')->toArray();
}
private function calcHours(?string $start, ?string $end): float
{
if (! $start || ! $end) return 1.0;
try {
$s = Carbon::createFromTimeString($start);
$e = Carbon::createFromTimeString($end);
$h = $e->floatDiffInHours($s);
return max(0, abs($h));
} catch (\Throwable $e) {
return 1.0;
}
}
public function getWeekLabel(): string
{
$s = Carbon::parse($this->weekStart);
$e = $s->copy()->addDays(6);
return $s->format('d.m') . ' — ' . $e->format('d.m.Y');
}
}
@@ -0,0 +1,143 @@
<?php
namespace App\Filament\Tenant\Pages;
use App\Models\Tenant\Supplier;
use App\Services\ExcelInvoiceImportService;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Storage;
use Livewire\WithFileUploads;
class ExcelImportWizard extends Page
{
use WithFileUploads;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-arrow-up-tray';
protected static ?string $navigationLabel = 'Import factură Excel';
protected static string|\UnitEnum|null $navigationGroup = 'Stoc & Finanțe';
protected static ?int $navigationSort = 65;
protected static ?string $title = 'Import factură Excel/CSV';
protected string $view = 'filament.tenant.pages.excel-import-wizard';
public int $step = 1;
public ?int $supplierId = null;
public $upload = null;
public ?string $storedPath = null;
public array $headersPreview = ['columns' => [], 'rows' => []];
public array $mapping = [
'article_col' => 'B',
'name_col' => 'C',
'qty_col' => 'E',
'price_col' => 'F',
'brand_col' => null,
'header_row' => 1,
];
public bool $rememberMapping = true;
public array $previewRows = [];
public array $previewSummary = ['total' => 0, 'found' => 0, 'new' => 0, 'no_article' => 0];
public bool $createNew = true;
public function getMaxContentWidth(): \Filament\Support\Enums\Width
{
return \Filament\Support\Enums\Width::Full;
}
public function getSupplierOptions(): array
{
return Supplier::orderBy('name')->pluck('name', 'id')->toArray();
}
public function goToStep2(): void
{
if (! $this->supplierId) {
Notification::make()->title('Selectează furnizorul')->danger()->send();
return;
}
if (! $this->upload) {
Notification::make()->title('Încarcă fișierul Excel sau CSV')->danger()->send();
return;
}
// Persist the uploaded file so Livewire reuses can resolve it
$this->storedPath = $this->upload->store('imports', 'local');
// Try to load remembered mapping for this supplier
$svc = app(ExcelInvoiceImportService::class);
$supplier = Supplier::find($this->supplierId);
$remembered = $svc->rememberedMappingFor($supplier);
if ($remembered) {
$this->mapping = array_merge($this->mapping, $remembered);
}
$absPath = Storage::disk('local')->path($this->storedPath);
$this->headersPreview = $svc->headersPreview($absPath);
$this->step = 2;
}
public function goToStep3(): void
{
$absPath = Storage::disk('local')->path($this->storedPath);
$svc = app(ExcelInvoiceImportService::class);
$result = $svc->preview($absPath, $this->mapping);
$this->previewRows = $result['rows'];
$this->previewSummary = $result['summary'];
if (empty($this->previewRows)) {
Notification::make()->title('Nu am găsit linii valide — verifică maparea coloanelor')->warning()->send();
return;
}
$this->step = 3;
}
public function confirmImport(): void
{
$svc = app(ExcelInvoiceImportService::class);
$supplier = Supplier::find($this->supplierId);
if ($this->rememberMapping) {
$svc->rememberMapping($supplier, $this->mapping, basename($this->storedPath ?? ''));
}
$purchase = $svc->import($supplier, $this->previewRows, $this->createNew);
Notification::make()
->title("Import reușit — Purchase {$purchase->number}")
->body("{$this->previewSummary['total']} linii importate")
->success()
->send();
// Cleanup uploaded file
if ($this->storedPath) {
Storage::disk('local')->delete($this->storedPath);
}
$this->step = 4;
$this->dispatch('purchase-created', purchaseId: $purchase->id);
// Set the redirect URL on the page so the blade can show a CTA
session()->flash('purchase_id', $purchase->id);
}
public function reset_(): void
{
$this->step = 1;
$this->supplierId = null;
$this->upload = null;
$this->storedPath = null;
$this->headersPreview = ['columns' => [], 'rows' => []];
$this->mapping = [
'article_col' => 'B', 'name_col' => 'C', 'qty_col' => 'E',
'price_col' => 'F', 'brand_col' => null, 'header_row' => 1,
];
$this->previewRows = [];
$this->previewSummary = ['total' => 0, 'found' => 0, 'new' => 0, 'no_article' => 0];
}
}
-58
View File
@@ -1,58 +0,0 @@
<?php
namespace App\Filament\Tenant\Pages;
use App\Models\Tenant\WorkOrder;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class Kanban extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-view-columns';
protected static ?string $navigationLabel = 'Kanban';
protected static string|\UnitEnum|null $navigationGroup = 'Service';
protected static ?int $navigationSort = 31;
protected static ?string $title = 'Kanban — Fișe de lucru';
protected string $view = 'filament.tenant.pages.kanban';
public function getColumns(): array
{
$statuses = ['new', 'diagnosis', 'agreement', 'in_work', 'awaiting_parts', 'ready'];
$byStatus = WorkOrder::whereIn('status', $statuses)
->with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name'])
->orderBy('opened_at')
->get()
->groupBy('status');
$columns = [];
foreach ($statuses as $status) {
$columns[$status] = [
'label' => WorkOrder::STATUSES[$status] ?? $status,
'cards' => $byStatus->get($status, collect())->all(),
'count' => $byStatus->get($status, collect())->count(),
];
}
return $columns;
}
public function moveCard(int $id, string $status): void
{
if (! in_array($status, array_keys(WorkOrder::STATUSES), true)) {
return;
}
$wo = WorkOrder::find($id);
if (! $wo) return;
$wo->update(['status' => $status]);
Notification::make()
->title("Fișa #{$wo->number}" . (WorkOrder::STATUSES[$status] ?? $status))
->success()
->send();
}
}
@@ -3,6 +3,8 @@
namespace App\Filament\Tenant\Pages;
use App\Models\Tenant\WorkOrder;
use App\Models\Tenant\WorkOrderWork;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
/**
@@ -65,6 +67,61 @@ class MechanicBoard extends Page
];
}
public ?int $blockingWorkId = null;
public string $blockReason = 'missing_part';
public string $blockNote = '';
public function startWork(int $id): void
{
$w = WorkOrderWork::find($id);
if ($w && $w->workOrder?->master_id === auth()->id()) $w->start();
}
public function pauseWork(int $id): void
{
$w = WorkOrderWork::find($id);
if ($w && $w->workOrder?->master_id === auth()->id()) $w->pause();
}
public function resumeWork(int $id): void
{
$w = WorkOrderWork::find($id);
if ($w && $w->workOrder?->master_id === auth()->id()) $w->resume();
}
public function doneWork(int $id): void
{
$w = WorkOrderWork::find($id);
if ($w && $w->workOrder?->master_id === auth()->id()) $w->markDone();
}
public function openBlockModal(int $id): void
{
$this->blockingWorkId = $id;
$this->blockReason = 'missing_part';
$this->blockNote = '';
}
public function confirmBlock(): void
{
if (! $this->blockingWorkId) return;
$work = WorkOrderWork::find($this->blockingWorkId);
if (! $work) return;
// Only own work
if ($work->workOrder?->master_id !== auth()->id()) {
$this->blockingWorkId = null;
return;
}
$work->block($this->blockReason, trim($this->blockNote) ?: null);
Notification::make()->title('Lucrare blocată')->body($work->name . ' · ' . WorkOrderWork::BLOCK_REASONS[$this->blockReason])->warning()->send();
$this->blockingWorkId = null;
}
public function getWorksFor(int $woId): array
{
return WorkOrderWork::where('work_order_id', $woId)->orderBy('id')->get()->all();
}
public function getCounts(): array
{
$userId = auth()->id();
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Filament\Tenant\Pages;
use App\Models\Tenant\User;
use App\Models\Tenant\WorkOrderWork;
use Carbon\Carbon;
use Filament\Pages\Page;
/**
* Aggregate KPI dashboard per mechanic over a period: tasks done, norm vs
* actual hours, efficiency %, revenue from manopere. Period defaults to
* current month.
*/
class MechanicKpi extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar';
protected static ?string $navigationLabel = 'KPI mecanici';
protected static string|\UnitEnum|null $navigationGroup = 'Service';
protected static ?int $navigationSort = 28;
protected static ?string $title = 'KPI mecanici';
protected string $view = 'filament.tenant.pages.mechanic-kpi';
public string $period = '';
public function mount(): void
{
$this->period = now()->format('Y-m');
}
public function shiftMonth(int $delta): void
{
$this->period = Carbon::parse($this->period . '-01')->addMonths($delta)->format('Y-m');
}
public function getRows(): array
{
[$y, $m] = explode('-', $this->period);
$rows = WorkOrderWork::query()
->with('workOrder:id,master_id')
->where('mechanic_status', 'done')
->whereYear('mechanic_done_at', $y)
->whereMonth('mechanic_done_at', $m)
->get()
->groupBy(fn ($w) => $w->workOrder?->master_id ?: 0);
$masters = User::whereIn('id', $rows->keys()->all())->get(['id', 'name'])->keyBy('id');
$out = [];
foreach ($rows as $masterId => $works) {
if (! $masterId) continue;
$totalNorm = (float) $works->sum('hours');
$totalActual = (float) $works->sum('actual_hours');
$efficiencyPct = $totalNorm > 0 ? round(100 * $totalActual / $totalNorm) : null;
$cls = match (true) {
$efficiencyPct === null => 'gray',
$efficiencyPct <= 100 => 'green',
$efficiencyPct <= 130 => 'amber',
default => 'red',
};
$out[] = [
'master_id' => $masterId,
'master_name' => $masters[$masterId]?->name ?? 'Mecanic #' . $masterId,
'tasks_done' => $works->count(),
'norm_hours' => round($totalNorm, 2),
'actual_hours' => round($totalActual, 2),
'efficiency_pct' => $efficiencyPct,
'efficiency_class' => $cls,
'revenue' => round((float) $works->sum('total'), 2),
];
}
usort($out, fn ($a, $b) => $b['revenue'] <=> $a['revenue']);
return $out;
}
public function getPeriodLabel(): string
{
return Carbon::parse($this->period . '-01')->locale('ro')->isoFormat('MMMM YYYY');
}
}
+787
View File
@@ -0,0 +1,787 @@
<?php
namespace App\Filament\Tenant\Pages;
use App\Models\Tenant\Deal;
use App\Models\Tenant\Lead;
use App\Models\Tenant\Payment;
use App\Models\Tenant\WorkOrder;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class PipelineBoard extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-funnel';
protected static ?string $navigationLabel = 'Pipeline';
protected static string|\UnitEnum|null $navigationGroup = 'CRM';
protected static ?int $navigationSort = 5;
protected static ?string $title = 'Pipeline';
protected string $view = 'filament.tenant.pages.pipeline-board';
public function getMaxContentWidth(): \Filament\Support\Enums\Width
{
return \Filament\Support\Enums\Width::Full;
}
public function getHeading(): string
{
return '';
}
public function getSubheading(): ?string
{
return null;
}
public string $activeFilter = 'all'; // all | mine | urgent | today
public ?string $openCardKey = null; // "lead:5" / "deal:8" / "wo:12"
public bool $showNewForm = false; // panel in "new request" mode
public string $searchQuery = '';
public string $newName = '';
public string $newPhone = '';
public string $newCar = '';
public string $newSource = 'call';
public string $newNotes = '';
public const COLUMNS = [
'request' => ['Cerere nouă', '#94A3B8'],
'quote' => ['Calculație', '#F59E0B'],
'scheduled' => ['Programat', '#3B82F6'],
'in_work' => ['În lucru', '#8B5CF6'],
'ready' => ['Gata de ridicare', '#10B981'],
'paid' => ['Achitat azi', '#6EE7B7'],
];
public function getColumns(): array
{
$userId = auth()->id();
$mineOnly = $this->activeFilter === 'mine';
$urgentOnly = $this->activeFilter === 'urgent';
$todayOnly = $this->activeFilter === 'today';
// Col 1: leads (not converted) + deals at stage=new
$leads = Lead::query()
->whereIn('status', ['new', 'contacted', 'no_answer'])
->whereNull('deal_id')
->when($mineOnly, fn ($q) => $q->where('assigned_to', $userId))
->orderByDesc('created_at')
->get();
$dealQ = Deal::query()
->with(['client:id,name,phone', 'vehicle:id,plate,make,model', 'assignedTo:id,name'])
->whereNotIn('stage', ['done', 'lost'])
->when($mineOnly, fn ($q) => $q->where('assigned_to', $userId))
->when($urgentOnly, fn ($q) => $q->where('urgent', true))
->orderByDesc('updated_at')
->get();
$woQ = WorkOrder::query()
->with(['client:id,name,phone', 'vehicle:id,plate,make,model', 'master:id,name'])
->whereNotIn('status', ['done', 'cancelled'])
->when($mineOnly, fn ($q) => $q->where('master_id', $userId))
->when($urgentOnly, fn ($q) => $q->where('urgency', '!=', 'normal'))
->orderBy('opened_at')
->get();
$paidToday = WorkOrder::query()
->with(['client:id,name,phone', 'vehicle:id,plate,make,model', 'master:id,name'])
->where('status', 'done')
->where('pay_status', 'paid')
->whereDate('closed_at', today())
->when($mineOnly, fn ($q) => $q->where('master_id', $userId))
->orderByDesc('closed_at')
->get();
$cards = [
'request' => [],
'quote' => [],
'scheduled' => [],
'in_work' => [],
'ready' => [],
'paid' => [],
];
foreach ($leads as $lead) {
$cards['request'][] = $this->leadCard($lead);
}
foreach ($dealQ as $deal) {
$col = match ($deal->stage) {
'new' => 'request',
'contact', 'agree' => 'quote',
'scheduled', 'arrived' => 'scheduled',
'in_work' => null, // shouldn't happen: in_work creates WO
default => null,
};
if ($col) {
$cards[$col][] = $this->dealCard($deal);
}
}
foreach ($woQ as $wo) {
$col = $wo->status === 'ready' ? 'ready' : 'in_work';
$cards[$col][] = $this->woCard($wo);
}
foreach ($paidToday as $wo) {
$cards['paid'][] = $this->woCard($wo);
}
// Apply search query
$q = trim($this->searchQuery);
if ($q !== '') {
$needle = mb_strtolower($q);
foreach ($cards as $col => $list) {
$cards[$col] = array_values(array_filter($list, function ($c) use ($needle) {
$hay = mb_strtolower(($c['subject'] ?? '') . ' ' . ($c['client_name'] ?? '') . ' ' . ($c['plate'] ?? '') . ' ' . ($c['code'] ?? '') . ' ' . ($c['phone'] ?? ''));
return str_contains($hay, $needle);
}));
}
}
// Sort: urgent first, then time
foreach ($cards as $col => $list) {
usort($list, fn ($a, $b) => ($b['urgent'] ?? false) <=> ($a['urgent'] ?? false));
$cards[$col] = $list;
}
// "Today" filter narrows further: only scheduled today OR opened today OR paid today.
if ($todayOnly) {
$cards['request'] = array_filter($cards['request'], fn ($c) => str_contains($c['time_text'], 'azi') || str_contains($c['time_text'], 'min'));
// others: keep — Scheduled column inherently shows soon dates; In Work / Ready / Paid show today by default
}
$columns = [];
foreach (self::COLUMNS as $key => [$label, $color]) {
$list = array_values($cards[$key]);
$sum = array_sum(array_map(fn ($c) => (float) $c['amount'], $list));
$columns[$key] = [
'label' => $label,
'color' => $color,
'count' => count($list),
'sum' => $sum,
'cards' => $list,
];
}
return $columns;
}
public function getStats(): array
{
$cols = $this->getColumns();
$active = $cols['request']['count'] + $cols['quote']['count'] + $cols['scheduled']['count'] + $cols['in_work']['count'] + $cols['ready']['count'];
$pipeline = $cols['request']['sum'] + $cols['quote']['sum'] + $cols['scheduled']['sum'] + $cols['in_work']['sum'] + $cols['ready']['sum'];
$closedToday = (float) Payment::whereDate('paid_at', today())->sum('amount');
$needAction = 0;
foreach (['request', 'quote', 'scheduled', 'in_work', 'ready'] as $key) {
foreach ($cols[$key]['cards'] as $card) {
if (! empty($card['time_overdue']) || ! empty($card['urgent']) || ! empty($card['has_pending_approval'])) {
$needAction++;
}
}
}
$overdue = WorkOrder::whereNotIn('status', ['done', 'cancelled'])
->whereNotNull('eta_at')
->where('eta_at', '<', now())
->count();
$won = Deal::whereNotNull('won_at')->where('won_at', '>=', now()->subDays(30))->count();
$lost = Deal::whereNotNull('lost_at')->where('lost_at', '>=', now()->subDays(30))->count();
$conversionRate = ($won + $lost) > 0 ? round(100 * $won / ($won + $lost)) : 0;
return [
'active' => $active,
'pipeline_mdl' => $pipeline,
'closed_today_mdl' => $closedToday,
'need_action' => $needAction,
'conversion_rate' => $conversionRate,
'overdue' => $overdue,
];
}
public function setFilter(string $filter): void
{
$this->activeFilter = in_array($filter, ['all', 'mine', 'urgent', 'today'], true) ? $filter : 'all';
}
public function openNewForm(): void
{
$this->showNewForm = true;
$this->openCardKey = null;
$this->newName = '';
$this->newPhone = '';
$this->newCar = '';
$this->newSource = 'call';
$this->newNotes = '';
}
public function createNewLead(): void
{
$data = ['name' => trim($this->newName), 'phone' => trim($this->newPhone), 'car' => trim($this->newCar) ?: null, 'source' => $this->newSource, 'message' => trim($this->newNotes) ?: null];
if ($data['name'] === '' || $data['phone'] === '') {
$this->notify('Nume și telefon sunt obligatorii');
return;
}
Lead::create(array_merge($data, ['status' => 'new']));
$this->showNewForm = false;
$this->notify('Cerere nouă adăugată');
}
public function exportCsv()
{
$columns = $this->getColumns();
$csv = "Etapă,Cod,Subiect,Client,Telefon,Auto,Sumă,Responsabil,Stare\n";
foreach ($columns as $col) {
foreach ($col['cards'] as $card) {
$csv .= sprintf(
"%s,%s,%s,%s,%s,%s,%.2f,%s,%s\n",
$col['label'],
$card['code'],
str_replace(',', ' ', $card['subject']),
str_replace(',', ' ', $card['client_name']),
$card['phone'] ?? '',
$card['plate'],
$card['amount'],
$card['assignee']['name'],
str_replace(',', ' ', $card['time_text']),
);
}
}
return response()->streamDownload(fn () => print $csv, 'pipeline-' . today()->format('Y-m-d') . '.csv', ['Content-Type' => 'text/csv']);
}
public function moveCard(string $key, string $toCol): void
{
[$kind, $id] = explode(':', $key, 2) + [null, null];
$id = (int) $id;
if (! $kind || ! $id || ! isset(self::COLUMNS[$toCol])) {
return;
}
DB::transaction(function () use ($kind, $id, $toCol) {
switch ($kind . '→' . $toCol) {
// Lead in col 1, dragged to col 2 → convert to Deal at quote stage
case "lead→quote":
$lead = Lead::find($id);
if (! $lead) return;
$deal = $lead->convert(['stage' => 'contact', 'quote_status' => 'sent', 'quote_sent_at' => now()]);
$this->notify("Lead → Deal CIU-{$deal->id} · Calculație trimisă");
return;
// Lead → scheduled / in_work: convert + skip
case "lead→scheduled":
$lead = Lead::find($id);
if (! $lead) return;
$deal = $lead->convert(['stage' => 'scheduled', 'scheduled_at' => now()->addDay()]);
$this->notify("Lead → Deal CIU-{$deal->id} · Programat");
return;
case "lead→in_work":
$lead = Lead::find($id);
if (! $lead) return;
$deal = $lead->convert(['stage' => 'in_work']);
$wo = $this->createWorkOrderFromDeal($deal);
$this->notify("Lead → Fișă {$wo->number}");
return;
// Deal in col 1/2/3 → moving across deal stages
case "deal→request":
Deal::where('id', $id)->update(['stage' => 'new']);
return;
case "deal→quote":
Deal::where('id', $id)->update([
'stage' => 'contact',
'quote_status' => 'sent',
'quote_sent_at' => now(),
]);
return;
case "deal→scheduled":
Deal::where('id', $id)->update([
'stage' => 'scheduled',
'scheduled_at' => Deal::find($id)?->scheduled_at ?? now()->addDay(),
]);
return;
// Deal → În lucru: create work order
case "deal→in_work":
$deal = Deal::find($id);
if (! $deal) return;
$wo = $this->createWorkOrderFromDeal($deal);
$this->notify("Fișă {$wo->number} creată din deal");
return;
// WO between cols
case "wo→in_work":
WorkOrder::where('id', $id)->update(['status' => 'in_work']);
return;
case "wo→ready":
WorkOrder::where('id', $id)->update(['status' => 'ready']);
$this->notify("Fișa marcată ca Gata de ridicare");
// Fire notification to client (dispatcher handles channel choice)
$wo = WorkOrder::find($id);
if ($wo) app(\App\Services\NotificationDispatcher::class)->workOrderReady($wo);
return;
case "wo→paid":
$wo = WorkOrder::find($id);
if (! $wo) return;
$due = (float) $wo->total - (float) Payment::where('work_order_id', $wo->id)->sum('amount');
if ($due > 0.01) {
Payment::create([
'work_order_id' => $wo->id,
'client_id' => $wo->client_id,
'paid_at' => today(),
'amount' => round($due, 2),
'method' => 'cash',
'notes' => 'Achitat din Pipeline',
]);
}
$wo->update(['status' => 'done', 'closed_at' => today()]);
$this->notify("Fișa {$wo->number} → Achitat");
return;
}
});
}
public function openCard(string $key): void
{
$this->openCardKey = $key;
}
/** Quick-schedule from a card: bumps the source to "Programat", creates an Appointment for tomorrow 10:00, returns calendar URL. */
public function quickSchedule(string $key): void
{
[$kind, $id] = explode(':', $key, 2) + [null, null];
$id = (int) $id;
if (! $kind || ! $id) return;
$clientId = null; $vehicleId = null; $dealId = null; $title = null; $masterId = null;
if ($kind === 'lead') {
$lead = Lead::find($id);
if (! $lead) return;
$deal = $lead->convert(['stage' => 'scheduled', 'scheduled_at' => now()->addDay()->setHour(10)->setMinute(0)]);
$clientId = $deal->client_id; $vehicleId = $deal->vehicle_id; $dealId = $deal->id;
$title = $deal->name;
} elseif ($kind === 'deal') {
$deal = Deal::find($id);
if (! $deal) return;
$deal->update(['stage' => 'scheduled', 'scheduled_at' => now()->addDay()->setHour(10)->setMinute(0)]);
$clientId = $deal->client_id; $vehicleId = $deal->vehicle_id; $dealId = $deal->id;
$title = $deal->name;
$masterId = $deal->assigned_to;
} elseif ($kind === 'wo') {
$wo = WorkOrder::find($id);
if (! $wo) return;
$clientId = $wo->client_id; $vehicleId = $wo->vehicle_id;
$title = $wo->number;
$masterId = $wo->master_id;
}
\App\Models\Tenant\Appointment::create([
'client_id' => $clientId,
'vehicle_id' => $vehicleId,
'master_id' => $masterId,
'deal_id' => $dealId,
'date' => today()->addDay(),
'time_start' => '10:00',
'time_end' => '11:00',
'title' => $title ?: 'Programare',
'status' => 'scheduled',
]);
$this->notify("Programare creată · mâine 10:00");
$this->openCardKey = null;
}
public function calendarUrl(): string
{
return route('filament.tenant.pages.calendar-board');
}
public function closeCard(): void
{
$this->openCardKey = null;
}
public function getOpenCardDetail(): ?array
{
if (! $this->openCardKey) return null;
[$kind, $id] = explode(':', $this->openCardKey, 2) + [null, null];
$id = (int) $id;
if (! $kind || ! $id) return null;
return match ($kind) {
'lead' => $this->leadDetail(Lead::find($id)),
'deal' => $this->dealDetail(Deal::with(['client', 'vehicle', 'assignedTo'])->find($id)),
'wo' => $this->woDetail(WorkOrder::with(['client', 'vehicle', 'master', 'parts', 'works'])->find($id)),
default => null,
};
}
// ============== card builders ==============
private function leadCard(Lead $lead): array
{
$tags = [];
if (in_array($lead->source, ['instagram', 'facebook', 'site', 'google', 'call'])) {
$tags[] = ['label' => Lead::SOURCES[$lead->source] ?? $lead->source, 'color' => 'gray'];
}
if ($lead->status === 'no_answer') {
$tags[] = ['label' => 'Fără răspuns', 'color' => 'red'];
}
$diffMin = $lead->created_at?->diffInMinutes(now()) ?? 0;
$overdue = $diffMin > 60 && $lead->status !== 'contacted';
return [
'kind' => 'lead',
'id' => $lead->id,
'key' => "lead:{$lead->id}",
'code' => 'CR-' . str_pad((string) $lead->id, 4, '0', STR_PAD_LEFT),
'subject' => trim(($lead->car ?: '') . ' ' . ($lead->model ?: '')) ?: $lead->name,
'plate' => $lead->car ?: '',
'client_name' => $lead->name ?: 'Anonim',
'phone' => $lead->phone,
'source' => $lead->source,
'amount' => (float) $lead->budget,
'urgent' => $overdue,
'tags' => $tags,
'time_text' => $this->humanTime($lead->created_at),
'time_overdue' => $overdue,
'time_icon' => 'clock',
'assignee' => $this->assignee($lead->assignedTo),
'progress_pct' => null,
'has_pending_approval' => false,
'edit_url' => route('filament.tenant.resources.leads.edit', ['record' => $lead->id]),
];
}
private function dealCard(Deal $deal): array
{
$tags = [];
if ($deal->urgent) {
$tags[] = ['label' => 'Urgent', 'color' => 'red'];
}
if ($deal->source && in_array($deal->source, ['instagram', 'site', 'call', 'whatsapp', 'telegram'])) {
$tags[] = ['label' => Lead::SOURCES[$deal->source] ?? $deal->source, 'color' => 'gray'];
}
if ($deal->stage === 'contact' && $deal->quote_status) {
$color = match ($deal->quote_status) {
'sent' => 'amber',
'seen' => 'blue',
'responded' => 'green',
default => 'gray',
};
$tags[] = ['label' => Deal::QUOTE_STATUSES[$deal->quote_status] ?? $deal->quote_status, 'color' => $color];
}
if (in_array($deal->stage, ['scheduled', 'arrived'])) {
if ($deal->scheduled_at) {
$tags[] = ['label' => $deal->scheduled_at->format('d.m · H:i'), 'color' => 'blue'];
}
if ($deal->bay) {
$tags[] = ['label' => $deal->bay, 'color' => 'gray'];
}
}
// Time line
$timeText = '';
$timeIcon = 'clock';
$overdue = false;
if ($deal->stage === 'contact' && $deal->quote_sent_at && in_array($deal->quote_status, ['sent', null])) {
$mins = $deal->quote_sent_at->diffInMinutes(now());
if ($mins > 120) {
$overdue = true;
$timeText = 'Trimis acum ' . $this->humanDiff($deal->quote_sent_at);
} else {
$timeText = 'Trimis ' . $this->humanDiff($deal->quote_sent_at);
}
} elseif ($deal->stage === 'contact' && $deal->quote_status === 'seen' && $deal->quote_seen_at) {
$timeText = 'văzut ' . $this->humanDiff($deal->quote_seen_at);
} elseif (in_array($deal->stage, ['scheduled', 'arrived']) && $deal->confirmed_at) {
$timeText = 'Confirmat ' . (Deal::CONFIRM_CHANNELS[$deal->confirmed_via] ?? '');
$timeIcon = 'check';
} elseif (in_array($deal->stage, ['scheduled', 'arrived'])) {
$timeText = 'Neconfirmat';
} else {
$timeText = $this->humanTime($deal->updated_at);
}
return [
'kind' => 'deal',
'id' => $deal->id,
'key' => "deal:{$deal->id}",
'code' => 'CIU-' . str_pad((string) $deal->id, 4, '0', STR_PAD_LEFT),
'subject' => $deal->name,
'plate' => $deal->vehicle?->plate ?: '',
'client_name' => $deal->client?->name ?? '—',
'phone' => $deal->client?->phone,
'source' => $deal->source,
'amount' => (float) $deal->price,
'urgent' => (bool) $deal->urgent,
'tags' => $tags,
'time_text' => $timeText,
'time_overdue' => $overdue,
'time_icon' => $timeIcon,
'assignee' => $this->assignee($deal->assignedTo),
'progress_pct' => null,
'has_pending_approval' => false,
'edit_url' => route('filament.tenant.resources.deals.edit', ['record' => $deal->id]),
];
}
private function woCard(WorkOrder $wo): array
{
$tags = [];
$tags[] = ['label' => "Fișă {$wo->number}", 'color' => 'purple'];
if ($wo->status === 'agreement') {
$tags[] = ['label' => 'Necesită aprobare', 'color' => 'amber'];
}
if ($wo->status === 'awaiting_parts') {
$tags[] = ['label' => 'Așteaptă piese', 'color' => 'amber'];
}
if ($wo->status === 'ready') {
$tags[] = ['label' => 'Gata', 'color' => 'green'];
if ($wo->pay_status === 'partial') {
$tags[] = ['label' => 'Avans achitat', 'color' => 'blue'];
} elseif ($wo->pay_status !== 'paid') {
$tags[] = ['label' => 'Neachitat', 'color' => 'amber'];
}
}
if ($wo->status === 'done' && $wo->pay_status === 'paid') {
$tags[] = ['label' => '✓ Achitat', 'color' => 'green'];
}
$progress = null;
if (in_array($wo->status, ['in_work', 'diagnosis', 'agreement', 'approved', 'awaiting_parts'])) {
$total = max(1, $wo->works()->count() + $wo->parts()->count());
$done = $wo->works()->where('status', 'done')->count() + $wo->parts()->where('status', 'installed')->count();
$progress = (int) round(100 * $done / $total);
}
$timeText = '';
$timeIcon = 'clock';
$overdue = false;
if ($wo->status === 'ready') {
$minsSinceReady = $wo->updated_at?->diffInMinutes(now()) ?? 0;
if ($minsSinceReady > 30 && $wo->pay_status !== 'paid') {
$overdue = true;
$timeText = 'Notificat acum ' . $this->humanDiff($wo->updated_at);
$timeIcon = 'phone';
} else {
$timeText = 'Notificat ' . $this->humanDiff($wo->updated_at);
$timeIcon = 'message';
}
} elseif ($wo->eta_at) {
$timeText = 'ETA ' . $wo->eta_at->format('H:i') . ($progress ? " · {$progress}% gata" : '');
$overdue = $wo->eta_at->isPast();
} else {
$timeText = $this->humanTime($wo->opened_at);
}
return [
'kind' => 'wo',
'id' => $wo->id,
'key' => "wo:{$wo->id}",
'code' => $wo->number,
'subject' => ($wo->vehicle?->make . ' ' . $wo->vehicle?->model) . ($wo->complaint ? ' — ' . str($wo->complaint)->limit(40) : ''),
'plate' => $wo->vehicle?->plate ?: '',
'client_name' => $wo->client?->name ?? '—',
'phone' => $wo->client?->phone,
'source' => null,
'amount' => (float) $wo->total,
'urgent' => $wo->urgency !== 'normal',
'tags' => $tags,
'time_text' => $timeText,
'time_overdue' => $overdue,
'time_icon' => $timeIcon,
'assignee' => $this->assignee($wo->master),
'progress_pct' => $progress,
'has_pending_approval' => $wo->status === 'agreement',
'edit_url' => route('filament.tenant.resources.work-orders.edit', ['record' => $wo->id]),
];
}
private function leadDetail(?Lead $lead): ?array
{
if (! $lead) return null;
$card = $this->leadCard($lead);
return array_merge($card, [
'title' => $card['subject'] ?: ('Cerere · ' . $lead->name),
'subtitle' => $card['code'] . ' · Cerere',
'stages' => $this->stageStepper(0),
'fields' => [
'Client' => $lead->name,
'Telefon' => $lead->phone,
'Email' => $lead->email,
'Automobil' => trim(($lead->car ?: '') . ' ' . ($lead->model ?: '')),
'Sursă' => Lead::SOURCES[$lead->source] ?? $lead->source,
'Buget estimat' => $lead->budget ? number_format($lead->budget, 0, '.', ' ') . ' MDL' : null,
],
'note' => $lead->message ?? $lead->notes,
'activity' => [],
'wo' => null,
]);
}
private function dealDetail(?Deal $deal): ?array
{
if (! $deal) return null;
$card = $this->dealCard($deal);
$stageIdx = match ($deal->stage) {
'new' => 0,
'contact', 'agree' => 1,
'scheduled', 'arrived' => 2,
default => 0,
};
return array_merge($card, [
'title' => $deal->name,
'subtitle' => $card['code'] . ' · ' . (Deal::STAGES[$deal->stage] ?? $deal->stage),
'stages' => $this->stageStepper($stageIdx),
'fields' => [
'Client' => $deal->client?->name,
'Telefon' => $deal->client?->phone,
'Automobil' => trim(($deal->vehicle?->make ?? '') . ' ' . ($deal->vehicle?->model ?? '') . ' · ' . ($deal->vehicle?->plate ?? '')),
'Sursă' => $deal->source ? (Lead::SOURCES[$deal->source] ?? $deal->source) : null,
'Responsabil' => $deal->assignedTo?->name,
'Sumă' => number_format($deal->price, 0, '.', ' ') . ' MDL',
],
'note' => $deal->note,
'activity' => $this->loadActivity($deal),
'wo' => null,
]);
}
private function woDetail(?WorkOrder $wo): ?array
{
if (! $wo) return null;
$card = $this->woCard($wo);
$stageIdx = match ($wo->status) {
'new', 'diagnosis', 'agreement', 'approved' => 3,
'in_work', 'awaiting_parts' => 3,
'ready' => 4,
'done' => 5,
default => 3,
};
$balanceDue = (float) $wo->total - (float) Payment::where('work_order_id', $wo->id)->sum('amount');
return array_merge($card, [
'title' => ($wo->vehicle?->make . ' ' . $wo->vehicle?->model) . ($wo->complaint ? ' — ' . str($wo->complaint)->limit(60) : ''),
'subtitle' => $wo->number . ' · ' . (WorkOrder::STATUSES[$wo->status] ?? $wo->status),
'stages' => $this->stageStepper($stageIdx),
'fields' => [
'Client' => $wo->client?->name,
'Telefon' => $wo->client?->phone,
'Automobil' => trim(($wo->vehicle?->make ?? '') . ' · ' . ($wo->vehicle?->plate ?? '')),
'Responsabil' => $wo->master?->name,
'Sumă' => number_format($wo->total, 0, '.', ' ') . ' MDL',
'De achitat' => number_format(max(0, $balanceDue), 0, '.', ' ') . ' MDL',
],
'note' => $wo->diagnosis ?: $wo->complaint,
'activity' => $this->loadActivity($wo),
'wo' => [
'number' => $wo->number,
'status_label' => WorkOrder::STATUSES[$wo->status] ?? $wo->status,
'progress_pct' => $card['progress_pct'],
'eta' => $wo->eta_at?->format('H:i'),
'has_pending_approval' => $card['has_pending_approval'],
'tracking_url' => $wo->trackingUrl(),
],
]);
}
private function loadActivity($model): array
{
$items = [];
if (method_exists($model, 'activities')) {
foreach ($model->activities()->latest()->take(6)->get() as $a) {
$items[] = [
'icon' => match ($a->event ?? 'updated') {
'created' => 'plus',
'deleted' => 'trash',
default => 'edit',
},
'color' => 'blue',
'text' => $a->description ?: ucfirst($a->event ?? 'actualizat'),
'time' => $a->created_at?->diffForHumans(),
];
}
}
return $items;
}
private function stageStepper(int $currentIdx): array
{
$labels = ['Cerere', 'Calcul.', 'Programat', 'În lucru', 'Gata', 'Achitat'];
$out = [];
foreach ($labels as $i => $label) {
$out[] = [
'label' => $label,
'done' => $i < $currentIdx,
'current' => $i === $currentIdx,
];
}
return $out;
}
private function assignee($user): array
{
if (! $user) {
return ['initials' => '?', 'name' => '—', 'color' => 'gray'];
}
$parts = preg_split('/\s+/', trim($user->name ?? '?'));
$initials = strtoupper(substr($parts[0] ?? '?', 0, 1) . substr($parts[1] ?? '', 0, 1));
// hash to a deterministic color
$colors = ['blue', 'green', 'purple', 'amber'];
$color = $colors[abs(crc32($user->id ?? 1)) % 4];
return [
'initials' => $initials ?: '?',
'name' => $this->shortName($user->name ?? ''),
'color' => $color,
];
}
private function shortName(string $name): string
{
$parts = preg_split('/\s+/', trim($name));
if (count($parts) < 2) return $name;
return $parts[0] . ' ' . strtoupper(substr($parts[1], 0, 1)) . '.';
}
private function humanTime(?Carbon $dt): string
{
if (! $dt) return '';
$mins = $dt->diffInMinutes(now());
if ($mins < 60) return "acum $mins min";
if ($mins < 60 * 24) return "acum " . round($mins / 60) . "h";
return $dt->format('d.m');
}
private function humanDiff(?Carbon $dt): string
{
if (! $dt) return '';
$mins = $dt->diffInMinutes(now());
if ($mins < 60) return "$mins min";
if ($mins < 60 * 24) return round($mins / 60) . "h";
return $dt->format('d.m');
}
private function createWorkOrderFromDeal(Deal $deal): WorkOrder
{
$wo = WorkOrder::create([
'number' => WorkOrder::generateNumber($deal->company_id),
'client_id' => $deal->client_id,
'vehicle_id' => $deal->vehicle_id,
'master_id' => $deal->assigned_to,
'deal_id' => $deal->id,
'opened_at' => today(),
'status' => 'in_work',
'total' => $deal->price ?: 0,
'complaint' => $deal->note ?: $deal->name,
]);
$deal->update(['stage' => 'in_work']);
return $wo;
}
private function notify(string $text): void
{
Notification::make()->title($text)->success()->send();
}
}
+76 -2
View File
@@ -3,6 +3,8 @@
namespace App\Filament\Tenant\Pages;
use App\Models\Tenant\Part;
use App\Models\Tenant\Purchase;
use App\Models\Tenant\PurchaseItem;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Livewire\Attributes\On;
@@ -30,17 +32,89 @@ class Scanner extends Page
protected string $view = 'filament.tenant.pages.scanner';
public string $manual = '';
public ?int $purchaseId = null;
public array $receivedToasts = [];
public function mount(): void
{
// Optional ?purchase=N → receipt mode: scans mark items received
$purchase = request()->query('purchase');
if ($purchase && ctype_digit((string) $purchase)) {
$this->purchaseId = (int) $purchase;
}
}
public function getActivePurchase(): ?Purchase
{
return $this->purchaseId ? Purchase::find($this->purchaseId) : null;
}
public function getPendingItems(): array
{
if (! $this->purchaseId) return [];
return PurchaseItem::where('purchase_id', $this->purchaseId)
->whereColumn('qty_received', '<', 'qty')
->orderBy('article')
->get(['id', 'article', 'name', 'qty', 'qty_received'])
->toArray();
}
#[On('scanner-decoded')]
public function decoded(string $text): void
{
$this->resolveAndRedirect(trim($text));
$this->process(trim($text));
}
public function submitManual(): void
{
if (trim($this->manual) === '') return;
$this->resolveAndRedirect(trim($this->manual));
$this->process(trim($this->manual));
$this->manual = '';
}
protected function process(string $code): void
{
// Receipt mode: increment qty_received on matching purchase item
if ($this->purchaseId) {
$this->markReceivedByScan($code);
return;
}
$this->resolveAndRedirect($code);
}
protected function markReceivedByScan(string $code): void
{
$clean = str_starts_with($code, 'PART:') ? substr($code, 5) : $code;
$item = PurchaseItem::where('purchase_id', $this->purchaseId)
->whereColumn('qty_received', '<', 'qty')
->where(function ($q) use ($clean, $code) {
$q->where('article', $clean)->orWhere('article', $code);
})
->first();
if (! $item) {
Notification::make()
->title('Articol nu se potrivește comenzii')
->body('Codul ' . $code . ' nu apare în liniile neîncasate ale acestei comenzi.')
->warning()->send();
return;
}
$item->qty_received = min((float) $item->qty, (float) $item->qty_received + 1);
$item->save();
$this->receivedToasts[] = [
'article' => $item->article,
'name' => $item->name,
'qty_received' => (float) $item->qty_received,
'qty_total' => (float) $item->qty,
'at' => now()->format('H:i:s'),
];
Notification::make()
->title("+1 {$item->article}{$item->qty_received}/{$item->qty}")
->success()->send();
}
protected function resolveAndRedirect(string $code): void
+20
View File
@@ -63,6 +63,9 @@ class Settings extends Page
'ai_claude_key' => $settings['ai']['claude_key'] ?? null,
'ai_gpt_key' => $settings['ai']['gpt_key'] ?? null,
'ai_gemini_key' => $settings['ai']['gemini_key'] ?? null,
'ai_model_claude' => data_get($settings, 'ai.models.claude', \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['claude']),
'ai_model_gpt' => data_get($settings, 'ai.models.gpt', \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gpt']),
'ai_model_gemini' => data_get($settings, 'ai.models.gemini', \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gemini']),
]);
}
@@ -193,8 +196,20 @@ class Settings extends Page
->options(['claude' => 'Claude (Anthropic)', 'gpt' => 'ChatGPT (OpenAI)', 'gemini' => 'Gemini (Google)'])
->default('claude'),
Forms\Components\TextInput::make('ai_claude_key')->label('Claude API Key')->password()->revealable()->placeholder('sk-ant-...'),
Forms\Components\Select::make('ai_model_claude')
->label('Model Claude')
->options(\App\Services\Ai\AiAssistantService::MODEL_OPTIONS['claude'])
->default(\App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['claude']),
Forms\Components\TextInput::make('ai_gpt_key')->label('OpenAI API Key')->password()->revealable()->placeholder('sk-proj-...'),
Forms\Components\Select::make('ai_model_gpt')
->label('Model OpenAI')
->options(\App\Services\Ai\AiAssistantService::MODEL_OPTIONS['gpt'])
->default(\App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gpt']),
Forms\Components\TextInput::make('ai_gemini_key')->label('Gemini API Key')->password()->revealable(),
Forms\Components\Select::make('ai_model_gemini')
->label('Model Gemini')
->options(\App\Services\Ai\AiAssistantService::MODEL_OPTIONS['gemini'])
->default(\App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gemini']),
]),
])
->statePath('data');
@@ -246,6 +261,11 @@ class Settings extends Page
'claude_key' => $data['ai_claude_key'] ?? null,
'gpt_key' => $data['ai_gpt_key'] ?? null,
'gemini_key' => $data['ai_gemini_key'] ?? null,
'models' => [
'claude' => $data['ai_model_claude'] ?? \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['claude'],
'gpt' => $data['ai_model_gpt'] ?? \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gpt'],
'gemini' => $data['ai_model_gemini'] ?? \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gemini'],
],
],
]),
]);
@@ -134,6 +134,27 @@ class BodyshopJobResource extends Resource
];
}
public static function getNavigationLabel(): string
{
return __('Tinichigerie / Detailing');
}
public static function getNavigationGroup(): ?string
{
return __('Tinichigerie');
}
public static function getModelLabel(): string
{
return __('lucrare caroserie');
}
public static function getPluralModelLabel(): string
{
return __('lucrări caroserie');
}
public static function getPages(): array
{
return [
@@ -22,10 +22,15 @@ class DealResource extends Resource
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-funnel';
protected static ?string $navigationLabel = 'Pipeline';
protected static ?string $navigationLabel = 'Pipeline (tabel)';
protected static string|\UnitEnum|null $navigationGroup = 'CRM';
public static function shouldRegisterNavigation(): bool
{
return false; // PipelineBoard page is the canonical entry; this resource keeps CRUD routes for edit/create.
}
protected static ?string $modelLabel = 'deal';
protected static ?string $pluralModelLabel = 'deal-uri';
@@ -29,6 +29,16 @@ class ExpenseResource extends Resource
protected static ?int $navigationSort = 51;
public static function canViewAny(): bool
{
return auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_OVERVIEW) ?? false;
}
public static function canCreate(): bool
{
return auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_CREATE_EXPENSE) ?? false;
}
public static function form(Schema $schema): Schema
{
return $schema->components([
@@ -99,6 +99,21 @@ class PartResource extends Resource
->options(fn () => Supplier::pluck('name', 'id'))
->searchable(),
]),
Schemas\Components\Section::make('Imagine')
->collapsible()
->schema([
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('image')
->label('Foto piesă')
->collection('image')
->multiple()
->reorderable()
->image()
->imageEditor()
->maxFiles(8)
->maxSize(2048)
->columnSpanFull()
->helperText('Galerie de până la 8 imagini. Prima e afișată în catalog. Max 2 MB / imagine.'),
]),
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
]);
}
@@ -107,6 +122,11 @@ class PartResource extends Resource
{
return $table
->columns([
\Filament\Tables\Columns\SpatieMediaLibraryImageColumn::make('image')
->label('')
->collection('image')
->circular()
->size(32),
Tables\Columns\TextColumn::make('name')->searchable()->sortable()->wrap(),
Tables\Columns\TextColumn::make('article')->label('Cod')->searchable()->copyable()->placeholder('—'),
Tables\Columns\TextColumn::make('brand')->placeholder('—'),
@@ -30,6 +30,21 @@ class PaymentResource extends Resource
protected static ?int $navigationSort = 50;
public static function canViewAny(): bool
{
return auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_OVERVIEW) ?? false;
}
public static function canCreate(): bool
{
return auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_CREATE_PAYMENT) ?? false;
}
public static function canDelete($record): bool
{
return auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_DELETE_PAYMENT) ?? false;
}
public static function form(Schema $schema): Schema
{
return $schema->components([
@@ -29,6 +29,16 @@ class PayrollAdjustmentResource extends Resource
protected static ?int $navigationSort = 54;
public static function canViewAny(): bool
{
return auth()->user()?->canDo(\App\Auth\Permissions::SALARIES_VIEW_ALL) ?? false;
}
public static function canCreate(): bool
{
return auth()->user()?->canDo(\App\Auth\Permissions::SALARIES_CALCULATE) ?? false;
}
public static function form(Schema $schema): Schema
{
return $schema->components([
@@ -31,6 +31,16 @@ class PayrollRunResource extends Resource
protected static ?int $navigationSort = 53;
public static function canViewAny(): bool
{
return auth()->user()?->canDo(\App\Auth\Permissions::SALARIES_VIEW_ALL) ?? false;
}
public static function canCreate(): bool
{
return auth()->user()?->canDo(\App\Auth\Permissions::SALARIES_CALCULATE) ?? false;
}
public static function form(Schema $schema): Schema
{
return $schema->components([
@@ -0,0 +1,103 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Auth\Permissions;
use App\Filament\Tenant\Resources\PostResource\Pages;
use App\Models\Tenant\Post;
use App\Models\Tenant\User;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class PostResource extends Resource
{
protected static ?string $model = Post::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
protected static ?string $navigationLabel = 'Posturi de lucru';
protected static string|\UnitEnum|null $navigationGroup = 'Admin';
protected static ?string $modelLabel = 'pod';
protected static ?string $pluralModelLabel = 'posturi de lucru';
protected static ?int $navigationSort = 76;
public static function canViewAny(): bool
{
return auth()->user()?->canDo(Permissions::ADMIN_SETTINGS_EDIT) ?? false;
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make('Pod / Spațiu lucru')
->columns(2)
->schema([
Forms\Components\TextInput::make('name')
->label('Nume')
->required()
->maxLength(80)
->placeholder('Ex: Pod 1, Curte 1, Atelier electric'),
Forms\Components\ColorPicker::make('color')
->default('#3b82f6'),
Forms\Components\TextInput::make('hours_per_day')
->label('Ore disponibile / zi')
->numeric()
->step(0.5)
->default(10)
->helperText('Capacitatea zilnică în ore'),
Forms\Components\Select::make('default_master_id')
->label('Mecanic implicit')
->options(fn () => User::where('status', 'active')->pluck('name', 'id'))
->searchable()
->placeholder('Niciun mecanic implicit')
->helperText('Va fi pre-completat când creezi o programare pentru acest pod'),
Forms\Components\TextInput::make('description')
->label('Descriere')
->maxLength(255)
->placeholder('Ex: cu lift, fără lift, doar diagnoză...')
->columnSpanFull(),
Forms\Components\TextInput::make('sort_order')
->numeric()
->default(100),
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\ColorColumn::make('color'),
Tables\Columns\TextColumn::make('hours_per_day')->label('Ore/zi')->sortable(),
Tables\Columns\TextColumn::make('defaultMaster.name')->label('Mecanic implicit')->placeholder('—'),
Tables\Columns\TextColumn::make('description')->placeholder('—')->limit(40)->toggleable(),
Tables\Columns\TextColumn::make('appointments_count')->counts('appointments')->label('Programări')->badge(),
Tables\Columns\ToggleColumn::make('is_active')->label('Activ'),
])
->actions([
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->defaultSort('sort_order');
}
public static function getPages(): array
{
return [
'index' => Pages\ListPosts::route('/'),
'create' => Pages\CreatePost::route('/create'),
'edit' => Pages\EditPost::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\PostResource\Pages;
use App\Filament\Tenant\Resources\PostResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePost extends CreateRecord
{
protected static string $resource = PostResource::class;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\PostResource\Pages;
use App\Filament\Tenant\Resources\PostResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditPost extends EditRecord
{
protected static string $resource = PostResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\PostResource\Pages;
use App\Filament\Tenant\Resources\PostResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListPosts extends ListRecords
{
protected static string $resource = PostResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}
@@ -58,6 +58,16 @@ class PricingCoefficientResource extends Resource
->options(PricingCoefficient::VEHICLE_CLASSES)
->columns(2)
->columnSpanFull(),
Forms\Components\CheckboxList::make('conditions.body_types')
->label('Caroserie')
->options(\App\Models\Tenant\Vehicle::BODY_TYPES)
->columns(3)
->columnSpanFull(),
Forms\Components\CheckboxList::make('conditions.transmissions')
->label('Cutie de viteze')
->options(\App\Models\Tenant\Vehicle::TRANSMISSION_TYPES)
->columns(3)
->columnSpanFull(),
Forms\Components\TextInput::make('conditions.age_min')->label('Vârstă min (ani)')->numeric(),
Forms\Components\TextInput::make('conditions.age_max')->label('Vârstă max (ani)')->numeric(),
Forms\Components\Toggle::make('conditions.client_vip')->label('Doar clienți VIP'),
@@ -97,6 +107,27 @@ class PricingCoefficientResource extends Resource
->defaultSort('priority');
}
public static function getNavigationLabel(): string
{
return __('Coeficienți preț');
}
public static function getNavigationGroup(): ?string
{
return __('Depozit');
}
public static function getModelLabel(): string
{
return __('coeficient');
}
public static function getPluralModelLabel(): string
{
return __('coeficienți preț');
}
public static function getPages(): array
{
return [
@@ -3,8 +3,15 @@
namespace App\Filament\Tenant\Resources\PurchaseResource\Pages;
use App\Filament\Tenant\Resources\PurchaseResource;
use App\Models\Tenant\Purchase;
use App\Models\Tenant\PurchaseItem;
use App\Models\Tenant\Supplier;
use App\Services\Ai\OcrInvoiceService;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Storage;
class ListPurchases extends ListRecords
{
@@ -12,6 +19,78 @@ class ListPurchases extends ListRecords
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
return [
Actions\Action::make('ocr')
->label('Import factură (OCR)')
->icon('heroicon-m-document-arrow-up')
->color('gray')
->modalHeading('Import factură via OCR')
->modalDescription('Încarcă o poză cu factura. AI-ul extrage furnizorul, data și liniile. Verifici și salvezi.')
->schema([
Forms\Components\FileUpload::make('invoice')
->label('Foto factură')
->image()
->disk('local')
->directory('ocr-imports')
->required()
->maxSize(5120),
])
->action(function (array $data) {
$abs = Storage::disk('local')->path($data['invoice']);
$result = app(OcrInvoiceService::class)->extract($abs);
if (! ($result['ok'] ?? false)) {
Notification::make()
->title('OCR eșuat')
->body($result['error'] ?? 'Eroare necunoscută.')
->danger()->send();
@unlink($abs);
return;
}
$payload = $result['data'];
// Match supplier by case-insensitive name.
$supplierId = null;
if ($payload['supplier_name']) {
$supplierId = Supplier::whereRaw('LOWER(name) = ?', [mb_strtolower($payload['supplier_name'])])
->value('id');
}
$purchase = Purchase::create([
'number' => Purchase::generateNumber(
app(\App\Tenancy\TenantManager::class)->currentId()
),
'supplier_id' => $supplierId,
'order_date' => $payload['date'] ?? today()->toDateString(),
'status' => 'draft',
'notes' => 'Importat OCR' . ($payload['supplier_name'] && ! $supplierId
? " · furnizor nemap-uit: „{$payload['supplier_name']}"
: ''),
]);
foreach ($payload['items'] as $item) {
PurchaseItem::create([
'purchase_id' => $purchase->id,
'name' => $item['name'],
'qty' => $item['qty'],
'unit' => 'buc',
'buy_price' => $item['unit_price'],
]);
}
$purchase->refresh()->recalcTotal();
@unlink($abs);
Notification::make()
->title('Factură importată')
->body(sprintf('%d linii, total %.2f. Verifică și ajustează înainte de a confirma.',
count($payload['items']), (float) $purchase->total))
->success()->send();
$this->redirect(PurchaseResource::getUrl('edit', ['record' => $purchase]));
}),
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,117 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Auth\Permissions;
use App\Filament\Tenant\Resources\RoleResource\Pages;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Spatie\Permission\Models\Role;
class RoleResource extends Resource
{
protected static ?string $model = Role::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static ?string $navigationLabel = 'Roluri & Drepturi';
protected static string|\UnitEnum|null $navigationGroup = 'Admin';
protected static ?string $modelLabel = 'rol';
protected static ?string $pluralModelLabel = 'roluri';
protected static ?int $navigationSort = 82;
public static function canViewAny(): bool
{
return auth()->user()?->canDo(Permissions::ADMIN_ROLES_MANAGE) ?? false;
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make('Rol')
->columns(2)
->schema([
Forms\Components\TextInput::make('name')->label('Slug')->required()->maxLength(64)
->disabled(fn ($record) => $record && in_array($record->name, array_keys(Permissions::roleMatrix()), true))
->helperText('Rolurile sistem (owner/admin/etc.) au numele blocat'),
Forms\Components\TextInput::make('guard_name')->default('web')->disabled(),
]),
Schemas\Components\Section::make('Drepturi')
->description('Bifează ce poate face acest rol. Modificările au efect imediat.')
->schema(self::permissionFields())
->columns(1),
]);
}
private static function permissionFields(): array
{
$fields = [];
$labels = Permissions::labels();
foreach (Permissions::grouped() as $module => $perms) {
$options = [];
foreach ($perms as $p) {
$options[$p] = $p;
}
$fields[] = Forms\Components\CheckboxList::make("permissions_{$module}")
->label($labels[$module] ?? ucfirst($module))
->options($options)
->columns(2)
->bulkToggleable()
->afterStateHydrated(function (Forms\Components\CheckboxList $component, $state, $record) use ($module) {
if (! $record) { $component->state([]); return; }
$names = $record->permissions->pluck('name')->all();
$module_prefix = $module . '.';
$component->state(array_values(array_filter($names, fn ($n) => str_starts_with($n, $module_prefix))));
})
->dehydrated(false);
}
return $fields;
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->label('Rol')
->formatStateUsing(fn ($state) => Permissions::roleLabels()[$state] ?? $state)
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('name')
->label('Slug')
->copyable()
->color('gray'),
Tables\Columns\TextColumn::make('permissions_count')
->counts('permissions')
->label('Drepturi')
->badge(),
Tables\Columns\TextColumn::make('users_count')
->counts('users')
->label('Utilizatori')
->badge(),
])
->actions([
Actions\EditAction::make()->label('Editează drepturi'),
Actions\DeleteAction::make()
->hidden(fn ($record) => in_array($record->name, array_keys(Permissions::roleMatrix()), true)),
])
->defaultSort('name');
}
public static function getPages(): array
{
return [
'index' => Pages\ListRoles::route('/'),
'edit' => Pages\EditRole::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Filament\Tenant\Resources\RoleResource\Pages;
use App\Auth\Permissions;
use App\Filament\Tenant\Resources\RoleResource;
use Filament\Resources\Pages\EditRecord;
use Spatie\Permission\PermissionRegistrar;
class EditRole extends EditRecord
{
protected static string $resource = RoleResource::class;
protected function mutateFormDataBeforeSave(array $data): array
{
// Collect all picked permissions across the module check-lists.
$picked = [];
foreach (Permissions::grouped() as $module => $_) {
$key = "permissions_{$module}";
if (isset($data[$key]) && is_array($data[$key])) {
$picked = array_merge($picked, $data[$key]);
}
unset($data[$key]);
}
$this->_pickedPermissions = $picked;
return $data;
}
/** @var array<string> */
protected array $_pickedPermissions = [];
protected function afterSave(): void
{
$this->record->syncPermissions($this->_pickedPermissions);
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\RoleResource\Pages;
use App\Filament\Tenant\Resources\RoleResource;
use Filament\Resources\Pages\ListRecords;
class ListRoles extends ListRecords
{
protected static string $resource = RoleResource::class;
}
@@ -0,0 +1,124 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\ShopCustomerResource\Pages;
use App\Filament\Tenant\Resources\ShopCustomerResource\RelationManagers;
use App\Models\Tenant\ShopCustomer;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
class ShopCustomerResource extends Resource
{
protected static ?string $model = ShopCustomer::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-user-circle';
protected static ?string $navigationLabel = 'Clienți magazin';
protected static string|\UnitEnum|null $navigationGroup = 'Magazin';
protected static ?string $modelLabel = 'client magazin';
protected static ?string $pluralModelLabel = 'clienți magazin';
protected static ?int $navigationSort = 52;
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make()->columns(2)->schema([
Forms\Components\TextInput::make('name')->label('Nume')->required()->maxLength(160),
Forms\Components\TextInput::make('phone')->label('Telefon')->required()->maxLength(40),
Forms\Components\TextInput::make('email')->label('Email')->email()->maxLength(160),
Forms\Components\Select::make('client_id')
->label('Client legat (CRM)')
->options(fn () => \App\Models\Tenant\Client::pluck('name', 'id'))
->searchable()
->helperText('Legătura cu fișa CRM (opțional). Auto-matched la înregistrare după telefon.'),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('phone')->copyable()->searchable(),
Tables\Columns\TextColumn::make('email')->placeholder('—')->copyable()->toggleable(),
Tables\Columns\TextColumn::make('client.name')->label('Client CRM')->placeholder('—')->toggleable(),
Tables\Columns\TextColumn::make('orders_count')->counts('orders')->label('Comenzi')->alignRight(),
Tables\Columns\TextColumn::make('last_login_at')->label('Ultim login')->since()->placeholder('Niciodată'),
Tables\Columns\TextColumn::make('created_at')->label('Înregistrat')->date('d.m.Y')->toggleable(),
])
->actions([
Actions\Action::make('reset_password')
->label('Trimite reset parolă')
->icon('heroicon-m-key')
->color('warning')
->visible(fn (ShopCustomer $r) => ! empty($r->email))
->requiresConfirmation()
->modalDescription('Trimite emailul standard de resetare a parolei către clientul magazinului.')
->action(function (ShopCustomer $r) {
$status = Password::broker('shop_customers')->sendResetLink(['email' => $r->email]);
Notification::make()
->title($status === Password::RESET_LINK_SENT
? 'Link de resetare trimis la ' . $r->email
: 'Eșec: ' . $status)
->{$status === Password::RESET_LINK_SENT ? 'success' : 'warning'}()
->send();
}),
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->emptyStateHeading('Niciun client magazin')
->emptyStateDescription('Aici apar clienții care și-au creat cont în magazinul online (/shop/register).')
->emptyStateIcon('heroicon-o-user-circle')
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
RelationManagers\OrdersRelationManager::class,
];
}
public static function getNavigationLabel(): string
{
return __('Clienți magazin');
}
public static function getNavigationGroup(): ?string
{
return __('Magazin');
}
public static function getModelLabel(): string
{
return __('client magazin');
}
public static function getPluralModelLabel(): string
{
return __('clienți magazin');
}
public static function getPages(): array
{
return [
'index' => Pages\ListShopCustomers::route('/'),
'edit' => Pages\EditShopCustomer::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\ShopCustomerResource\Pages;
use App\Filament\Tenant\Resources\ShopCustomerResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditShopCustomer extends EditRecord
{
protected static string $resource = ShopCustomerResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\ShopCustomerResource\Pages;
use App\Filament\Tenant\Resources\ShopCustomerResource;
use Filament\Resources\Pages\ListRecords;
class ListShopCustomers extends ListRecords
{
protected static string $resource = ShopCustomerResource::class;
}
@@ -0,0 +1,38 @@
<?php
namespace App\Filament\Tenant\Resources\ShopCustomerResource\RelationManagers;
use App\Models\Tenant\OnlineOrder;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class OrdersRelationManager extends RelationManager
{
protected static string $relationship = 'orders';
protected static ?string $title = 'Comenzi';
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('number')
->columns([
Tables\Columns\TextColumn::make('number')->label('Nr.'),
Tables\Columns\TextColumn::make('created_at')->label('Data')->dateTime('d.m.Y H:i'),
Tables\Columns\TextColumn::make('status')
->formatStateUsing(fn ($s) => OnlineOrder::STATUSES[$s] ?? $s)
->badge()
->colors([
'warning' => ['new'],
'info' => ['confirmed', 'packed'],
'primary' => ['shipped'],
'success' => ['delivered'],
'danger' => ['cancelled'],
]),
Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(),
])
->defaultSort('created_at', 'desc')
->emptyStateHeading('Nicio comandă încă');
}
}
@@ -122,6 +122,27 @@ class SubcontractJobResource extends Resource
->defaultSort('created_at', 'desc');
}
public static function getNavigationLabel(): string
{
return __('Lucrări terți');
}
public static function getNavigationGroup(): ?string
{
return __('Subcontractare');
}
public static function getModelLabel(): string
{
return __('lucrare terți');
}
public static function getPluralModelLabel(): string
{
return __('lucrări terți');
}
public static function getPages(): array
{
return [
@@ -73,6 +73,27 @@ class SubcontractorResource extends Resource
->defaultSort('name');
}
public static function getNavigationLabel(): string
{
return __('Subcontractori');
}
public static function getNavigationGroup(): ?string
{
return __('Subcontractare');
}
public static function getModelLabel(): string
{
return __('subcontractor');
}
public static function getPluralModelLabel(): string
{
return __('subcontractori');
}
public static function getPages(): array
{
return [
@@ -185,6 +185,27 @@ class TireSetResource extends Resource
];
}
public static function getNavigationLabel(): string
{
return __('Seturi anvelope');
}
public static function getNavigationGroup(): ?string
{
return __('Anvelope');
}
public static function getModelLabel(): string
{
return __('set anvelope');
}
public static function getPluralModelLabel(): string
{
return __('seturi anvelope');
}
public static function getPages(): array
{
return [
+88 -13
View File
@@ -3,6 +3,7 @@
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\UserResource\Pages;
use App\Filament\Tenant\Resources\UserResource\RelationManagers;
use App\Models\Tenant\User;
use Filament\Forms;
use Filament\Resources\Resource;
@@ -31,8 +32,17 @@ class UserResource extends Resource
public static function canViewAny(): bool
{
$u = auth()->user();
return $u && $u->role === 'admin';
return auth()->user()?->canDo(\App\Auth\Permissions::ADMIN_USERS_VIEW) ?? false;
}
public static function canCreate(): bool
{
return auth()->user()?->canDo(\App\Auth\Permissions::ADMIN_USERS_MANAGE) ?? false;
}
public static function canDelete($record): bool
{
return auth()->user()?->canDo(\App\Auth\Permissions::ADMIN_USERS_MANAGE) ?? false;
}
public static function form(Schema $schema): Schema
@@ -53,17 +63,10 @@ class UserResource extends Resource
->schema([
Forms\Components\Select::make('role')
->label('Rol primar')
->options([
'admin' => 'Administrator',
'manager' => 'Manager',
'receptionist' => 'Recepție',
'mechanic' => 'Mecanic',
'parts_manager' => 'Magazioner piese',
'accountant' => 'Contabil',
'marketer' => 'Marketing',
])
->options(\App\Auth\Permissions::roleLabels())
->required()
->default('mechanic'),
->default('mechanic')
->helperText('Rolul principal — sincronizat automat cu drepturile RBAC.'),
Forms\Components\Select::make('status')
->options(['active' => 'Activ', 'inactive' => 'Inactiv', 'blocked' => 'Blocat'])
->default('active')
@@ -76,6 +79,26 @@ class UserResource extends Resource
->dehydrateStateUsing(fn ($state) => Hash::make($state))
->minLength(6)
->helperText('La editare lasă gol pentru a păstra parola actuală.'),
Forms\Components\Select::make('roles_picked')
->label('Roluri suplimentare')
->multiple()
->options(\App\Auth\Permissions::roleLabels())
->afterStateHydrated(function ($component, $record) {
if ($record) $component->state($record->roles->pluck('name')->all());
})
->dehydrated(false)
->columnSpanFull()
->helperText('Roluri suplimentare peste rolul primar — drepturile se cumulează.'),
]),
Schemas\Components\Section::make('Securitate')
->columns(2)
->schema([
Forms\Components\Placeholder::make('mfa_status')
->label('Autentificare 2FA')
->content(fn ($record) => $record && $record->hasTwoFactorEnabled() ? '✓ Activat (TOTP)' : '✗ Dezactivat'),
Forms\Components\Placeholder::make('last_login')
->label('Ultima autentificare')
->content(fn ($record) => $record?->last_login_at?->diffForHumans() ?? '—'),
]),
]);
}
@@ -87,7 +110,29 @@ class UserResource extends Resource
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('email')->searchable()->copyable(),
Tables\Columns\TextColumn::make('phone')->placeholder('—'),
Tables\Columns\TextColumn::make('role')->badge(),
Tables\Columns\TextColumn::make('role')
->formatStateUsing(fn ($state) => \App\Auth\Permissions::roleLabels()[$state] ?? $state)
->badge(),
Tables\Columns\IconColumn::make('app_authentication_secret')
->label('2FA')
->boolean()
->getStateUsing(fn ($record) => $record->hasTwoFactorEnabled())
->trueIcon('heroicon-o-shield-check')
->trueColor('success')
->falseIcon('heroicon-o-shield-exclamation')
->falseColor('warning'),
Tables\Columns\TextColumn::make('active_sessions')
->label('Sesiuni')
->getStateUsing(fn ($record) => \Illuminate\Support\Facades\DB::table('sessions')->where('user_id', $record->id)->count())
->badge()
->color(fn ($state) => $state > 0 ? 'success' : 'gray')
->toggleable(),
Tables\Columns\TextColumn::make('permission_overrides_count')
->counts('permissionOverrides')
->label('Excepții')
->badge()
->color('warning')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('status')
->badge()
->colors([
@@ -109,11 +154,41 @@ class UserResource extends Resource
])
->actions([
Actions\EditAction::make(),
Actions\Action::make('force_logout')
->label('Force logout')
->icon('heroicon-o-arrow-right-on-rectangle')
->color('warning')
->visible(fn ($record) => \Illuminate\Support\Facades\DB::table('sessions')->where('user_id', $record->id)->exists())
->requiresConfirmation()
->modalDescription('Va deconecta utilizatorul pe toate device-urile.')
->action(function ($record) {
$n = \Illuminate\Support\Facades\DB::table('sessions')->where('user_id', $record->id)->delete();
\Filament\Notifications\Notification::make()->title("$n sesiuni revoke-uite")->success()->send();
}),
Actions\Action::make('reset_2fa')
->label('Resetează 2FA')
->icon('heroicon-o-shield-exclamation')
->color('warning')
->visible(fn ($record) => $record && $record->hasTwoFactorEnabled())
->requiresConfirmation()
->modalDescription('Dezactivează 2FA pentru acest utilizator. Va trebui să re-configureze TOTP la următoarea autentificare.')
->action(function ($record) {
$record->saveAppAuthenticationSecret(null);
$record->saveAppAuthenticationRecoveryCodes(null);
\Filament\Notifications\Notification::make()->title('2FA resetat')->success()->send();
}),
Actions\DeleteAction::make(),
])
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
RelationManagers\PermissionOverridesRelationManager::class,
];
}
public static function getPages(): array
{
return [
@@ -8,4 +8,23 @@ use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
/** @var array<string> */
protected array $_rolesPicked = [];
protected function mutateFormDataBeforeCreate(array $data): array
{
$this->_rolesPicked = $data['roles_picked'] ?? [];
unset($data['roles_picked']);
return $data;
}
protected function afterCreate(): void
{
$picked = $this->_rolesPicked;
if (! in_array($this->record->role, $picked, true)) {
$picked[] = $this->record->role;
}
$this->record->syncRoles(array_unique($picked));
}
}
@@ -14,4 +14,24 @@ class EditUser extends EditRecord
{
return [Actions\DeleteAction::make()];
}
/** @var array<string> */
protected array $_rolesPicked = [];
protected function mutateFormDataBeforeSave(array $data): array
{
$this->_rolesPicked = $data['roles_picked'] ?? [];
unset($data['roles_picked']);
return $data;
}
protected function afterSave(): void
{
// Always include the primary role
$picked = $this->_rolesPicked;
if (! in_array($this->record->role, $picked, true)) {
$picked[] = $this->record->role;
}
$this->record->syncRoles(array_unique($picked));
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Filament\Tenant\Resources\UserResource\RelationManagers;
use App\Auth\Permissions;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Spatie\Permission\Models\Permission;
class PermissionOverridesRelationManager extends RelationManager
{
protected static string $relationship = 'permissionOverrides';
protected static ?string $title = 'Excepții drepturi';
protected static string|\BackedEnum|null $icon = 'heroicon-o-shield-exclamation';
public function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make('Excepție')
->columns(2)
->schema([
Forms\Components\Select::make('permission_id')
->label('Drept')
->required()
->searchable()
->options(fn () => Permission::orderBy('name')->pluck('name', 'id'))
->columnSpanFull(),
Forms\Components\Select::make('mode')
->required()
->options(['grant' => 'GRANT — adaugă dreptul', 'deny' => 'DENY — interzice dreptul'])
->default('grant'),
Forms\Components\DatePicker::make('expires_at')
->label('Expiră la (opțional)')
->minDate(now()),
Forms\Components\Textarea::make('reason')
->label('Motiv')
->columnSpanFull()
->placeholder('Ex: lockdown temporar; acces pentru audit; etc.')
->rows(2),
]),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('mode')
->columns([
Tables\Columns\TextColumn::make('permission.name')
->label('Drept')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('mode')
->badge()
->colors(['success' => 'grant', 'danger' => 'deny'])
->formatStateUsing(fn ($state) => strtoupper($state)),
Tables\Columns\TextColumn::make('reason')
->limit(40)
->placeholder('—'),
Tables\Columns\TextColumn::make('expires_at')
->label('Expiră')
->date()
->placeholder('niciodată')
->color(fn ($record) => $record?->isExpired() ? 'danger' : 'gray'),
Tables\Columns\TextColumn::make('grantedBy.name')
->label('Acordat de')
->placeholder('—')
->toggleable(),
])
->headerActions([
Actions\CreateAction::make()
->mutateDataUsing(fn (array $data) => array_merge($data, [
'granted_at' => now(),
'granted_by_id' => auth()->id(),
])),
])
->actions([
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->defaultSort('granted_at', 'desc');
}
}
@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Tenant\WorkOrder;
use App\Models\Tenant\WorkOrderWork;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MechanicApiController extends Controller
{
/** GET /api/v1/mechanic/board — only OWN WOs with their works expanded. */
public function board(): JsonResponse
{
$userId = auth()->id();
$wos = WorkOrder::with(['client:id,name', 'vehicle:id,plate,make,model', 'works'])
->where('master_id', $userId)
->whereNotIn('status', ['done', 'cancelled'])
->orderBy('opened_at')
->get()
->map(fn ($wo) => [
'id' => $wo->id, 'number' => $wo->number, 'status' => $wo->status,
'client_name' => $wo->client?->name,
'vehicle' => trim(($wo->vehicle?->make ?? '') . ' ' . ($wo->vehicle?->model ?? '')),
'plate' => $wo->vehicle?->plate,
'complaint' => $wo->complaint,
'eta_at' => $wo->eta_at?->toIso8601String(),
'works' => $wo->works->map(fn ($w) => $this->workPayload($w))->all(),
]);
return response()->json(['data' => $wos]);
}
/** POST /api/v1/mechanic/tasks/{work}/start */
public function startTask(WorkOrderWork $work): JsonResponse
{
$this->authorizeOwn($work);
$work->start();
return response()->json(['data' => $this->workPayload($work->fresh())]);
}
public function pauseTask(WorkOrderWork $work): JsonResponse
{
$this->authorizeOwn($work);
$work->pause();
return response()->json(['data' => $this->workPayload($work->fresh())]);
}
public function resumeTask(WorkOrderWork $work): JsonResponse
{
$this->authorizeOwn($work);
$work->resume();
return response()->json(['data' => $this->workPayload($work->fresh())]);
}
public function doneTask(WorkOrderWork $work): JsonResponse
{
$this->authorizeOwn($work);
$work->markDone();
return response()->json(['data' => $this->workPayload($work->fresh())]);
}
public function blockTask(Request $request, WorkOrderWork $work): JsonResponse
{
$this->authorizeOwn($work);
$data = $request->validate([
'reason' => 'required|in:' . implode(',', array_keys(WorkOrderWork::BLOCK_REASONS)),
'note' => 'nullable|string|max:1000',
]);
$work->block($data['reason'], $data['note'] ?? null);
return response()->json(['data' => $this->workPayload($work->fresh())]);
}
/** GET /api/v1/mechanic/kpi?period=2026-06 — own efficiency aggregates. */
public function kpi(Request $request): JsonResponse
{
$userId = auth()->id();
$period = $request->query('period', now()->format('Y-m'));
[$y, $m] = explode('-', $period);
$rows = WorkOrderWork::whereHas('workOrder', fn ($q) => $q->where('master_id', $userId))
->where('mechanic_status', 'done')
->whereYear('mechanic_done_at', $y)
->whereMonth('mechanic_done_at', $m)
->get();
$totalNorm = (float) $rows->sum('hours');
$totalActual = (float) $rows->sum('actual_hours');
$tasksDone = $rows->count();
$totalRevenue = (float) $rows->sum('total');
$efficiencyPct = $totalNorm > 0 ? round(100 * $totalActual / $totalNorm) : null;
return response()->json([
'period' => $period,
'tasks_done' => $tasksDone,
'norm_hours' => round($totalNorm, 2),
'actual_hours' => round($totalActual, 2),
'efficiency_pct' => $efficiencyPct,
'revenue_manopere' => round($totalRevenue, 2),
]);
}
private function workPayload(WorkOrderWork $w): array
{
return [
'id' => $w->id,
'name' => $w->name,
'mechanic_status' => $w->mechanic_status,
'norm_hours' => (float) $w->hours,
'actual_hours' => (float) $w->actual_hours,
'efficiency_pct' => $w->efficiencyPct(),
'efficiency_class' => $w->efficiencyClass(),
'block_reason' => $w->block_reason,
'block_note' => $w->block_note,
'mechanic_started_at' => $w->mechanic_started_at?->toIso8601String(),
'mechanic_done_at' => $w->mechanic_done_at?->toIso8601String(),
];
}
private function authorizeOwn(WorkOrderWork $work): void
{
if ($work->workOrder?->master_id !== auth()->id()) {
abort(403, 'Work belongs to a different mechanic.');
}
}
}
@@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers\Api;
use App\Auth\Permissions;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class RoleApiController extends Controller
{
public function index(): JsonResponse
{
$this->authorize(Permissions::ADMIN_ROLES_MANAGE);
$roles = Role::withCount('permissions')->orderBy('name')->get();
return response()->json(['data' => $roles]);
}
public function show(Role $role): JsonResponse
{
$this->authorize(Permissions::ADMIN_ROLES_MANAGE);
return response()->json(['data' => $role->load('permissions')]);
}
public function store(Request $request): JsonResponse
{
$this->authorize(Permissions::ADMIN_ROLES_MANAGE);
$data = $request->validate([
'name' => 'required|string|max:64',
'permissions' => 'sometimes|array',
'permissions.*' => 'string',
]);
// Disallow overwriting system roles
if (in_array($data['name'], array_keys(Permissions::roleMatrix()), true)) {
return response()->json(['error' => 'System role name is reserved'], 422);
}
$role = Role::create(['name' => $data['name'], 'guard_name' => 'web']);
if (! empty($data['permissions'])) {
$role->syncPermissions($data['permissions']);
}
return response()->json(['data' => $role->load('permissions')], 201);
}
public function update(Request $request, Role $role): JsonResponse
{
$this->authorize(Permissions::ADMIN_ROLES_MANAGE);
if (in_array($role->name, array_keys(Permissions::roleMatrix()), true)) {
return response()->json(['error' => 'Cannot rename system role'], 422);
}
$data = $request->validate(['name' => 'required|string|max:64']);
$role->update($data);
return response()->json(['data' => $role]);
}
public function destroy(Role $role): JsonResponse
{
$this->authorize(Permissions::ADMIN_ROLES_MANAGE);
if (in_array($role->name, array_keys(Permissions::roleMatrix()), true)) {
return response()->json(['error' => 'Cannot delete system role'], 422);
}
$role->delete();
return response()->json(['deleted' => true]);
}
public function permissions(Role $role): JsonResponse
{
$this->authorize(Permissions::ADMIN_ROLES_MANAGE);
return response()->json(['data' => $role->permissions->pluck('name')]);
}
public function syncPermissions(Request $request, Role $role): JsonResponse
{
$this->authorize(Permissions::ADMIN_ROLES_MANAGE);
$data = $request->validate([
'permissions' => 'required|array',
'permissions.*' => 'string',
]);
$role->syncPermissions($data['permissions']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
return response()->json(['data' => $role->fresh()->permissions->pluck('name')]);
}
public function permissionCatalog(): JsonResponse
{
return response()->json([
'data' => Permission::orderBy('name')->get(['id', 'name']),
'grouped' => Permissions::grouped(),
'labels' => Permissions::labels(),
'roles' => Permissions::roleLabels(),
]);
}
private function authorize(string $permission): void
{
if (! auth()->user() || ! auth()->user()->canDo($permission)) {
abort(403, "Missing permission: $permission");
}
}
}
@@ -0,0 +1,228 @@
<?php
namespace App\Http\Controllers\Api;
use App\Auth\Permissions;
use App\Http\Controllers\Controller;
use App\Models\Tenant\User;
use App\Models\Tenant\UserPermissionOverride;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Permission;
class UserApiController extends Controller
{
public function index(Request $request): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_VIEW);
$q = User::query();
if ($role = $request->query('role')) $q->where('role', $role);
if ($status = $request->query('status')) $q->where('status', $status);
if ($search = $request->query('q')) {
$q->where(fn ($qq) => $qq->where('name', 'like', "%$search%")->orWhere('email', 'like', "%$search%"));
}
return response()->json([
'data' => $q->paginate((int) $request->query('per_page', 25))->items(),
'meta' => ['total' => $q->count()],
]);
}
public function show(User $user): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_VIEW);
return response()->json(['data' => $user->load('roles', 'permissionOverrides.permission', 'invitedBy:id,name')]);
}
public function store(Request $request): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_MANAGE);
$data = $request->validate([
'name' => 'required|string|max:120',
'email' => 'required|email|max:120',
'phone' => 'nullable|string|max:40',
'role' => 'required|string|in:' . implode(',', array_keys(Permissions::roleMatrix())),
'locale' => 'nullable|in:ro,ru,en',
'send_invitation' => 'nullable|boolean',
]);
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'phone' => $data['phone'] ?? null,
'role' => $data['role'],
'locale' => $data['locale'] ?? 'ro',
'status' => 'inactive',
'password' => Hash::make(bin2hex(random_bytes(16))), // placeholder until invitation accept
]);
$user->syncRoles([$data['role']]);
if ($data['send_invitation'] ?? true) {
$user->sendInvitation(auth()->user());
}
return response()->json(['data' => $user->fresh(), 'invitation_sent' => $data['send_invitation'] ?? true], 201);
}
public function update(Request $request, User $user): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_MANAGE);
$data = $request->validate([
'name' => 'sometimes|string|max:120',
'email' => 'sometimes|email|max:120',
'phone' => 'sometimes|nullable|string|max:40',
'locale' => 'sometimes|in:ro,ru,en',
'role' => 'sometimes|in:' . implode(',', array_keys(Permissions::roleMatrix())),
'status' => 'sometimes|in:active,inactive,blocked',
]);
$user->update($data);
if (isset($data['role'])) $user->syncRoles([$data['role']]);
return response()->json(['data' => $user->fresh()]);
}
public function destroy(User $user): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_MANAGE);
$user->delete();
return response()->json(['deleted' => true]);
}
public function activate(User $user): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_MANAGE);
$user->update(['status' => 'active']);
return response()->json(['data' => $user->fresh()]);
}
public function deactivate(User $user): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_MANAGE);
$user->update(['status' => 'inactive']);
DB::table('sessions')->where('user_id', $user->id)->delete();
return response()->json(['data' => $user->fresh()]);
}
public function resendInvitation(User $user): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_MANAGE);
if ($user->accepted_at) {
return response()->json(['error' => 'User already accepted invitation'], 422);
}
$user->sendInvitation(auth()->user());
return response()->json(['invitation_sent' => true]);
}
public function forcePasswordReset(User $user): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_MANAGE);
$user->update(['status' => 'inactive', 'accepted_at' => null]);
DB::table('sessions')->where('user_id', $user->id)->delete();
$user->sendInvitation(auth()->user());
return response()->json(['invitation_sent' => true]);
}
public function sessions(User $user): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_MANAGE);
$rows = DB::table('sessions')->where('user_id', $user->id)
->select('id', 'ip_address', 'user_agent', 'last_activity')->get();
return response()->json(['data' => $rows]);
}
public function revokeSession(User $user, string $sessionId): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_MANAGE);
$n = DB::table('sessions')->where('user_id', $user->id)->where('id', $sessionId)->delete();
return response()->json(['revoked' => $n > 0]);
}
public function revokeAllSessions(User $user): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_MANAGE);
$n = DB::table('sessions')->where('user_id', $user->id)->delete();
return response()->json(['revoked_count' => $n]);
}
public function roles(User $user): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_VIEW);
return response()->json(['data' => $user->roles->pluck('name')]);
}
public function assignRole(Request $request, User $user): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_MANAGE);
$data = $request->validate(['role' => 'required|string']);
$user->assignRole($data['role']);
return response()->json(['data' => $user->roles->pluck('name')]);
}
public function removeRole(User $user, string $role): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_MANAGE);
$user->removeRole($role);
return response()->json(['data' => $user->roles->pluck('name')]);
}
public function permissions(User $user): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_VIEW);
// Effective: roles + grants - denies (active only)
$rolePerms = $user->getAllPermissions()->pluck('name');
$denies = $user->permissionOverrides()
->where('mode', 'deny')
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->with('permission')->get()->pluck('permission.name');
$grants = $user->permissionOverrides()
->where('mode', 'grant')
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->with('permission')->get()->pluck('permission.name');
$effective = $rolePerms->merge($grants)->unique()->reject(fn ($p) => $denies->contains($p))->values();
return response()->json([
'data' => $effective,
'overrides' => ['grants' => $grants, 'denies' => $denies],
]);
}
public function addOverride(Request $request, User $user): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_MANAGE);
$data = $request->validate([
'permission' => 'required|string',
'mode' => 'required|in:grant,deny',
'reason' => 'nullable|string',
'expires_at' => 'nullable|date',
]);
$perm = Permission::where('name', $data['permission'])->firstOrFail();
UserPermissionOverride::updateOrCreate(
['user_id' => $user->id, 'permission_id' => $perm->id],
[
'mode' => $data['mode'],
'reason' => $data['reason'] ?? null,
'expires_at' => $data['expires_at'] ?? null,
'granted_by_id' => auth()->id(),
'granted_at' => now(),
]
);
return response()->json(['data' => $user->load('permissionOverrides.permission')]);
}
public function removeOverride(User $user, string $permission): JsonResponse
{
$this->authorize(Permissions::ADMIN_USERS_MANAGE);
$perm = Permission::where('name', $permission)->firstOrFail();
UserPermissionOverride::where('user_id', $user->id)->where('permission_id', $perm->id)->delete();
return response()->json(['removed' => true]);
}
private function authorize(string $permission): void
{
if (! auth()->user() || ! auth()->user()->canDo($permission)) {
abort(403, "Missing permission: $permission");
}
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tenant\User;
use Illuminate\Http\Request;
class InvitationController extends Controller
{
public function show(string $token)
{
$user = User::findByInvitationToken($token);
if (! $user || ! $user->isPendingInvitation()) {
return view('invitations.invalid');
}
// Invitations expire after 7 days
if ($user->invited_at && $user->invited_at->lt(now()->subDays(7))) {
return view('invitations.expired');
}
return view('invitations.accept', [
'token' => $token,
'name' => $user->name,
'email' => $user->email,
'company' => $user->company?->display_name ?? $user->company?->name ?? 'AutoCRM',
]);
}
public function accept(string $token, Request $request)
{
$request->validate([
'password' => 'required|min:8|confirmed',
]);
$user = User::findByInvitationToken($token);
if (! $user || ! $user->isPendingInvitation()) {
return view('invitations.invalid');
}
if ($user->invited_at && $user->invited_at->lt(now()->subDays(7))) {
return view('invitations.expired');
}
$user->acceptInvitation($request->input('password'));
// Redirect to tenant login on the appropriate subdomain
$loginUrl = $user->company?->url('/app/login') ?? '/app/login';
return redirect($loginUrl)->with('status', 'Invitația a fost acceptată. Loghează-te cu noua parolă.');
}
}
+184
View File
@@ -0,0 +1,184 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tenant\Client;
use App\Models\Tenant\ShopCustomer;
use App\Tenancy\TenantManager;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ShopAuthController extends Controller
{
private function tenantOrFail()
{
$tenant = app(TenantManager::class)->current();
if (! $tenant || ! data_get($tenant->settings, 'shop.enabled')) {
throw new NotFoundHttpException('Magazinul online nu este activ.');
}
return $tenant;
}
public function showRegister()
{
$tenant = $this->tenantOrFail();
if (Auth::guard('shop')->check()) return redirect('/shop/account');
return view('shop.auth.register', ['tenant' => $tenant, 'cartCount' => $this->cartCount()]);
}
public function register(Request $request)
{
$tenant = $this->tenantOrFail();
$data = $request->validate([
'name' => 'required|string|max:160',
'phone' => 'required|string|max:40',
'email' => 'nullable|email|max:160',
'password' => 'required|string|min:6|confirmed',
]);
// Unique per tenant (handled by composite index, but check for nicer error).
if (ShopCustomer::where('phone', $data['phone'])->exists()) {
return back()->withErrors(['phone' => 'Există deja un cont cu acest telefon.'])->withInput();
}
// Auto-link to existing Client by phone if present.
$client = Client::where('phone', $data['phone'])->first();
$customer = ShopCustomer::create([
'client_id' => $client?->id,
'name' => $data['name'],
'phone' => $data['phone'],
'email' => $data['email'] ?? null,
'password' => $data['password'], // hashed by cast
]);
event(new Registered($customer));
Auth::guard('shop')->login($customer, remember: true);
$customer->forceFill(['last_login_at' => now()])->save();
return redirect('/shop/account');
}
public function showLogin()
{
$tenant = $this->tenantOrFail();
if (Auth::guard('shop')->check()) return redirect('/shop/account');
return view('shop.auth.login', ['tenant' => $tenant, 'cartCount' => $this->cartCount()]);
}
public function login(Request $request)
{
$tenant = $this->tenantOrFail();
$data = $request->validate([
'phone' => 'required|string|max:40',
'password' => 'required|string',
]);
$ok = Auth::guard('shop')->attempt(
['phone' => $data['phone'], 'password' => $data['password']],
remember: true
);
if (! $ok) {
return back()->withErrors(['phone' => 'Telefon sau parolă incorecte.'])->withInput();
}
$request->session()->regenerate();
Auth::guard('shop')->user()?->forceFill(['last_login_at' => now()])->save();
return redirect()->intended('/shop/account');
}
public function logout(Request $request)
{
Auth::guard('shop')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/shop');
}
public function account()
{
$tenant = $this->tenantOrFail();
$customer = Auth::guard('shop')->user();
if (! $customer) return redirect('/shop/login');
$orders = $customer->orders()
->latest('created_at')
->limit(50)
->get();
return view('shop.account', [
'tenant' => $tenant,
'customer' => $customer,
'orders' => $orders,
'cartCount' => $this->cartCount(),
]);
}
public function showForgotPassword()
{
$tenant = $this->tenantOrFail();
return view('shop.auth.forgot', ['tenant' => $tenant, 'cartCount' => $this->cartCount()]);
}
public function sendResetLink(Request $request)
{
$this->tenantOrFail();
$data = $request->validate(['email' => 'required|email']);
// Send (always returns generic "sent" message — don't disclose if email exists).
Password::broker('shop_customers')->sendResetLink(['email' => $data['email']]);
return back()->with('status', 'Dacă există un cont cu acest email, am trimis un link de resetare.');
}
public function showResetPassword(string $token, Request $request)
{
$tenant = $this->tenantOrFail();
return view('shop.auth.reset', [
'tenant' => $tenant,
'token' => $token,
'email' => $request->query('email'),
'cartCount' => $this->cartCount(),
]);
}
public function resetPassword(Request $request)
{
$this->tenantOrFail();
$data = $request->validate([
'token' => 'required|string',
'email' => 'required|email',
'password' => 'required|string|min:6|confirmed',
]);
$status = Password::broker('shop_customers')->reset(
$data,
function (ShopCustomer $customer, string $password) {
$customer->forceFill([
'password' => Hash::make($password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($customer));
}
);
if ($status === Password::PASSWORD_RESET) {
return redirect('/shop/login')->with('status', 'Parola a fost resetată. Te poți loga acum.');
}
return back()->withErrors(['email' => 'Link invalid sau expirat. Cere unul nou.'])->withInput();
}
private function cartCount(): int
{
$tenant = app(TenantManager::class)->current();
$cart = (array) session('shop_cart_' . ($tenant?->id ?? '0'), []);
return (int) collect($cart)->sum('qty');
}
}
+7 -1
View File
@@ -155,11 +155,13 @@ class ShopController extends Controller
if (empty($cart)) return redirect('/shop');
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
$customer = \Illuminate\Support\Facades\Auth::guard('shop')->user();
return view('shop.checkout', [
'tenant' => $tenant,
'cart' => $cart,
'subtotal' => $subtotal,
'customer' => $customer,
'deliveryOptions' => (array) data_get($tenant->settings, 'shop.delivery_methods', ['pickup']),
'cartCount' => $this->cartCount(),
]);
@@ -188,9 +190,13 @@ class ShopController extends Controller
$deliveryFee = ($freeOver > 0 && $subtotal >= $freeOver) ? 0.0 : $fee;
}
$order = DB::transaction(function () use ($tenant, $cart, $data, $deliveryFee) {
$shopCustomer = \Illuminate\Support\Facades\Auth::guard('shop')->user();
$order = DB::transaction(function () use ($tenant, $cart, $data, $deliveryFee, $shopCustomer) {
$order = OnlineOrder::create([
'number' => OnlineOrder::generateNumber($tenant->id),
'shop_customer_id' => $shopCustomer?->id,
'client_id' => $shopCustomer?->client_id,
'customer_name' => $data['customer_name'],
'customer_phone' => $data['customer_phone'],
'customer_email' => $data['customer_email'] ?? null,
+112 -3
View File
@@ -3,6 +3,8 @@
namespace App\Http\Controllers;
use App\Models\Tenant\WorkOrder;
use App\Models\Tenant\WorkOrderPart;
use App\Models\Tenant\WorkOrderWork;
use App\Tenancy\TenantManager;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -11,8 +13,6 @@ class TrackingController extends Controller
{
/**
* Public WO tracking page accessed via QR code or SMS link.
* Tenant is resolved by ResolveTenant from the host, so the global
* BelongsToTenant scope already filters to the correct tenant.
*/
public function show(Request $request, string $token)
{
@@ -21,7 +21,7 @@ class TrackingController extends Controller
throw new NotFoundHttpException('Tracking only available on tenant subdomain.');
}
$wo = WorkOrder::with(['client', 'vehicle', 'master', 'media'])
$wo = WorkOrder::with(['client', 'vehicle', 'master', 'media', 'works', 'parts'])
->where('tracking_token', $token)
->first();
@@ -29,10 +29,119 @@ class TrackingController extends Controller
throw new NotFoundHttpException('Fișa nu a fost găsită.');
}
$pendingWorks = $wo->works->filter(fn ($w) => $w->isPendingApproval());
$pendingParts = $wo->parts->filter(fn ($p) => $p->isPendingApproval());
return view('tracking.show', [
'wo' => $wo,
'tenant' => $tenant,
'photos' => $wo->getMedia('photos'),
'pendingWorks' => $pendingWorks,
'pendingParts' => $pendingParts,
'approvalStatus' => $request->session()->pull('approval_status'),
]);
}
/**
* Client approves or declines a pending work/part line via the unique
* approval_token. The line's approval_token IS the credential anyone
* with the URL can act (clients won't share it).
*/
public function approve(Request $request, string $token, string $kind, string $lineToken)
{
$tenant = app(TenantManager::class)->current();
if (! $tenant) throw new NotFoundHttpException();
$wo = WorkOrder::where('tracking_token', $token)->first();
if (! $wo) throw new NotFoundHttpException();
$decision = $request->input('decision', 'approve');
$line = match ($kind) {
'work' => WorkOrderWork::where('work_order_id', $wo->id)->where('approval_token', $lineToken)->first(),
'part' => WorkOrderPart::where('work_order_id', $wo->id)->where('approval_token', $lineToken)->first(),
default => null,
};
if (! $line || ! $line->isPendingApproval()) {
$request->session()->flash('approval_status', ['kind' => 'error', 'message' => 'Linia nu mai necesită aprobare.']);
return redirect()->route('tracking.show', ['token' => $token]);
}
if ($decision === 'approve') {
$line->forceFill(['approved_at' => now()])->save();
$msg = '✅ Lucrarea „' . $line->name . '" a fost aprobată. Mulțumim!';
} else {
$line->forceFill(['declined_at' => now()])->save();
$msg = '❌ Lucrarea „' . $line->name . '" a fost respinsă. Vă vom contacta.';
}
$request->session()->flash('approval_status', ['kind' => 'success', 'message' => $msg]);
return redirect()->route('tracking.show', ['token' => $token]);
}
/**
* GET /api/track/{token} JSON status payload for native apps.
* Public, no auth (token IS the credential). Tenant-scoped via subdomain.
*/
public function jsonStatus(Request $request, string $token)
{
$tenant = app(TenantManager::class)->current();
if (! $tenant) {
return response()->json(['error' => 'tenant_required'], 404);
}
$wo = WorkOrder::with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name', 'works', 'parts'])
->where('tracking_token', $token)
->first();
if (! $wo) return response()->json(['error' => 'not_found'], 404);
$statuses = WorkOrder::STATUSES;
$flow = ['new', 'diagnosis', 'agreement', 'approved', 'in_work', 'awaiting_parts', 'ready', 'done'];
$currentIdx = array_search($wo->status, $flow, true);
$pendingApprovals = collect()
->merge($wo->works->filter(fn ($w) => $w->isPendingApproval())->map(fn ($w) => [
'kind' => 'work', 'id' => $w->id, 'token' => $w->approval_token,
'name' => $w->name, 'amount' => (float) $w->total,
'approve_url' => url("/t/{$token}/approve/work/{$w->approval_token}"),
]))
->merge($wo->parts->filter(fn ($p) => $p->isPendingApproval())->map(fn ($p) => [
'kind' => 'part', 'id' => $p->id, 'token' => $p->approval_token,
'name' => $p->name, 'amount' => (float) $p->total,
'approve_url' => url("/t/{$token}/approve/part/{$p->approval_token}"),
]));
// Timeline from activity_log (best-effort — empty array if not configured)
$timeline = [];
try {
$timeline = \DB::table('activity_log')
->where('subject_type', WorkOrder::class)
->where('subject_id', $wo->id)
->orderBy('created_at')
->limit(20)
->get(['event', 'description', 'created_at'])
->map(fn ($r) => [
'event' => $r->event,
'description' => $r->description,
'at' => $r->created_at,
])->toArray();
} catch (\Throwable $e) { /* activity_log table may not exist in some tenants */ }
return response()->json([
'number' => $wo->number,
'status' => $wo->status,
'status_label' => $statuses[$wo->status] ?? $wo->status,
'progress' => $currentIdx !== false ? round(100 * ($currentIdx + 1) / count($flow)) : null,
'client' => $wo->client?->name,
'vehicle' => trim(($wo->vehicle?->make ?? '') . ' ' . ($wo->vehicle?->model ?? '')),
'plate' => $wo->vehicle?->plate,
'master' => $wo->master?->name,
'eta_promised' => $wo->eta_promised?->toIso8601String(),
'eta_current' => $wo->eta_at?->toIso8601String(),
'eta_change_reason' => $wo->eta_change_reason,
'total' => (float) $wo->total,
'pay_status' => $wo->pay_status,
'pending_approvals' => $pendingApprovals->values(),
'timeline' => $timeline,
]);
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Jobs;
use App\Models\Tenant\OcrJob;
use App\Services\Ai\OcrInvoiceService;
use App\Tenancy\TenantManager;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
class ProcessOcrJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 2;
public int $timeout = 120;
public function __construct(public int $ocrJobId, public int $companyId) {}
public function handle(OcrInvoiceService $svc, TenantManager $tenants): void
{
// Re-establish tenant context inside the queue worker
$company = \App\Models\Central\Company::find($this->companyId);
if (! $company) { return; }
$tenants->setCurrent($company);
$job = OcrJob::find($this->ocrJobId);
if (! $job) return;
$job->update(['status' => 'processing']);
try {
$absPath = Storage::disk('local')->path($job->file_path);
$result = $svc->extract($absPath);
$job->update([
'status' => 'done',
'result' => $result,
'processed_at' => now(),
'ai_provider' => 'claude',
]);
} catch (\Throwable $e) {
$job->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
'processed_at' => now(),
]);
throw $e;
}
}
public function failed(\Throwable $e): void
{
$job = OcrJob::find($this->ocrJobId);
$job?->update(['status' => 'failed', 'error_message' => $e->getMessage()]);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Mail;
use App\Models\Central\Company;
use App\Models\Tenant\OnlineOrder;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ShopOrderConfirmationMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public OnlineOrder $order,
public Company $company,
) {}
public function envelope(): Envelope
{
$brand = $this->company->display_name ?? $this->company->name;
return new Envelope(
subject: "Comanda #{$this->order->number} primită — {$brand}",
);
}
public function content(): Content
{
return new Content(
view: 'emails.shop.order-confirmation',
with: [
'order' => $this->order,
'company' => $this->company,
'items' => $this->order->items()->get(),
'trackingUrl' => $this->order->trackingUrl(),
'currency' => $this->company->settings['currency'] ?? 'MDL',
],
);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Mail;
use App\Models\Central\Company;
use App\Models\Tenant\ShopCustomer;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ShopPasswordResetMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public ShopCustomer $customer,
public Company $company,
public string $resetUrl,
) {}
public function envelope(): Envelope
{
$brand = $this->company->display_name ?? $this->company->name;
return new Envelope(
subject: "Resetare parolă — {$brand}",
);
}
public function content(): Content
{
return new Content(
view: 'emails.shop.password-reset',
with: [
'customer' => $this->customer,
'company' => $this->company,
'resetUrl' => $this->resetUrl,
],
);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Mail;
use App\Models\Tenant\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class UserInvitationMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public User $user,
public string $rawToken,
) {}
public function envelope(): Envelope
{
$company = $this->user->company?->display_name ?? $this->user->company?->name ?? 'AutoCRM';
return new Envelope(
subject: "Invitație de acces — {$company}",
);
}
public function content(): Content
{
$company = $this->user->company;
return new Content(
view: 'emails.user-invitation',
with: [
'name' => $this->user->name,
'invitedBy' => $this->user->invitedBy?->name ?? 'Echipa',
'companyName' => $company?->display_name ?? $company?->name ?? 'AutoCRM',
'acceptUrl' => url('/invitations/' . $this->rawToken),
],
);
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ class Company extends BaseTenant implements HasMedia
return [
'id',
'slug', 'name', 'display_name', 'city', 'phone', 'email', 'contact_name',
'status', 'plan_id',
'status', 'plan_id', 'is_demo', 'default_warehouse_id',
'trial_ends_at', 'active_until',
'settings',
'created_at', 'updated_at', 'deleted_at',
@@ -0,0 +1,44 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ClientNotificationLog extends Model
{
use BelongsToTenant;
public $timestamps = false;
protected $table = 'client_notifications_log';
public const CHANNELS = [
'sms' => 'SMS',
'whatsapp' => 'WhatsApp',
'telegram' => 'Telegram',
'email' => 'Email',
'push' => 'Web Push',
];
public const STATUSES = [
'sent' => 'Trimis',
'delivered' => 'Livrat',
'failed' => 'Eșuat',
'read' => 'Citit',
];
protected $fillable = [
'company_id', 'work_order_id', 'client_id',
'channel', 'template_key', 'message_text', 'status', 'error_detail',
'sent_at', 'delivered_at',
];
protected $casts = [
'sent_at' => 'datetime',
'delivered_at' => 'datetime',
];
public function workOrder(): BelongsTo { return $this->belongsTo(WorkOrder::class); }
public function client(): BelongsTo { return $this->belongsTo(Client::class); }
}
+31 -1
View File
@@ -14,7 +14,7 @@ class Deal extends Model
public const STAGES = [
'new' => 'Nou',
'contact' => 'Contact',
'contact' => 'Calculație',
'agree' => 'Aprobare',
'scheduled' => 'Programat',
'arrived' => 'Sosit',
@@ -23,18 +23,48 @@ class Deal extends Model
'lost' => 'Pierdut',
];
public const QUOTE_STATUSES = [
'pending' => 'În așteptare',
'sent' => 'Trimis · fără răspuns',
'seen' => 'Văzut ✓',
'responded' => 'A răspuns',
];
public const CONFIRM_CHANNELS = [
'whatsapp' => 'WhatsApp',
'sms' => 'SMS',
'telegram' => 'Telegram',
'call' => 'Apel',
];
protected $fillable = [
'company_id', 'client_id', 'vehicle_id',
'name', 'price', 'stage', 'source', 'note',
'assigned_to', 'won_at', 'lost_at', 'lost_reason',
'urgent', 'quote_sent_at', 'quote_status', 'quote_seen_at',
'scheduled_at', 'bay', 'confirmed_at', 'confirmed_via',
'last_action_at',
];
protected $casts = [
'price' => 'decimal:2',
'won_at' => 'datetime',
'lost_at' => 'datetime',
'urgent' => 'boolean',
'quote_sent_at' => 'datetime',
'quote_seen_at' => 'datetime',
'scheduled_at' => 'datetime',
'confirmed_at' => 'datetime',
'last_action_at' => 'datetime',
];
protected static function booted(): void
{
static::saving(function (self $deal) {
$deal->last_action_at = now();
});
}
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OcrJob extends Model
{
use BelongsToTenant;
public const STATUSES = [
'pending' => 'În așteptare',
'processing' => 'Procesare',
'done' => 'Finalizat',
'failed' => 'Eșuat',
];
protected $fillable = [
'company_id', 'supplier_id', 'source_type', 'file_path', 'status',
'result', 'error_message', 'ai_provider', 'tokens_used',
'purchase_id', 'processed_at',
];
protected $casts = [
'result' => 'array',
'processed_at' => 'datetime',
'tokens_used' => 'integer',
];
public function supplier(): BelongsTo { return $this->belongsTo(Supplier::class); }
public function purchase(): BelongsTo { return $this->belongsTo(Purchase::class); }
}
+6 -1
View File
@@ -29,7 +29,7 @@ class OnlineOrder extends Model
];
protected $fillable = [
'company_id', 'number', 'tracking_token', 'client_id',
'company_id', 'number', 'tracking_token', 'client_id', 'shop_customer_id',
'customer_name', 'customer_phone', 'customer_email',
'delivery_method', 'address', 'status',
'subtotal', 'delivery_fee', 'total', 'notes',
@@ -51,6 +51,11 @@ class OnlineOrder extends Model
return $this->belongsTo(Client::class);
}
public function shopCustomer(): BelongsTo
{
return $this->belongsTo(ShopCustomer::class);
}
public function trackingUrl(): string
{
return url('/shop/order/' . $this->tracking_token);
+37 -3
View File
@@ -7,10 +7,35 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class Part extends Model
class Part extends Model implements HasMedia
{
use BelongsToTenant, SoftDeletes;
use BelongsToTenant, InteractsWithMedia, SoftDeletes;
public function registerMediaCollections(): void
{
// Multi-image gallery (catalog uses imageUrl() = first; detail page renders all).
$this->addMediaCollection('image');
}
public function imageUrl(): ?string
{
$m = $this->getFirstMedia('image');
if (! $m) return null;
if (! @file_exists($m->getPath())) return null;
return $m->getUrl();
}
/** @return list<string> All published image URLs (excluding any whose file is missing). */
public function imageUrls(): array
{
return $this->getMedia('image')
->filter(fn ($m) => @file_exists($m->getPath()))
->map(fn ($m) => $m->getUrl())
->values()->all();
}
public const CATEGORIES = [
'Ulei', 'Filtre', 'Frâne', 'Suspensie', 'Lichide',
@@ -20,7 +45,7 @@ class Part extends Model
protected $fillable = [
'company_id', 'name', 'article', 'brand', 'category',
'qty', 'qty_reserved', 'unit', 'min_qty',
'buy_price', 'sell_price',
'buy_price', 'sell_price', 'hidden_markup_pct',
'location', 'barcode', 'preferred_supplier_id',
'is_active', 'is_published', 'notes',
];
@@ -31,10 +56,19 @@ class Part extends Model
'min_qty' => 'decimal:2',
'buy_price' => 'decimal:2',
'sell_price' => 'decimal:2',
'hidden_markup_pct' => 'decimal:2',
'is_active' => 'boolean',
'is_published' => 'boolean',
];
/** Internal cost+hidden markup (NOT shown to customer). Used for margin analytics + B2B contract pricing. */
public function internalCostWithHiddenMarkup(): float
{
$base = (float) $this->buy_price;
$pct = (float) ($this->hidden_markup_pct ?: 0);
return round($base * (1 + $pct / 100), 2);
}
public function preferredSupplier(): BelongsTo
{
return $this->belongsTo(Supplier::class, 'preferred_supplier_id');
+7 -1
View File
@@ -10,14 +10,20 @@ class Post extends Model
{
use BelongsToTenant;
protected $fillable = ['company_id', 'name', 'color', 'is_active', 'sort_order'];
protected $fillable = ['company_id', 'name', 'color', 'is_active', 'sort_order', 'hours_per_day', 'description', 'default_master_id'];
protected $casts = [
'is_active' => 'boolean',
'hours_per_day' => 'decimal:1',
];
public function appointments(): HasMany
{
return $this->hasMany(Appointment::class);
}
public function defaultMaster(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class, 'default_master_id');
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* Append-only audit log: every PricingEngine::quote() call writes one row
* here so we can reconstruct "why was this part priced at 218 lei?" later.
*/
class PricingApplicationLog extends Model
{
use BelongsToTenant;
public $timestamps = false;
protected $fillable = [
'company_id', 'subject_type', 'subject_id', 'part_id', 'vehicle_id', 'client_id',
'base_price', 'final_price', 'applied_coefficients', 'context', 'calculated_at',
];
protected $casts = [
'base_price' => 'decimal:2',
'final_price' => 'decimal:2',
'applied_coefficients' => 'array',
'context' => 'array',
'calculated_at' => 'datetime',
];
public function subject(): MorphTo { return $this->morphTo(); }
public function part(): BelongsTo { return $this->belongsTo(Part::class); }
public function vehicle(): BelongsTo { return $this->belongsTo(Vehicle::class); }
public function client(): BelongsTo { return $this->belongsTo(Client::class); }
}
+16
View File
@@ -53,6 +53,22 @@ class PricingCoefficient extends Model
}
}
// Body type — sedan|suv|pickup|...
$bodyTypes = (array) ($c['body_types'] ?? []);
if (! empty($bodyTypes)) {
if (empty($ctx['body_type']) || ! in_array($ctx['body_type'], $bodyTypes, true)) {
return false;
}
}
// Transmission — dsg|cvt|automatic|...
$transmissions = (array) ($c['transmissions'] ?? []);
if (! empty($transmissions)) {
if (empty($ctx['transmission']) || ! in_array($ctx['transmission'], $transmissions, true)) {
return false;
}
}
// Vehicle age range.
if (isset($c['age_min']) && $c['age_min'] !== null && $c['age_min'] !== '') {
if (($ctx['age'] ?? null) === null || $ctx['age'] < (int) $c['age_min']) return false;
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class ShopCustomer extends Authenticatable
{
use BelongsToTenant, Notifiable, SoftDeletes;
protected $fillable = [
'company_id', 'client_id', 'name', 'phone', 'email', 'password', 'last_login_at',
];
protected $hidden = ['password', 'remember_token'];
protected $casts = [
'last_login_at' => 'datetime',
'password' => 'hashed',
];
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function orders(): HasMany
{
return $this->hasMany(OnlineOrder::class);
}
/** Auth column for Laravel's session guard. */
public function getAuthIdentifierName()
{
return 'id';
}
/** Send custom reset mail with a /shop/password/reset URL on the tenant subdomain. */
public function sendPasswordResetNotification($token): void
{
$tenant = \App\Models\Central\Company::withoutGlobalScopes()->find($this->company_id);
if (! $tenant || ! $this->email) return;
$central = config('app.central_domain') ?: config('tenancy.central_domains.0', 'service.mir.md');
$url = "https://{$tenant->slug}.{$central}/shop/password/reset/{$token}?email=" . urlencode($this->email);
\Illuminate\Support\Facades\Mail::to($this->email)->send(
new \App\Mail\ShopPasswordResetMail($this, $tenant, $url)
);
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SupplierInvoiceMapping extends Model
{
use BelongsToTenant;
protected $fillable = [
'company_id', 'supplier_id', 'mapping_config',
'sample_file_name', 'last_used_at',
];
protected $casts = [
'mapping_config' => 'array',
'last_used_at' => 'datetime',
];
public function supplier(): BelongsTo
{
return $this->belongsTo(Supplier::class);
}
}
+145 -1
View File
@@ -11,6 +11,7 @@ use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
@@ -34,6 +35,7 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication
'email_verified_at', 'password', 'last_login_at',
'email_authentication_at',
'app_authentication_secret', 'app_authentication_recovery_codes',
'invited_at', 'invited_by_id', 'accepted_at', 'invitation_token',
];
protected $hidden = [
@@ -46,6 +48,8 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication
'email_verified_at' => 'datetime',
'last_login_at' => 'datetime',
'email_authentication_at' => 'datetime',
'invited_at' => 'datetime',
'accepted_at' => 'datetime',
'password' => 'hashed',
'app_authentication_secret' => 'encrypted',
'app_authentication_recovery_codes' => 'encrypted:array',
@@ -60,7 +64,147 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication
public function isAdmin(): bool
{
return $this->role === 'admin';
return $this->role === 'admin' || $this->role === 'owner' || $this->hasAnyRole(['admin', 'owner']);
}
public function isOwner(): bool
{
return $this->role === 'owner' || $this->hasRole('owner');
}
public function isAccountant(): bool
{
return $this->role === 'accountant' || $this->hasRole('accountant');
}
public function isMechanic(): bool
{
return in_array($this->role, ['mechanic', 'master'], true) || $this->hasAnyRole(['mechanic']);
}
public function permissionOverrides(): HasMany
{
return $this->hasMany(UserPermissionOverride::class);
}
public function invitedBy(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(self::class, 'invited_by_id');
}
public function company(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(\App\Models\Central\Company::class);
}
/**
* Permission check honoring (in order):
* 1. Active deny-override false
* 2. Active grant-override true
* 3. Admin/owner bypass true
* 4. Standard role-based check
*/
public function canDo(string $permission): bool
{
$override = $this->activeOverrideFor($permission);
if ($override) {
if ($override->mode === 'deny') {
$this->logDeniedIfSensitive($permission);
return false;
}
if ($override->mode === 'grant') return true;
}
// Owner + admin bypass for permissions without explicit deny.
if ($this->isAdmin()) return true;
try {
$allowed = $this->can($permission);
if (! $allowed) $this->logDeniedIfSensitive($permission);
return $allowed;
} catch (\Throwable $e) {
return false;
}
}
private function activeOverrideFor(string $permissionSlug): ?UserPermissionOverride
{
return $this->permissionOverrides()
->whereHas('permission', fn ($q) => $q->where('name', $permissionSlug))
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->first();
}
/** Sensitive permissions whose deny we should record for audit. */
private const AUDITED_DENIALS = [
'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',
];
private function logDeniedIfSensitive(string $permission): void
{
if (! in_array($permission, self::AUDITED_DENIALS, true)) return;
try {
activity('permissions')
->causedBy($this)
->withProperties(['permission' => $permission])
->event('permission_denied')
->log("permission denied: $permission for user #{$this->id}");
} catch (\Throwable $e) {
// activity-log may be misconfigured in some contexts — never let auth fail because of it.
}
}
/** Has 2FA app authentication enabled (Filament native). */
public function hasTwoFactorEnabled(): bool
{
return $this->app_authentication_secret !== null;
}
/** Pending invitation (sent but not yet accepted). */
public function isPendingInvitation(): bool
{
return $this->invited_at !== null && $this->accepted_at === null;
}
/**
* Create + send an invitation: generates a random token, marks invited_at,
* and queues the email with the signed accept link. Idempotent calling
* again regenerates the token (useful for "resend invitation").
*/
public function sendInvitation(?User $invitedBy = null): string
{
$token = bin2hex(random_bytes(32)); // 64 chars
$this->forceFill([
'invitation_token' => hash('sha256', $token),
'invited_at' => now(),
'invited_by_id' => $invitedBy?->id ?? auth()->id(),
'accepted_at' => null,
'status' => 'inactive', // can't login until accepted
])->saveQuietly();
\Illuminate\Support\Facades\Mail::to($this->email)
->queue(new \App\Mail\UserInvitationMail($this, $token));
return $token; // returned mainly for tests / API
}
public static function findByInvitationToken(string $rawToken): ?self
{
return self::where('invitation_token', hash('sha256', $rawToken))->first();
}
public function acceptInvitation(string $password): void
{
$this->forceFill([
'password' => $password, // hashed cast handles it
'invitation_token' => null,
'accepted_at' => now(),
'status' => 'active',
'email_verified_at' => now(),
])->save();
}
public function hasEmailAuthentication(): bool
@@ -0,0 +1,50 @@
<?php
namespace App\Models\Tenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\Permission\Models\Permission;
class UserPermissionOverride extends Model
{
protected $table = 'user_permission_overrides';
public $incrementing = false;
protected $primaryKey = null;
public $timestamps = false;
protected $fillable = ['user_id', 'permission_id', 'mode', 'reason', 'granted_at', 'granted_by_id', 'expires_at'];
protected $casts = [
'granted_at' => 'datetime',
'expires_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function permission(): BelongsTo
{
return $this->belongsTo(Permission::class);
}
public function grantedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'granted_by_id');
}
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
public function isActive(): bool
{
return ! $this->isExpired();
}
}
+18 -1
View File
@@ -12,10 +12,27 @@ class Vehicle extends Model
{
use Auditable, BelongsToTenant, SoftDeletes;
public const BODY_TYPES = [
'sedan' => 'Sedan', 'hatchback' => 'Hatchback', 'suv' => 'SUV',
'crossover' => 'Crossover', 'pickup' => 'Pickup', 'van' => 'Van',
'truck' => 'Camion', 'coupe' => 'Coupé', 'wagon' => 'Break',
'convertible' => 'Cabrio', 'minivan' => 'Minivan', 'moto' => 'Motocicletă',
];
public const TRANSMISSION_TYPES = [
'manual' => 'Manuală',
'automatic' => 'Automată',
'cvt' => 'CVT',
'dsg' => 'DSG',
'dct' => 'DCT (Dual-Clutch)',
'amt' => 'AMT (Robot)',
];
protected $fillable = [
'company_id', 'client_id',
'make', 'model', 'year', 'vin', 'plate',
'engine', 'gearbox', 'fuel', 'vehicle_class', 'mileage', 'color', 'notes',
'engine', 'gearbox', 'fuel', 'vehicle_class', 'body_type', 'transmission_type',
'mileage', 'color', 'notes',
];
public function client(): BelongsTo
+1
View File
@@ -31,6 +31,7 @@ class WarehouseEvent extends Model
'type', 'qty_delta', 'unit_cost',
'ref_type', 'ref_id', 'user_id',
'occurred_at', 'notes',
'signature_b64', 'scan_payload',
];
protected $casts = [
+4 -1
View File
@@ -40,7 +40,8 @@ class WorkOrder extends Model implements HasMedia
'complaint', 'diagnosis', 'recommendations',
'status', 'urgency', 'pay_status', 'approved', 'approved_at',
'discount_pct', 'total',
'eta_at', 'tracking_token',
'eta_at', 'eta_promised', 'eta_change_reason', 'eta_updated_at',
'tracking_token',
];
protected $casts = [
@@ -48,6 +49,8 @@ class WorkOrder extends Model implements HasMedia
'closed_at' => 'date',
'approved_at' => 'datetime',
'eta_at' => 'datetime',
'eta_promised' => 'datetime',
'eta_updated_at' => 'datetime',
'approved' => 'boolean',
'discount_pct' => 'decimal:2',
'total' => 'decimal:2',
+12
View File
@@ -24,6 +24,7 @@ class WorkOrderPart extends Model
'name', 'article', 'brand',
'qty', 'unit', 'buy_price', 'sell_price',
'discount_pct', 'total', 'status', 'notes',
'requires_approval', 'approved_at', 'approval_token', 'declined_at',
];
protected $casts = [
@@ -32,8 +33,16 @@ class WorkOrderPart extends Model
'sell_price' => 'decimal:2',
'discount_pct' => 'decimal:2',
'total' => 'decimal:2',
'requires_approval' => 'boolean',
'approved_at' => 'datetime',
'declined_at' => 'datetime',
];
public function isPendingApproval(): bool
{
return $this->requires_approval && $this->approved_at === null && $this->declined_at === null;
}
public function workOrder(): BelongsTo
{
return $this->belongsTo(WorkOrder::class);
@@ -50,6 +59,9 @@ class WorkOrderPart extends Model
$sub = (float) $row->qty * (float) $row->sell_price;
$disc = (float) $row->discount_pct;
$row->total = round($sub * (1 - $disc / 100), 2);
if ($row->requires_approval && empty($row->approval_token)) {
$row->approval_token = \Illuminate\Support\Str::random(24);
}
});
// Reserve batches as soon as a catalog-linked part line is created.
+124
View File
@@ -12,23 +12,144 @@ class WorkOrderWork extends Model
protected $table = 'wo_works';
protected $attributes = [
'mechanic_status' => 'pending',
'paused_seconds_total' => 0,
];
public const STATUSES = [
'todo' => 'De făcut',
'in_progress' => 'În lucru',
'done' => 'Finalizat',
];
public const MECHANIC_STATUSES = [
'pending' => 'În așteptare',
'in_progress' => 'În lucru',
'paused' => 'Pe pauză',
'done' => 'Finalizat',
'blocked' => 'Blocat',
];
public const BLOCK_REASONS = [
'missing_part' => 'Lipsă piesă',
'awaiting_approval' => 'Aștept aprobare client',
'broken_equipment' => 'Echipament defect',
'other' => 'Altă problemă',
];
protected $fillable = [
'company_id', 'work_order_id', 'labor_id', 'master_id',
'name', 'hours', 'price_per_hour', 'total', 'status', 'notes',
'requires_approval', 'approved_at', 'approval_token', 'declined_at',
'mechanic_status', 'mechanic_started_at', 'mechanic_done_at',
'actual_hours', 'paused_seconds_total', 'paused_at',
'block_reason', 'block_note',
];
protected $casts = [
'hours' => 'decimal:2',
'price_per_hour' => 'decimal:2',
'total' => 'decimal:2',
'requires_approval' => 'boolean',
'approved_at' => 'datetime',
'declined_at' => 'datetime',
'mechanic_started_at' => 'datetime',
'mechanic_done_at' => 'datetime',
'paused_at' => 'datetime',
'actual_hours' => 'decimal:2',
'paused_seconds_total' => 'integer',
];
// ── State machine ────────────────────────────────────────────
public function start(): void
{
if ($this->mechanic_status === 'done') return;
$this->forceFill([
'mechanic_status' => 'in_progress',
'mechanic_started_at' => $this->mechanic_started_at ?? now(),
'paused_at' => null,
'block_reason' => null,
'block_note' => null,
'status' => 'in_progress',
])->save();
}
public function pause(): void
{
if ($this->mechanic_status !== 'in_progress') return;
$this->forceFill([
'mechanic_status' => 'paused',
'paused_at' => now(),
])->save();
}
public function resume(): void
{
if ($this->mechanic_status !== 'paused') return;
$added = $this->paused_at ? $this->paused_at->diffInSeconds(now()) : 0;
$this->forceFill([
'mechanic_status' => 'in_progress',
'paused_seconds_total' => (int) $this->paused_seconds_total + (int) $added,
'paused_at' => null,
])->save();
}
public function markDone(): void
{
// If currently paused, count up till now as paused time before stopping.
if ($this->mechanic_status === 'paused' && $this->paused_at) {
$this->paused_seconds_total = (int) $this->paused_seconds_total + (int) $this->paused_at->diffInSeconds(now());
$this->paused_at = null;
}
$started = $this->mechanic_started_at ?? now();
$endedAt = now();
$elapsedSec = max(0, $started->diffInSeconds($endedAt) - (int) $this->paused_seconds_total);
$actualHours = round($elapsedSec / 3600, 2);
$this->forceFill([
'mechanic_status' => 'done',
'mechanic_done_at' => $endedAt,
'actual_hours' => $actualHours,
'status' => 'done',
'block_reason' => null,
])->save();
}
public function block(string $reason, ?string $note = null): void
{
if (! array_key_exists($reason, self::BLOCK_REASONS)) return;
$this->forceFill([
'mechanic_status' => 'blocked',
'block_reason' => $reason,
'block_note' => $note,
'paused_at' => null,
])->save();
}
/** 'green' if faster than norm, 'amber' if 30%+ over, 'red' if 100%+ over. */
public function efficiencyClass(): ?string
{
if ((float) $this->actual_hours <= 0 || (float) $this->hours <= 0) return null;
$ratio = (float) $this->actual_hours / (float) $this->hours;
return match (true) {
$ratio <= 1.0 => 'green',
$ratio <= 1.3 => 'amber',
default => 'red',
};
}
public function efficiencyPct(): ?int
{
if ((float) $this->actual_hours <= 0 || (float) $this->hours <= 0) return null;
return (int) round(100 * (float) $this->actual_hours / (float) $this->hours);
}
public function isPendingApproval(): bool
{
return $this->requires_approval && $this->approved_at === null && $this->declined_at === null;
}
public function workOrder(): BelongsTo
{
return $this->belongsTo(WorkOrder::class);
@@ -48,6 +169,9 @@ class WorkOrderWork extends Model
{
static::saving(function (self $row) {
$row->total = round((float) $row->hours * (float) $row->price_per_hour, 2);
if ($row->requires_approval && empty($row->approval_token)) {
$row->approval_token = \Illuminate\Support\Str::random(24);
}
});
static::saved(fn (self $row) => $row->workOrder?->recalcTotal());
static::deleted(fn (self $row) => $row->workOrder?->recalcTotal());
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class WorkPhoto extends Model
{
use BelongsToTenant;
public const TYPES = [
'defect' => 'Defect',
'before' => 'Înainte',
'after' => 'După',
'general' => 'General',
];
protected $fillable = [
'company_id', 'work_order_id', 'subject_type', 'subject_id',
'uploaded_by_id', 'path', 'type', 'caption', 'taken_at',
];
protected $casts = [
'taken_at' => 'datetime',
];
public function subject(): MorphTo { return $this->morphTo(); }
public function workOrder(): BelongsTo { return $this->belongsTo(WorkOrder::class); }
public function uploadedBy(): BelongsTo { return $this->belongsTo(User::class, 'uploaded_by_id'); }
public function url(): string
{
return \Illuminate\Support\Facades\Storage::url($this->path);
}
}
+108 -23
View File
@@ -21,6 +21,37 @@ use Illuminate\Support\Facades\Http;
*/
class AiAssistantService
{
/** Per-provider default model + the dropdown options exposed in Settings. */
public const MODEL_DEFAULTS = [
'claude' => 'claude-sonnet-4-6',
'gpt' => 'gpt-4o-mini',
'gemini' => 'gemini-1.5-flash',
];
public const MODEL_OPTIONS = [
'claude' => [
'claude-opus-4-7' => 'Opus 4.7 — cel mai capabil',
'claude-sonnet-4-6' => 'Sonnet 4.6 — echilibrat (recomandat)',
'claude-haiku-4-5-20251001' => 'Haiku 4.5 — rapid și ieftin',
],
'gpt' => [
'gpt-4o' => 'GPT-4o',
'gpt-4o-mini' => 'GPT-4o mini (recomandat)',
],
'gemini' => [
'gemini-1.5-pro' => 'Gemini 1.5 Pro',
'gemini-1.5-flash' => 'Gemini 1.5 Flash (recomandat)',
],
];
/** Resolve the model id for a provider — tenant override > global default. */
public function modelFor(string $provider, ?Company $company = null): string
{
$company ??= $this->currentCompany();
$override = $company ? data_get($company->settings, "ai.models.{$provider}") : null;
return $override ?: (self::MODEL_DEFAULTS[$provider] ?? 'claude-sonnet-4-6');
}
public function ask(AiChat $chat, string $userMessage): AiMessage
{
// Persist user message.
@@ -117,36 +148,90 @@ TXT;
protected function callClaude(string $key, AiChat $chat, string $msg, Company $company): array
{
$messages = $this->historyMessages($chat);
// Anthropic requires alternating user/assistant; system is separate.
$messages = array_values(array_filter($messages, fn ($m) => in_array($m['role'], ['user', 'assistant'], true)));
$r = Http::withHeaders([
// Normalize history to the structured content-block form (Claude tool-use
// requires content blocks for the assistant turn that emitted tool_use).
foreach ($messages as &$m) {
if (is_string($m['content'])) {
$m['content'] = [['type' => 'text', 'text' => $m['content']]];
}
}
unset($m);
$headers = [
'x-api-key' => $key,
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])
->timeout(60)
->post('https://api.anthropic.com/v1/messages', [
'model' => 'claude-sonnet-4-5',
];
$system = $this->buildSystemPrompt($company);
$tools = AiToolExecutor::TOOLS;
$executor = app(AiToolExecutor::class);
$tokensIn = 0;
$tokensOut = 0;
$toolCalls = [];
$finalText = '';
$model = null;
// Loop on tool_use up to 5 rounds, then bail out with whatever text we have.
for ($round = 0; $round < 5; $round++) {
$r = Http::withHeaders($headers)->timeout(60)->post(
'https://api.anthropic.com/v1/messages',
[
'model' => $this->modelFor('claude', $company),
'max_tokens' => 1024,
'system' => $this->buildSystemPrompt($company),
'system' => $system,
'tools' => $tools,
'messages' => $messages,
]);
]
);
if (! $r->successful()) {
return ['❌ ' . ($r->json('error.message') ?? 'Anthropic API error ' . $r->status()), ['status' => $r->status()]];
}
$body = $r->json();
$text = collect($body['content'] ?? [])
->where('type', 'text')
->pluck('text')
->implode("\n");
$model = $body['model'] ?? $model;
$tokensIn += (int) ($body['usage']['input_tokens'] ?? 0);
$tokensOut += (int) ($body['usage']['output_tokens'] ?? 0);
return [$text ?: '(răspuns gol)', [
'model' => $body['model'] ?? null,
'tokens_in' => $body['usage']['input_tokens'] ?? null,
'tokens_out' => $body['usage']['output_tokens'] ?? null,
$blocks = $body['content'] ?? [];
$finalText = collect($blocks)->where('type', 'text')->pluck('text')->implode("\n");
if (($body['stop_reason'] ?? null) !== 'tool_use') {
break;
}
// Append the assistant turn (with the tool_use block) to history.
$messages[] = ['role' => 'assistant', 'content' => $blocks];
// Execute every tool_use block and build the user reply with tool_results.
$toolResults = [];
foreach ($blocks as $b) {
if (($b['type'] ?? '') !== 'tool_use') continue;
$name = $b['name'];
$input = (array) ($b['input'] ?? []);
try {
$out = $executor->execute($name, $input);
} catch (\Throwable $e) {
$out = ['error' => $e->getMessage()];
}
$toolCalls[] = ['name' => $name, 'input' => $input];
$toolResults[] = [
'type' => 'tool_result',
'tool_use_id' => $b['id'],
'content' => json_encode($out, JSON_UNESCAPED_UNICODE),
];
}
$messages[] = ['role' => 'user', 'content' => $toolResults];
}
return [$finalText ?: '(răspuns gol)', [
'model' => $model,
'tokens_in' => $tokensIn,
'tokens_out' => $tokensOut,
'tools' => $toolCalls,
]];
}
@@ -160,7 +245,7 @@ TXT;
$r = Http::withHeaders(['Authorization' => 'Bearer ' . $key, 'content-type' => 'application/json'])
->timeout(60)
->post('https://api.openai.com/v1/chat/completions', [
'model' => 'gpt-4o-mini',
'model' => $this->modelFor('gpt', $company),
'messages' => $messages,
'max_tokens' => 1024,
]);
@@ -336,7 +421,7 @@ TXT;
])
->timeout(60)
->post('https://api.anthropic.com/v1/messages', [
'model' => 'claude-sonnet-4-5',
'model' => $this->modelFor('claude'),
'max_tokens' => 1024,
'system' => $system,
'messages' => $messages,
@@ -359,7 +444,7 @@ TXT;
$r = Http::withHeaders(['Authorization' => 'Bearer ' . $key, 'content-type' => 'application/json'])
->timeout(60)
->post('https://api.openai.com/v1/chat/completions', [
'model' => 'gpt-4o-mini',
'model' => $this->modelFor('gpt'),
'messages' => array_merge([['role' => 'system', 'content' => $system]], $messages),
'max_tokens' => 1024,
]);
@@ -388,7 +473,7 @@ TXT;
}
$r = Http::withHeaders(['content-type' => 'application/json'])
->timeout(60)
->post('https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=' . $key, [
->post('https://generativelanguage.googleapis.com/v1beta/models/' . $this->modelFor('gemini') . ':generateContent?key=' . $key, [
'systemInstruction' => ['parts' => [['text' => $system]]],
'contents' => $contents,
'generationConfig' => ['maxOutputTokens' => 1024],
@@ -398,7 +483,7 @@ TXT;
}
$body = $r->json();
$text = $body['candidates'][0]['content']['parts'][0]['text'] ?? '(răspuns gol)';
return [$text, ['model' => 'gemini-1.5-flash', 'tokens' => $body['usageMetadata'] ?? null]];
return [$text, ['model' => $this->modelFor('gemini'), 'tokens' => $body['usageMetadata'] ?? null]];
}
protected function currentCompany(): ?Company
@@ -420,7 +505,7 @@ TXT;
$r = Http::withHeaders(['content-type' => 'application/json'])
->timeout(60)
->post('https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=' . $key, [
->post('https://generativelanguage.googleapis.com/v1beta/models/' . $this->modelFor('gemini', $company) . ':generateContent?key=' . $key, [
'systemInstruction' => ['parts' => [['text' => $this->buildSystemPrompt($company)]]],
'contents' => $contents,
'generationConfig' => ['maxOutputTokens' => 1024],
@@ -432,7 +517,7 @@ TXT;
$body = $r->json();
$text = $body['candidates'][0]['content']['parts'][0]['text'] ?? '(răspuns gol)';
return [$text, [
'model' => 'gemini-1.5-flash',
'model' => $this->modelFor('gemini', $company),
'tokens' => $body['usageMetadata'] ?? null,
]];
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace App\Services\Ai;
use App\Models\Tenant\Client;
use App\Models\Tenant\Part;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
/**
* Executes tool calls dispatched by the chat AI. Each tool runs against the
* current tenant context (BelongsToTenant scopes apply automatically) and
* returns a small, AI-digestible result (capped row counts, truncated fields).
*/
class AiToolExecutor
{
public const TOOLS = [
[
'name' => 'search_clients',
'description' => 'Caută clienți după nume sau telefon (snippet, case-insensitive). Returnează max 10.',
'input_schema' => [
'type' => 'object',
'properties' => [
'query' => ['type' => 'string', 'description' => 'Fragment nume sau telefon'],
],
'required' => ['query'],
],
],
[
'name' => 'get_vehicle',
'description' => 'Detalii vehicul după placă (plate) sau VIN, plus ultima fișă de lucru.',
'input_schema' => [
'type' => 'object',
'properties' => [
'plate_or_vin' => ['type' => 'string', 'description' => 'Numărul de înmatriculare sau VIN'],
],
'required' => ['plate_or_vin'],
],
],
[
'name' => 'find_parts',
'description' => 'Caută piese în catalog după nume/cod/brand. Returnează max 15 cu stoc și preț.',
'input_schema' => [
'type' => 'object',
'properties' => [
'query' => ['type' => 'string'],
],
'required' => ['query'],
],
],
[
'name' => 'recent_workorders',
'description' => 'Ultimele fișe de lucru deschise/recente. Folosește pentru a răspunde la „ce lucrăm acum".',
'input_schema' => [
'type' => 'object',
'properties' => [
'limit' => ['type' => 'integer', 'description' => 'Câte (max 20)'],
'only_open' => ['type' => 'boolean', 'description' => 'Doar cele necelinate (default true)'],
],
],
],
[
'name' => 'low_stock_parts',
'description' => 'Listează piesele cu stoc sub minimum (alertă reaprovizionare).',
'input_schema' => [
'type' => 'object',
'properties' => [
'limit' => ['type' => 'integer'],
],
],
],
];
/** @return array result payload, JSON-encodable */
public function execute(string $name, array $input): array
{
return match ($name) {
'search_clients' => $this->searchClients((string) ($input['query'] ?? '')),
'get_vehicle' => $this->getVehicle((string) ($input['plate_or_vin'] ?? '')),
'find_parts' => $this->findParts((string) ($input['query'] ?? '')),
'recent_workorders' => $this->recentWorkOrders(
(int) ($input['limit'] ?? 5),
(bool) ($input['only_open'] ?? true),
),
'low_stock_parts' => $this->lowStockParts((int) ($input['limit'] ?? 15)),
default => ['error' => "unknown tool: {$name}"],
};
}
private function searchClients(string $q): array
{
if (trim($q) === '') return ['rows' => []];
$like = '%' . $q . '%';
$rows = Client::where(fn ($w) => $w->where('name', 'like', $like)
->orWhere('phone', 'like', $like)
->orWhere('email', 'like', $like))
->limit(10)
->get(['id', 'name', 'phone', 'email', 'status'])
->toArray();
return ['count' => count($rows), 'rows' => $rows];
}
private function getVehicle(string $key): array
{
if (trim($key) === '') return ['found' => false];
$v = Vehicle::with('client:id,name,phone')
->where(fn ($w) => $w->where('plate', $key)->orWhere('vin', $key))
->first();
if (! $v) return ['found' => false];
$lastWo = WorkOrder::where('vehicle_id', $v->id)
->latest('opened_at')->first(['number', 'opened_at', 'status', 'total']);
return [
'found' => true,
'id' => $v->id,
'make' => $v->make, 'model' => $v->model, 'year' => $v->year,
'plate' => $v->plate, 'vin' => $v->vin, 'mileage' => $v->mileage,
'client' => $v->client ? ['name' => $v->client->name, 'phone' => $v->client->phone] : null,
'last_workorder' => $lastWo,
];
}
private function findParts(string $q): array
{
if (trim($q) === '') return ['rows' => []];
$like = '%' . $q . '%';
$rows = Part::where('is_active', true)
->where(fn ($w) => $w->where('name', 'like', $like)
->orWhere('article', 'like', $like)
->orWhere('brand', 'like', $like))
->limit(15)
->get(['id', 'name', 'article', 'brand', 'category', 'qty', 'unit', 'sell_price'])
->toArray();
return ['count' => count($rows), 'rows' => $rows];
}
private function recentWorkOrders(int $limit, bool $onlyOpen): array
{
$limit = max(1, min(20, $limit));
$q = WorkOrder::with(['client:id,name', 'vehicle:id,plate'])
->orderByDesc('opened_at');
if ($onlyOpen) $q->whereNotIn('status', ['done', 'cancelled']);
$rows = $q->limit($limit)->get(['id', 'number', 'client_id', 'vehicle_id', 'status', 'opened_at', 'total']);
return ['count' => $rows->count(), 'rows' => $rows->map(fn ($w) => [
'number' => $w->number,
'client' => $w->client?->name,
'plate' => $w->vehicle?->plate,
'status' => $w->status,
'opened_at' => $w->opened_at?->toDateString(),
'total' => (float) $w->total,
])->all()];
}
private function lowStockParts(int $limit): array
{
$limit = max(1, min(50, $limit));
$rows = Part::where('is_active', true)
->whereColumn('qty', '<=', 'min_qty')
->orderBy('qty')
->limit($limit)
->get(['id', 'name', 'article', 'qty', 'min_qty', 'unit'])
->toArray();
return ['count' => count($rows), 'rows' => $rows];
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace App\Services\Ai;
use App\Models\Central\Company;
use App\Tenancy\TenantManager;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Extracts structured invoice data from an uploaded image using Claude Vision.
* Output shape (when ok=true):
* data => [
* 'supplier_name' => string|null,
* 'date' => string|null (YYYY-MM-DD),
* 'currency' => string|null,
* 'items' => [{name, qty, unit_price, total?}],
* 'total' => float|null,
* ]
*/
class OcrInvoiceService
{
public const SUPPORTED_MIME = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
public function extract(string $absPath, ?string $mime = null): array
{
if (! is_file($absPath)) {
return ['ok' => false, 'error' => 'Fișierul nu există.'];
}
$mime ??= mime_content_type($absPath) ?: 'image/jpeg';
if (! in_array($mime, self::SUPPORTED_MIME, true)) {
return ['ok' => false, 'error' => "Tip fișier neacceptat: {$mime}. Folosește JPG/PNG/WebP."];
}
$company = $this->currentCompany();
if (! $company) {
return ['ok' => false, 'error' => 'Tenant nerezolvat.'];
}
// Per-tenant rate limit — caps Claude Vision spend even if a user
// accidentally (or maliciously) submits many invoices.
$key = 'ocr-invoice:' . $company->id;
if (\Illuminate\Support\Facades\RateLimiter::tooManyAttempts($key, 30)) {
$retry = \Illuminate\Support\Facades\RateLimiter::availableIn($key);
return ['ok' => false, 'error' => "Prea multe importuri OCR. Reîncearcă în {$retry} sec."];
}
\Illuminate\Support\Facades\RateLimiter::hit($key, 3600); // 30 / hour
$key = data_get($company->settings, 'ai.claude_key');
if (! $key) {
return ['ok' => false, 'error' => '⚠️ Lipsește cheia Claude în Setări → AI.'];
}
$b64 = base64_encode(file_get_contents($absPath));
$r = Http::withHeaders([
'x-api-key' => $key,
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])
->timeout(60)
->post('https://api.anthropic.com/v1/messages', [
'model' => app(AiAssistantService::class)->modelFor('claude', $company),
'max_tokens' => 2048,
'system' => $this->systemPrompt(),
'messages' => [[
'role' => 'user',
'content' => [
['type' => 'image', 'source' => [
'type' => 'base64', 'media_type' => $mime, 'data' => $b64,
]],
['type' => 'text', 'text' => 'Extrage datele din această factură. Răspunde DOAR cu obiectul JSON, fără text suplimentar și fără ```.'],
],
]],
]);
if (! $r->successful()) {
Log::warning('ocr.extract API error', ['status' => $r->status(), 'body' => $r->body()]);
return ['ok' => false, 'error' => 'API Claude: ' . ($r->json('error.message') ?? $r->status())];
}
$body = $r->json();
$text = collect($body['content'] ?? [])
->where('type', 'text')->pluck('text')->implode("\n");
$text = trim($text);
$data = $this->parseJson($text);
if ($data === null) {
return ['ok' => false, 'error' => 'Răspuns AI ne-parsabil ca JSON.', 'raw' => $text];
}
return [
'ok' => true,
'data' => $this->normalize($data),
'raw' => $text,
'tokens' => [
'in' => $body['usage']['input_tokens'] ?? null,
'out' => $body['usage']['output_tokens'] ?? null,
],
];
}
private function systemPrompt(): string
{
return <<<TXT
Ești expert OCR pentru facturi auto-service / piese auto din România/Moldova.
Primești o imagine de factură. Extragi datele într-un obiect JSON STRICT cu schema:
{
"supplier_name": string | null,
"date": string | null, // format YYYY-MM-DD
"currency": string | null, // MDL / EUR / USD / RON
"items": [
{ "name": string, "qty": number, "unit_price": number, "total": number | null }
],
"total": number | null
}
Reguli:
- Răspunde DOAR cu JSON-ul, fără cod de ghilimele markdown.
- Dacă o valoare nu poate fi citită clar, pune null (NU ghicești).
- Cantitățile și prețurile sunt numere zecimale (punct, nu virgulă).
- Numele piesei = scurt, fără TVA/observații.
TXT;
}
/** Tolerate markdown code fences + leading/trailing text around the JSON object. */
private function parseJson(string $text): ?array
{
// Strip markdown fences if present.
$clean = preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $text);
$clean = trim($clean);
$data = json_decode($clean, true);
if (is_array($data)) return $data;
// Fallback: find first {...} block.
if (preg_match('/\{.*\}/s', $clean, $m)) {
$data = json_decode($m[0], true);
if (is_array($data)) return $data;
}
return null;
}
private function normalize(array $d): array
{
$items = [];
foreach ((array) ($d['items'] ?? []) as $it) {
if (! is_array($it)) continue;
$name = trim((string) ($it['name'] ?? ''));
if ($name === '') continue;
$qty = (float) ($it['qty'] ?? 1);
$unit = (float) ($it['unit_price'] ?? 0);
$total = isset($it['total']) ? (float) $it['total'] : round($qty * $unit, 2);
$items[] = ['name' => $name, 'qty' => $qty, 'unit_price' => $unit, 'total' => $total];
}
return [
'supplier_name' => $d['supplier_name'] ?? null,
'date' => $d['date'] ?? null,
'currency' => $d['currency'] ?? null,
'items' => $items,
'total' => isset($d['total']) ? (float) $d['total'] : array_sum(array_column($items, 'total')),
];
}
private function currentCompany(): ?Company
{
$id = app(TenantManager::class)->currentId();
if (! $id) return null;
return Company::withoutGlobalScopes()->find($id);
}
}
+2 -4
View File
@@ -59,10 +59,8 @@ class CompanyProvisioner
$this->tenants->setCurrent($company);
$this->permissions->setPermissionsTeamId($company->id);
// Default roles per tenant.
foreach (['admin', 'manager', 'receptionist', 'mechanic', 'parts_manager', 'accountant', 'marketer'] as $r) {
Role::findOrCreate($r, 'web');
}
// Seed full RBAC catalog: 7 roles + 51 permissions + matrix per TZ.
app(\App\Services\RbacSeeder::class)->seedTenantRoles($company->id);
// Admin user.
$adminEmail = $data['admin_email'] ?? "admin@{$company->slug}.local";
+3 -2
View File
@@ -47,7 +47,7 @@ class CsvImportExport
Vehicle::with('client:id,phone')->orderBy('plate')->chunk(500, function ($rows) use ($out) {
foreach ($rows as $row) {
fputcsv($out, [
$row->plate, $row->vin, $row->brand, $row->model, $row->year,
$row->plate, $row->vin, $row->make, $row->model, $row->year,
$row->engine, $row->gearbox, $row->fuel, $row->mileage,
$row->color, $row->notes, $row->client?->phone,
]);
@@ -108,7 +108,8 @@ class CsvImportExport
'client_id' => $client->id,
'plate' => $row['plate'],
'vin' => $row['vin'] ?? null,
'brand' => $row['brand'] ?? null,
// CSV header keeps the user-friendly "brand" name, but the column is `make`.
'make' => $row['brand'] ?? null,
'model' => $row['model'] ?? null,
'year' => (int) ($row['year'] ?? 0) ?: null,
'engine' => $row['engine'] ?? null,
+231
View File
@@ -0,0 +1,231 @@
<?php
namespace App\Services;
use App\Models\Tenant\Part;
use App\Models\Tenant\Purchase;
use App\Models\Tenant\PurchaseItem;
use App\Models\Tenant\Supplier;
use App\Models\Tenant\SupplierInvoiceMapping;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
/**
* Parses XLSX / CSV files coming from suppliers and turns them into
* Purchase drafts. Mapping (column letters field) is stored per supplier
* so the second import becomes instant.
*
* Three steps:
* 1. headersPreview($path) first 5 rows + detected column letters
* 2. preview($path, $mapping) parse all rows + classify each as
* found (article matches part) / new
* (article exists in file but not in DB) /
* no_article (line has data but no article col)
* 3. import($supplier, $rows) create Purchase + PurchaseItems; auto-creates
* Parts for "new" rows when create_new = true
*/
class ExcelInvoiceImportService
{
/** Read first $maxRows of a spreadsheet for the wizard preview. */
public function headersPreview(string $absPath, int $maxRows = 5): array
{
$sheet = $this->loadSheet($absPath);
$highestColumn = $sheet->getHighestColumn();
$highestColIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn);
$cols = [];
for ($i = 1; $i <= min($highestColIndex, 20); $i++) {
$cols[] = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($i);
}
$rows = [];
for ($r = 1; $r <= $maxRows; $r++) {
$row = [];
foreach ($cols as $col) {
$row[$col] = (string) ($sheet->getCell("$col$r")->getValue() ?? '');
}
$rows[] = $row;
}
return ['columns' => $cols, 'rows' => $rows];
}
/**
* Parse the file according to $mapping and classify each line.
*
* Mapping keys:
* article_col, name_col, qty_col, price_col, brand_col (optional),
* header_row (1-based row number to skip), sheet_name (optional).
*/
public function preview(string $absPath, array $mapping): array
{
$sheet = $this->loadSheet($absPath, $mapping['sheet_name'] ?? null);
$headerRow = (int) ($mapping['header_row'] ?? 1);
$highestRow = $sheet->getHighestRow();
$rows = [];
$articlesInFile = [];
for ($r = $headerRow + 1; $r <= $highestRow; $r++) {
$article = $this->cellString($sheet, $mapping['article_col'] ?? null, $r);
$name = $this->cellString($sheet, $mapping['name_col'] ?? null, $r);
$brand = $this->cellString($sheet, $mapping['brand_col'] ?? null, $r);
$qty = $this->cellDecimal($sheet, $mapping['qty_col'] ?? null, $r);
$price = $this->cellDecimal($sheet, $mapping['price_col'] ?? null, $r);
// Skip totally empty lines
if ($article === '' && $name === '' && $qty <= 0) continue;
$status = 'no_article';
$partId = null;
if ($article !== '') {
$articlesInFile[] = $article;
}
$rows[] = [
'row' => $r,
'article' => $article,
'name' => $name,
'brand' => $brand,
'qty' => $qty,
'price' => $price,
'status' => $status,
'part_id' => $partId,
];
}
// Single batch query for all articles → status
if (! empty($articlesInFile)) {
$existing = Part::whereIn('article', array_unique($articlesInFile))->get()->keyBy('article');
foreach ($rows as &$row) {
if ($row['article'] === '') continue;
if ($existing->has($row['article'])) {
$row['status'] = 'found';
$row['part_id'] = $existing[$row['article']]->id;
} else {
$row['status'] = 'new';
}
}
unset($row);
}
return [
'rows' => $rows,
'summary' => [
'total' => count($rows),
'found' => count(array_filter($rows, fn ($r) => $r['status'] === 'found')),
'new' => count(array_filter($rows, fn ($r) => $r['status'] === 'new')),
'no_article' => count(array_filter($rows, fn ($r) => $r['status'] === 'no_article')),
],
];
}
/**
* Create a draft Purchase + items from the rows. Rows with status='new'
* automatically get a Part created if $createNew is true.
*/
public function import(Supplier $supplier, array $rows, bool $createNew = true): Purchase
{
return DB::transaction(function () use ($supplier, $rows, $createNew) {
$purchase = Purchase::create([
'supplier_id' => $supplier->id,
'number' => $this->generatePurchaseNumber($supplier->company_id),
'order_date' => today(),
'status' => 'ordered',
'total' => 0,
'notes' => 'Auto-import Excel/CSV',
]);
$total = 0;
foreach ($rows as $row) {
$partId = $row['part_id'] ?? null;
if (! $partId && $row['status'] === 'new' && $createNew && $row['article'] !== '') {
$part = Part::create([
'name' => $row['name'] ?: $row['article'],
'article' => $row['article'],
'brand' => $row['brand'] ?? null,
'buy_price' => $row['price'],
'preferred_supplier_id' => $supplier->id,
]);
$partId = $part->id;
}
PurchaseItem::create([
'purchase_id' => $purchase->id,
'part_id' => $partId,
'name' => $row['name'] ?: $row['article'],
'article' => $row['article'],
'qty' => $row['qty'] ?: 1,
'qty_received' => 0,
'buy_price' => $row['price'],
'total' => round(($row['qty'] ?: 1) * $row['price'], 2),
]);
$total += ($row['qty'] ?: 1) * $row['price'];
}
$purchase->update(['total' => round($total, 2)]);
return $purchase;
});
}
/**
* Save mapping config for a supplier so subsequent imports skip the wizard.
*/
public function rememberMapping(Supplier $supplier, array $mapping, ?string $sampleFileName = null): SupplierInvoiceMapping
{
return SupplierInvoiceMapping::updateOrCreate(
['supplier_id' => $supplier->id],
[
'mapping_config' => $mapping,
'sample_file_name' => $sampleFileName,
'last_used_at' => now(),
]
);
}
public function rememberedMappingFor(Supplier $supplier): ?array
{
$m = SupplierInvoiceMapping::where('supplier_id', $supplier->id)->first();
return $m?->mapping_config;
}
private function loadSheet(string $absPath, ?string $sheetName = null)
{
$reader = IOFactory::createReaderForFile($absPath);
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($absPath);
return $sheetName ? $spreadsheet->getSheetByName($sheetName) ?? $spreadsheet->getActiveSheet() : $spreadsheet->getActiveSheet();
}
private function cellString($sheet, ?string $col, int $row): string
{
if (! $col) return '';
$value = $sheet->getCell("$col$row")->getValue();
return trim((string) ($value ?? ''));
}
private function cellDecimal($sheet, ?string $col, int $row): float
{
if (! $col) return 0.0;
$value = $sheet->getCell("$col$row")->getValue();
if ($value === null || $value === '') return 0.0;
// Normalize "1 234,56" / "1,234.56" → 1234.56
// PCRE doesn't support \u{XXXX} — use \x{00A0} (non-breaking space) instead
$clean = preg_replace('/[\s\x{00A0}]/u', '', (string) $value);
$clean = str_replace(',', '.', $clean);
// remove duplicate dots if any
$parts = explode('.', $clean);
if (count($parts) > 2) {
$clean = implode('', array_slice($parts, 0, -1)) . '.' . end($parts);
}
return (float) $clean;
}
private function generatePurchaseNumber(int $companyId): string
{
$year = date('Y');
$last = Purchase::where('company_id', $companyId)
->where('number', 'like', "P-$year-%")
->orderByDesc('id')->first();
$next = 1;
if ($last && preg_match('/P-\d{4}-(\d+)$/', $last->number, $m)) {
$next = (int) $m[1] + 1;
}
return sprintf('P-%s-%04d', $year, $next);
}
}
+61 -5
View File
@@ -47,7 +47,7 @@ class NotificationDispatcher
fn () => Mail::to($client->email)->send(new WorkOrderReadyMail($wo, $company)),
'workOrderReady', ['wo' => $wo->id]
),
]);
], workOrderId: $wo->id);
}
public function paymentReceived(Payment $payment): bool
@@ -62,7 +62,7 @@ class NotificationDispatcher
fn () => Mail::to($client->email)->send(new PaymentReceivedMail($payment, $company)),
'paymentReceived', ['payment' => $payment->id]
),
]);
], workOrderId: $payment->work_order_id);
}
public function appointmentConfirmed(Appointment $a): bool
@@ -95,30 +95,86 @@ class NotificationDispatcher
]);
}
public function tireSeasonalSwap(\App\Models\Tenant\TireSet $set): bool
{
$company = $this->companyFor($set);
$client = $set->client;
if (! $client) return false;
return $this->dispatch($company, $client, 'reminder', [
'telegram' => fn () => $this->tgTireSeasonalSwap($set, $company, $client),
'email' => fn () => $set->vehicle ? $this->emailSafe(
fn () => Mail::to($client->email)->send(new ServiceReminderMail(
$set->vehicle,
'tire_swap',
'E timpul să schimbi anvelopele ' . ($set->season === 'winter' ? 'de iarnă' : 'de vară') .
' (' . $set->sizeLabel() . ').',
$company
)),
'tireSeasonalSwap', ['set' => $set->id]
) : false,
]);
}
protected function tgTireSeasonalSwap(\App\Models\Tenant\TireSet $set, Company $company, Client $client): bool
{
$brand = htmlspecialchars($company->display_name ?? $company->name);
$size = htmlspecialchars($set->sizeLabel());
$seasonRo = $set->season === 'winter' ? 'de iarnă' : 'de vară';
$loc = $set->currentStorage()?->location;
$plate = $set->vehicle?->plate ? ' · ' . htmlspecialchars($set->vehicle->plate) : '';
$text = "🔧 <b>Schimb sezonier anvelope</b>\n"
. "Setul tău {$seasonRo} ({$size}){$plate}"
. ($loc ? " e în depozit la <b>{$loc}</b>." : '.')
. "\n\nProgramează-te la <b>{$brand}</b>.";
return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
}
// ─── Channel dispatch ─────────────────────────────────────────
/**
* @param array<string, callable(): bool> $senders channel-key sender callback
* @return bool Returns the channel name that delivered, or null on full miss.
*/
protected function dispatch(Company $company, Client $client, string $key, array $senders): bool
protected function dispatch(Company $company, Client $client, string $key, array $senders, ?int $workOrderId = null): bool
{
$any = false;
foreach ($this->channelsFor($company, $client, $key) as $channel) {
if (! isset($senders[$channel])) continue;
try {
if (($senders[$channel])() === true) {
$ok = ($senders[$channel])() === true;
$this->logNotification($company->id, $workOrderId, $client->id, $channel, $key, $ok);
if ($ok) {
$any = true;
// Try only one channel — first that succeeds is enough.
break;
}
} catch (\Throwable $e) {
Log::warning("notify.{$key} {$channel} threw", ['err' => $e->getMessage()]);
$this->logNotification($company->id, $workOrderId, $client->id, $channel, $key, false, $e->getMessage());
}
}
return $any;
}
/** Append-only log entry — never throw from here, swallow DB errors. */
protected function logNotification(int $companyId, ?int $workOrderId, ?int $clientId, string $channel, string $key, bool $success, ?string $error = null): void
{
try {
\App\Models\Tenant\ClientNotificationLog::create([
'company_id' => $companyId,
'work_order_id' => $workOrderId,
'client_id' => $clientId,
'channel' => $channel,
'template_key' => $key,
'status' => $success ? 'sent' : 'failed',
'error_detail' => $error,
'sent_at' => now(),
]);
} catch (\Throwable $e) { /* never break sending because of logging */ }
}
/**
* Resolve which channels to try and in what order, applying per-client
* preference if set, otherwise the tenant default.
@@ -54,5 +54,17 @@ class ShopOrderNotifier
$this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
}
}
// ── Customer: email confirmation when address given ──
if ($order->customer_email) {
try {
\Illuminate\Support\Facades\Mail::to($order->customer_email)
->send(new \App\Mail\ShopOrderConfirmationMail($order, $company));
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('shop order confirmation mail failed', [
'order' => $order->id, 'err' => $e->getMessage(),
]);
}
}
}
}
+27 -1
View File
@@ -33,6 +33,8 @@ class PricingEngine
$ctx = [
'class' => $this->vehicleClass($vehicle),
'age' => $this->vehicleAge($vehicle),
'body_type' => $vehicle?->body_type,
'transmission' => $vehicle?->transmission_type,
'vip' => (bool) ($client?->is_vip),
'urgency' => $urgency ?: 'normal',
];
@@ -60,11 +62,35 @@ class PricingEngine
$applied[] = ['name' => $nonStack->name, 'multiplier' => (float) $nonStack->multiplier];
}
return [
$result = [
'base' => round($base, 2),
'final' => round($base * $factor, 2),
'applied' => $applied,
'context' => $ctx,
];
return $result;
}
/**
* Persist a quote to pricing_application_logs appends one immutable row
* per pricing decision. Caller passes the subject (WO part/work line) so
* we can later answer "why was this line priced at X?".
*/
public function logApplication(array $quote, $subject, ?Vehicle $vehicle = null, ?Client $client = null, ?Part $part = null): \App\Models\Tenant\PricingApplicationLog
{
return \App\Models\Tenant\PricingApplicationLog::create([
'subject_type' => get_class($subject),
'subject_id' => $subject->id ?? 0,
'part_id' => $part?->id,
'vehicle_id' => $vehicle?->id,
'client_id' => $client?->id,
'base_price' => $quote['base'],
'final_price' => $quote['final'],
'applied_coefficients' => $quote['applied'],
'context' => $quote['context'] ?? [],
'calculated_at' => now(),
]);
}
private function basePrice(Part $part): float
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Services;
use App\Auth\Permissions;
use App\Models\Tenant\User;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* Seeds the RBAC catalog for a tenant: 50 permissions (global) + 7 system roles
* + role-permission matrix per the TZ.
*
* Idempotent safe to run multiple times. Adding new permissions later is also
* idempotent: each Permission::firstOrCreate skips existing rows.
*/
class RbacSeeder
{
/** Seed permissions globally (they're not tenant-scoped). */
public function seedPermissions(): void
{
foreach (Permissions::all() as $slug) {
Permission::firstOrCreate(['name' => $slug, 'guard_name' => 'web']);
}
}
/** Seed 7 system roles + role-permission matrix for a specific tenant (team). */
public function seedTenantRoles(int $companyId): void
{
$this->seedPermissions();
$registrar = app(PermissionRegistrar::class);
$registrar->setPermissionsTeamId($companyId);
// Bust cache so newly created roles are visible immediately.
$registrar->forgetCachedPermissions();
foreach (Permissions::roleMatrix() as $roleName => $perms) {
$role = Role::firstOrCreate(
['name' => $roleName, 'guard_name' => 'web', 'company_id' => $companyId]
);
$role->syncPermissions($perms);
}
}
/**
* After seeding roles, sync each user's role assignment based on legacy
* users.role string column. Idempotent: re-running just re-syncs.
*/
public function syncUsersToRoles(int $companyId): int
{
$registrar = app(PermissionRegistrar::class);
$registrar->setPermissionsTeamId($companyId);
$registrar->forgetCachedPermissions();
$count = 0;
$validRoles = array_keys(Permissions::roleMatrix());
User::query()
->where('company_id', $companyId)
->whereNotNull('role')
->chunk(100, function ($users) use ($validRoles, &$count) {
foreach ($users as $user) {
// Map legacy role strings to the new catalog
$role = match ($user->role) {
'admin' => 'admin',
'manager' => 'manager',
'receptionist' => 'receptionist',
'mechanic', 'master' => 'mechanic',
'accountant' => 'accountant',
'parts_manager' => 'manager', // map to manager
'marketer' => 'manager',
'owner' => 'owner',
'viewer', 'user' => 'viewer',
default => 'viewer',
};
if (in_array($role, $validRoles, true)) {
$user->syncRoles([$role]);
$count++;
}
}
});
return $count;
}
}
+9 -3
View File
@@ -267,9 +267,9 @@ class WarehouseService
* takes the part from the shelf before the WO is closed. Same logic as
* consume() but scoped to one work_order_part_id.
*/
public function issueNow(WorkOrderPart $wop): int
public function issueNow(WorkOrderPart $wop, ?string $signatureB64 = null, ?string $scanPayload = null): int
{
return DB::transaction(function () use ($wop) {
return DB::transaction(function () use ($wop, $signatureB64, $scanPayload) {
$active = PartReservation::with(['batch', 'part'])
->where('work_order_part_id', $wop->id)
->where('status', PartReservation::STATUS_ACTIVE)
@@ -285,11 +285,17 @@ class WarehouseService
$batch->qty_remaining = (float) $batch->qty_remaining - $take;
$batch->save();
$this->logEvent(
$event = $this->logEvent(
$r->part, $batch, $batch->warehouse,
'issue', -$take, (float) $batch->buy_price,
$wop->workOrder, "WO part #{$wop->id} (issue now)"
);
if ($signatureB64 || $scanPayload) {
$event->forceFill([
'signature_b64' => $signatureB64,
'scan_payload' => $scanPayload,
])->save();
}
$r->status = PartReservation::STATUS_CONSUMED;
$r->consumed_at = now();
+2
View File
@@ -16,6 +16,8 @@
"laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1",
"minishlink/web-push": "^10.0",
"phpoffice/phpspreadsheet": "^5.7",
"resend/resend-laravel": "^1.4",
"spatie/laravel-activitylog": "^5.0",
"spatie/laravel-medialibrary": "^11.22",
"spatie/laravel-permission": "^7.4",
Generated
+422 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "72e35bc95dd2b8489e5a7b77b421d237",
"content-hash": "5b5b5d8a2a2a4bac8ef246a2b165992c",
"packages": [
{
"name": "barryvdh/laravel-dompdf",
@@ -654,6 +654,85 @@
],
"time": "2025-01-03T16:18:33+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "composer/semver",
"version": "3.4.4",
@@ -4210,6 +4289,113 @@
],
"time": "2026-04-11T18:38:28+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.0",
@@ -5190,6 +5376,115 @@
},
"time": "2025-12-30T16:12:18+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
"version": "5.7.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-filter": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.1",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"ext-intl": "*",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.7.0"
},
"time": "2026-04-20T02:42:17+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.5",
@@ -6723,6 +7018,132 @@
],
"time": "2024-06-11T12:45:25+00:00"
},
{
"name": "resend/resend-laravel",
"version": "v1.4.0",
"source": {
"type": "git",
"url": "https://github.com/resend/resend-laravel.git",
"reference": "6dd5f5ec607404068c5af067fd7f6ba4b659262b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/resend/resend-laravel/zipball/6dd5f5ec607404068c5af067fd7f6ba4b659262b",
"reference": "6dd5f5ec607404068c5af067fd7f6ba4b659262b",
"shasum": ""
},
"require": {
"illuminate/http": "^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
"php": "^8.1",
"resend/resend-php": "^1.0.0",
"symfony/mailer": "^6.2|^7.0|^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.14",
"mockery/mockery": "^1.5",
"orchestra/testbench": "^8.17|^9.0|^10.8|^11.0",
"pestphp/pest": "^1.0|^2.0|^3.7|^4.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Resend\\Laravel\\ResendServiceProvider"
]
},
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Resend\\Laravel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Resend and contributors",
"homepage": "https://github.com/resend/resend-laravel/contributors"
}
],
"description": "Resend for Laravel",
"homepage": "https://resend.com/",
"keywords": [
"api",
"client",
"laravel",
"php",
"resend",
"sdk"
],
"support": {
"issues": "https://github.com/resend/resend-laravel/issues",
"source": "https://github.com/resend/resend-laravel/tree/v1.4.0"
},
"time": "2026-05-06T17:08:44+00:00"
},
{
"name": "resend/resend-php",
"version": "v1.3.0",
"source": {
"type": "git",
"url": "https://github.com/resend/resend-php.git",
"reference": "87d29d98271a0ab1c09cdbee102daa2f9b3419db"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/resend/resend-php/zipball/87d29d98271a0ab1c09cdbee102daa2f9b3419db",
"reference": "87d29d98271a0ab1c09cdbee102daa2f9b3419db",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^7.5",
"php": "^8.1.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.13",
"mockery/mockery": "^1.6",
"pestphp/pest": "^1.0|^2.0|^3.0|^4.0"
},
"type": "library",
"autoload": {
"files": [
"src/Resend.php"
],
"psr-4": {
"Resend\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Resend and contributors",
"homepage": "https://github.com/resend/resend-php/contributors"
}
],
"description": "Resend PHP library.",
"homepage": "https://resend.com/",
"keywords": [
"api",
"client",
"php",
"resend",
"sdk"
],
"support": {
"issues": "https://github.com/resend/resend-php/issues",
"source": "https://github.com/resend/resend-php/tree/v1.3.0"
},
"time": "2026-04-11T10:48:32+00:00"
},
{
"name": "ryangjchandler/blade-capture-directive",
"version": "v1.1.1",
+16
View File
@@ -22,6 +22,12 @@ return [
'driver' => 'session',
'provider' => 'super_admins',
],
// Public storefront customer auth (per-tenant).
'shop' => [
'driver' => 'session',
'provider' => 'shop_customers',
],
],
'providers' => [
@@ -33,6 +39,10 @@ return [
'driver' => 'eloquent',
'model' => SuperAdmin::class,
],
'shop_customers' => [
'driver' => 'eloquent',
'model' => \App\Models\Tenant\ShopCustomer::class,
],
],
'passwords' => [
@@ -48,6 +58,12 @@ return [
'expire' => 60,
'throttle' => 60,
],
'shop_customers' => [
'provider' => 'shop_customers',
'table' => 'password_reset_tokens',
'expire' => 60,
'throttle' => 60,
],
],
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
+15
View File
@@ -60,6 +60,21 @@ return [
'report' => false,
],
// Backblaze B2 (S3-compatible). Offsite backup target for backup:tenants.
// Required env: B2_KEY, B2_SECRET, B2_BUCKET, B2_REGION (e.g. us-west-002),
// B2_ENDPOINT (e.g. https://s3.us-west-002.backblazeb2.com).
'b2' => [
'driver' => 's3',
'key' => env('B2_KEY'),
'secret' => env('B2_SECRET'),
'region' => env('B2_REGION', 'us-west-002'),
'bucket' => env('B2_BUCKET'),
'endpoint' => env('B2_ENDPOINT'),
'use_path_style_endpoint' => false,
'throw' => false,
'report' => false,
],
],
/*
@@ -9,18 +9,26 @@ return new class extends Migration
{
public function up(): void
{
// Idempotent: MariaDB has no transactional DDL, so a half-applied prior
// run can leave columns/tables behind without recording the migration.
// Guard each step so a re-run completes instead of erroring on duplicates.
if (! Schema::hasColumn('purchases', 'warehouse_id')) {
Schema::table('purchases', function (Blueprint $t) {
$t->foreignId('warehouse_id')->nullable()->after('supplier_id')
->constrained('warehouses')->nullOnDelete();
});
}
if (! Schema::hasColumn('purchase_items', 'qty_received')) {
Schema::table('purchase_items', function (Blueprint $t) {
$t->decimal('qty_received', 10, 2)->default(0)->after('qty');
});
}
// Backfill: items previously marked `received=true` were fully received.
DB::statement('UPDATE purchase_items SET qty_received = qty WHERE received = 1');
if (! Schema::hasTable('supplier_part_prices')) {
Schema::create('supplier_part_prices', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
@@ -36,6 +44,7 @@ return new class extends Migration
$t->index(['company_id', 'part_id', 'observed_at']);
});
}
}
public function down(): void
{
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shop_customers', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
$t->foreignId('client_id')->nullable()->constrained()->nullOnDelete();
$t->string('name', 160);
$t->string('phone', 40);
$t->string('email', 160)->nullable();
$t->string('password');
$t->dateTime('last_login_at')->nullable();
$t->rememberToken();
$t->timestamps();
$t->softDeletes();
$t->unique(['company_id', 'phone'], 'shop_customers_company_phone_unique');
$t->index(['company_id', 'email']);
});
Schema::table('online_orders', function (Blueprint $t) {
$t->foreignId('shop_customer_id')->nullable()->after('client_id')
->constrained()->nullOnDelete();
$t->index(['company_id', 'shop_customer_id']);
});
}
public function down(): void
{
Schema::table('online_orders', function (Blueprint $t) {
$t->dropForeign(['shop_customer_id']);
$t->dropColumn('shop_customer_id');
});
Schema::dropIfExists('shop_customers');
}
};
@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('deals', function (Blueprint $t) {
if (! Schema::hasColumn('deals', 'urgent')) {
$t->boolean('urgent')->default(false)->after('source');
}
if (! Schema::hasColumn('deals', 'quote_sent_at')) {
$t->timestamp('quote_sent_at')->nullable()->after('urgent');
}
if (! Schema::hasColumn('deals', 'quote_status')) {
$t->string('quote_status', 16)->nullable()->after('quote_sent_at');
}
if (! Schema::hasColumn('deals', 'quote_seen_at')) {
$t->timestamp('quote_seen_at')->nullable()->after('quote_status');
}
if (! Schema::hasColumn('deals', 'scheduled_at')) {
$t->timestamp('scheduled_at')->nullable()->after('quote_seen_at');
}
if (! Schema::hasColumn('deals', 'bay')) {
$t->string('bay', 32)->nullable()->after('scheduled_at');
}
if (! Schema::hasColumn('deals', 'confirmed_at')) {
$t->timestamp('confirmed_at')->nullable()->after('bay');
}
if (! Schema::hasColumn('deals', 'confirmed_via')) {
$t->string('confirmed_via', 16)->nullable()->after('confirmed_at');
}
if (! Schema::hasColumn('deals', 'last_action_at')) {
$t->timestamp('last_action_at')->nullable()->after('confirmed_via');
}
});
}
public function down(): void
{
Schema::table('deals', function (Blueprint $t) {
foreach (['urgent', 'quote_sent_at', 'quote_status', 'quote_seen_at', 'scheduled_at', 'bay', 'confirmed_at', 'confirmed_via', 'last_action_at'] as $col) {
if (Schema::hasColumn('deals', $col)) {
$t->dropColumn($col);
}
}
});
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('posts', function (Blueprint $t) {
if (! Schema::hasColumn('posts', 'hours_per_day')) {
$t->decimal('hours_per_day', 5, 1)->default(10)->after('color');
}
if (! Schema::hasColumn('posts', 'description')) {
$t->string('description', 255)->nullable()->after('hours_per_day');
}
});
Schema::table('parts', function (Blueprint $t) {
if (! Schema::hasColumn('parts', 'hidden_markup_pct')) {
$t->decimal('hidden_markup_pct', 5, 2)->nullable()->after('sell_price');
}
});
}
public function down(): void
{
Schema::table('posts', function (Blueprint $t) {
foreach (['hours_per_day', 'description'] as $col) {
if (Schema::hasColumn('posts', $col)) {
$t->dropColumn($col);
}
}
});
Schema::table('parts', function (Blueprint $t) {
if (Schema::hasColumn('parts', 'hidden_markup_pct')) {
$t->dropColumn('hidden_markup_pct');
}
});
}
};
@@ -0,0 +1,24 @@
<?php
use App\Models\Central\Company;
use App\Services\RbacSeeder;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
$seeder = app(RbacSeeder::class);
$seeder->seedPermissions();
Company::query()->each(function (Company $company) use ($seeder) {
$seeder->seedTenantRoles($company->id);
$seeder->syncUsersToRoles($company->id);
});
}
public function down(): void
{
// Permissions/roles stay — dropping them would break access.
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_permission_overrides', function (Blueprint $t) {
$t->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$t->foreignId('permission_id')->constrained('permissions')->cascadeOnDelete();
$t->string('mode', 8); // 'grant' | 'deny'
$t->text('reason')->nullable();
$t->timestamp('granted_at')->nullable();
$t->foreignId('granted_by_id')->nullable()->constrained('users')->nullOnDelete();
$t->timestamp('expires_at')->nullable();
$t->primary(['user_id', 'permission_id']);
$t->index(['user_id', 'expires_at']);
});
}
public function down(): void
{
Schema::dropIfExists('user_permission_overrides');
}
};
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $t) {
if (! Schema::hasColumn('users', 'invited_at')) {
$t->timestamp('invited_at')->nullable()->after('status');
}
if (! Schema::hasColumn('users', 'invited_by_id')) {
$t->foreignId('invited_by_id')->nullable()->after('invited_at')->constrained('users')->nullOnDelete();
}
if (! Schema::hasColumn('users', 'accepted_at')) {
$t->timestamp('accepted_at')->nullable()->after('invited_by_id');
}
if (! Schema::hasColumn('users', 'invitation_token')) {
$t->string('invitation_token', 80)->nullable()->after('accepted_at');
}
});
// index on token for fast lookup
if (Schema::hasColumn('users', 'invitation_token')) {
try {
Schema::table('users', function (Blueprint $t) {
$t->index('invitation_token', 'users_invitation_token_idx');
});
} catch (\Throwable $e) { /* idempotent */ }
}
}
public function down(): void
{
Schema::table('users', function (Blueprint $t) {
try { $t->dropIndex('users_invitation_token_idx'); } catch (\Throwable $e) {}
foreach (['invitation_token', 'accepted_at', 'invited_by_id', 'invited_at'] as $col) {
if (Schema::hasColumn('users', $col)) {
if ($col === 'invited_by_id') {
try { $t->dropForeign(['invited_by_id']); } catch (\Throwable $e) {}
}
$t->dropColumn($col);
}
}
});
}
};
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('supplier_invoice_mappings', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
$t->foreignId('supplier_id')->constrained('suppliers')->cascadeOnDelete();
$t->json('mapping_config');
// {article_col:"B", name_col:"C", qty_col:"E", price_col:"F",
// brand_col:"D"|null, header_row:2, sheet_name:"Товары"|null}
$t->string('sample_file_name', 200)->nullable();
$t->timestamp('last_used_at')->nullable();
$t->timestamps();
$t->unique(['company_id', 'supplier_id'], 'sim_company_supplier_uniq');
});
}
public function down(): void
{
Schema::dropIfExists('supplier_invoice_mappings');
}
};
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
foreach (['wo_works', 'wo_parts'] as $table) {
Schema::table($table, function (Blueprint $t) use ($table) {
if (! Schema::hasColumn($table, 'requires_approval')) {
$t->boolean('requires_approval')->default(false)->after('status');
}
if (! Schema::hasColumn($table, 'approved_at')) {
$t->timestamp('approved_at')->nullable()->after('requires_approval');
}
if (! Schema::hasColumn($table, 'approval_token')) {
$t->string('approval_token', 32)->nullable()->after('approved_at');
}
if (! Schema::hasColumn($table, 'declined_at')) {
$t->timestamp('declined_at')->nullable()->after('approval_token');
}
});
}
// Index for fast token lookup
try {
Schema::table('wo_works', fn (Blueprint $t) => $t->index('approval_token', 'wow_approval_token_idx'));
} catch (\Throwable $e) {}
try {
Schema::table('wo_parts', fn (Blueprint $t) => $t->index('approval_token', 'wop_approval_token_idx'));
} catch (\Throwable $e) {}
}
public function down(): void
{
foreach (['wo_works', 'wo_parts'] as $table) {
Schema::table($table, function (Blueprint $t) use ($table) {
foreach (['requires_approval', 'approved_at', 'approval_token', 'declined_at'] as $col) {
if (Schema::hasColumn($table, $col)) $t->dropColumn($col);
}
});
}
}
};
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('wo_works', function (Blueprint $t) {
if (! Schema::hasColumn('wo_works', 'mechanic_status')) {
$t->string('mechanic_status', 16)->default('pending')->after('status');
// values: pending | in_progress | paused | done | blocked
}
if (! Schema::hasColumn('wo_works', 'mechanic_started_at')) {
$t->timestamp('mechanic_started_at')->nullable()->after('mechanic_status');
}
if (! Schema::hasColumn('wo_works', 'mechanic_done_at')) {
$t->timestamp('mechanic_done_at')->nullable()->after('mechanic_started_at');
}
if (! Schema::hasColumn('wo_works', 'actual_hours')) {
$t->decimal('actual_hours', 6, 2)->nullable()->after('mechanic_done_at');
}
if (! Schema::hasColumn('wo_works', 'paused_seconds_total')) {
$t->integer('paused_seconds_total')->default(0)->after('actual_hours');
}
if (! Schema::hasColumn('wo_works', 'paused_at')) {
$t->timestamp('paused_at')->nullable()->after('paused_seconds_total');
}
if (! Schema::hasColumn('wo_works', 'block_reason')) {
$t->string('block_reason', 32)->nullable()->after('paused_at');
// values: missing_part | awaiting_approval | broken_equipment | other
}
if (! Schema::hasColumn('wo_works', 'block_note')) {
$t->text('block_note')->nullable()->after('block_reason');
}
});
}
public function down(): void
{
Schema::table('wo_works', function (Blueprint $t) {
foreach (['mechanic_status', 'mechanic_started_at', 'mechanic_done_at', 'actual_hours', 'paused_seconds_total', 'paused_at', 'block_reason', 'block_note'] as $col) {
if (Schema::hasColumn('wo_works', $col)) $t->dropColumn($col);
}
});
}
};
@@ -0,0 +1,103 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// M12: separate body_type + transmission_type on vehicles
Schema::table('vehicles', function (Blueprint $t) {
if (! Schema::hasColumn('vehicles', 'body_type')) {
$t->string('body_type', 16)->nullable()->after('vehicle_class');
// sedan | hatchback | suv | crossover | pickup | van | truck | coupe | wagon | convertible | minivan | moto
}
if (! Schema::hasColumn('vehicles', 'transmission_type')) {
$t->string('transmission_type', 16)->nullable()->after('body_type');
// manual | automatic | cvt | dsg | dct | amt | robot
}
});
// M12: pricing application audit log
Schema::create('pricing_application_logs', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
$t->morphs('subject'); // WorkOrderPart or WorkOrderWork
$t->foreignId('part_id')->nullable()->constrained('parts')->nullOnDelete();
$t->foreignId('vehicle_id')->nullable()->constrained('vehicles')->nullOnDelete();
$t->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete();
$t->decimal('base_price', 12, 2);
$t->decimal('final_price', 12, 2);
$t->json('applied_coefficients'); // [{name, multiplier, type}, ...]
$t->json('context'); // {class, age, body_type, transmission, vip, urgency}
$t->timestamp('calculated_at')->useCurrent();
});
// M14: ocr_jobs queue
Schema::create('ocr_jobs', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
$t->foreignId('supplier_id')->nullable()->constrained('suppliers')->nullOnDelete();
$t->string('source_type', 16); // pdf | image | xlsx | barcode_scan
$t->string('file_path', 500)->nullable(); // storage path
$t->string('status', 16)->default('pending'); // pending|processing|done|failed
$t->json('result')->nullable();
$t->text('error_message')->nullable();
$t->string('ai_provider', 32)->nullable();
$t->integer('tokens_used')->nullable();
$t->foreignId('purchase_id')->nullable()->constrained('purchases')->nullOnDelete();
$t->timestamp('processed_at')->nullable();
$t->timestamps();
$t->index(['company_id', 'status']);
});
// M15: eta_promised distinct from eta_at + change reason audit
Schema::table('work_orders', function (Blueprint $t) {
if (! Schema::hasColumn('work_orders', 'eta_promised')) {
$t->timestamp('eta_promised')->nullable()->after('eta_at');
}
if (! Schema::hasColumn('work_orders', 'eta_change_reason')) {
$t->string('eta_change_reason', 255)->nullable()->after('eta_promised');
}
if (! Schema::hasColumn('work_orders', 'eta_updated_at')) {
$t->timestamp('eta_updated_at')->nullable()->after('eta_change_reason');
}
});
// M15: client notifications log
Schema::create('client_notifications_log', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
$t->foreignId('work_order_id')->nullable()->constrained('work_orders')->nullOnDelete();
$t->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete();
$t->string('channel', 16); // sms | whatsapp | telegram | email | push
$t->string('template_key', 64); // wo_ready | eta_updated | approval_needed | service_reminder
$t->text('message_text')->nullable();
$t->string('status', 16)->default('sent'); // sent | delivered | failed | read
$t->text('error_detail')->nullable();
$t->timestamp('sent_at')->useCurrent();
$t->timestamp('delivered_at')->nullable();
$t->index(['company_id', 'sent_at']);
$t->index(['work_order_id', 'sent_at']);
});
}
public function down(): void
{
Schema::dropIfExists('client_notifications_log');
Schema::dropIfExists('ocr_jobs');
Schema::dropIfExists('pricing_application_logs');
Schema::table('vehicles', function (Blueprint $t) {
foreach (['body_type', 'transmission_type'] as $col) {
if (Schema::hasColumn('vehicles', $col)) $t->dropColumn($col);
}
});
Schema::table('work_orders', function (Blueprint $t) {
foreach (['eta_promised', 'eta_change_reason', 'eta_updated_at'] as $col) {
if (Schema::hasColumn('work_orders', $col)) $t->dropColumn($col);
}
});
}
};
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// M13: work_photos polymorphic table (per work line OR per part line OR WO-level)
Schema::create('work_photos', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
$t->foreignId('work_order_id')->constrained()->cascadeOnDelete();
$t->morphs('subject'); // WorkOrderWork | WorkOrderPart | WorkOrder
$t->foreignId('uploaded_by_id')->nullable()->constrained('users')->nullOnDelete();
$t->string('path', 500); // storage path
$t->string('type', 16)->default('general'); // defect | before | after | general
$t->text('caption')->nullable();
$t->timestamp('taken_at')->nullable();
$t->timestamps();
$t->index(['company_id', 'work_order_id']);
});
// M13: e-signature + barcode scan on warehouse events
Schema::table('warehouse_events', function (Blueprint $t) {
if (! Schema::hasColumn('warehouse_events', 'signature_b64')) {
$t->longText('signature_b64')->nullable();
}
if (! Schema::hasColumn('warehouse_events', 'scan_payload')) {
$t->string('scan_payload', 255)->nullable();
}
});
}
public function down(): void
{
Schema::dropIfExists('work_photos');
Schema::table('warehouse_events', function (Blueprint $t) {
foreach (['signature_b64', 'scan_payload'] as $col) {
if (Schema::hasColumn('warehouse_events', $col)) $t->dropColumn($col);
}
});
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('posts', function (Blueprint $t) {
if (! Schema::hasColumn('posts', 'default_master_id')) {
$t->foreignId('default_master_id')->nullable()->after('hours_per_day')->constrained('users')->nullOnDelete();
}
});
}
public function down(): void
{
Schema::table('posts', function (Blueprint $t) {
if (Schema::hasColumn('posts', 'default_master_id')) {
$t->dropForeign(['default_master_id']);
$t->dropColumn('default_master_id');
}
});
}
};
+21 -2
View File
@@ -21,7 +21,6 @@
"Status": "Status",
"Actions": "Actions",
"Notifications": "Notifications",
"Clienți": "Clients",
"Mașini": "Vehicles",
"Cereri": "Leads",
@@ -53,5 +52,25 @@
"Jurnal": "Audit log",
"Telefonie": "Calls",
"Finanțe": "Finance",
"Site PSauto": "Public site"
"Site PSauto": "Public site",
"Seturi anvelope": "Tire sets",
"Anvelope": "Tires",
"set anvelope": "tire set",
"seturi anvelope": "tire sets",
"Tinichigerie / Detailing": "Body / Detailing",
"Tinichigerie": "Body shop",
"lucrare caroserie": "body job",
"lucrări caroserie": "body jobs",
"Subcontractori": "Subcontractors",
"Subcontractare": "Subcontracting",
"subcontractor": "subcontractor",
"Lucrări terți": "Outsourced jobs",
"lucrare terți": "outsourced job",
"lucrări terți": "outsourced jobs",
"Coeficienți preț": "Pricing coefficients",
"coeficient": "coefficient",
"Magazin": "Shop",
"Clienți magazin": "Shop customers",
"client magazin": "shop customer",
"clienți magazin": "shop customers"
}

Some files were not shown because too many files have changed in this diff Show More