Compare commits
25 Commits
d6a0bfb890
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 80c3834263 | |||
| 2c66547967 | |||
| 03e030d6d2 | |||
| cbcf08b28c | |||
| 0e3119a6e2 | |||
| d9180e16b3 | |||
| 1d4ac3db38 | |||
| 58004b65c4 | |||
| 1d5ea6d261 | |||
| d9b198a235 | |||
| 3c0f3ba39e | |||
| 3603c0e43b | |||
| 0620635abb | |||
| 439ef605a1 | |||
| 51917bcbaf | |||
| 0e3f9e8bca | |||
| 3da1f5412a | |||
| fca4f75e9c | |||
| 75386c354a | |||
| dfb92bf5e2 | |||
| 8fdfc9ef85 | |||
| b9ff9c6583 | |||
| c84ef5d9bd | |||
| 40478dd2aa | |||
| ac7d5b4733 |
+22
-1
@@ -48,7 +48,10 @@ REDIS_DB=0
|
|||||||
# Broadcasting (Reverb — adăugăm la nevoie)
|
# Broadcasting (Reverb — adăugăm la nevoie)
|
||||||
BROADCAST_CONNECTION=log
|
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_MAILER=smtp
|
||||||
MAIL_HOST=autocrm-mailpit
|
MAIL_HOST=autocrm-mailpit
|
||||||
MAIL_PORT=1025
|
MAIL_PORT=1025
|
||||||
@@ -58,11 +61,29 @@ MAIL_ENCRYPTION=null
|
|||||||
MAIL_FROM_ADDRESS="noreply@service.mir.md"
|
MAIL_FROM_ADDRESS="noreply@service.mir.md"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
# Resend API — necesar dacă MAIL_MAILER=resend
|
||||||
|
RESEND_API_KEY=
|
||||||
|
|
||||||
# Web Push (VAPID) — generate with: php artisan push:vapid
|
# Web Push (VAPID) — generate with: php artisan push:vapid
|
||||||
VAPID_SUBJECT=mailto:admin@service.mir.md
|
VAPID_SUBJECT=mailto:admin@service.mir.md
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
VAPID_PRIVATE_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
|
# Storage — local pentru MVP, S3-compatible mai târziu
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
$size = round(filesize($dest) / 1024, 1);
|
||||||
$this->info("✓ {$company->slug} → {$size}KB");
|
$this->info("✓ {$company->slug} → {$size}KB");
|
||||||
|
|
||||||
|
// Offsite copy to B2 (if configured) — disk lazily resolved.
|
||||||
|
$this->uploadOffsite($dest, "{$date}/{$company->slug}.zip");
|
||||||
$ok++;
|
$ok++;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->error("✗ {$company->slug}: {$e->getMessage()}");
|
$this->error("✗ {$company->slug}: {$e->getMessage()}");
|
||||||
@@ -65,6 +68,20 @@ class BackupAllTenantsCommand extends Command
|
|||||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
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
|
private function cleanupOld(int $keep): void
|
||||||
{
|
{
|
||||||
$backupsDir = storage_path('app/backups');
|
$backupsDir = storage_path('app/backups');
|
||||||
|
|||||||
@@ -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
|
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
|
public function getTitle(): string
|
||||||
@@ -50,8 +70,8 @@ class ViewCompany extends Page
|
|||||||
'work_orders_open' => WorkOrder::whereNotIn('status', ['done', 'cancelled'])->count(),
|
'work_orders_open' => WorkOrder::whereNotIn('status', ['done', 'cancelled'])->count(),
|
||||||
'parts' => Part::count(),
|
'parts' => Part::count(),
|
||||||
'parts_low_stock' => Part::where('is_active', true)
|
'parts_low_stock' => Part::where('is_active', true)
|
||||||
->whereColumn('stock', '<=', 'low_stock_threshold')
|
->whereColumn('qty', '<=', 'min_qty')
|
||||||
->where('stock', '>', 0)
|
->where('qty', '>', 0)
|
||||||
->count(),
|
->count(),
|
||||||
'revenue_this_month' => (float) Payment::whereYear('paid_at', date('Y'))
|
'revenue_this_month' => (float) Payment::whereYear('paid_at', date('Y'))
|
||||||
->whereMonth('paid_at', date('m'))->sum('amount'),
|
->whereMonth('paid_at', date('m'))->sum('amount'),
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ use App\Models\Tenant\Post;
|
|||||||
use App\Models\Tenant\User;
|
use App\Models\Tenant\User;
|
||||||
use App\Models\Tenant\Vehicle;
|
use App\Models\Tenant\Vehicle;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Filament\Forms;
|
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Schemas;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
|
|
||||||
class CalendarBoard extends Page
|
class CalendarBoard extends Page
|
||||||
{
|
{
|
||||||
@@ -24,147 +21,503 @@ class CalendarBoard extends Page
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 8;
|
protected static ?int $navigationSort = 8;
|
||||||
|
|
||||||
protected static ?string $title = 'Calendar';
|
protected static ?string $title = 'Calendar vizual';
|
||||||
|
|
||||||
protected string $view = 'filament.tenant.pages.calendar';
|
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;
|
public function getMaxContentWidth(): \Filament\Support\Enums\Width
|
||||||
|
|
||||||
/** Register all forms used by this page (Filament v5 multi-form pattern). */
|
|
||||||
protected function getForms(): array
|
|
||||||
{
|
{
|
||||||
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'])
|
$this->weekStart = Carbon::now()->startOfWeek()->toDateString();
|
||||||
->whereBetween('date', [$start, $end])
|
}
|
||||||
->get()
|
|
||||||
->map(fn (Appointment $a) => [
|
public function shiftWeek(int $deltaWeeks): void
|
||||||
'id' => $a->id,
|
{
|
||||||
'title' => trim($a->title ?: ($a->client?->name ?? '—')),
|
// delta semantic depends on view mode
|
||||||
'start' => $a->date->format('Y-m-d') . 'T' . ($a->time_start ?? '08:00:00'),
|
$current = Carbon::parse($this->weekStart);
|
||||||
'end' => $a->date->format('Y-m-d') . 'T' . ($a->time_end ?? '09:00:00'),
|
$this->weekStart = match ($this->viewMode) {
|
||||||
'backgroundColor' => $a->color ?: ($a->master?->color ?? '#3b82f6'),
|
'day' => $current->addDays($deltaWeeks)->toDateString(),
|
||||||
'borderColor' => $a->color ?: ($a->master?->color ?? '#3b82f6'),
|
'month' => $current->addMonths($deltaWeeks)->startOfMonth()->toDateString(),
|
||||||
'extendedProps' => [
|
default => $current->addWeeks($deltaWeeks)->toDateString(),
|
||||||
'client' => $a->client?->name,
|
};
|
||||||
'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')),
|
}
|
||||||
'plate' => $a->vehicle?->plate,
|
|
||||||
'master' => $a->master?->name,
|
public function setWeekToday(): void
|
||||||
'post' => $a->post?->name,
|
{
|
||||||
'status' => $a->status,
|
$this->weekStart = match ($this->viewMode) {
|
||||||
'notes' => $a->notes,
|
'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();
|
])->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drag-drop reschedule. */
|
/** Returns map [rowId][date] => ['events'=>[], 'load_hours'=>float, 'capacity'=>float] */
|
||||||
public function moveEvent(int $id, string $start, string $end): void
|
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);
|
$a = Appointment::find($id);
|
||||||
if (! $a) return;
|
if (! $a) return;
|
||||||
|
if ($this->groupBy === 'post') {
|
||||||
[$startDate, $startTime] = $this->splitIso($start);
|
$a->post_id = $toRowId ?: null;
|
||||||
[, $endTime] = $this->splitIso($end);
|
} else {
|
||||||
|
$a->master_id = $toRowId ?: null;
|
||||||
$a->update([
|
}
|
||||||
'date' => $startDate,
|
$a->date = $toDate;
|
||||||
'time_start' => $startTime,
|
$a->save();
|
||||||
'time_end' => $endTime,
|
Notification::make()->title('Programare mutată')->body($a->title . ' → ' . $toDate)->success()->send();
|
||||||
]);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Programare mutată')
|
|
||||||
->body($a->title . ' → ' . $startDate . ' ' . substr($startTime, 0, 5))
|
|
||||||
->success()->send();
|
|
||||||
|
|
||||||
$this->dispatch('events-changed');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function quickCreate(string $start, string $end): void
|
public function openEvent(int $id): void
|
||||||
{
|
{
|
||||||
$this->createData = [
|
$this->openEventId = $id;
|
||||||
'date' => substr($start, 0, 10),
|
$this->showNewForm = false;
|
||||||
'time_start' => substr($start, 11, 5),
|
}
|
||||||
'time_end' => substr($end, 11, 5),
|
|
||||||
|
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([
|
$this->openEventId = null;
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteEvent(int $id): void
|
public function deleteEvent(int $id): void
|
||||||
{
|
{
|
||||||
Appointment::where('id', $id)->delete();
|
Appointment::where('id', $id)->delete();
|
||||||
|
$this->openEventId = null;
|
||||||
Notification::make()->title('Programare ștearsă')->success()->send();
|
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"]
|
$masterId = $this->groupBy === 'master' && $rowId ? $rowId : null;
|
||||||
if (str_contains($iso, 'T')) {
|
$postId = $this->groupBy === 'post' && $rowId ? $rowId : null;
|
||||||
return explode('T', $iso);
|
|
||||||
}
|
// Auto-fill default master from post if one is set
|
||||||
return [substr($iso, 0, 10), substr($iso, 11) ?: '08:00:00'];
|
if ($postId && ! $masterId) {
|
||||||
|
$post = Post::find($postId);
|
||||||
|
if ($post && $post->default_master_id) {
|
||||||
|
$masterId = $post->default_master_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
namespace App\Filament\Tenant\Pages;
|
||||||
|
|
||||||
use App\Models\Tenant\WorkOrder;
|
use App\Models\Tenant\WorkOrder;
|
||||||
|
use App\Models\Tenant\WorkOrderWork;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
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
|
public function getCounts(): array
|
||||||
{
|
{
|
||||||
$userId = auth()->id();
|
$userId = auth()->id();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Filament\Tenant\Pages;
|
namespace App\Filament\Tenant\Pages;
|
||||||
|
|
||||||
use App\Models\Tenant\Part;
|
use App\Models\Tenant\Part;
|
||||||
|
use App\Models\Tenant\Purchase;
|
||||||
|
use App\Models\Tenant\PurchaseItem;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
@@ -30,17 +32,89 @@ class Scanner extends Page
|
|||||||
protected string $view = 'filament.tenant.pages.scanner';
|
protected string $view = 'filament.tenant.pages.scanner';
|
||||||
|
|
||||||
public string $manual = '';
|
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')]
|
#[On('scanner-decoded')]
|
||||||
public function decoded(string $text): void
|
public function decoded(string $text): void
|
||||||
{
|
{
|
||||||
$this->resolveAndRedirect(trim($text));
|
$this->process(trim($text));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submitManual(): void
|
public function submitManual(): void
|
||||||
{
|
{
|
||||||
if (trim($this->manual) === '') return;
|
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
|
protected function resolveAndRedirect(string $code): void
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ class Settings extends Page
|
|||||||
'ai_claude_key' => $settings['ai']['claude_key'] ?? null,
|
'ai_claude_key' => $settings['ai']['claude_key'] ?? null,
|
||||||
'ai_gpt_key' => $settings['ai']['gpt_key'] ?? null,
|
'ai_gpt_key' => $settings['ai']['gpt_key'] ?? null,
|
||||||
'ai_gemini_key' => $settings['ai']['gemini_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)'])
|
->options(['claude' => 'Claude (Anthropic)', 'gpt' => 'ChatGPT (OpenAI)', 'gemini' => 'Gemini (Google)'])
|
||||||
->default('claude'),
|
->default('claude'),
|
||||||
Forms\Components\TextInput::make('ai_claude_key')->label('Claude API Key')->password()->revealable()->placeholder('sk-ant-...'),
|
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\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\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');
|
->statePath('data');
|
||||||
@@ -246,6 +261,11 @@ class Settings extends Page
|
|||||||
'claude_key' => $data['ai_claude_key'] ?? null,
|
'claude_key' => $data['ai_claude_key'] ?? null,
|
||||||
'gpt_key' => $data['ai_gpt_key'] ?? null,
|
'gpt_key' => $data['ai_gpt_key'] ?? null,
|
||||||
'gemini_key' => $data['ai_gemini_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
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -22,10 +22,15 @@ class DealResource extends Resource
|
|||||||
|
|
||||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-funnel';
|
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';
|
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 $modelLabel = 'deal';
|
||||||
|
|
||||||
protected static ?string $pluralModelLabel = 'deal-uri';
|
protected static ?string $pluralModelLabel = 'deal-uri';
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ class ExpenseResource extends Resource
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 51;
|
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
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->components([
|
return $schema->components([
|
||||||
|
|||||||
@@ -99,6 +99,21 @@ class PartResource extends Resource
|
|||||||
->options(fn () => Supplier::pluck('name', 'id'))
|
->options(fn () => Supplier::pluck('name', 'id'))
|
||||||
->searchable(),
|
->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),
|
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -107,6 +122,11 @@ class PartResource extends Resource
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->columns([
|
->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('name')->searchable()->sortable()->wrap(),
|
||||||
Tables\Columns\TextColumn::make('article')->label('Cod')->searchable()->copyable()->placeholder('—'),
|
Tables\Columns\TextColumn::make('article')->label('Cod')->searchable()->copyable()->placeholder('—'),
|
||||||
Tables\Columns\TextColumn::make('brand')->placeholder('—'),
|
Tables\Columns\TextColumn::make('brand')->placeholder('—'),
|
||||||
|
|||||||
@@ -30,6 +30,21 @@ class PaymentResource extends Resource
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 50;
|
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
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->components([
|
return $schema->components([
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ class PayrollAdjustmentResource extends Resource
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 54;
|
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
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->components([
|
return $schema->components([
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ class PayrollRunResource extends Resource
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 53;
|
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
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->components([
|
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)
|
->options(PricingCoefficient::VEHICLE_CLASSES)
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->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_min')->label('Vârstă min (ani)')->numeric(),
|
||||||
Forms\Components\TextInput::make('conditions.age_max')->label('Vârstă max (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'),
|
Forms\Components\Toggle::make('conditions.client_vip')->label('Doar clienți VIP'),
|
||||||
@@ -97,6 +107,27 @@ class PricingCoefficientResource extends Resource
|
|||||||
->defaultSort('priority');
|
->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
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -3,8 +3,15 @@
|
|||||||
namespace App\Filament\Tenant\Resources\PurchaseResource\Pages;
|
namespace App\Filament\Tenant\Resources\PurchaseResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\PurchaseResource;
|
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\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class ListPurchases extends ListRecords
|
class ListPurchases extends ListRecords
|
||||||
{
|
{
|
||||||
@@ -12,6 +19,78 @@ class ListPurchases extends ListRecords
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
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;
|
||||||
|
}
|
||||||
+38
@@ -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');
|
->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
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -73,6 +73,27 @@ class SubcontractorResource extends Resource
|
|||||||
->defaultSort('name');
|
->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
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
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
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Tenant\Resources;
|
namespace App\Filament\Tenant\Resources;
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\UserResource\Pages;
|
use App\Filament\Tenant\Resources\UserResource\Pages;
|
||||||
|
use App\Filament\Tenant\Resources\UserResource\RelationManagers;
|
||||||
use App\Models\Tenant\User;
|
use App\Models\Tenant\User;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
@@ -31,8 +32,17 @@ class UserResource extends Resource
|
|||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
$u = auth()->user();
|
return auth()->user()?->canDo(\App\Auth\Permissions::ADMIN_USERS_VIEW) ?? false;
|
||||||
return $u && $u->role === 'admin';
|
}
|
||||||
|
|
||||||
|
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
|
public static function form(Schema $schema): Schema
|
||||||
@@ -53,17 +63,10 @@ class UserResource extends Resource
|
|||||||
->schema([
|
->schema([
|
||||||
Forms\Components\Select::make('role')
|
Forms\Components\Select::make('role')
|
||||||
->label('Rol primar')
|
->label('Rol primar')
|
||||||
->options([
|
->options(\App\Auth\Permissions::roleLabels())
|
||||||
'admin' => 'Administrator',
|
|
||||||
'manager' => 'Manager',
|
|
||||||
'receptionist' => 'Recepție',
|
|
||||||
'mechanic' => 'Mecanic',
|
|
||||||
'parts_manager' => 'Magazioner piese',
|
|
||||||
'accountant' => 'Contabil',
|
|
||||||
'marketer' => 'Marketing',
|
|
||||||
])
|
|
||||||
->required()
|
->required()
|
||||||
->default('mechanic'),
|
->default('mechanic')
|
||||||
|
->helperText('Rolul principal — sincronizat automat cu drepturile RBAC.'),
|
||||||
Forms\Components\Select::make('status')
|
Forms\Components\Select::make('status')
|
||||||
->options(['active' => 'Activ', 'inactive' => 'Inactiv', 'blocked' => 'Blocat'])
|
->options(['active' => 'Activ', 'inactive' => 'Inactiv', 'blocked' => 'Blocat'])
|
||||||
->default('active')
|
->default('active')
|
||||||
@@ -76,6 +79,26 @@ class UserResource extends Resource
|
|||||||
->dehydrateStateUsing(fn ($state) => Hash::make($state))
|
->dehydrateStateUsing(fn ($state) => Hash::make($state))
|
||||||
->minLength(6)
|
->minLength(6)
|
||||||
->helperText('La editare lasă gol pentru a păstra parola actuală.'),
|
->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('name')->searchable()->sortable(),
|
||||||
Tables\Columns\TextColumn::make('email')->searchable()->copyable(),
|
Tables\Columns\TextColumn::make('email')->searchable()->copyable(),
|
||||||
Tables\Columns\TextColumn::make('phone')->placeholder('—'),
|
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')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->colors([
|
->colors([
|
||||||
@@ -109,11 +154,41 @@ class UserResource extends Resource
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\EditAction::make(),
|
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(),
|
Actions\DeleteAction::make(),
|
||||||
])
|
])
|
||||||
->defaultSort('created_at', 'desc');
|
->defaultSort('created_at', 'desc');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
RelationManagers\PermissionOverridesRelationManager::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -8,4 +8,23 @@ use Filament\Resources\Pages\CreateRecord;
|
|||||||
class CreateUser extends CreateRecord
|
class CreateUser extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = UserResource::class;
|
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()];
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+90
@@ -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ă.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -155,11 +155,13 @@ class ShopController extends Controller
|
|||||||
if (empty($cart)) return redirect('/shop');
|
if (empty($cart)) return redirect('/shop');
|
||||||
|
|
||||||
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
|
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
|
||||||
|
$customer = \Illuminate\Support\Facades\Auth::guard('shop')->user();
|
||||||
|
|
||||||
return view('shop.checkout', [
|
return view('shop.checkout', [
|
||||||
'tenant' => $tenant,
|
'tenant' => $tenant,
|
||||||
'cart' => $cart,
|
'cart' => $cart,
|
||||||
'subtotal' => $subtotal,
|
'subtotal' => $subtotal,
|
||||||
|
'customer' => $customer,
|
||||||
'deliveryOptions' => (array) data_get($tenant->settings, 'shop.delivery_methods', ['pickup']),
|
'deliveryOptions' => (array) data_get($tenant->settings, 'shop.delivery_methods', ['pickup']),
|
||||||
'cartCount' => $this->cartCount(),
|
'cartCount' => $this->cartCount(),
|
||||||
]);
|
]);
|
||||||
@@ -188,9 +190,13 @@ class ShopController extends Controller
|
|||||||
$deliveryFee = ($freeOver > 0 && $subtotal >= $freeOver) ? 0.0 : $fee;
|
$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([
|
$order = OnlineOrder::create([
|
||||||
'number' => OnlineOrder::generateNumber($tenant->id),
|
'number' => OnlineOrder::generateNumber($tenant->id),
|
||||||
|
'shop_customer_id' => $shopCustomer?->id,
|
||||||
|
'client_id' => $shopCustomer?->client_id,
|
||||||
'customer_name' => $data['customer_name'],
|
'customer_name' => $data['customer_name'],
|
||||||
'customer_phone' => $data['customer_phone'],
|
'customer_phone' => $data['customer_phone'],
|
||||||
'customer_email' => $data['customer_email'] ?? null,
|
'customer_email' => $data['customer_email'] ?? null,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Tenant\WorkOrder;
|
use App\Models\Tenant\WorkOrder;
|
||||||
|
use App\Models\Tenant\WorkOrderPart;
|
||||||
|
use App\Models\Tenant\WorkOrderWork;
|
||||||
use App\Tenancy\TenantManager;
|
use App\Tenancy\TenantManager;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
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.
|
* 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)
|
public function show(Request $request, string $token)
|
||||||
{
|
{
|
||||||
@@ -21,7 +21,7 @@ class TrackingController extends Controller
|
|||||||
throw new NotFoundHttpException('Tracking only available on tenant subdomain.');
|
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)
|
->where('tracking_token', $token)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -29,10 +29,119 @@ class TrackingController extends Controller
|
|||||||
throw new NotFoundHttpException('Fișa nu a fost găsită.');
|
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', [
|
return view('tracking.show', [
|
||||||
'wo' => $wo,
|
'wo' => $wo,
|
||||||
'tenant' => $tenant,
|
'tenant' => $tenant,
|
||||||
'photos' => $wo->getMedia('photos'),
|
'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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ class Company extends BaseTenant implements HasMedia
|
|||||||
return [
|
return [
|
||||||
'id',
|
'id',
|
||||||
'slug', 'name', 'display_name', 'city', 'phone', 'email', 'contact_name',
|
'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',
|
'trial_ends_at', 'active_until',
|
||||||
'settings',
|
'settings',
|
||||||
'created_at', 'updated_at', 'deleted_at',
|
'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); }
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ class Deal extends Model
|
|||||||
|
|
||||||
public const STAGES = [
|
public const STAGES = [
|
||||||
'new' => 'Nou',
|
'new' => 'Nou',
|
||||||
'contact' => 'Contact',
|
'contact' => 'Calculație',
|
||||||
'agree' => 'Aprobare',
|
'agree' => 'Aprobare',
|
||||||
'scheduled' => 'Programat',
|
'scheduled' => 'Programat',
|
||||||
'arrived' => 'Sosit',
|
'arrived' => 'Sosit',
|
||||||
@@ -23,18 +23,48 @@ class Deal extends Model
|
|||||||
'lost' => 'Pierdut',
|
'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 = [
|
protected $fillable = [
|
||||||
'company_id', 'client_id', 'vehicle_id',
|
'company_id', 'client_id', 'vehicle_id',
|
||||||
'name', 'price', 'stage', 'source', 'note',
|
'name', 'price', 'stage', 'source', 'note',
|
||||||
'assigned_to', 'won_at', 'lost_at', 'lost_reason',
|
'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 = [
|
protected $casts = [
|
||||||
'price' => 'decimal:2',
|
'price' => 'decimal:2',
|
||||||
'won_at' => 'datetime',
|
'won_at' => 'datetime',
|
||||||
'lost_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
|
public function client(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Client::class);
|
return $this->belongsTo(Client::class);
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ class OnlineOrder extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected $fillable = [
|
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',
|
'customer_name', 'customer_phone', 'customer_email',
|
||||||
'delivery_method', 'address', 'status',
|
'delivery_method', 'address', 'status',
|
||||||
'subtotal', 'delivery_fee', 'total', 'notes',
|
'subtotal', 'delivery_fee', 'total', 'notes',
|
||||||
@@ -51,6 +51,11 @@ class OnlineOrder extends Model
|
|||||||
return $this->belongsTo(Client::class);
|
return $this->belongsTo(Client::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function shopCustomer(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ShopCustomer::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function trackingUrl(): string
|
public function trackingUrl(): string
|
||||||
{
|
{
|
||||||
return url('/shop/order/' . $this->tracking_token);
|
return url('/shop/order/' . $this->tracking_token);
|
||||||
|
|||||||
@@ -7,10 +7,35 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
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 = [
|
public const CATEGORIES = [
|
||||||
'Ulei', 'Filtre', 'Frâne', 'Suspensie', 'Lichide',
|
'Ulei', 'Filtre', 'Frâne', 'Suspensie', 'Lichide',
|
||||||
@@ -20,7 +45,7 @@ class Part extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'company_id', 'name', 'article', 'brand', 'category',
|
'company_id', 'name', 'article', 'brand', 'category',
|
||||||
'qty', 'qty_reserved', 'unit', 'min_qty',
|
'qty', 'qty_reserved', 'unit', 'min_qty',
|
||||||
'buy_price', 'sell_price',
|
'buy_price', 'sell_price', 'hidden_markup_pct',
|
||||||
'location', 'barcode', 'preferred_supplier_id',
|
'location', 'barcode', 'preferred_supplier_id',
|
||||||
'is_active', 'is_published', 'notes',
|
'is_active', 'is_published', 'notes',
|
||||||
];
|
];
|
||||||
@@ -31,10 +56,19 @@ class Part extends Model
|
|||||||
'min_qty' => 'decimal:2',
|
'min_qty' => 'decimal:2',
|
||||||
'buy_price' => 'decimal:2',
|
'buy_price' => 'decimal:2',
|
||||||
'sell_price' => 'decimal:2',
|
'sell_price' => 'decimal:2',
|
||||||
|
'hidden_markup_pct' => 'decimal:2',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'is_published' => '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
|
public function preferredSupplier(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Supplier::class, 'preferred_supplier_id');
|
return $this->belongsTo(Supplier::class, 'preferred_supplier_id');
|
||||||
|
|||||||
@@ -10,14 +10,20 @@ class Post extends Model
|
|||||||
{
|
{
|
||||||
use BelongsToTenant;
|
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 = [
|
protected $casts = [
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
|
'hours_per_day' => 'decimal:1',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function appointments(): HasMany
|
public function appointments(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Appointment::class);
|
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); }
|
||||||
|
}
|
||||||
@@ -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.
|
// Vehicle age range.
|
||||||
if (isset($c['age_min']) && $c['age_min'] !== null && $c['age_min'] !== '') {
|
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;
|
if (($ctx['age'] ?? null) === null || $ctx['age'] < (int) $c['age_min']) return false;
|
||||||
|
|||||||
@@ -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
@@ -11,6 +11,7 @@ use Filament\Panel;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
use Spatie\Permission\Traits\HasRoles;
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
@@ -34,6 +35,7 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication
|
|||||||
'email_verified_at', 'password', 'last_login_at',
|
'email_verified_at', 'password', 'last_login_at',
|
||||||
'email_authentication_at',
|
'email_authentication_at',
|
||||||
'app_authentication_secret', 'app_authentication_recovery_codes',
|
'app_authentication_secret', 'app_authentication_recovery_codes',
|
||||||
|
'invited_at', 'invited_by_id', 'accepted_at', 'invitation_token',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
@@ -46,6 +48,8 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication
|
|||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'last_login_at' => 'datetime',
|
'last_login_at' => 'datetime',
|
||||||
'email_authentication_at' => 'datetime',
|
'email_authentication_at' => 'datetime',
|
||||||
|
'invited_at' => 'datetime',
|
||||||
|
'accepted_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'app_authentication_secret' => 'encrypted',
|
'app_authentication_secret' => 'encrypted',
|
||||||
'app_authentication_recovery_codes' => 'encrypted:array',
|
'app_authentication_recovery_codes' => 'encrypted:array',
|
||||||
@@ -60,7 +64,147 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication
|
|||||||
|
|
||||||
public function isAdmin(): bool
|
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
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,27 @@ class Vehicle extends Model
|
|||||||
{
|
{
|
||||||
use Auditable, BelongsToTenant, SoftDeletes;
|
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 = [
|
protected $fillable = [
|
||||||
'company_id', 'client_id',
|
'company_id', 'client_id',
|
||||||
'make', 'model', 'year', 'vin', 'plate',
|
'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
|
public function client(): BelongsTo
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class WarehouseEvent extends Model
|
|||||||
'type', 'qty_delta', 'unit_cost',
|
'type', 'qty_delta', 'unit_cost',
|
||||||
'ref_type', 'ref_id', 'user_id',
|
'ref_type', 'ref_id', 'user_id',
|
||||||
'occurred_at', 'notes',
|
'occurred_at', 'notes',
|
||||||
|
'signature_b64', 'scan_payload',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ class WorkOrder extends Model implements HasMedia
|
|||||||
'complaint', 'diagnosis', 'recommendations',
|
'complaint', 'diagnosis', 'recommendations',
|
||||||
'status', 'urgency', 'pay_status', 'approved', 'approved_at',
|
'status', 'urgency', 'pay_status', 'approved', 'approved_at',
|
||||||
'discount_pct', 'total',
|
'discount_pct', 'total',
|
||||||
'eta_at', 'tracking_token',
|
'eta_at', 'eta_promised', 'eta_change_reason', 'eta_updated_at',
|
||||||
|
'tracking_token',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -48,6 +49,8 @@ class WorkOrder extends Model implements HasMedia
|
|||||||
'closed_at' => 'date',
|
'closed_at' => 'date',
|
||||||
'approved_at' => 'datetime',
|
'approved_at' => 'datetime',
|
||||||
'eta_at' => 'datetime',
|
'eta_at' => 'datetime',
|
||||||
|
'eta_promised' => 'datetime',
|
||||||
|
'eta_updated_at' => 'datetime',
|
||||||
'approved' => 'boolean',
|
'approved' => 'boolean',
|
||||||
'discount_pct' => 'decimal:2',
|
'discount_pct' => 'decimal:2',
|
||||||
'total' => 'decimal:2',
|
'total' => 'decimal:2',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class WorkOrderPart extends Model
|
|||||||
'name', 'article', 'brand',
|
'name', 'article', 'brand',
|
||||||
'qty', 'unit', 'buy_price', 'sell_price',
|
'qty', 'unit', 'buy_price', 'sell_price',
|
||||||
'discount_pct', 'total', 'status', 'notes',
|
'discount_pct', 'total', 'status', 'notes',
|
||||||
|
'requires_approval', 'approved_at', 'approval_token', 'declined_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -32,8 +33,16 @@ class WorkOrderPart extends Model
|
|||||||
'sell_price' => 'decimal:2',
|
'sell_price' => 'decimal:2',
|
||||||
'discount_pct' => 'decimal:2',
|
'discount_pct' => 'decimal:2',
|
||||||
'total' => '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
|
public function workOrder(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(WorkOrder::class);
|
return $this->belongsTo(WorkOrder::class);
|
||||||
@@ -50,6 +59,9 @@ class WorkOrderPart extends Model
|
|||||||
$sub = (float) $row->qty * (float) $row->sell_price;
|
$sub = (float) $row->qty * (float) $row->sell_price;
|
||||||
$disc = (float) $row->discount_pct;
|
$disc = (float) $row->discount_pct;
|
||||||
$row->total = round($sub * (1 - $disc / 100), 2);
|
$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.
|
// Reserve batches as soon as a catalog-linked part line is created.
|
||||||
|
|||||||
@@ -12,23 +12,144 @@ class WorkOrderWork extends Model
|
|||||||
|
|
||||||
protected $table = 'wo_works';
|
protected $table = 'wo_works';
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'mechanic_status' => 'pending',
|
||||||
|
'paused_seconds_total' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
public const STATUSES = [
|
public const STATUSES = [
|
||||||
'todo' => 'De făcut',
|
'todo' => 'De făcut',
|
||||||
'in_progress' => 'În lucru',
|
'in_progress' => 'În lucru',
|
||||||
'done' => 'Finalizat',
|
'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 = [
|
protected $fillable = [
|
||||||
'company_id', 'work_order_id', 'labor_id', 'master_id',
|
'company_id', 'work_order_id', 'labor_id', 'master_id',
|
||||||
'name', 'hours', 'price_per_hour', 'total', 'status', 'notes',
|
'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 = [
|
protected $casts = [
|
||||||
'hours' => 'decimal:2',
|
'hours' => 'decimal:2',
|
||||||
'price_per_hour' => 'decimal:2',
|
'price_per_hour' => 'decimal:2',
|
||||||
'total' => '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
|
public function workOrder(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(WorkOrder::class);
|
return $this->belongsTo(WorkOrder::class);
|
||||||
@@ -48,6 +169,9 @@ class WorkOrderWork extends Model
|
|||||||
{
|
{
|
||||||
static::saving(function (self $row) {
|
static::saving(function (self $row) {
|
||||||
$row->total = round((float) $row->hours * (float) $row->price_per_hour, 2);
|
$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::saved(fn (self $row) => $row->workOrder?->recalcTotal());
|
||||||
static::deleted(fn (self $row) => $row->workOrder?->recalcTotal());
|
static::deleted(fn (self $row) => $row->workOrder?->recalcTotal());
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,37 @@ use Illuminate\Support\Facades\Http;
|
|||||||
*/
|
*/
|
||||||
class AiAssistantService
|
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
|
public function ask(AiChat $chat, string $userMessage): AiMessage
|
||||||
{
|
{
|
||||||
// Persist user message.
|
// Persist user message.
|
||||||
@@ -117,36 +148,90 @@ TXT;
|
|||||||
protected function callClaude(string $key, AiChat $chat, string $msg, Company $company): array
|
protected function callClaude(string $key, AiChat $chat, string $msg, Company $company): array
|
||||||
{
|
{
|
||||||
$messages = $this->historyMessages($chat);
|
$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)));
|
$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,
|
'x-api-key' => $key,
|
||||||
'anthropic-version' => '2023-06-01',
|
'anthropic-version' => '2023-06-01',
|
||||||
'content-type' => 'application/json',
|
'content-type' => 'application/json',
|
||||||
])
|
];
|
||||||
->timeout(60)
|
$system = $this->buildSystemPrompt($company);
|
||||||
->post('https://api.anthropic.com/v1/messages', [
|
$tools = AiToolExecutor::TOOLS;
|
||||||
'model' => 'claude-sonnet-4-5',
|
$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,
|
'max_tokens' => 1024,
|
||||||
'system' => $this->buildSystemPrompt($company),
|
'system' => $system,
|
||||||
|
'tools' => $tools,
|
||||||
'messages' => $messages,
|
'messages' => $messages,
|
||||||
]);
|
]
|
||||||
|
);
|
||||||
|
|
||||||
if (! $r->successful()) {
|
if (! $r->successful()) {
|
||||||
return ['❌ ' . ($r->json('error.message') ?? 'Anthropic API error ' . $r->status()), ['status' => $r->status()]];
|
return ['❌ ' . ($r->json('error.message') ?? 'Anthropic API error ' . $r->status()), ['status' => $r->status()]];
|
||||||
}
|
}
|
||||||
|
|
||||||
$body = $r->json();
|
$body = $r->json();
|
||||||
$text = collect($body['content'] ?? [])
|
$model = $body['model'] ?? $model;
|
||||||
->where('type', 'text')
|
$tokensIn += (int) ($body['usage']['input_tokens'] ?? 0);
|
||||||
->pluck('text')
|
$tokensOut += (int) ($body['usage']['output_tokens'] ?? 0);
|
||||||
->implode("\n");
|
|
||||||
|
|
||||||
return [$text ?: '(răspuns gol)', [
|
$blocks = $body['content'] ?? [];
|
||||||
'model' => $body['model'] ?? null,
|
$finalText = collect($blocks)->where('type', 'text')->pluck('text')->implode("\n");
|
||||||
'tokens_in' => $body['usage']['input_tokens'] ?? null,
|
|
||||||
'tokens_out' => $body['usage']['output_tokens'] ?? null,
|
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'])
|
$r = Http::withHeaders(['Authorization' => 'Bearer ' . $key, 'content-type' => 'application/json'])
|
||||||
->timeout(60)
|
->timeout(60)
|
||||||
->post('https://api.openai.com/v1/chat/completions', [
|
->post('https://api.openai.com/v1/chat/completions', [
|
||||||
'model' => 'gpt-4o-mini',
|
'model' => $this->modelFor('gpt', $company),
|
||||||
'messages' => $messages,
|
'messages' => $messages,
|
||||||
'max_tokens' => 1024,
|
'max_tokens' => 1024,
|
||||||
]);
|
]);
|
||||||
@@ -336,7 +421,7 @@ TXT;
|
|||||||
])
|
])
|
||||||
->timeout(60)
|
->timeout(60)
|
||||||
->post('https://api.anthropic.com/v1/messages', [
|
->post('https://api.anthropic.com/v1/messages', [
|
||||||
'model' => 'claude-sonnet-4-5',
|
'model' => $this->modelFor('claude'),
|
||||||
'max_tokens' => 1024,
|
'max_tokens' => 1024,
|
||||||
'system' => $system,
|
'system' => $system,
|
||||||
'messages' => $messages,
|
'messages' => $messages,
|
||||||
@@ -359,7 +444,7 @@ TXT;
|
|||||||
$r = Http::withHeaders(['Authorization' => 'Bearer ' . $key, 'content-type' => 'application/json'])
|
$r = Http::withHeaders(['Authorization' => 'Bearer ' . $key, 'content-type' => 'application/json'])
|
||||||
->timeout(60)
|
->timeout(60)
|
||||||
->post('https://api.openai.com/v1/chat/completions', [
|
->post('https://api.openai.com/v1/chat/completions', [
|
||||||
'model' => 'gpt-4o-mini',
|
'model' => $this->modelFor('gpt'),
|
||||||
'messages' => array_merge([['role' => 'system', 'content' => $system]], $messages),
|
'messages' => array_merge([['role' => 'system', 'content' => $system]], $messages),
|
||||||
'max_tokens' => 1024,
|
'max_tokens' => 1024,
|
||||||
]);
|
]);
|
||||||
@@ -388,7 +473,7 @@ TXT;
|
|||||||
}
|
}
|
||||||
$r = Http::withHeaders(['content-type' => 'application/json'])
|
$r = Http::withHeaders(['content-type' => 'application/json'])
|
||||||
->timeout(60)
|
->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]]],
|
'systemInstruction' => ['parts' => [['text' => $system]]],
|
||||||
'contents' => $contents,
|
'contents' => $contents,
|
||||||
'generationConfig' => ['maxOutputTokens' => 1024],
|
'generationConfig' => ['maxOutputTokens' => 1024],
|
||||||
@@ -398,7 +483,7 @@ TXT;
|
|||||||
}
|
}
|
||||||
$body = $r->json();
|
$body = $r->json();
|
||||||
$text = $body['candidates'][0]['content']['parts'][0]['text'] ?? '(răspuns gol)';
|
$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
|
protected function currentCompany(): ?Company
|
||||||
@@ -420,7 +505,7 @@ TXT;
|
|||||||
|
|
||||||
$r = Http::withHeaders(['content-type' => 'application/json'])
|
$r = Http::withHeaders(['content-type' => 'application/json'])
|
||||||
->timeout(60)
|
->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)]]],
|
'systemInstruction' => ['parts' => [['text' => $this->buildSystemPrompt($company)]]],
|
||||||
'contents' => $contents,
|
'contents' => $contents,
|
||||||
'generationConfig' => ['maxOutputTokens' => 1024],
|
'generationConfig' => ['maxOutputTokens' => 1024],
|
||||||
@@ -432,7 +517,7 @@ TXT;
|
|||||||
$body = $r->json();
|
$body = $r->json();
|
||||||
$text = $body['candidates'][0]['content']['parts'][0]['text'] ?? '(răspuns gol)';
|
$text = $body['candidates'][0]['content']['parts'][0]['text'] ?? '(răspuns gol)';
|
||||||
return [$text, [
|
return [$text, [
|
||||||
'model' => 'gemini-1.5-flash',
|
'model' => $this->modelFor('gemini', $company),
|
||||||
'tokens' => $body['usageMetadata'] ?? null,
|
'tokens' => $body['usageMetadata'] ?? null,
|
||||||
]];
|
]];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,10 +59,8 @@ class CompanyProvisioner
|
|||||||
$this->tenants->setCurrent($company);
|
$this->tenants->setCurrent($company);
|
||||||
$this->permissions->setPermissionsTeamId($company->id);
|
$this->permissions->setPermissionsTeamId($company->id);
|
||||||
|
|
||||||
// Default roles per tenant.
|
// Seed full RBAC catalog: 7 roles + 51 permissions + matrix per TZ.
|
||||||
foreach (['admin', 'manager', 'receptionist', 'mechanic', 'parts_manager', 'accountant', 'marketer'] as $r) {
|
app(\App\Services\RbacSeeder::class)->seedTenantRoles($company->id);
|
||||||
Role::findOrCreate($r, 'web');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin user.
|
// Admin user.
|
||||||
$adminEmail = $data['admin_email'] ?? "admin@{$company->slug}.local";
|
$adminEmail = $data['admin_email'] ?? "admin@{$company->slug}.local";
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class CsvImportExport
|
|||||||
Vehicle::with('client:id,phone')->orderBy('plate')->chunk(500, function ($rows) use ($out) {
|
Vehicle::with('client:id,phone')->orderBy('plate')->chunk(500, function ($rows) use ($out) {
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
fputcsv($out, [
|
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->engine, $row->gearbox, $row->fuel, $row->mileage,
|
||||||
$row->color, $row->notes, $row->client?->phone,
|
$row->color, $row->notes, $row->client?->phone,
|
||||||
]);
|
]);
|
||||||
@@ -108,7 +108,8 @@ class CsvImportExport
|
|||||||
'client_id' => $client->id,
|
'client_id' => $client->id,
|
||||||
'plate' => $row['plate'],
|
'plate' => $row['plate'],
|
||||||
'vin' => $row['vin'] ?? null,
|
'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,
|
'model' => $row['model'] ?? null,
|
||||||
'year' => (int) ($row['year'] ?? 0) ?: null,
|
'year' => (int) ($row['year'] ?? 0) ?: null,
|
||||||
'engine' => $row['engine'] ?? null,
|
'engine' => $row['engine'] ?? null,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ class NotificationDispatcher
|
|||||||
fn () => Mail::to($client->email)->send(new WorkOrderReadyMail($wo, $company)),
|
fn () => Mail::to($client->email)->send(new WorkOrderReadyMail($wo, $company)),
|
||||||
'workOrderReady', ['wo' => $wo->id]
|
'workOrderReady', ['wo' => $wo->id]
|
||||||
),
|
),
|
||||||
]);
|
], workOrderId: $wo->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function paymentReceived(Payment $payment): bool
|
public function paymentReceived(Payment $payment): bool
|
||||||
@@ -62,7 +62,7 @@ class NotificationDispatcher
|
|||||||
fn () => Mail::to($client->email)->send(new PaymentReceivedMail($payment, $company)),
|
fn () => Mail::to($client->email)->send(new PaymentReceivedMail($payment, $company)),
|
||||||
'paymentReceived', ['payment' => $payment->id]
|
'paymentReceived', ['payment' => $payment->id]
|
||||||
),
|
),
|
||||||
]);
|
], workOrderId: $payment->work_order_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function appointmentConfirmed(Appointment $a): bool
|
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 ─────────────────────────────────────────
|
// ─── Channel dispatch ─────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, callable(): bool> $senders channel-key → sender callback
|
* @param array<string, callable(): bool> $senders channel-key → sender callback
|
||||||
* @return bool Returns the channel name that delivered, or null on full miss.
|
* @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;
|
$any = false;
|
||||||
foreach ($this->channelsFor($company, $client, $key) as $channel) {
|
foreach ($this->channelsFor($company, $client, $key) as $channel) {
|
||||||
if (! isset($senders[$channel])) continue;
|
if (! isset($senders[$channel])) continue;
|
||||||
try {
|
try {
|
||||||
if (($senders[$channel])() === true) {
|
$ok = ($senders[$channel])() === true;
|
||||||
|
$this->logNotification($company->id, $workOrderId, $client->id, $channel, $key, $ok);
|
||||||
|
if ($ok) {
|
||||||
$any = true;
|
$any = true;
|
||||||
// Try only one channel — first that succeeds is enough.
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::warning("notify.{$key} {$channel} threw", ['err' => $e->getMessage()]);
|
Log::warning("notify.{$key} {$channel} threw", ['err' => $e->getMessage()]);
|
||||||
|
$this->logNotification($company->id, $workOrderId, $client->id, $channel, $key, false, $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $any;
|
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
|
* Resolve which channels to try and in what order, applying per-client
|
||||||
* preference if set, otherwise the tenant default.
|
* preference if set, otherwise the tenant default.
|
||||||
|
|||||||
@@ -54,5 +54,17 @@ class ShopOrderNotifier
|
|||||||
$this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
|
$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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ class PricingEngine
|
|||||||
$ctx = [
|
$ctx = [
|
||||||
'class' => $this->vehicleClass($vehicle),
|
'class' => $this->vehicleClass($vehicle),
|
||||||
'age' => $this->vehicleAge($vehicle),
|
'age' => $this->vehicleAge($vehicle),
|
||||||
|
'body_type' => $vehicle?->body_type,
|
||||||
|
'transmission' => $vehicle?->transmission_type,
|
||||||
'vip' => (bool) ($client?->is_vip),
|
'vip' => (bool) ($client?->is_vip),
|
||||||
'urgency' => $urgency ?: 'normal',
|
'urgency' => $urgency ?: 'normal',
|
||||||
];
|
];
|
||||||
@@ -60,11 +62,35 @@ class PricingEngine
|
|||||||
$applied[] = ['name' => $nonStack->name, 'multiplier' => (float) $nonStack->multiplier];
|
$applied[] = ['name' => $nonStack->name, 'multiplier' => (float) $nonStack->multiplier];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
$result = [
|
||||||
'base' => round($base, 2),
|
'base' => round($base, 2),
|
||||||
'final' => round($base * $factor, 2),
|
'final' => round($base * $factor, 2),
|
||||||
'applied' => $applied,
|
'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
|
private function basePrice(Part $part): float
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -267,9 +267,9 @@ class WarehouseService
|
|||||||
* takes the part from the shelf before the WO is closed. Same logic as
|
* takes the part from the shelf before the WO is closed. Same logic as
|
||||||
* consume() but scoped to one work_order_part_id.
|
* 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'])
|
$active = PartReservation::with(['batch', 'part'])
|
||||||
->where('work_order_part_id', $wop->id)
|
->where('work_order_part_id', $wop->id)
|
||||||
->where('status', PartReservation::STATUS_ACTIVE)
|
->where('status', PartReservation::STATUS_ACTIVE)
|
||||||
@@ -285,11 +285,17 @@ class WarehouseService
|
|||||||
$batch->qty_remaining = (float) $batch->qty_remaining - $take;
|
$batch->qty_remaining = (float) $batch->qty_remaining - $take;
|
||||||
$batch->save();
|
$batch->save();
|
||||||
|
|
||||||
$this->logEvent(
|
$event = $this->logEvent(
|
||||||
$r->part, $batch, $batch->warehouse,
|
$r->part, $batch, $batch->warehouse,
|
||||||
'issue', -$take, (float) $batch->buy_price,
|
'issue', -$take, (float) $batch->buy_price,
|
||||||
$wop->workOrder, "WO part #{$wop->id} (issue now)"
|
$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->status = PartReservation::STATUS_CONSUMED;
|
||||||
$r->consumed_at = now();
|
$r->consumed_at = now();
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
"laravel/sanctum": "^4.3",
|
"laravel/sanctum": "^4.3",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"minishlink/web-push": "^10.0",
|
"minishlink/web-push": "^10.0",
|
||||||
|
"phpoffice/phpspreadsheet": "^5.7",
|
||||||
|
"resend/resend-laravel": "^1.4",
|
||||||
"spatie/laravel-activitylog": "^5.0",
|
"spatie/laravel-activitylog": "^5.0",
|
||||||
"spatie/laravel-medialibrary": "^11.22",
|
"spatie/laravel-medialibrary": "^11.22",
|
||||||
"spatie/laravel-permission": "^7.4",
|
"spatie/laravel-permission": "^7.4",
|
||||||
|
|||||||
Generated
+422
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "72e35bc95dd2b8489e5a7b77b421d237",
|
"content-hash": "5b5b5d8a2a2a4bac8ef246a2b165992c",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "barryvdh/laravel-dompdf",
|
"name": "barryvdh/laravel-dompdf",
|
||||||
@@ -654,6 +654,85 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-01-03T16:18:33+00:00"
|
"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",
|
"name": "composer/semver",
|
||||||
"version": "3.4.4",
|
"version": "3.4.4",
|
||||||
@@ -4210,6 +4289,113 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-04-11T18:38:28+00:00"
|
"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",
|
"name": "masterminds/html5",
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
@@ -5190,6 +5376,115 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-12-30T16:12:18+00:00"
|
"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",
|
"name": "phpoption/phpoption",
|
||||||
"version": "1.9.5",
|
"version": "1.9.5",
|
||||||
@@ -6723,6 +7018,132 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-06-11T12:45:25+00:00"
|
"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",
|
"name": "ryangjchandler/blade-capture-directive",
|
||||||
"version": "v1.1.1",
|
"version": "v1.1.1",
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ return [
|
|||||||
'driver' => 'session',
|
'driver' => 'session',
|
||||||
'provider' => 'super_admins',
|
'provider' => 'super_admins',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Public storefront customer auth (per-tenant).
|
||||||
|
'shop' => [
|
||||||
|
'driver' => 'session',
|
||||||
|
'provider' => 'shop_customers',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'providers' => [
|
'providers' => [
|
||||||
@@ -33,6 +39,10 @@ return [
|
|||||||
'driver' => 'eloquent',
|
'driver' => 'eloquent',
|
||||||
'model' => SuperAdmin::class,
|
'model' => SuperAdmin::class,
|
||||||
],
|
],
|
||||||
|
'shop_customers' => [
|
||||||
|
'driver' => 'eloquent',
|
||||||
|
'model' => \App\Models\Tenant\ShopCustomer::class,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'passwords' => [
|
'passwords' => [
|
||||||
@@ -48,6 +58,12 @@ return [
|
|||||||
'expire' => 60,
|
'expire' => 60,
|
||||||
'throttle' => 60,
|
'throttle' => 60,
|
||||||
],
|
],
|
||||||
|
'shop_customers' => [
|
||||||
|
'provider' => 'shop_customers',
|
||||||
|
'table' => 'password_reset_tokens',
|
||||||
|
'expire' => 60,
|
||||||
|
'throttle' => 60,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||||
|
|||||||
@@ -60,6 +60,21 @@ return [
|
|||||||
'report' => false,
|
'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
|
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) {
|
Schema::table('purchases', function (Blueprint $t) {
|
||||||
$t->foreignId('warehouse_id')->nullable()->after('supplier_id')
|
$t->foreignId('warehouse_id')->nullable()->after('supplier_id')
|
||||||
->constrained('warehouses')->nullOnDelete();
|
->constrained('warehouses')->nullOnDelete();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Schema::hasColumn('purchase_items', 'qty_received')) {
|
||||||
Schema::table('purchase_items', function (Blueprint $t) {
|
Schema::table('purchase_items', function (Blueprint $t) {
|
||||||
$t->decimal('qty_received', 10, 2)->default(0)->after('qty');
|
$t->decimal('qty_received', 10, 2)->default(0)->after('qty');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Backfill: items previously marked `received=true` were fully received.
|
// Backfill: items previously marked `received=true` were fully received.
|
||||||
DB::statement('UPDATE purchase_items SET qty_received = qty WHERE received = 1');
|
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) {
|
Schema::create('supplier_part_prices', function (Blueprint $t) {
|
||||||
$t->id();
|
$t->id();
|
||||||
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
|
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
|
||||||
@@ -36,6 +44,7 @@ return new class extends Migration
|
|||||||
$t->index(['company_id', 'part_id', 'observed_at']);
|
$t->index(['company_id', 'part_id', 'observed_at']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function down(): void
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+42
@@ -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
@@ -21,7 +21,6 @@
|
|||||||
"Status": "Status",
|
"Status": "Status",
|
||||||
"Actions": "Actions",
|
"Actions": "Actions",
|
||||||
"Notifications": "Notifications",
|
"Notifications": "Notifications",
|
||||||
|
|
||||||
"Clienți": "Clients",
|
"Clienți": "Clients",
|
||||||
"Mașini": "Vehicles",
|
"Mașini": "Vehicles",
|
||||||
"Cereri": "Leads",
|
"Cereri": "Leads",
|
||||||
@@ -53,5 +52,25 @@
|
|||||||
"Jurnal": "Audit log",
|
"Jurnal": "Audit log",
|
||||||
"Telefonie": "Calls",
|
"Telefonie": "Calls",
|
||||||
"Finanțe": "Finance",
|
"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
Reference in New Issue
Block a user