Compare commits
39 Commits
59409e1b11
..
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 | |||
| d6a0bfb890 | |||
| 5e255b7b40 | |||
| e8078f157a | |||
| 94938f24d7 | |||
| a1be01b0d5 | |||
| c90c35d930 | |||
| 954ba8f059 | |||
| c413004930 | |||
| e48ef1b755 | |||
| 1ff888131f | |||
| 85ef2f6e00 | |||
| a2026f640a | |||
| 426156fe45 | |||
| edcdba9d53 |
+27
-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,6 +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
|
||||||
|
VAPID_SUBJECT=mailto:admin@service.mir.md
|
||||||
|
VAPID_PUBLIC_KEY=
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
|
||||||
|
# Internal health monitor → Telegram alerts every 10 min on DB/cache/storage/backup failure.
|
||||||
|
# Create a separate bot at @BotFather and a private group; put the bot in it
|
||||||
|
# and use the group's chat_id (negative number).
|
||||||
|
HEALTH_ALERT_BOT_TOKEN=
|
||||||
|
HEALTH_ALERT_CHAT_ID=
|
||||||
|
|
||||||
|
# Backblaze B2 (S3-compatible) — offsite backup target for backup:tenants.
|
||||||
|
# Creează un bucket privat + Application Key cu acces la el. Fără aceste env
|
||||||
|
# vars, backup-urile rămân doar pe VPS (single point of failure).
|
||||||
|
B2_KEY=
|
||||||
|
B2_SECRET=
|
||||||
|
B2_BUCKET=
|
||||||
|
B2_REGION=us-west-002
|
||||||
|
B2_ENDPOINT=https://s3.us-west-002.backblazeb2.com
|
||||||
|
|
||||||
# Storage — local pentru MVP, S3-compatible mai târziu
|
# Storage — local pentru MVP, S3-compatible mai târziu
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -41,7 +41,10 @@ RUN install-php-extensions \
|
|||||||
opcache \
|
opcache \
|
||||||
pcntl \
|
pcntl \
|
||||||
sockets \
|
sockets \
|
||||||
exif
|
exif \
|
||||||
|
curl \
|
||||||
|
mbstring \
|
||||||
|
gmp
|
||||||
|
|
||||||
# System tools
|
# System tools
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
|||||||
@@ -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,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Minishlink\WebPush\VAPID;
|
||||||
|
|
||||||
|
class GenerateVapidKeysCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'push:vapid';
|
||||||
|
|
||||||
|
protected $description = 'Generate a VAPID keypair for Web Push and print the .env lines.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$keys = VAPID::createVapidKeys();
|
||||||
|
|
||||||
|
$this->info('VAPID keys generated. Add these to your .env:');
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('VAPID_SUBJECT=mailto:admin@service.mir.md');
|
||||||
|
$this->line('VAPID_PUBLIC_KEY=' . $keys['publicKey']);
|
||||||
|
$this->line('VAPID_PRIVATE_KEY=' . $keys['privateKey']);
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn('Keep the private key secret. Re-generating invalidates existing subscriptions.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Central\Company;
|
||||||
|
use App\Models\Tenant\Supplier;
|
||||||
|
use App\Services\Warehouse\SupplierAnalytics;
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class RateSuppliersCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'suppliers:rate
|
||||||
|
{--days=90 : Look-back window in days}
|
||||||
|
{--slug= : Only one tenant by slug}';
|
||||||
|
|
||||||
|
protected $description = 'Recompute auto-rating for every supplier based on on-time deliveries, speed and volume.';
|
||||||
|
|
||||||
|
public function handle(SupplierAnalytics $analytics): int
|
||||||
|
{
|
||||||
|
$query = Company::query()->where('status', '!=', 'archived');
|
||||||
|
if ($slug = $this->option('slug')) {
|
||||||
|
$query->where('slug', $slug);
|
||||||
|
}
|
||||||
|
$companies = $query->get();
|
||||||
|
$days = (int) $this->option('days');
|
||||||
|
|
||||||
|
$totalUpdated = 0;
|
||||||
|
|
||||||
|
foreach ($companies as $company) {
|
||||||
|
app(TenantManager::class)->setCurrent($company);
|
||||||
|
|
||||||
|
$suppliers = Supplier::where('is_active', true)->get();
|
||||||
|
$changed = 0;
|
||||||
|
foreach ($suppliers as $supplier) {
|
||||||
|
$score = $analytics->computedRating($supplier, $days);
|
||||||
|
if ($score !== null && $score !== (int) $supplier->rating) {
|
||||||
|
$supplier->rating = $score;
|
||||||
|
$supplier->saveQuietly();
|
||||||
|
$changed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf('[%s] suppliers rated, %d updated', $company->slug, $changed));
|
||||||
|
$totalUpdated += $changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Total suppliers updated: {$totalUpdated}");
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Central\Company;
|
||||||
|
use App\Models\Tenant\ServiceReminderSent;
|
||||||
|
use App\Models\Tenant\Vehicle;
|
||||||
|
use App\Models\Tenant\WorkOrder;
|
||||||
|
use App\Services\NotificationDispatcher;
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class SendServiceRemindersCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'reminders:send
|
||||||
|
{--slug= : Only one tenant by slug}
|
||||||
|
{--dry-run : Show candidates without sending}';
|
||||||
|
|
||||||
|
protected $description = 'Scan vehicles for due service reminders and send via configured channels.';
|
||||||
|
|
||||||
|
public function handle(NotificationDispatcher $dispatcher): int
|
||||||
|
{
|
||||||
|
$query = Company::query()->where('status', '!=', 'archived');
|
||||||
|
if ($slug = $this->option('slug')) {
|
||||||
|
$query->where('slug', $slug);
|
||||||
|
}
|
||||||
|
$companies = $query->get();
|
||||||
|
$dry = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
$totalSent = 0;
|
||||||
|
|
||||||
|
foreach ($companies as $company) {
|
||||||
|
app(TenantManager::class)->setCurrent($company);
|
||||||
|
|
||||||
|
$settings = (array) ($company->settings ?? []);
|
||||||
|
$reminderDays = (int) data_get($settings, 'reminder.after_days', 365);
|
||||||
|
$cooldownDays = (int) data_get($settings, 'reminder.cooldown_days', 30);
|
||||||
|
|
||||||
|
$cutoff = Carbon::now()->subDays($reminderDays);
|
||||||
|
$cooldown = Carbon::now()->subDays($cooldownDays);
|
||||||
|
|
||||||
|
// Pick vehicles whose last *closed* WO was before $cutoff (or never).
|
||||||
|
$vehicles = Vehicle::with('client')
|
||||||
|
->whereHas('client', fn ($q) => $q->where('status', 'active'))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$sentThisTenant = 0;
|
||||||
|
foreach ($vehicles as $v) {
|
||||||
|
$lastClosedAt = WorkOrder::where('vehicle_id', $v->id)
|
||||||
|
->whereNotNull('closed_at')
|
||||||
|
->max('closed_at');
|
||||||
|
|
||||||
|
if (! $lastClosedAt) continue; // never serviced — skip (handled by other logic)
|
||||||
|
if (Carbon::parse($lastClosedAt)->gt($cutoff)) continue;
|
||||||
|
|
||||||
|
$recent = ServiceReminderSent::where('vehicle_id', $v->id)
|
||||||
|
->where('sent_at', '>=', $cooldown)
|
||||||
|
->exists();
|
||||||
|
if ($recent) continue;
|
||||||
|
|
||||||
|
if ($dry) {
|
||||||
|
$this->line(" - [{$company->slug}] Vehicle #{$v->id} {$v->plate} last serviced {$lastClosedAt}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = $dispatcher->serviceReminder($v, 'general');
|
||||||
|
if ($ok) {
|
||||||
|
ServiceReminderSent::create([
|
||||||
|
'company_id' => $company->id,
|
||||||
|
'vehicle_id' => $v->id,
|
||||||
|
'client_id' => $v->client_id,
|
||||||
|
'channel' => $v->client?->telegram_chat_id ? 'telegram' : 'email',
|
||||||
|
'type' => 'general',
|
||||||
|
'sent_at' => now(),
|
||||||
|
]);
|
||||||
|
$sentThisTenant++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf('[%s] reminders sent: %d', $company->slug, $sentThisTenant));
|
||||||
|
$totalSent += $sentThisTenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Total reminders sent: {$totalSent}" . ($dry ? ' (dry run)' : ''));
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ class AiAssistant extends Page
|
|||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getUsage(): array
|
||||||
|
{
|
||||||
|
return app(AiAssistantService::class)->monthlyUsage();
|
||||||
|
}
|
||||||
|
|
||||||
public function newChat(): void
|
public function newChat(): void
|
||||||
{
|
{
|
||||||
$chat = AiChat::create([
|
$chat = AiChat::create([
|
||||||
|
|||||||
@@ -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
|
||||||
|
{
|
||||||
|
// delta semantic depends on view mode
|
||||||
|
$current = Carbon::parse($this->weekStart);
|
||||||
|
$this->weekStart = match ($this->viewMode) {
|
||||||
|
'day' => $current->addDays($deltaWeeks)->toDateString(),
|
||||||
|
'month' => $current->addMonths($deltaWeeks)->startOfMonth()->toDateString(),
|
||||||
|
default => $current->addWeeks($deltaWeeks)->toDateString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setWeekToday(): void
|
||||||
|
{
|
||||||
|
$this->weekStart = match ($this->viewMode) {
|
||||||
|
'day' => Carbon::today()->toDateString(),
|
||||||
|
'month' => Carbon::now()->startOfMonth()->toDateString(),
|
||||||
|
default => Carbon::now()->startOfWeek()->toDateString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setGroupBy(string $g): void
|
||||||
|
{
|
||||||
|
$this->groupBy = in_array($g, ['post', 'master'], true) ? $g : 'post';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setViewMode(string $m): void
|
||||||
|
{
|
||||||
|
if (! in_array($m, ['day', 'week', 'month', 'list', 'custom'], true)) return;
|
||||||
|
$this->viewMode = $m;
|
||||||
|
// Snap weekStart to a sensible anchor for the new view
|
||||||
|
$this->weekStart = match ($m) {
|
||||||
|
'day' => Carbon::today()->toDateString(),
|
||||||
|
'month' => Carbon::parse($this->weekStart)->startOfMonth()->toDateString(),
|
||||||
|
'custom' => $this->customStart ?: Carbon::today()->toDateString(),
|
||||||
|
default => Carbon::parse($this->weekStart)->startOfWeek()->toDateString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStatusFilter(string $s): void
|
||||||
|
{
|
||||||
|
$this->statusFilter = in_array($s, ['all', 'confirmed', 'unconfirmed', 'in_work'], true) ? $s : 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMasterFilter($id): void
|
||||||
|
{
|
||||||
|
$this->masterFilter = $id ? (int) $id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build day headers — count varies by view mode. */
|
||||||
|
public function getDays(): array
|
||||||
|
{
|
||||||
|
$today = Carbon::today()->toDateString();
|
||||||
|
$names = ['Luni', 'Marți', 'Miercuri', 'Joi', 'Vineri', 'Sâmbătă', 'Duminică'];
|
||||||
|
$start = Carbon::parse($this->weekStart);
|
||||||
|
|
||||||
|
$count = match ($this->viewMode) {
|
||||||
|
'day' => 1,
|
||||||
|
'month' => $start->daysInMonth,
|
||||||
|
'custom' => $this->customStart && $this->customEnd
|
||||||
|
? max(1, min(31, Carbon::parse($this->customStart)->diffInDays(Carbon::parse($this->customEnd)) + 1))
|
||||||
|
: 7,
|
||||||
|
default => 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($this->viewMode === 'month') {
|
||||||
|
$start = Carbon::parse($this->weekStart)->startOfMonth();
|
||||||
|
}
|
||||||
|
if ($this->viewMode === 'custom' && $this->customStart) {
|
||||||
|
$start = Carbon::parse($this->customStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = [];
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$d = $start->copy()->addDays($i);
|
||||||
|
$dow = (int) $d->dayOfWeek; // 0=Sunday, 6=Saturday in Carbon
|
||||||
|
$isoDow = (int) $d->isoWeekday(); // 1=Mon..7=Sun
|
||||||
|
$days[] = [
|
||||||
|
'date' => $d->toDateString(),
|
||||||
|
'label' => $d->format('d.m'),
|
||||||
|
'name' => $names[($isoDow - 1) % 7],
|
||||||
|
'is_today' => $d->toDateString() === $today,
|
||||||
|
'is_weekend' => $isoDow >= 6,
|
||||||
|
'is_closed' => $isoDow === 7, // Sunday default
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $days;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows: either Posts or active Masters depending on $groupBy. */
|
||||||
|
public function getRows(): array
|
||||||
|
{
|
||||||
|
if ($this->groupBy === 'master') {
|
||||||
|
$rows = User::query()
|
||||||
|
->where('status', 'active')
|
||||||
|
->whereNotNull('role')
|
||||||
|
->where(function ($q) { $q->where('role', 'master')->orWhere('role', 'mecanic')->orWhereNull('role'); })
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'color', 'specialization'])
|
||||||
|
->map(fn ($u) => [
|
||||||
|
'kind' => 'master',
|
||||||
|
'id' => $u->id,
|
||||||
|
'name' => $u->name,
|
||||||
|
'color' => $u->color ?: '#3b82f6',
|
||||||
|
'meta' => $u->specialization ?: '8h/zi',
|
||||||
|
'capacity_hours' => 8.0,
|
||||||
|
])->all();
|
||||||
|
// Always include "Fără maistru" row at the bottom
|
||||||
|
$rows[] = ['kind' => 'master', 'id' => 0, 'name' => 'Fără maistru', 'color' => '#94a3b8', 'meta' => '—', 'capacity_hours' => 0];
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = Post::where('is_active', true)->orderBy('sort_order')->orderBy('name')->get();
|
||||||
|
// Fallback: synthesize a default post if none configured yet
|
||||||
|
if ($posts->isEmpty()) {
|
||||||
|
return [
|
||||||
|
['kind' => 'post', 'id' => 0, 'name' => 'Pod 1 (default)', 'color' => '#3b82f6', 'meta' => '10h/zi', 'capacity_hours' => 10.0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $posts->map(fn ($p) => [
|
||||||
|
'kind' => 'post',
|
||||||
|
'id' => $p->id,
|
||||||
|
'name' => $p->name,
|
||||||
|
'color' => $p->color ?: '#3b82f6',
|
||||||
|
'meta' => ($p->hours_per_day ? $p->hours_per_day . 'h/zi' : '10h/zi') . ($p->description ? ' · ' . $p->description : ''),
|
||||||
|
'capacity_hours' => (float) ($p->hours_per_day ?: 10),
|
||||||
|
])->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns map [rowId][date] => ['events'=>[], 'load_hours'=>float, 'capacity'=>float] */
|
||||||
|
public function getMatrix(): array
|
||||||
|
{
|
||||||
|
$start = $this->weekStart;
|
||||||
|
$end = Carbon::parse($this->weekStart)->addDays(6)->toDateString();
|
||||||
|
|
||||||
|
$q = Appointment::with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name,color', 'post:id,name,color'])
|
||||||
|
->whereBetween('date', [$start, $end]);
|
||||||
|
|
||||||
|
if ($this->masterFilter) {
|
||||||
|
$q->where('master_id', $this->masterFilter);
|
||||||
|
}
|
||||||
|
if ($this->statusFilter === 'confirmed') {
|
||||||
|
$q->where('status', 'arrived');
|
||||||
|
} elseif ($this->statusFilter === 'unconfirmed') {
|
||||||
|
$q->where('status', 'scheduled');
|
||||||
|
} elseif ($this->statusFilter === 'in_work') {
|
||||||
|
$q->where('status', 'in_work');
|
||||||
|
}
|
||||||
|
|
||||||
|
$events = $q->get();
|
||||||
|
$rows = $this->getRows();
|
||||||
|
$days = $this->getDays();
|
||||||
|
|
||||||
|
$matrix = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
foreach ($days as $day) {
|
||||||
|
$matrix[$row['id']][$day['date']] = ['events' => [], 'load_hours' => 0, 'capacity' => $row['capacity_hours']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($events as $a) {
|
||||||
|
$rowId = $this->groupBy === 'post' ? ($a->post_id ?: ($rows[0]['id'] ?? 0)) : ($a->master_id ?: 0);
|
||||||
|
if (! isset($matrix[$rowId])) continue;
|
||||||
|
$date = $a->date->toDateString();
|
||||||
|
if (! isset($matrix[$rowId][$date])) continue;
|
||||||
|
|
||||||
|
$hours = $this->calcHours($a->time_start, $a->time_end);
|
||||||
|
$matrix[$rowId][$date]['load_hours'] += $hours;
|
||||||
|
$matrix[$rowId][$date]['events'][] = [
|
||||||
'id' => $a->id,
|
'id' => $a->id,
|
||||||
'title' => trim($a->title ?: ($a->client?->name ?? '—')),
|
'title' => $a->title ?: ($a->client?->name ?? '—'),
|
||||||
'start' => $a->date->format('Y-m-d') . 'T' . ($a->time_start ?? '08:00:00'),
|
'client_name' => $a->client?->name,
|
||||||
'end' => $a->date->format('Y-m-d') . 'T' . ($a->time_end ?? '09:00:00'),
|
'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')),
|
||||||
'backgroundColor' => $a->color ?: ($a->master?->color ?? '#3b82f6'),
|
'plate' => $a->vehicle?->plate,
|
||||||
'borderColor' => $a->color ?: ($a->master?->color ?? '#3b82f6'),
|
'master_name' => $a->master?->name,
|
||||||
'extendedProps' => [
|
'master_initial' => $a->master ? strtoupper(mb_substr($a->master->name, 0, 1)) . '.' : '',
|
||||||
'client' => $a->client?->name,
|
'time' => substr($a->time_start ?? '', 0, 5) . '–' . substr($a->time_end ?? '', 0, 5),
|
||||||
'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')),
|
'color' => $a->color ?: ($a->master?->color ?? '#3b82f6'),
|
||||||
'plate' => $a->vehicle?->plate,
|
'status' => $a->status,
|
||||||
'master' => $a->master?->name,
|
];
|
||||||
'post' => $a->post?->name,
|
}
|
||||||
'status' => $a->status,
|
|
||||||
'notes' => $a->notes,
|
return $matrix;
|
||||||
],
|
|
||||||
])->all();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drag-drop reschedule. */
|
public function getStats(): array
|
||||||
public function moveEvent(int $id, string $start, string $end): void
|
{
|
||||||
|
$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
|
||||||
|
if ($postId && ! $masterId) {
|
||||||
|
$post = Post::find($postId);
|
||||||
|
if ($post && $post->default_master_id) {
|
||||||
|
$masterId = $post->default_master_id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [substr($iso, 0, 10), substr($iso, 11) ?: '08:00:00'];
|
|
||||||
|
$this->newAppt = [
|
||||||
|
'date' => $date ?: today()->toDateString(),
|
||||||
|
'time_start' => '09:00',
|
||||||
|
'time_end' => '10:00',
|
||||||
|
'title' => '',
|
||||||
|
'client_id' => null,
|
||||||
|
'vehicle_id' => null,
|
||||||
|
'master_id' => $masterId,
|
||||||
|
'post_id' => $postId,
|
||||||
|
'notes' => '',
|
||||||
|
];
|
||||||
|
$this->showNewForm = true;
|
||||||
|
$this->openEventId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quick-add post from calendar toolbar. */
|
||||||
|
public function openNewPostForm(): void
|
||||||
|
{
|
||||||
|
$this->showNewPostForm = true;
|
||||||
|
$this->newPost = ['name' => '', 'color' => '#3b82f6', 'hours_per_day' => 10, 'description' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createPost(): void
|
||||||
|
{
|
||||||
|
$name = trim($this->newPost['name'] ?? '');
|
||||||
|
if ($name === '') {
|
||||||
|
Notification::make()->title('Numele este obligatoriu')->danger()->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Post::create([
|
||||||
|
'name' => $name,
|
||||||
|
'color' => $this->newPost['color'] ?? '#3b82f6',
|
||||||
|
'hours_per_day' => (float) ($this->newPost['hours_per_day'] ?? 10),
|
||||||
|
'description' => trim($this->newPost['description'] ?? '') ?: null,
|
||||||
|
'is_active' => true,
|
||||||
|
'sort_order' => 100,
|
||||||
|
]);
|
||||||
|
$this->showNewPostForm = false;
|
||||||
|
Notification::make()->title('Spațiu de lucru adăugat')->success()->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline rename + reassign default master from row label click. */
|
||||||
|
public function openRenamePost(int $postId): void
|
||||||
|
{
|
||||||
|
$post = Post::find($postId);
|
||||||
|
if (! $post) return;
|
||||||
|
$this->renamingPostId = $postId;
|
||||||
|
$this->renamingPostName = $post->name;
|
||||||
|
$this->renamingPostMasterId = $post->default_master_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveRenamePost(): void
|
||||||
|
{
|
||||||
|
if (! $this->renamingPostId) return;
|
||||||
|
$post = Post::find($this->renamingPostId);
|
||||||
|
if (! $post) return;
|
||||||
|
$name = trim($this->renamingPostName);
|
||||||
|
if ($name === '') return;
|
||||||
|
$post->update([
|
||||||
|
'name' => $name,
|
||||||
|
'default_master_id' => $this->renamingPostMasterId ?: null,
|
||||||
|
]);
|
||||||
|
$this->renamingPostId = null;
|
||||||
|
Notification::make()->title('Post actualizat')->success()->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate PDF for all appointments in the visible period. */
|
||||||
|
public function exportPdf()
|
||||||
|
{
|
||||||
|
$days = $this->getDays();
|
||||||
|
$firstDate = $days[0]['date'] ?? today()->toDateString();
|
||||||
|
$lastDate = end($days)['date'] ?? today()->toDateString();
|
||||||
|
$appointments = \App\Models\Tenant\Appointment::with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name', 'post:id,name'])
|
||||||
|
->whereBetween('date', [$firstDate, $lastDate])
|
||||||
|
->orderBy('date')
|
||||||
|
->orderBy('time_start')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('pdf.appointments', [
|
||||||
|
'appointments' => $appointments->groupBy(fn ($a) => $a->date->toDateString()),
|
||||||
|
'periodLabel' => Carbon::parse($firstDate)->format('d.m.Y') . ' — ' . Carbon::parse($lastDate)->format('d.m.Y'),
|
||||||
|
'generatedAt' => now()->format('d.m.Y H:i'),
|
||||||
|
])->setPaper('a4', 'portrait');
|
||||||
|
|
||||||
|
return response()->streamDownload(
|
||||||
|
fn () => print $pdf->output(),
|
||||||
|
'programari_' . $firstDate . '_' . $lastDate . '.pdf',
|
||||||
|
['Content-Type' => 'application/pdf']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flat list of appointments for the visible period — used by list view. */
|
||||||
|
public function getListAppointments(): array
|
||||||
|
{
|
||||||
|
$days = $this->getDays();
|
||||||
|
$firstDate = $days[0]['date'] ?? today()->toDateString();
|
||||||
|
$lastDate = end($days)['date'] ?? today()->toDateString();
|
||||||
|
return \App\Models\Tenant\Appointment::with(['client:id,name,phone', 'vehicle:id,plate,make,model', 'master:id,name', 'post:id,name'])
|
||||||
|
->whereBetween('date', [$firstDate, $lastDate])
|
||||||
|
->when($this->masterFilter, fn ($q) => $q->where('master_id', $this->masterFilter))
|
||||||
|
->orderBy('date')
|
||||||
|
->orderBy('time_start')
|
||||||
|
->get()
|
||||||
|
->map(fn ($a) => [
|
||||||
|
'id' => $a->id,
|
||||||
|
'date' => $a->date->format('d.m.Y'),
|
||||||
|
'time' => substr($a->time_start ?? '', 0, 5) . '–' . substr($a->time_end ?? '', 0, 5),
|
||||||
|
'title' => $a->title,
|
||||||
|
'client_name' => $a->client?->name,
|
||||||
|
'client_phone' => $a->client?->phone,
|
||||||
|
'vehicle' => trim(($a->vehicle?->make ?? '') . ' ' . ($a->vehicle?->model ?? '')) . ' · ' . ($a->vehicle?->plate ?? '—'),
|
||||||
|
'master_name' => $a->master?->name ?? '—',
|
||||||
|
'post_name' => $a->post?->name ?? '—',
|
||||||
|
'status' => $a->status,
|
||||||
|
])->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createAppt(): void
|
||||||
|
{
|
||||||
|
$d = $this->newAppt;
|
||||||
|
if (empty($d['title']) || empty($d['date'])) {
|
||||||
|
Notification::make()->title('Subiect și data sunt obligatorii')->danger()->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Appointment::create([
|
||||||
|
'date' => $d['date'],
|
||||||
|
'time_start' => $d['time_start'] ?: '09:00',
|
||||||
|
'time_end' => $d['time_end'] ?: '10:00',
|
||||||
|
'title' => $d['title'],
|
||||||
|
'client_id' => $d['client_id'] ?: null,
|
||||||
|
'vehicle_id' => $d['vehicle_id'] ?: null,
|
||||||
|
'master_id' => $d['master_id'] ?: null,
|
||||||
|
'post_id' => $d['post_id'] ?: null,
|
||||||
|
'notes' => $d['notes'] ?: null,
|
||||||
|
'status' => 'scheduled',
|
||||||
|
]);
|
||||||
|
$this->showNewForm = false;
|
||||||
|
Notification::make()->title('Programare adăugată')->success()->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMasterOptions(): array
|
||||||
|
{
|
||||||
|
return User::where('status', 'active')->pluck('name', 'id')->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPostOptions(): array
|
||||||
|
{
|
||||||
|
return Post::where('is_active', true)->pluck('name', 'id')->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClientOptions(): array
|
||||||
|
{
|
||||||
|
return Client::orderBy('name')->limit(50)->pluck('name', 'id')->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVehicleOptions(?int $clientId): array
|
||||||
|
{
|
||||||
|
if (! $clientId) return [];
|
||||||
|
return Vehicle::where('client_id', $clientId)->pluck('plate', 'id')->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calcHours(?string $start, ?string $end): float
|
||||||
|
{
|
||||||
|
if (! $start || ! $end) return 1.0;
|
||||||
|
try {
|
||||||
|
$s = Carbon::createFromTimeString($start);
|
||||||
|
$e = Carbon::createFromTimeString($end);
|
||||||
|
$h = $e->floatDiffInHours($s);
|
||||||
|
return max(0, abs($h));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWeekLabel(): string
|
||||||
|
{
|
||||||
|
$s = Carbon::parse($this->weekStart);
|
||||||
|
$e = $s->copy()->addDays(6);
|
||||||
|
return $s->format('d.m') . ' — ' . $e->format('d.m.Y');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Pages;
|
||||||
|
|
||||||
|
use App\Models\Tenant\Supplier;
|
||||||
|
use App\Services\ExcelInvoiceImportService;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
|
class ExcelImportWizard extends Page
|
||||||
|
{
|
||||||
|
use WithFileUploads;
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-arrow-up-tray';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Import factură Excel';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Stoc & Finanțe';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 65;
|
||||||
|
|
||||||
|
protected static ?string $title = 'Import factură Excel/CSV';
|
||||||
|
|
||||||
|
protected string $view = 'filament.tenant.pages.excel-import-wizard';
|
||||||
|
|
||||||
|
public int $step = 1;
|
||||||
|
public ?int $supplierId = null;
|
||||||
|
public $upload = null;
|
||||||
|
public ?string $storedPath = null;
|
||||||
|
public array $headersPreview = ['columns' => [], 'rows' => []];
|
||||||
|
public array $mapping = [
|
||||||
|
'article_col' => 'B',
|
||||||
|
'name_col' => 'C',
|
||||||
|
'qty_col' => 'E',
|
||||||
|
'price_col' => 'F',
|
||||||
|
'brand_col' => null,
|
||||||
|
'header_row' => 1,
|
||||||
|
];
|
||||||
|
public bool $rememberMapping = true;
|
||||||
|
public array $previewRows = [];
|
||||||
|
public array $previewSummary = ['total' => 0, 'found' => 0, 'new' => 0, 'no_article' => 0];
|
||||||
|
public bool $createNew = true;
|
||||||
|
|
||||||
|
public function getMaxContentWidth(): \Filament\Support\Enums\Width
|
||||||
|
{
|
||||||
|
return \Filament\Support\Enums\Width::Full;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSupplierOptions(): array
|
||||||
|
{
|
||||||
|
return Supplier::orderBy('name')->pluck('name', 'id')->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function goToStep2(): void
|
||||||
|
{
|
||||||
|
if (! $this->supplierId) {
|
||||||
|
Notification::make()->title('Selectează furnizorul')->danger()->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! $this->upload) {
|
||||||
|
Notification::make()->title('Încarcă fișierul Excel sau CSV')->danger()->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the uploaded file so Livewire reuses can resolve it
|
||||||
|
$this->storedPath = $this->upload->store('imports', 'local');
|
||||||
|
|
||||||
|
// Try to load remembered mapping for this supplier
|
||||||
|
$svc = app(ExcelInvoiceImportService::class);
|
||||||
|
$supplier = Supplier::find($this->supplierId);
|
||||||
|
$remembered = $svc->rememberedMappingFor($supplier);
|
||||||
|
if ($remembered) {
|
||||||
|
$this->mapping = array_merge($this->mapping, $remembered);
|
||||||
|
}
|
||||||
|
|
||||||
|
$absPath = Storage::disk('local')->path($this->storedPath);
|
||||||
|
$this->headersPreview = $svc->headersPreview($absPath);
|
||||||
|
|
||||||
|
$this->step = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function goToStep3(): void
|
||||||
|
{
|
||||||
|
$absPath = Storage::disk('local')->path($this->storedPath);
|
||||||
|
$svc = app(ExcelInvoiceImportService::class);
|
||||||
|
$result = $svc->preview($absPath, $this->mapping);
|
||||||
|
$this->previewRows = $result['rows'];
|
||||||
|
$this->previewSummary = $result['summary'];
|
||||||
|
|
||||||
|
if (empty($this->previewRows)) {
|
||||||
|
Notification::make()->title('Nu am găsit linii valide — verifică maparea coloanelor')->warning()->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->step = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirmImport(): void
|
||||||
|
{
|
||||||
|
$svc = app(ExcelInvoiceImportService::class);
|
||||||
|
$supplier = Supplier::find($this->supplierId);
|
||||||
|
|
||||||
|
if ($this->rememberMapping) {
|
||||||
|
$svc->rememberMapping($supplier, $this->mapping, basename($this->storedPath ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase = $svc->import($supplier, $this->previewRows, $this->createNew);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title("Import reușit — Purchase {$purchase->number}")
|
||||||
|
->body("{$this->previewSummary['total']} linii importate")
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
// Cleanup uploaded file
|
||||||
|
if ($this->storedPath) {
|
||||||
|
Storage::disk('local')->delete($this->storedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->step = 4;
|
||||||
|
$this->dispatch('purchase-created', purchaseId: $purchase->id);
|
||||||
|
|
||||||
|
// Set the redirect URL on the page so the blade can show a CTA
|
||||||
|
session()->flash('purchase_id', $purchase->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reset_(): void
|
||||||
|
{
|
||||||
|
$this->step = 1;
|
||||||
|
$this->supplierId = null;
|
||||||
|
$this->upload = null;
|
||||||
|
$this->storedPath = null;
|
||||||
|
$this->headersPreview = ['columns' => [], 'rows' => []];
|
||||||
|
$this->mapping = [
|
||||||
|
'article_col' => 'B', 'name_col' => 'C', 'qty_col' => 'E',
|
||||||
|
'price_col' => 'F', 'brand_col' => null, 'header_row' => 1,
|
||||||
|
];
|
||||||
|
$this->previewRows = [];
|
||||||
|
$this->previewSummary = ['total' => 0, 'found' => 0, 'new' => 0, 'no_article' => 0];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Pages;
|
||||||
|
|
||||||
|
use App\Models\Tenant\WorkOrder;
|
||||||
|
use App\Models\Tenant\WorkOrderWork;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile-first dashboard for a single mechanic — shows ONLY work orders
|
||||||
|
* assigned to the currently logged-in user (master_id = auth()->id()).
|
||||||
|
* Kanban-style grouped by status.
|
||||||
|
*/
|
||||||
|
class MechanicBoard extends Page
|
||||||
|
{
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Atelierul meu';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Service';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 25;
|
||||||
|
|
||||||
|
protected static ?string $title = 'Atelierul meu';
|
||||||
|
|
||||||
|
protected string $view = 'filament.tenant.pages.mechanic-board';
|
||||||
|
|
||||||
|
public function getColumns(): array
|
||||||
|
{
|
||||||
|
$userId = auth()->id();
|
||||||
|
if (! $userId) return [];
|
||||||
|
|
||||||
|
$all = WorkOrder::with(['client', 'vehicle'])
|
||||||
|
->where('master_id', $userId)
|
||||||
|
->whereIn('status', ['in_work', 'awaiting_parts', 'ready', 'done', 'approved', 'diagnosis'])
|
||||||
|
->orderBy('opened_at', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'key' => 'in_work',
|
||||||
|
'label' => 'În lucru',
|
||||||
|
'color' => '#f59e0b',
|
||||||
|
'items' => $all->where('status', 'in_work')->values(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'awaiting_parts',
|
||||||
|
'label' => 'Așteaptă piese',
|
||||||
|
'color' => '#8b5cf6',
|
||||||
|
'items' => $all->whereIn('status', ['awaiting_parts'])->values(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'ready',
|
||||||
|
'label' => 'Gata',
|
||||||
|
'color' => '#10b981',
|
||||||
|
'items' => $all->where('status', 'ready')->values(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'recent',
|
||||||
|
'label' => 'Recente / restul',
|
||||||
|
'color' => '#64748b',
|
||||||
|
'items' => $all->whereIn('status', ['done', 'approved', 'diagnosis'])
|
||||||
|
->take(20)
|
||||||
|
->values(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public ?int $blockingWorkId = null;
|
||||||
|
public string $blockReason = 'missing_part';
|
||||||
|
public string $blockNote = '';
|
||||||
|
|
||||||
|
public function startWork(int $id): void
|
||||||
|
{
|
||||||
|
$w = WorkOrderWork::find($id);
|
||||||
|
if ($w && $w->workOrder?->master_id === auth()->id()) $w->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pauseWork(int $id): void
|
||||||
|
{
|
||||||
|
$w = WorkOrderWork::find($id);
|
||||||
|
if ($w && $w->workOrder?->master_id === auth()->id()) $w->pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resumeWork(int $id): void
|
||||||
|
{
|
||||||
|
$w = WorkOrderWork::find($id);
|
||||||
|
if ($w && $w->workOrder?->master_id === auth()->id()) $w->resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function doneWork(int $id): void
|
||||||
|
{
|
||||||
|
$w = WorkOrderWork::find($id);
|
||||||
|
if ($w && $w->workOrder?->master_id === auth()->id()) $w->markDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openBlockModal(int $id): void
|
||||||
|
{
|
||||||
|
$this->blockingWorkId = $id;
|
||||||
|
$this->blockReason = 'missing_part';
|
||||||
|
$this->blockNote = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirmBlock(): void
|
||||||
|
{
|
||||||
|
if (! $this->blockingWorkId) return;
|
||||||
|
$work = WorkOrderWork::find($this->blockingWorkId);
|
||||||
|
if (! $work) return;
|
||||||
|
// Only own work
|
||||||
|
if ($work->workOrder?->master_id !== auth()->id()) {
|
||||||
|
$this->blockingWorkId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$work->block($this->blockReason, trim($this->blockNote) ?: null);
|
||||||
|
Notification::make()->title('Lucrare blocată')->body($work->name . ' · ' . WorkOrderWork::BLOCK_REASONS[$this->blockReason])->warning()->send();
|
||||||
|
$this->blockingWorkId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWorksFor(int $woId): array
|
||||||
|
{
|
||||||
|
return WorkOrderWork::where('work_order_id', $woId)->orderBy('id')->get()->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCounts(): array
|
||||||
|
{
|
||||||
|
$userId = auth()->id();
|
||||||
|
return [
|
||||||
|
'active' => $userId
|
||||||
|
? WorkOrder::where('master_id', $userId)
|
||||||
|
->whereIn('status', ['in_work', 'awaiting_parts', 'ready'])
|
||||||
|
->count()
|
||||||
|
: 0,
|
||||||
|
'closed_today' => $userId
|
||||||
|
? WorkOrder::where('master_id', $userId)
|
||||||
|
->where('status', 'done')
|
||||||
|
->whereDate('closed_at', today())
|
||||||
|
->count()
|
||||||
|
: 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Pages;
|
||||||
|
|
||||||
|
use App\Models\Tenant\Part;
|
||||||
|
use App\Models\Tenant\Purchase;
|
||||||
|
use App\Models\Tenant\PurchaseItem;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile scanner: opens camera in the browser, decodes QR/barcode, looks up
|
||||||
|
* Part by:
|
||||||
|
* - `PART:<article|id>` payload (our own QR labels)
|
||||||
|
* - exact barcode match on parts.barcode
|
||||||
|
* - exact article match on parts.article
|
||||||
|
* On match → redirect to Part edit page.
|
||||||
|
*/
|
||||||
|
class Scanner extends Page
|
||||||
|
{
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-qr-code';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Scaner';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Depozit';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 39;
|
||||||
|
|
||||||
|
protected static ?string $title = 'Scaner cod QR / Bare';
|
||||||
|
|
||||||
|
protected string $view = 'filament.tenant.pages.scanner';
|
||||||
|
|
||||||
|
public string $manual = '';
|
||||||
|
public ?int $purchaseId = null;
|
||||||
|
public array $receivedToasts = [];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
// Optional ?purchase=N → receipt mode: scans mark items received
|
||||||
|
$purchase = request()->query('purchase');
|
||||||
|
if ($purchase && ctype_digit((string) $purchase)) {
|
||||||
|
$this->purchaseId = (int) $purchase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActivePurchase(): ?Purchase
|
||||||
|
{
|
||||||
|
return $this->purchaseId ? Purchase::find($this->purchaseId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPendingItems(): array
|
||||||
|
{
|
||||||
|
if (! $this->purchaseId) return [];
|
||||||
|
return PurchaseItem::where('purchase_id', $this->purchaseId)
|
||||||
|
->whereColumn('qty_received', '<', 'qty')
|
||||||
|
->orderBy('article')
|
||||||
|
->get(['id', 'article', 'name', 'qty', 'qty_received'])
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[On('scanner-decoded')]
|
||||||
|
public function decoded(string $text): void
|
||||||
|
{
|
||||||
|
$this->process(trim($text));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submitManual(): void
|
||||||
|
{
|
||||||
|
if (trim($this->manual) === '') return;
|
||||||
|
$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
|
||||||
|
{
|
||||||
|
$clean = $code;
|
||||||
|
if (str_starts_with($clean, 'PART:')) {
|
||||||
|
$clean = substr($clean, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
$part = Part::where(function ($q) use ($clean, $code) {
|
||||||
|
$q->where('article', $clean)
|
||||||
|
->orWhere('barcode', $clean)
|
||||||
|
->orWhere('barcode', $code);
|
||||||
|
if (ctype_digit($clean)) $q->orWhere('id', (int) $clean);
|
||||||
|
})
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $part) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Cod necunoscut')
|
||||||
|
->body('Nu am găsit nicio piesă pentru: ' . $code)
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Piesă găsită: ' . $part->name)
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->redirect(
|
||||||
|
route('filament.tenant.resources.parts.edit', ['record' => $part->id])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Filament\Tenant\Pages;
|
namespace App\Filament\Tenant\Pages;
|
||||||
|
|
||||||
|
use App\Services\Notifications\TelegramService;
|
||||||
use App\Tenancy\TenantManager;
|
use App\Tenancy\TenantManager;
|
||||||
|
use Filament\Actions;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
@@ -50,10 +52,20 @@ class Settings extends Page
|
|||||||
'notify_payment' => $notify['payment'] ?? true,
|
'notify_payment' => $notify['payment'] ?? true,
|
||||||
'notify_appointment' => $notify['appointment'] ?? true,
|
'notify_appointment' => $notify['appointment'] ?? true,
|
||||||
'notify_reminder' => $notify['reminder'] ?? true,
|
'notify_reminder' => $notify['reminder'] ?? true,
|
||||||
|
'telegram_bot_token' => data_get($settings, 'telegram.bot_token'),
|
||||||
|
'reminder_after_days' => data_get($settings, 'reminder.after_days', 365),
|
||||||
|
'reminder_cooldown_days' => data_get($settings, 'reminder.cooldown_days', 30),
|
||||||
|
'shop_enabled' => data_get($settings, 'shop.enabled', false),
|
||||||
|
'shop_delivery_methods' => data_get($settings, 'shop.delivery_methods', ['pickup']),
|
||||||
|
'shop_delivery_fee' => data_get($settings, 'shop.delivery_fee', 0),
|
||||||
|
'shop_free_delivery_over' => data_get($settings, 'shop.free_delivery_over', 0),
|
||||||
'ai_default_provider' => $settings['ai']['default_provider'] ?? 'claude',
|
'ai_default_provider' => $settings['ai']['default_provider'] ?? 'claude',
|
||||||
'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']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +138,8 @@ class Settings extends Page
|
|||||||
->maxSize(512)
|
->maxSize(512)
|
||||||
->helperText('PNG/ICO, max 512 KB.'),
|
->helperText('PNG/ICO, max 512 KB.'),
|
||||||
]),
|
]),
|
||||||
Schemas\Components\Section::make('Notificări email')
|
Schemas\Components\Section::make('Notificări')
|
||||||
->description('Activează / dezactivează emailurile auto către clienți.')
|
->description('Activează / dezactivează notificările auto către clienți. Telegram are prioritate dacă clientul are cont legat.')
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->schema([
|
->schema([
|
||||||
Forms\Components\Toggle::make('notify_wo_ready')->label('Mașina e gata de ridicat')->default(true),
|
Forms\Components\Toggle::make('notify_wo_ready')->label('Mașina e gata de ridicat')->default(true),
|
||||||
@@ -135,6 +147,46 @@ class Settings extends Page
|
|||||||
Forms\Components\Toggle::make('notify_appointment')->label('Programare confirmată')->default(true),
|
Forms\Components\Toggle::make('notify_appointment')->label('Programare confirmată')->default(true),
|
||||||
Forms\Components\Toggle::make('notify_reminder')->label('Reminder ITP / revizie')->default(true),
|
Forms\Components\Toggle::make('notify_reminder')->label('Reminder ITP / revizie')->default(true),
|
||||||
]),
|
]),
|
||||||
|
Schemas\Components\Section::make('Telegram bot')
|
||||||
|
->description('Creează un bot la @BotFather, lipește token-ul aici și apasă „Setează webhook". Clienții îți scriu la bot, partajează telefonul, iar codul se leagă automat de fișa lor.')
|
||||||
|
->columns(1)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('telegram_bot_token')
|
||||||
|
->label('Bot token')
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->placeholder('123456:ABC-XYZ...')
|
||||||
|
->helperText(fn () => 'Webhook URL: ' .
|
||||||
|
app(\App\Services\Notifications\TelegramService::class)
|
||||||
|
->webhookUrlFor(app(\App\Tenancy\TenantManager::class)->current())),
|
||||||
|
]),
|
||||||
|
Schemas\Components\Section::make('Reminder service auto')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('reminder_after_days')
|
||||||
|
->label('Trimite reminder după X zile fără vizită')
|
||||||
|
->numeric()
|
||||||
|
->minValue(30)
|
||||||
|
->default(365),
|
||||||
|
Forms\Components\TextInput::make('reminder_cooldown_days')
|
||||||
|
->label('Nu re-trimite mai des de X zile')
|
||||||
|
->numeric()
|
||||||
|
->minValue(7)
|
||||||
|
->default(30),
|
||||||
|
]),
|
||||||
|
Schemas\Components\Section::make('Magazin online')
|
||||||
|
->description('Activează magazinul public la <slug>.service.mir.md/shop. Piesele apar doar dacă sunt marcate „Publicat".')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Toggle::make('shop_enabled')->label('Magazin activ')->columnSpanFull(),
|
||||||
|
Forms\Components\CheckboxList::make('shop_delivery_methods')
|
||||||
|
->label('Metode de livrare')
|
||||||
|
->options(\App\Models\Tenant\OnlineOrder::DELIVERY)
|
||||||
|
->default(['pickup'])
|
||||||
|
->columnSpanFull(),
|
||||||
|
Forms\Components\TextInput::make('shop_delivery_fee')->label('Taxă livrare')->numeric()->default(0),
|
||||||
|
Forms\Components\TextInput::make('shop_free_delivery_over')->label('Livrare gratuită peste')->numeric()->default(0)->helperText('0 = dezactivat'),
|
||||||
|
]),
|
||||||
Schemas\Components\Section::make('Asistent AI')
|
Schemas\Components\Section::make('Asistent AI')
|
||||||
->description('Adaugă chei API ca să activezi asistentul. Cheile rămân la voi — nu sunt partajate.')
|
->description('Adaugă chei API ca să activezi asistentul. Cheile rămân la voi — nu sunt partajate.')
|
||||||
->columns(2)
|
->columns(2)
|
||||||
@@ -144,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');
|
||||||
@@ -178,11 +242,30 @@ class Settings extends Page
|
|||||||
'appointment' => (bool) ($data['notify_appointment'] ?? true),
|
'appointment' => (bool) ($data['notify_appointment'] ?? true),
|
||||||
'reminder' => (bool) ($data['notify_reminder'] ?? true),
|
'reminder' => (bool) ($data['notify_reminder'] ?? true),
|
||||||
],
|
],
|
||||||
|
'telegram' => array_replace(
|
||||||
|
(array) data_get($company->settings, 'telegram', []),
|
||||||
|
['bot_token' => $data['telegram_bot_token'] ?? null]
|
||||||
|
),
|
||||||
|
'reminder' => [
|
||||||
|
'after_days' => (int) ($data['reminder_after_days'] ?? 365),
|
||||||
|
'cooldown_days' => (int) ($data['reminder_cooldown_days'] ?? 30),
|
||||||
|
],
|
||||||
|
'shop' => [
|
||||||
|
'enabled' => (bool) ($data['shop_enabled'] ?? false),
|
||||||
|
'delivery_methods' => array_values((array) ($data['shop_delivery_methods'] ?? ['pickup'])),
|
||||||
|
'delivery_fee' => (float) ($data['shop_delivery_fee'] ?? 0),
|
||||||
|
'free_delivery_over' => (float) ($data['shop_free_delivery_over'] ?? 0),
|
||||||
|
],
|
||||||
'ai' => [
|
'ai' => [
|
||||||
'default_provider' => $data['ai_default_provider'] ?? 'claude',
|
'default_provider' => $data['ai_default_provider'] ?? 'claude',
|
||||||
'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'],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
@@ -201,4 +284,76 @@ class Settings extends Page
|
|||||||
|
|
||||||
Notification::make()->title('Setări salvate')->success()->send();
|
Notification::make()->title('Setări salvate')->success()->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\Action::make('push_test')
|
||||||
|
->label('Test notificare push')
|
||||||
|
->icon('heroicon-m-bell-alert')
|
||||||
|
->color('gray')
|
||||||
|
->action(function () {
|
||||||
|
$svc = app(\App\Services\Notifications\WebPushService::class);
|
||||||
|
if (! $svc->configured()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Web Push neconfigurat')
|
||||||
|
->body('Rulează `php artisan push:vapid` și adaugă cheile în .env.')
|
||||||
|
->warning()->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$r = $svc->sendToUser(
|
||||||
|
(int) auth()->id(),
|
||||||
|
'Test AutoCRM',
|
||||||
|
'Notificările push funcționează ✅',
|
||||||
|
'/app',
|
||||||
|
);
|
||||||
|
Notification::make()
|
||||||
|
->title($r['sent'] > 0 ? "Trimis pe {$r['sent']} dispozitiv(e)" : 'Niciun dispozitiv abonat')
|
||||||
|
->body($r['sent'] > 0 ? null : 'Deschide panoul pe telefon și acceptă notificările întâi.')
|
||||||
|
->{$r['sent'] > 0 ? 'success' : 'warning'}()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Actions\Action::make('telegram_test')
|
||||||
|
->label('Testează bot Telegram')
|
||||||
|
->icon('heroicon-m-bolt')
|
||||||
|
->color('gray')
|
||||||
|
->action(function () {
|
||||||
|
$company = app(TenantManager::class)->current();
|
||||||
|
if (! $company) return;
|
||||||
|
$r = app(TelegramService::class)->getMe($company);
|
||||||
|
if (! ($r['ok'] ?? false)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Bot Telegram nu răspunde')
|
||||||
|
->body($r['error'] ?? 'Verifică token-ul.')
|
||||||
|
->danger()->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$name = data_get($r, 'response.result.username', '?');
|
||||||
|
Notification::make()
|
||||||
|
->title("Bot OK: @{$name}")
|
||||||
|
->success()->send();
|
||||||
|
}),
|
||||||
|
Actions\Action::make('telegram_webhook')
|
||||||
|
->label('Setează webhook')
|
||||||
|
->icon('heroicon-m-link')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalDescription('Telegram va trimite mesajele primite la URL-ul webhook de mai jos.')
|
||||||
|
->action(function () {
|
||||||
|
$company = app(TenantManager::class)->current();
|
||||||
|
if (! $company) return;
|
||||||
|
$r = app(TelegramService::class)->setWebhook($company);
|
||||||
|
if (! ($r['ok'] ?? false)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Webhook eșuat')
|
||||||
|
->body($r['error'] ?? json_encode($r['response'] ?? []))
|
||||||
|
->danger()->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Notification::make()
|
||||||
|
->title('Webhook setat — botul e gata')
|
||||||
|
->success()->send();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\BodyshopJobResource\Pages;
|
||||||
|
use App\Filament\Tenant\Resources\BodyshopJobResource\RelationManagers;
|
||||||
|
use App\Models\Tenant\BodyshopJob;
|
||||||
|
use App\Models\Tenant\Client;
|
||||||
|
use App\Models\Tenant\Vehicle;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class BodyshopJobResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = BodyshopJob::class;
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-paint-brush';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Tinichigerie / Detailing';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Tinichigerie';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'lucrare caroserie';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'lucrări caroserie';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 80;
|
||||||
|
|
||||||
|
public static function getNavigationBadge(): ?string
|
||||||
|
{
|
||||||
|
$open = static::getModel()::query()
|
||||||
|
->whereNotIn('status', ['delivered', 'cancelled'])->count();
|
||||||
|
return $open > 0 ? (string) $open : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Schemas\Components\Section::make('Lucrare')
|
||||||
|
->columns(3)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('number')->label('Nr.')->disabled()->dehydrated(false)->placeholder('Generat automat'),
|
||||||
|
Forms\Components\Select::make('type')->label('Tip')->options(BodyshopJob::TYPES)->default('body_repair')->required(),
|
||||||
|
Forms\Components\Select::make('status')->label('Status')->options(BodyshopJob::STATUSES)->default('estimate')->required(),
|
||||||
|
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 (Get $get) => $get('client_id')
|
||||||
|
? Vehicle::where('client_id', $get('client_id'))->get()
|
||||||
|
->mapWithKeys(fn ($v) => [$v->id => "{$v->make} {$v->model} {$v->plate}"])->toArray()
|
||||||
|
: [])
|
||||||
|
->searchable(),
|
||||||
|
Forms\Components\TextInput::make('estimate_amount')->label('Deviz')->numeric()->default(0),
|
||||||
|
Forms\Components\TextInput::make('approved_amount')->label('Aprobat')->numeric()->default(0),
|
||||||
|
]),
|
||||||
|
Schemas\Components\Section::make('Asigurare')
|
||||||
|
->collapsible()
|
||||||
|
->columns(3)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Toggle::make('is_insurance')->label('Caz de asigurare')->live()->columnSpanFull(),
|
||||||
|
Forms\Components\TextInput::make('insurer')->label('Asigurător')
|
||||||
|
->visible(fn (Get $get) => $get('is_insurance')),
|
||||||
|
Forms\Components\TextInput::make('policy_no')->label('Nr. poliță')
|
||||||
|
->visible(fn (Get $get) => $get('is_insurance')),
|
||||||
|
Forms\Components\TextInput::make('claim_no')->label('Nr. dosar daună')
|
||||||
|
->visible(fn (Get $get) => $get('is_insurance')),
|
||||||
|
Forms\Components\Select::make('insurance_status')->label('Status dosar')
|
||||||
|
->options(BodyshopJob::INSURANCE_STATUSES)
|
||||||
|
->visible(fn (Get $get) => $get('is_insurance')),
|
||||||
|
]),
|
||||||
|
Schemas\Components\Section::make('Foto înainte / după')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos_before')
|
||||||
|
->label('Înainte')->collection('photos_before')->multiple()->image()->reorderable()->maxFiles(20),
|
||||||
|
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos_after')
|
||||||
|
->label('După')->collection('photos_after')->multiple()->image()->reorderable()->maxFiles(20),
|
||||||
|
]),
|
||||||
|
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('number')->label('Nr.')->searchable()->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('client.name')->label('Client')->searchable()->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('vehicle.plate')->label('Auto')->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('type')
|
||||||
|
->formatStateUsing(fn ($s) => BodyshopJob::TYPES[$s] ?? $s)
|
||||||
|
->badge()->color('info'),
|
||||||
|
Tables\Columns\IconColumn::make('is_insurance')->label('Asig.')->boolean()->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('damage_points_count')->counts('damagePoints')->label('Daune')->alignRight(),
|
||||||
|
Tables\Columns\TextColumn::make('approved_amount')->label('Aprobat')->money('MDL')->alignRight(),
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->formatStateUsing(fn ($s) => BodyshopJob::STATUSES[$s] ?? $s)
|
||||||
|
->badge()
|
||||||
|
->colors([
|
||||||
|
'gray' => ['estimate'],
|
||||||
|
'info' => ['approved', 'in_progress'],
|
||||||
|
'success' => ['done', 'delivered'],
|
||||||
|
'danger' => ['cancelled'],
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('type')->options(BodyshopJob::TYPES),
|
||||||
|
Tables\Filters\SelectFilter::make('status')->options(BodyshopJob::STATUSES),
|
||||||
|
Tables\Filters\TernaryFilter::make('is_insurance')->label('Caz asigurare'),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\EditAction::make(),
|
||||||
|
Actions\DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->emptyStateHeading('Nicio lucrare de caroserie')
|
||||||
|
->emptyStateDescription('Înregistrează lucrări de tinichigerie, vopsitorie, PDR, detailing, ceramică, PPF sau polish. Hartă daune, dosar asigurare și arhivă foto înainte/după.')
|
||||||
|
->emptyStateIcon('heroicon-o-paint-brush')
|
||||||
|
->defaultSort('created_at', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
RelationManagers\DamagePointsRelationManager::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Tinichigerie / Detailing');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): ?string
|
||||||
|
{
|
||||||
|
return __('Tinichigerie');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('lucrare caroserie');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPluralModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('lucrări caroserie');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListBodyshopJobs::route('/'),
|
||||||
|
'create' => Pages\CreateBodyshopJob::route('/create'),
|
||||||
|
'edit' => Pages\EditBodyshopJob::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\BodyshopJobResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\BodyshopJobResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateBodyshopJob extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BodyshopJobResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\BodyshopJobResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\BodyshopJobResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditBodyshopJob extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BodyshopJobResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [Actions\DeleteAction::make()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\BodyshopJobResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\BodyshopJobResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListBodyshopJobs extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = BodyshopJobResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [Actions\CreateAction::make()];
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\BodyshopJobResource\RelationManagers;
|
||||||
|
|
||||||
|
use App\Models\Tenant\DamagePoint;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class DamagePointsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'damagePoints';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Hartă daune';
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Forms\Components\Select::make('zone')
|
||||||
|
->label('Zonă')
|
||||||
|
->options(array_combine(DamagePoint::ZONES, DamagePoint::ZONES))
|
||||||
|
->searchable()
|
||||||
|
->required(),
|
||||||
|
Forms\Components\Select::make('kind')
|
||||||
|
->label('Tip daună')
|
||||||
|
->options(array_combine(DamagePoint::KINDS, DamagePoint::KINDS))
|
||||||
|
->required(),
|
||||||
|
Forms\Components\Select::make('severity')
|
||||||
|
->label('Gravitate')
|
||||||
|
->options(DamagePoint::SEVERITIES)
|
||||||
|
->default('minor')
|
||||||
|
->required(),
|
||||||
|
Forms\Components\Textarea::make('notes')->label('Observații')->rows(2)->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->recordTitleAttribute('zone')
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('zone')->label('Zonă')->badge()->color('gray'),
|
||||||
|
Tables\Columns\TextColumn::make('kind')->label('Tip'),
|
||||||
|
Tables\Columns\TextColumn::make('severity')
|
||||||
|
->label('Gravitate')
|
||||||
|
->formatStateUsing(fn ($s) => DamagePoint::SEVERITIES[$s] ?? $s)
|
||||||
|
->badge()
|
||||||
|
->colors(['gray' => ['minor'], 'warning' => ['medium'], 'danger' => ['severe']]),
|
||||||
|
Tables\Columns\TextColumn::make('notes')->limit(40)->placeholder('—'),
|
||||||
|
])
|
||||||
|
->headerActions([Actions\CreateAction::make()])
|
||||||
|
->actions([Actions\EditAction::make(), Actions\DeleteAction::make()])
|
||||||
|
->emptyStateHeading('Nicio daună marcată')
|
||||||
|
->emptyStateDescription('Adaugă punctele de daună pe zone (capotă, ușă, aripă) cu tip și gravitate — formează harta de daune a mașinii.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,9 @@ class ClientResource extends Resource
|
|||||||
])
|
])
|
||||||
->default('active')
|
->default('active')
|
||||||
->required(),
|
->required(),
|
||||||
|
Forms\Components\Toggle::make('is_vip')
|
||||||
|
->label('Client VIP')
|
||||||
|
->helperText('Activează coeficienții de preț VIP pe fișele acestui client.'),
|
||||||
]),
|
]),
|
||||||
Schemas\Components\Section::make('Contacte')
|
Schemas\Components\Section::make('Contacte')
|
||||||
->columns(2)
|
->columns(2)
|
||||||
@@ -73,6 +76,14 @@ class ClientResource extends Resource
|
|||||||
Forms\Components\TextInput::make('phone_alt')->label('Telefon alternativ')->tel()->maxLength(40),
|
Forms\Components\TextInput::make('phone_alt')->label('Telefon alternativ')->tel()->maxLength(40),
|
||||||
Forms\Components\TextInput::make('email')->email()->maxLength(120),
|
Forms\Components\TextInput::make('email')->email()->maxLength(120),
|
||||||
Forms\Components\TextInput::make('telegram')->maxLength(60),
|
Forms\Components\TextInput::make('telegram')->maxLength(60),
|
||||||
|
Forms\Components\TextInput::make('telegram_chat_id')
|
||||||
|
->label('Telegram chat ID')
|
||||||
|
->disabled()
|
||||||
|
->dehydrated(false)
|
||||||
|
->placeholder('Se completează automat când clientul scrie la bot')
|
||||||
|
->helperText(fn ($record) => $record?->telegram_chat_id
|
||||||
|
? '✅ Telegram legat — notificările vor merge prin bot'
|
||||||
|
: null),
|
||||||
Forms\Components\TextInput::make('whatsapp')->maxLength(60),
|
Forms\Components\TextInput::make('whatsapp')->maxLength(60),
|
||||||
Forms\Components\TextInput::make('viber')->maxLength(60),
|
Forms\Components\TextInput::make('viber')->maxLength(60),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Tenant\Resources;
|
namespace App\Filament\Tenant\Resources;
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\LaborResource\Pages;
|
use App\Filament\Tenant\Resources\LaborResource\Pages;
|
||||||
|
use App\Filament\Tenant\Resources\LaborResource\RelationManagers;
|
||||||
use App\Models\Tenant\Labor;
|
use App\Models\Tenant\Labor;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
@@ -42,8 +43,17 @@ class LaborResource extends Resource
|
|||||||
Forms\Components\TextInput::make('code')->label('Cod')->maxLength(32),
|
Forms\Components\TextInput::make('code')->label('Cod')->maxLength(32),
|
||||||
Forms\Components\TextInput::make('name_ro')->label('Nume (RO)')->required()->maxLength(160),
|
Forms\Components\TextInput::make('name_ro')->label('Nume (RO)')->required()->maxLength(160),
|
||||||
Forms\Components\TextInput::make('name_ru')->label('Nume (RU)')->maxLength(160),
|
Forms\Components\TextInput::make('name_ru')->label('Nume (RU)')->maxLength(160),
|
||||||
Forms\Components\TextInput::make('hours')->label('Ore')->numeric()->default(1)->required(),
|
Forms\Components\Select::make('pricing_mode')
|
||||||
Forms\Components\TextInput::make('price')->label('Preț (MDL)')->numeric()->default(0),
|
->label('Mod tarifare')
|
||||||
|
->options(Labor::PRICING_MODES)
|
||||||
|
->default('hourly')
|
||||||
|
->live()
|
||||||
|
->required(),
|
||||||
|
Forms\Components\TextInput::make('hours')->label('Ore (normă)')->numeric()->default(1)
|
||||||
|
->visible(fn (Schemas\Components\Utilities\Get $get) => $get('pricing_mode') !== 'fixed'),
|
||||||
|
Forms\Components\TextInput::make('fixed_price')->label('Preț fix (MDL)')->numeric()->default(0)
|
||||||
|
->visible(fn (Schemas\Components\Utilities\Get $get) => $get('pricing_mode') === 'fixed'),
|
||||||
|
Forms\Components\TextInput::make('price')->label('Preț orientativ (MDL)')->numeric()->default(0),
|
||||||
Forms\Components\Toggle::make('is_active')->label('Activă')->default(true),
|
Forms\Components\Toggle::make('is_active')->label('Activă')->default(true),
|
||||||
]),
|
]),
|
||||||
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
|
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
|
||||||
@@ -56,8 +66,15 @@ class LaborResource extends Resource
|
|||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('category')->label('Categorie')->badge()->sortable(),
|
Tables\Columns\TextColumn::make('category')->label('Categorie')->badge()->sortable(),
|
||||||
Tables\Columns\TextColumn::make('name_ro')->label('Manoperă')->searchable()->sortable(),
|
Tables\Columns\TextColumn::make('name_ro')->label('Manoperă')->searchable()->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('pricing_mode')
|
||||||
|
->label('Tarifare')
|
||||||
|
->formatStateUsing(fn ($s) => $s === 'fixed' ? 'Fix' : 'Pe oră')
|
||||||
|
->badge()
|
||||||
|
->color(fn ($s) => $s === 'fixed' ? 'info' : 'gray'),
|
||||||
Tables\Columns\TextColumn::make('hours')->label('Ore')->numeric(decimalPlaces: 2)->alignRight(),
|
Tables\Columns\TextColumn::make('hours')->label('Ore')->numeric(decimalPlaces: 2)->alignRight(),
|
||||||
Tables\Columns\TextColumn::make('price')->label('Preț')->money('MDL')->alignRight(),
|
Tables\Columns\TextColumn::make('fixed_price')->label('Preț fix')->money('MDL')->alignRight()
|
||||||
|
->placeholder('—')->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('laborParts_count')->counts('laborParts')->label('Piese impl.')->alignRight()->toggleable(),
|
||||||
Tables\Columns\IconColumn::make('is_active')->label('Activă')->boolean(),
|
Tables\Columns\IconColumn::make('is_active')->label('Activă')->boolean(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
@@ -73,6 +90,13 @@ class LaborResource extends Resource
|
|||||||
->defaultGroup('category');
|
->defaultGroup('category');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
RelationManagers\DefaultPartsRelationManager::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\LaborResource\RelationManagers;
|
||||||
|
|
||||||
|
use App\Models\Tenant\Part;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Schemas\Components\Utilities\Set;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class DefaultPartsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'laborParts';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Piese implicite';
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Forms\Components\Select::make('part_id')
|
||||||
|
->label('Piesă')
|
||||||
|
->options(fn () => Part::where('is_active', true)
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(fn ($p) => [$p->id => "{$p->name} " . ($p->article ? "[{$p->article}]" : '')])
|
||||||
|
->toArray())
|
||||||
|
->searchable()
|
||||||
|
->required()
|
||||||
|
->live()
|
||||||
|
->afterStateUpdated(function ($state, Set $set) {
|
||||||
|
if ($state && $p = Part::find($state)) {
|
||||||
|
$set('unit', $p->unit);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->default(1)->required(),
|
||||||
|
Forms\Components\TextInput::make('unit')->label('UM')->default('buc')->maxLength(16),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->recordTitleAttribute('part.name')
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('part.name')->label('Piesă')->wrap(),
|
||||||
|
Tables\Columns\TextColumn::make('part.article')->label('Cod')->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('qty')->label('Cant.')->alignRight(),
|
||||||
|
Tables\Columns\TextColumn::make('unit')->label('UM'),
|
||||||
|
])
|
||||||
|
->headerActions([Actions\CreateAction::make()])
|
||||||
|
->actions([Actions\EditAction::make(), Actions\DeleteAction::make()])
|
||||||
|
->emptyStateHeading('Nicio piesă implicită')
|
||||||
|
->emptyStateDescription('Adaugă piesele care se montează de obicei la această manoperă — se adaugă automat în fișă când selectezi manopera.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\OnlineOrderResource\Pages;
|
||||||
|
use App\Filament\Tenant\Resources\OnlineOrderResource\RelationManagers;
|
||||||
|
use App\Models\Tenant\OnlineOrder;
|
||||||
|
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;
|
||||||
|
|
||||||
|
class OnlineOrderResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = OnlineOrder::class;
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shopping-bag';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Comenzi online';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Magazin';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'comandă';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'comenzi online';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 50;
|
||||||
|
|
||||||
|
public static function getNavigationBadge(): ?string
|
||||||
|
{
|
||||||
|
$new = static::getModel()::query()->where('status', 'new')->count();
|
||||||
|
return $new > 0 ? (string) $new : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationBadgeColor(): ?string
|
||||||
|
{
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Schemas\Components\Section::make('Comandă')
|
||||||
|
->columns(3)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('number')->label('Nr.')->disabled()->dehydrated(false),
|
||||||
|
Forms\Components\Select::make('status')->options(OnlineOrder::STATUSES)->required(),
|
||||||
|
Forms\Components\Select::make('delivery_method')->label('Livrare')->options(OnlineOrder::DELIVERY)->required(),
|
||||||
|
Forms\Components\TextInput::make('customer_name')->label('Client')->required(),
|
||||||
|
Forms\Components\TextInput::make('customer_phone')->label('Telefon')->required(),
|
||||||
|
Forms\Components\TextInput::make('customer_email')->label('Email'),
|
||||||
|
Forms\Components\TextInput::make('address')->label('Adresă')->columnSpan(2),
|
||||||
|
Forms\Components\TextInput::make('delivery_fee')->label('Taxă livrare')->numeric(),
|
||||||
|
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('number')->label('Nr.')->searchable()->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')->label('Data')->dateTime('d.m.Y H:i')->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('customer_name')->label('Client')->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('customer_phone')->label('Telefon')->copyable(),
|
||||||
|
Tables\Columns\TextColumn::make('delivery_method')
|
||||||
|
->label('Livrare')
|
||||||
|
->formatStateUsing(fn ($s) => OnlineOrder::DELIVERY[$s] ?? $s),
|
||||||
|
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()->sortable(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('status')->options(OnlineOrder::STATUSES),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('fulfill')
|
||||||
|
->label('Onorează (scade stoc)')
|
||||||
|
->icon('heroicon-m-check-badge')
|
||||||
|
->color('success')
|
||||||
|
->visible(fn (OnlineOrder $r) => ! in_array($r->status, ['delivered', 'cancelled'], true))
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalDescription('Scade din stoc piesele legate de catalog (FIFO) și marchează comanda confirmată.')
|
||||||
|
->action(function (OnlineOrder $r) {
|
||||||
|
$svc = app(\App\Services\Warehouse\WarehouseService::class);
|
||||||
|
$issued = 0; $skipped = 0;
|
||||||
|
foreach ($r->items as $item) {
|
||||||
|
if ($item->fulfilled) continue;
|
||||||
|
if (! $item->part_id) { $skipped++; continue; }
|
||||||
|
$part = \App\Models\Tenant\Part::find($item->part_id);
|
||||||
|
if (! $part) { $skipped++; continue; }
|
||||||
|
try {
|
||||||
|
$svc->issue($part, (float) $item->qty, null, $r, "Comandă online #{$r->number}");
|
||||||
|
$item->fulfilled = true;
|
||||||
|
$item->save();
|
||||||
|
$issued++;
|
||||||
|
} catch (\App\Services\Warehouse\InsufficientStockException $e) {
|
||||||
|
$skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($r->status === 'new') {
|
||||||
|
$r->status = 'confirmed';
|
||||||
|
$r->save();
|
||||||
|
}
|
||||||
|
Notification::make()
|
||||||
|
->title("Onorat: {$issued} linii scăzute" . ($skipped ? ", {$skipped} sărite (stoc/lipsă link)" : ''))
|
||||||
|
->{$skipped ? 'warning' : 'success'}()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Actions\EditAction::make(),
|
||||||
|
])
|
||||||
|
->defaultSort('created_at', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
RelationManagers\ItemsRelationManager::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListOnlineOrders::route('/'),
|
||||||
|
'edit' => Pages\EditOnlineOrder::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\OnlineOrderResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\OnlineOrderResource;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditOnlineOrder extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = OnlineOrderResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\OnlineOrderResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\OnlineOrderResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListOnlineOrders extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = OnlineOrderResource::class;
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\OnlineOrderResource\RelationManagers;
|
||||||
|
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class ItemsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'items';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Produse';
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->recordTitleAttribute('name')
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('name')->label('Piesă')->wrap(),
|
||||||
|
Tables\Columns\TextColumn::make('article')->label('Cod')->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('qty')->label('Cant.')->alignRight(),
|
||||||
|
Tables\Columns\TextColumn::make('price')->label('Preț')->money('MDL')->alignRight(),
|
||||||
|
Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(),
|
||||||
|
Tables\Columns\IconColumn::make('fulfilled')->label('Onorat')->boolean(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Tenant\Resources;
|
namespace App\Filament\Tenant\Resources;
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\PartResource\Pages;
|
use App\Filament\Tenant\Resources\PartResource\Pages;
|
||||||
|
use App\Filament\Tenant\Resources\PartResource\RelationManagers;
|
||||||
use App\Models\Tenant\Part;
|
use App\Models\Tenant\Part;
|
||||||
use App\Models\Tenant\Supplier;
|
use App\Models\Tenant\Supplier;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@@ -79,6 +80,10 @@ class PartResource extends Resource
|
|||||||
Forms\Components\TextInput::make('unit')->label('UM')->default('buc')->maxLength(16),
|
Forms\Components\TextInput::make('unit')->label('UM')->default('buc')->maxLength(16),
|
||||||
Forms\Components\TextInput::make('min_qty')->label('Minim')->numeric()->default(0),
|
Forms\Components\TextInput::make('min_qty')->label('Minim')->numeric()->default(0),
|
||||||
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
|
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
|
||||||
|
Forms\Components\Toggle::make('is_published')
|
||||||
|
->label('Publicat în magazin')
|
||||||
|
->helperText('Apare în magazinul online public.')
|
||||||
|
->default(false),
|
||||||
]),
|
]),
|
||||||
Schemas\Components\Section::make('Prețuri')
|
Schemas\Components\Section::make('Prețuri')
|
||||||
->columns(2)
|
->columns(2)
|
||||||
@@ -94,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),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -102,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('—'),
|
||||||
@@ -112,9 +137,16 @@ class PartResource extends Resource
|
|||||||
->alignRight()
|
->alignRight()
|
||||||
->color(fn ($state, $record) => $record->qty <= 0 ? 'danger' : ($record->qty <= $record->min_qty ? 'warning' : null))
|
->color(fn ($state, $record) => $record->qty <= 0 ? 'danger' : ($record->qty <= $record->min_qty ? 'warning' : null))
|
||||||
->weight(fn ($state, $record) => $record->qty <= $record->min_qty ? 'bold' : null),
|
->weight(fn ($state, $record) => $record->qty <= $record->min_qty ? 'bold' : null),
|
||||||
|
Tables\Columns\TextColumn::make('qty_reserved')
|
||||||
|
->label('Rezervat')
|
||||||
|
->numeric(decimalPlaces: 2)
|
||||||
|
->alignRight()
|
||||||
|
->color(fn ($state) => (float) $state > 0 ? 'info' : null)
|
||||||
|
->toggleable(),
|
||||||
Tables\Columns\TextColumn::make('unit')->label('UM'),
|
Tables\Columns\TextColumn::make('unit')->label('UM'),
|
||||||
Tables\Columns\TextColumn::make('location')->label('Loc.')->placeholder('—'),
|
Tables\Columns\TextColumn::make('location')->label('Loc.')->placeholder('—'),
|
||||||
Tables\Columns\TextColumn::make('sell_price')->label('Preț vz.')->money('MDL')->alignRight(),
|
Tables\Columns\TextColumn::make('sell_price')->label('Preț vz.')->money('MDL')->alignRight(),
|
||||||
|
Tables\Columns\IconColumn::make('is_published')->label('Magazin')->boolean()->toggleable(),
|
||||||
Tables\Columns\TextColumn::make('preferredSupplier.name')->label('Furnizor')->placeholder('—')->toggleable(),
|
Tables\Columns\TextColumn::make('preferredSupplier.name')->label('Furnizor')->placeholder('—')->toggleable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
@@ -128,15 +160,115 @@ class PartResource extends Resource
|
|||||||
->query(fn ($q) => $q->where('qty', '<=', 0)),
|
->query(fn ($q) => $q->where('qty', '<=', 0)),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
|
Actions\Action::make('qr')
|
||||||
|
->label('QR')
|
||||||
|
->icon('heroicon-m-qr-code')
|
||||||
|
->color('gray')
|
||||||
|
->modalHeading(fn (Part $r) => 'QR pentru ' . $r->name)
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelActionLabel('Închide')
|
||||||
|
->modalContent(function (Part $r) {
|
||||||
|
$payload = 'PART:' . ($r->article ?: $r->id);
|
||||||
|
$svg = (new \chillerlan\QRCode\QRCode(new \chillerlan\QRCode\QROptions([
|
||||||
|
'outputType' => \chillerlan\QRCode\QRCode::OUTPUT_MARKUP_SVG,
|
||||||
|
'eccLevel' => \chillerlan\QRCode\QRCode::ECC_M,
|
||||||
|
'scale' => 8,
|
||||||
|
'imageBase64' => false,
|
||||||
|
'addQuietzone' => true,
|
||||||
|
])))->render($payload);
|
||||||
|
return view('filament.tenant.part-qr', [
|
||||||
|
'part' => $r, 'svg' => $svg, 'payload' => $payload,
|
||||||
|
]);
|
||||||
|
}),
|
||||||
|
Actions\Action::make('ai_price')
|
||||||
|
->label('AI: preț recomandat')
|
||||||
|
->icon('heroicon-m-sparkles')
|
||||||
|
->color('primary')
|
||||||
|
->modalHeading(fn (Part $r) => "AI: preț pentru {$r->name}")
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelActionLabel('Închide')
|
||||||
|
->modalContent(function (Part $r) {
|
||||||
|
[$reply, $meta] = app(\App\Services\Ai\AiAssistantService::class)
|
||||||
|
->suggestPrice($r);
|
||||||
|
return view('filament.tenant.ai-reply', ['reply' => $reply, 'meta' => $meta]);
|
||||||
|
}),
|
||||||
|
Actions\Action::make('receive')
|
||||||
|
->label('Recepție')
|
||||||
|
->icon('heroicon-m-arrow-down-tray')
|
||||||
|
->color('success')
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->required()->minValue(0.001),
|
||||||
|
Forms\Components\TextInput::make('buy_price')->label('Preț unitar')->numeric()->required(),
|
||||||
|
Forms\Components\Select::make('supplier_id')
|
||||||
|
->label('Furnizor')
|
||||||
|
->options(fn () => \App\Models\Tenant\Supplier::pluck('name', 'id')),
|
||||||
|
Forms\Components\Select::make('warehouse_id')
|
||||||
|
->label('Depozit')
|
||||||
|
->options(fn () => \App\Models\Tenant\Warehouse::where('is_active', true)->pluck('name', 'id'))
|
||||||
|
->default(fn () => \App\Models\Tenant\Warehouse::where('is_default', true)->value('id')),
|
||||||
|
Forms\Components\TextInput::make('batch_ref')->label('Ref. lot/factură')->maxLength(64),
|
||||||
|
])
|
||||||
|
->action(function (Part $record, array $data) {
|
||||||
|
$warehouse = $data['warehouse_id']
|
||||||
|
? \App\Models\Tenant\Warehouse::find($data['warehouse_id'])
|
||||||
|
: null;
|
||||||
|
$supplier = $data['supplier_id']
|
||||||
|
? \App\Models\Tenant\Supplier::find($data['supplier_id'])
|
||||||
|
: null;
|
||||||
|
app(\App\Services\Warehouse\WarehouseService::class)->receive(
|
||||||
|
part: $record,
|
||||||
|
qty: (float) $data['qty'],
|
||||||
|
buyPrice: (float) $data['buy_price'],
|
||||||
|
warehouse: $warehouse,
|
||||||
|
supplier: $supplier,
|
||||||
|
batchRef: $data['batch_ref'] ?? null,
|
||||||
|
);
|
||||||
|
\Filament\Notifications\Notification::make()
|
||||||
|
->title('Stoc adăugat')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make(),
|
||||||
])
|
])
|
||||||
|
->bulkActions([
|
||||||
|
Actions\BulkAction::make('print_labels')
|
||||||
|
->label('Tipărește etichete QR')
|
||||||
|
->icon('heroicon-m-printer')
|
||||||
|
->color('gray')
|
||||||
|
->action(function ($records) {
|
||||||
|
$ids = collect($records)->pluck('id')->implode(',');
|
||||||
|
return redirect()->away('/parts/labels?ids=' . $ids);
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
Actions\BulkAction::make('publish')
|
||||||
|
->label('Publică în magazin')
|
||||||
|
->icon('heroicon-m-globe-alt')
|
||||||
|
->color('success')
|
||||||
|
->action(fn ($records) => collect($records)->each->update(['is_published' => true]))
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
Actions\BulkAction::make('unpublish')
|
||||||
|
->label('Scoate din magazin')
|
||||||
|
->icon('heroicon-m-eye-slash')
|
||||||
|
->color('gray')
|
||||||
|
->action(fn ($records) => collect($records)->each->update(['is_published' => false]))
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
])
|
||||||
->emptyStateHeading('Depozit gol')
|
->emptyStateHeading('Depozit gol')
|
||||||
->emptyStateDescription('Adaugă piese manual, sau folosește Achiziții ca să le adaugi prin recepție de la furnizor (cu prețuri și stoc auto). Procentaj poate seta automat prețul de vânzare.')
|
->emptyStateDescription('Adaugă piese manual, sau folosește Achiziții ca să le adaugi prin recepție de la furnizor (cu prețuri și stoc auto). Procentaj poate seta automat prețul de vânzare.')
|
||||||
->emptyStateIcon('heroicon-o-cube')
|
->emptyStateIcon('heroicon-o-cube')
|
||||||
->defaultSort('name');
|
->defaultSort('name');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
RelationManagers\BatchesRelationManager::class,
|
||||||
|
RelationManagers\PriceHistoryRelationManager::class,
|
||||||
|
RelationManagers\CrossRefsRelationManager::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\PartResource\RelationManagers;
|
||||||
|
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class BatchesRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'batches';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Loturi (FIFO)';
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('received_at')
|
||||||
|
->label('Recepție')
|
||||||
|
->dateTime('d.m.Y H:i')
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('warehouse.code')->label('Depozit')->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('batch_ref')->label('Ref.')->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('supplier.name')->label('Furnizor')->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('qty_in')
|
||||||
|
->label('Intrat')
|
||||||
|
->numeric(decimalPlaces: 2)
|
||||||
|
->alignRight(),
|
||||||
|
Tables\Columns\TextColumn::make('qty_remaining')
|
||||||
|
->label('Rămas')
|
||||||
|
->numeric(decimalPlaces: 2)
|
||||||
|
->alignRight()
|
||||||
|
->weight('bold')
|
||||||
|
->color(fn ($state) => (float) $state <= 0 ? 'gray' : 'success'),
|
||||||
|
Tables\Columns\TextColumn::make('buy_price')
|
||||||
|
->label('Preț unit.')
|
||||||
|
->money('MDL')
|
||||||
|
->alignRight(),
|
||||||
|
])
|
||||||
|
->defaultSort('received_at')
|
||||||
|
->emptyStateHeading('Niciun lot înregistrat')
|
||||||
|
->emptyStateDescription('Apasă „Recepție" pe lista de piese pentru a înregistra prima intrare în depozit.');
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\PartResource\RelationManagers;
|
||||||
|
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class CrossRefsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'crossRefs';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Coduri cross (OEM/echivalente)';
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Forms\Components\TextInput::make('cross_article')->label('Cod echivalent')->required()->maxLength(64),
|
||||||
|
Forms\Components\TextInput::make('brand')->label('Brand')->maxLength(64),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->recordTitleAttribute('cross_article')
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('cross_article')->label('Cod')->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('brand')->placeholder('—'),
|
||||||
|
])
|
||||||
|
->headerActions([Actions\CreateAction::make()])
|
||||||
|
->actions([Actions\EditAction::make(), Actions\DeleteAction::make()])
|
||||||
|
->emptyStateHeading('Niciun cod cross')
|
||||||
|
->emptyStateDescription('Adaugă coduri echivalente OEM/aftermarket ca să fie găsite în căutarea din magazin.');
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\PartResource\RelationManagers;
|
||||||
|
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class PriceHistoryRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'priceHistory';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Istoric prețuri furnizori';
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('observed_at')
|
||||||
|
->label('Data')
|
||||||
|
->dateTime('d.m.Y H:i')
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('supplier.name')->label('Furnizor')->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('purchase.number')->label('PO')->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('price')
|
||||||
|
->money('MDL')
|
||||||
|
->alignRight()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('currency')->label('Val.'),
|
||||||
|
])
|
||||||
|
->defaultSort('observed_at', 'desc')
|
||||||
|
->emptyStateHeading('Niciun preț înregistrat')
|
||||||
|
->emptyStateDescription('Prețurile se înregistrează automat la fiecare recepție de PO.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\PricingCoefficientResource\Pages;
|
||||||
|
use App\Models\Tenant\PricingCoefficient;
|
||||||
|
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 PricingCoefficientResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = PricingCoefficient::class;
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-adjustments-horizontal';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Coeficienți preț';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Depozit';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'coeficient';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'coeficienți preț';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 46;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Schemas\Components\Section::make('Coeficient')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('name')->label('Denumire')->required()
|
||||||
|
->placeholder('ex: Mașină veche, Client VIP, Express')->columnSpanFull(),
|
||||||
|
Forms\Components\TextInput::make('multiplier')
|
||||||
|
->label('Multiplicator')
|
||||||
|
->numeric()
|
||||||
|
->required()
|
||||||
|
->default(1.10)
|
||||||
|
->helperText('1.15 = +15% peste prețul de bază. 0.95 = -5%.'),
|
||||||
|
Forms\Components\TextInput::make('priority')->label('Prioritate')->numeric()->default(100),
|
||||||
|
Forms\Components\Toggle::make('stackable')
|
||||||
|
->label('Cumulabil')
|
||||||
|
->default(true)
|
||||||
|
->helperText('Cumulabil = se înmulțește cu alți coeficienți. Necumulabil = doar cel mai mare necumulabil se aplică.'),
|
||||||
|
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
|
||||||
|
]),
|
||||||
|
Schemas\Components\Section::make('Condiții (toate trebuie îndeplinite)')
|
||||||
|
->description('Lasă gol = se aplică mereu. Combină condițiile pentru a ținti situații specifice.')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\CheckboxList::make('conditions.classes')
|
||||||
|
->label('Clase auto')
|
||||||
|
->options(PricingCoefficient::VEHICLE_CLASSES)
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
Forms\Components\CheckboxList::make('conditions.body_types')
|
||||||
|
->label('Caroserie')
|
||||||
|
->options(\App\Models\Tenant\Vehicle::BODY_TYPES)
|
||||||
|
->columns(3)
|
||||||
|
->columnSpanFull(),
|
||||||
|
Forms\Components\CheckboxList::make('conditions.transmissions')
|
||||||
|
->label('Cutie de viteze')
|
||||||
|
->options(\App\Models\Tenant\Vehicle::TRANSMISSION_TYPES)
|
||||||
|
->columns(3)
|
||||||
|
->columnSpanFull(),
|
||||||
|
Forms\Components\TextInput::make('conditions.age_min')->label('Vârstă min (ani)')->numeric(),
|
||||||
|
Forms\Components\TextInput::make('conditions.age_max')->label('Vârstă max (ani)')->numeric(),
|
||||||
|
Forms\Components\Toggle::make('conditions.client_vip')->label('Doar clienți VIP'),
|
||||||
|
Forms\Components\CheckboxList::make('conditions.urgency')
|
||||||
|
->label('Urgență')
|
||||||
|
->options(PricingCoefficient::URGENCY)
|
||||||
|
->columns(3)
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('priority')->label('Prio')->sortable()->alignRight(),
|
||||||
|
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('multiplier')
|
||||||
|
->label('Multiplicator')
|
||||||
|
->formatStateUsing(fn ($s) => '×' . rtrim(rtrim(number_format((float) $s, 3), '0'), '.'))
|
||||||
|
->alignRight()
|
||||||
|
->color(fn ($s) => (float) $s >= 1 ? 'success' : 'warning'),
|
||||||
|
Tables\Columns\IconColumn::make('stackable')->label('Cumul.')->boolean(),
|
||||||
|
Tables\Columns\IconColumn::make('is_active')->label('Activ')->boolean(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\TernaryFilter::make('is_active')->label('Active'),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\EditAction::make(),
|
||||||
|
Actions\DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->emptyStateHeading('Niciun coeficient')
|
||||||
|
->emptyStateDescription('Adaugă reguli care ajustează prețul în funcție de vârsta mașinii, clasă (SUV, comercial, hibrid), client VIP sau urgență. Se aplică peste markup-ul de bază pe fișele de lucru.')
|
||||||
|
->emptyStateIcon('heroicon-o-adjustments-horizontal')
|
||||||
|
->defaultSort('priority');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Coeficienți preț');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): ?string
|
||||||
|
{
|
||||||
|
return __('Depozit');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('coeficient');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPluralModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('coeficienți preț');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListPricingCoefficients::route('/'),
|
||||||
|
'create' => Pages\CreatePricingCoefficient::route('/create'),
|
||||||
|
'edit' => Pages\EditPricingCoefficient::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\PricingCoefficientResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\PricingCoefficientResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreatePricingCoefficient extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PricingCoefficientResource::class;
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\PricingCoefficientResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\PricingCoefficientResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditPricingCoefficient extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PricingCoefficientResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [Actions\DeleteAction::make()];
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\PricingCoefficientResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\PricingCoefficientResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListPricingCoefficients extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = PricingCoefficientResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [Actions\CreateAction::make()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Filament\Tenant\Resources\PurchaseResource\Pages;
|
|||||||
use App\Filament\Tenant\Resources\PurchaseResource\RelationManagers;
|
use App\Filament\Tenant\Resources\PurchaseResource\RelationManagers;
|
||||||
use App\Models\Tenant\Purchase;
|
use App\Models\Tenant\Purchase;
|
||||||
use App\Models\Tenant\Supplier;
|
use App\Models\Tenant\Supplier;
|
||||||
|
use App\Models\Tenant\Warehouse;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@@ -43,6 +44,11 @@ class PurchaseResource extends Resource
|
|||||||
->options(fn () => Supplier::where('is_active', true)->pluck('name', 'id'))
|
->options(fn () => Supplier::where('is_active', true)->pluck('name', 'id'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->required(),
|
->required(),
|
||||||
|
Forms\Components\Select::make('warehouse_id')
|
||||||
|
->label('Depozit țintă')
|
||||||
|
->options(fn () => Warehouse::where('is_active', true)->pluck('name', 'id'))
|
||||||
|
->default(fn () => Warehouse::where('is_default', true)->value('id'))
|
||||||
|
->required(),
|
||||||
Forms\Components\Select::make('status')
|
Forms\Components\Select::make('status')
|
||||||
->options(Purchase::STATUSES)
|
->options(Purchase::STATUSES)
|
||||||
->default('draft')
|
->default('draft')
|
||||||
@@ -71,9 +77,19 @@ class PurchaseResource extends Resource
|
|||||||
->colors([
|
->colors([
|
||||||
'gray' => ['draft'],
|
'gray' => ['draft'],
|
||||||
'warning' => ['ordered'],
|
'warning' => ['ordered'],
|
||||||
|
'info' => ['partial'],
|
||||||
'success' => ['received'],
|
'success' => ['received'],
|
||||||
'danger' => ['cancelled'],
|
'danger' => ['cancelled'],
|
||||||
]),
|
]),
|
||||||
|
Tables\Columns\TextColumn::make('received_progress')
|
||||||
|
->label('Progres')
|
||||||
|
->state(function (Purchase $r) {
|
||||||
|
$items = $r->items;
|
||||||
|
$ord = (float) $items->sum('qty');
|
||||||
|
$rec = (float) $items->sum('qty_received');
|
||||||
|
return $ord > 0 ? sprintf('%d%%', (int) round($rec / $ord * 100)) : '—';
|
||||||
|
})
|
||||||
|
->alignRight(),
|
||||||
Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(),
|
Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
@@ -83,19 +99,27 @@ class PurchaseResource extends Resource
|
|||||||
->options(fn () => Supplier::pluck('name', 'id')),
|
->options(fn () => Supplier::pluck('name', 'id')),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('receive')
|
Actions\Action::make('receive_all')
|
||||||
->label('Recepționează')
|
->label('Recepție totală')
|
||||||
->icon('heroicon-m-check-circle')
|
->icon('heroicon-m-check-circle')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (Purchase $r) => $r->status !== 'received' && $r->status !== 'cancelled')
|
->visible(fn (Purchase $r) => ! in_array($r->status, ['received', 'cancelled', 'draft'], true))
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalDescription('Se va incrementa stocul pieselor legate.')
|
->modalDescription('Se vor crea batch-uri pentru toate restanțele rămase în depozitul țintă.')
|
||||||
->action(function (Purchase $r) {
|
->action(function (Purchase $r) {
|
||||||
$r->markReceived();
|
try {
|
||||||
Notification::make()
|
$r->receiveAllRemaining();
|
||||||
->title('Recepționat — stoc actualizat')
|
Notification::make()
|
||||||
->success()
|
->title('Recepție completă — batch-uri create')
|
||||||
->send();
|
->success()
|
||||||
|
->send();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Eroare')
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-2
@@ -3,8 +3,11 @@
|
|||||||
namespace App\Filament\Tenant\Resources\PurchaseResource\RelationManagers;
|
namespace App\Filament\Tenant\Resources\PurchaseResource\RelationManagers;
|
||||||
|
|
||||||
use App\Models\Tenant\Part;
|
use App\Models\Tenant\Part;
|
||||||
|
use App\Models\Tenant\PurchaseItem;
|
||||||
|
use App\Models\Tenant\Warehouse;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Schemas\Components\Utilities\Set;
|
use Filament\Schemas\Components\Utilities\Set;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
@@ -52,16 +55,58 @@ class ItemsRelationManager extends RelationManager
|
|||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('name')->wrap(),
|
Tables\Columns\TextColumn::make('name')->wrap(),
|
||||||
Tables\Columns\TextColumn::make('article')->placeholder('—'),
|
Tables\Columns\TextColumn::make('article')->placeholder('—'),
|
||||||
Tables\Columns\TextColumn::make('qty')->alignRight(),
|
Tables\Columns\TextColumn::make('qty')->label('Comandat')->alignRight(),
|
||||||
|
Tables\Columns\TextColumn::make('qty_received')
|
||||||
|
->label('Recepționat')
|
||||||
|
->alignRight()
|
||||||
|
->color(fn ($state, $record) => $record->isFullyReceived() ? 'success' : ((float) $state > 0 ? 'warning' : 'gray'))
|
||||||
|
->formatStateUsing(fn ($state, $record) => sprintf('%.2f / %.2f', (float) $state, (float) $record->qty)),
|
||||||
Tables\Columns\TextColumn::make('unit')->label('UM'),
|
Tables\Columns\TextColumn::make('unit')->label('UM'),
|
||||||
Tables\Columns\TextColumn::make('buy_price')->money('MDL')->alignRight(),
|
Tables\Columns\TextColumn::make('buy_price')->money('MDL')->alignRight(),
|
||||||
Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(),
|
Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(),
|
||||||
Tables\Columns\IconColumn::make('received')->boolean()->label('Recepț.'),
|
|
||||||
])
|
])
|
||||||
->headerActions([
|
->headerActions([
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
|
Actions\Action::make('receive_item')
|
||||||
|
->label('Recepționează')
|
||||||
|
->icon('heroicon-m-arrow-down-tray')
|
||||||
|
->color('success')
|
||||||
|
->visible(fn (PurchaseItem $r) => ! $r->isFullyReceived())
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Placeholder::make('outstanding')
|
||||||
|
->label('Restanță')
|
||||||
|
->content(fn (PurchaseItem $r) => sprintf('%.2f %s', $r->outstanding(), $r->unit ?? 'buc')),
|
||||||
|
Forms\Components\TextInput::make('qty')
|
||||||
|
->label('Cantitate recepționată')
|
||||||
|
->numeric()
|
||||||
|
->required()
|
||||||
|
->minValue(0.001)
|
||||||
|
->default(fn (PurchaseItem $r) => $r->outstanding()),
|
||||||
|
Forms\Components\Select::make('warehouse_id')
|
||||||
|
->label('Depozit țintă')
|
||||||
|
->options(fn () => Warehouse::where('is_active', true)->pluck('name', 'id'))
|
||||||
|
->default(fn (PurchaseItem $r) => $r->purchase?->warehouse_id
|
||||||
|
?? Warehouse::where('is_default', true)->value('id'))
|
||||||
|
->required(),
|
||||||
|
])
|
||||||
|
->action(function (PurchaseItem $r, array $data) {
|
||||||
|
$wh = $data['warehouse_id'] ? Warehouse::find($data['warehouse_id']) : null;
|
||||||
|
try {
|
||||||
|
$r->purchase->receiveItem($r, (float) $data['qty'], $wh);
|
||||||
|
Notification::make()
|
||||||
|
->title('Recepționat — batch creat')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Eroare la recepție')
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::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,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\ServiceTemplateResource\Pages;
|
||||||
|
use App\Filament\Tenant\Resources\ServiceTemplateResource\RelationManagers;
|
||||||
|
use App\Models\Tenant\Labor;
|
||||||
|
use App\Models\Tenant\ServiceTemplate;
|
||||||
|
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 ServiceTemplateResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = ServiceTemplate::class;
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Șabloane servicii';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Service';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'șablon';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'șabloane servicii';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 33;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Schemas\Components\Section::make()
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('name')->label('Denumire')->required()
|
||||||
|
->placeholder('ex: Revizie completă 15.000 km')->columnSpanFull(),
|
||||||
|
Forms\Components\Select::make('category')
|
||||||
|
->label('Categorie')
|
||||||
|
->options(array_combine(Labor::CATEGORIES, Labor::CATEGORIES))
|
||||||
|
->searchable(),
|
||||||
|
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
|
||||||
|
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('category')->badge()->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('items_count')->counts('items')->label('Linii')->alignRight(),
|
||||||
|
Tables\Columns\IconColumn::make('is_active')->label('Activ')->boolean(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\TernaryFilter::make('is_active')->label('Active'),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\EditAction::make(),
|
||||||
|
Actions\DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->emptyStateHeading('Niciun șablon')
|
||||||
|
->emptyStateDescription('Grupează manopere + piese frecvente într-un șablon (ex: „Schimb ulei complet") și aplică-l pe o fișă cu un click.')
|
||||||
|
->emptyStateIcon('heroicon-o-clipboard-document-list')
|
||||||
|
->defaultSort('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
RelationManagers\ItemsRelationManager::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListServiceTemplates::route('/'),
|
||||||
|
'create' => Pages\CreateServiceTemplate::route('/create'),
|
||||||
|
'edit' => Pages\EditServiceTemplate::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\ServiceTemplateResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\ServiceTemplateResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateServiceTemplate extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = ServiceTemplateResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\ServiceTemplateResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\ServiceTemplateResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditServiceTemplate extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = ServiceTemplateResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [Actions\DeleteAction::make()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\ServiceTemplateResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\ServiceTemplateResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListServiceTemplates extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = ServiceTemplateResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [Actions\CreateAction::make()];
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\ServiceTemplateResource\RelationManagers;
|
||||||
|
|
||||||
|
use App\Models\Tenant\Labor;
|
||||||
|
use App\Models\Tenant\Part;
|
||||||
|
use App\Models\Tenant\ServiceTemplateItem;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
|
use Filament\Schemas\Components\Utilities\Set;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class ItemsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'items';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Conținut șablon';
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Forms\Components\Select::make('kind')
|
||||||
|
->label('Tip')
|
||||||
|
->options(ServiceTemplateItem::KINDS)
|
||||||
|
->default('labor')
|
||||||
|
->live()
|
||||||
|
->required(),
|
||||||
|
Forms\Components\Select::make('labor_id')
|
||||||
|
->label('Manoperă')
|
||||||
|
->options(fn () => Labor::where('is_active', true)->pluck('name_ro', 'id'))
|
||||||
|
->searchable()
|
||||||
|
->visible(fn (Get $get) => $get('kind') === 'labor')
|
||||||
|
->live()
|
||||||
|
->afterStateUpdated(function ($state, Set $set) {
|
||||||
|
if ($state && $l = Labor::find($state)) {
|
||||||
|
$set('name', $l->name_ro);
|
||||||
|
$set('hours', $l->hours);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Forms\Components\Select::make('part_id')
|
||||||
|
->label('Piesă')
|
||||||
|
->options(fn () => Part::where('is_active', true)
|
||||||
|
->get()->mapWithKeys(fn ($p) => [$p->id => "{$p->name} " . ($p->article ? "[{$p->article}]" : '')])->toArray())
|
||||||
|
->searchable()
|
||||||
|
->visible(fn (Get $get) => $get('kind') === 'part')
|
||||||
|
->live()
|
||||||
|
->afterStateUpdated(function ($state, Set $set) {
|
||||||
|
if ($state && $p = Part::find($state)) $set('name', $p->name);
|
||||||
|
}),
|
||||||
|
Forms\Components\TextInput::make('name')->label('Denumire')->required()->columnSpanFull(),
|
||||||
|
Forms\Components\TextInput::make('hours')->label('Ore')->numeric()
|
||||||
|
->visible(fn (Get $get) => $get('kind') === 'labor'),
|
||||||
|
Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->default(1)
|
||||||
|
->visible(fn (Get $get) => $get('kind') === 'part'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->recordTitleAttribute('name')
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('kind')
|
||||||
|
->label('Tip')
|
||||||
|
->formatStateUsing(fn ($s) => ServiceTemplateItem::KINDS[$s] ?? $s)
|
||||||
|
->badge()
|
||||||
|
->color(fn ($s) => $s === 'labor' ? 'info' : 'gray'),
|
||||||
|
Tables\Columns\TextColumn::make('name')->wrap(),
|
||||||
|
Tables\Columns\TextColumn::make('hours')->label('Ore')->placeholder('—')->alignRight(),
|
||||||
|
Tables\Columns\TextColumn::make('qty')->label('Cant.')->placeholder('—')->alignRight(),
|
||||||
|
])
|
||||||
|
->headerActions([Actions\CreateAction::make()])
|
||||||
|
->actions([Actions\EditAction::make(), Actions\DeleteAction::make()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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ă');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\SubcontractJobResource\Pages;
|
||||||
|
use App\Models\Tenant\Subcontractor;
|
||||||
|
use App\Models\Tenant\SubcontractJob;
|
||||||
|
use App\Models\Tenant\WorkOrder;
|
||||||
|
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 SubcontractJobResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = SubcontractJob::class;
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-arrow-top-right-on-square';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Lucrări terți';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Subcontractare';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'lucrare terți';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'lucrări terți';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 71;
|
||||||
|
|
||||||
|
public static function getNavigationBadge(): ?string
|
||||||
|
{
|
||||||
|
$open = static::getModel()::query()->whereNotIn('status', ['done', 'returned', 'cancelled'])->count();
|
||||||
|
return $open > 0 ? (string) $open : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Schemas\Components\Section::make('Lucrare')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('number')->label('Nr.')->disabled()->dehydrated(false)->placeholder('Generat automat'),
|
||||||
|
Forms\Components\Select::make('status')->options(SubcontractJob::STATUSES)->default('sent')->required(),
|
||||||
|
Forms\Components\Select::make('subcontractor_id')
|
||||||
|
->label('Subcontractor')
|
||||||
|
->options(fn () => Subcontractor::where('is_active', true)->pluck('name', 'id'))
|
||||||
|
->searchable(),
|
||||||
|
Forms\Components\Select::make('work_order_id')
|
||||||
|
->label('Fișă asociată')
|
||||||
|
->options(fn () => WorkOrder::whereNotIn('status', ['done', 'cancelled'])
|
||||||
|
->get()->mapWithKeys(fn ($w) => [$w->id => "#{$w->number} · " . ($w->vehicle?->plate ?? '')])->toArray())
|
||||||
|
->searchable(),
|
||||||
|
Forms\Components\Select::make('category')
|
||||||
|
->label('Categorie')
|
||||||
|
->options(array_combine(Subcontractor::SPECIALTIES, Subcontractor::SPECIALTIES))
|
||||||
|
->searchable(),
|
||||||
|
Forms\Components\Textarea::make('description')->label('Descriere')->rows(2)->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
Schemas\Components\Section::make('Cost & marjă')
|
||||||
|
->columns(3)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('cost')->label('Cost (de la terț)')->numeric()->default(0)->required(),
|
||||||
|
Forms\Components\TextInput::make('markup_pct')->label('Markup %')->numeric()->default(0)
|
||||||
|
->helperText('> 0 calculează automat prețul client.'),
|
||||||
|
Forms\Components\TextInput::make('client_price')->label('Preț client')->numeric()->default(0)
|
||||||
|
->helperText('Setat manual dacă markup = 0.'),
|
||||||
|
Forms\Components\Toggle::make('paid_to_sub')->label('Plătit către terț'),
|
||||||
|
]),
|
||||||
|
Schemas\Components\Section::make('Termene')
|
||||||
|
->columns(3)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\DatePicker::make('sent_at')->label('Trimis')->default(today()),
|
||||||
|
Forms\Components\DatePicker::make('eta')->label('ETA'),
|
||||||
|
Forms\Components\DatePicker::make('returned_at')->label('Returnat'),
|
||||||
|
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('number')->label('Nr.')->searchable()->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('subcontractor.name')->label('Terț')->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('category')->badge()->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('workOrder.number')->label('Fișă')->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('cost')->label('Cost')->money('MDL')->alignRight(),
|
||||||
|
Tables\Columns\TextColumn::make('client_price')->label('Preț client')->money('MDL')->alignRight(),
|
||||||
|
Tables\Columns\TextColumn::make('margin')
|
||||||
|
->label('Marjă')
|
||||||
|
->state(fn (SubcontractJob $r) => $r->margin())
|
||||||
|
->money('MDL')
|
||||||
|
->alignRight()
|
||||||
|
->color(fn ($state) => (float) $state > 0 ? 'success' : ((float) $state < 0 ? 'danger' : 'gray')),
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->formatStateUsing(fn ($s) => SubcontractJob::STATUSES[$s] ?? $s)
|
||||||
|
->badge()
|
||||||
|
->colors([
|
||||||
|
'warning' => ['sent', 'in_progress'],
|
||||||
|
'success' => ['done', 'returned'],
|
||||||
|
'danger' => ['cancelled'],
|
||||||
|
]),
|
||||||
|
Tables\Columns\IconColumn::make('paid_to_sub')->label('Plătit terț')->boolean()->toggleable(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('status')->options(SubcontractJob::STATUSES),
|
||||||
|
Tables\Filters\SelectFilter::make('subcontractor_id')
|
||||||
|
->label('Subcontractor')
|
||||||
|
->options(fn () => Subcontractor::pluck('name', 'id')),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\EditAction::make(),
|
||||||
|
Actions\DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->emptyStateHeading('Nicio lucrare la terți')
|
||||||
|
->emptyStateDescription('Înregistrează lucrările trimise la ateliere externe (turbo, cutii, vopsitorie). Costul terțului + markup intră automat în totalul fișei asociate.')
|
||||||
|
->emptyStateIcon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->defaultSort('created_at', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Lucrări terți');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): ?string
|
||||||
|
{
|
||||||
|
return __('Subcontractare');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('lucrare terți');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPluralModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('lucrări terți');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListSubcontractJobs::route('/'),
|
||||||
|
'create' => Pages\CreateSubcontractJob::route('/create'),
|
||||||
|
'edit' => Pages\EditSubcontractJob::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\SubcontractJobResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\SubcontractJobResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateSubcontractJob extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = SubcontractJobResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\SubcontractJobResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\SubcontractJobResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditSubcontractJob extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = SubcontractJobResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [Actions\DeleteAction::make()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\SubcontractJobResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\SubcontractJobResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListSubcontractJobs extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = SubcontractJobResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [Actions\CreateAction::make()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\SubcontractorResource\Pages;
|
||||||
|
use App\Models\Tenant\Subcontractor;
|
||||||
|
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 SubcontractorResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Subcontractor::class;
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Subcontractori';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Subcontractare';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'subcontractor';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'subcontractori';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 70;
|
||||||
|
|
||||||
|
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\Select::make('specialty')
|
||||||
|
->label('Specialitate')
|
||||||
|
->options(array_combine(Subcontractor::SPECIALTIES, Subcontractor::SPECIALTIES))
|
||||||
|
->searchable(),
|
||||||
|
Forms\Components\TextInput::make('phone')->label('Telefon')->tel()->maxLength(40),
|
||||||
|
Forms\Components\TextInput::make('email')->email()->maxLength(120),
|
||||||
|
Forms\Components\Select::make('rating')
|
||||||
|
->label('Rating')
|
||||||
|
->options([1 => '★', 2 => '★★', 3 => '★★★', 4 => '★★★★', 5 => '★★★★★'])
|
||||||
|
->default(3),
|
||||||
|
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
|
||||||
|
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('specialty')->badge()->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('phone')->copyable()->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('rating')->formatStateUsing(fn ($s) => str_repeat('★', (int) $s)),
|
||||||
|
Tables\Columns\TextColumn::make('jobs_count')->counts('jobs')->label('Lucrări')->alignRight(),
|
||||||
|
Tables\Columns\IconColumn::make('is_active')->boolean(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\TernaryFilter::make('is_active')->label('Activi'),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\EditAction::make(),
|
||||||
|
Actions\DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->emptyStateHeading('Niciun subcontractor')
|
||||||
|
->emptyStateDescription('Adaugă atelierele terțe la care trimiți lucrări (turbo, cutii, vopsitorie, PDR) și urmărește costul + marja.')
|
||||||
|
->emptyStateIcon('heroicon-o-user-group')
|
||||||
|
->defaultSort('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Subcontractori');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): ?string
|
||||||
|
{
|
||||||
|
return __('Subcontractare');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('subcontractor');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPluralModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('subcontractori');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListSubcontractors::route('/'),
|
||||||
|
'create' => Pages\CreateSubcontractor::route('/create'),
|
||||||
|
'edit' => Pages\EditSubcontractor::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\SubcontractorResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\SubcontractorResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateSubcontractor extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = SubcontractorResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\SubcontractorResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\SubcontractorResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditSubcontractor extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = SubcontractorResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [Actions\DeleteAction::make()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\SubcontractorResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\SubcontractorResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListSubcontractors extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = SubcontractorResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [Actions\CreateAction::make()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,15 +68,56 @@ class SupplierResource extends Resource
|
|||||||
Tables\Columns\TextColumn::make('rating')
|
Tables\Columns\TextColumn::make('rating')
|
||||||
->label('Rating')
|
->label('Rating')
|
||||||
->formatStateUsing(fn ($s) => str_repeat('★', (int) $s)),
|
->formatStateUsing(fn ($s) => str_repeat('★', (int) $s)),
|
||||||
Tables\Columns\TextColumn::make('delivery_days')->label('Livrare (zile)')->alignRight(),
|
Tables\Columns\TextColumn::make('on_time_pct')
|
||||||
|
->label('La timp 90d')
|
||||||
|
->state(fn (Supplier $r) => app(\App\Services\Warehouse\SupplierAnalytics::class)->onTimeRate($r))
|
||||||
|
->formatStateUsing(fn ($s) => $s === null ? '—' : "{$s}%")
|
||||||
|
->color(fn ($s) => $s === null ? 'gray' : ($s >= 90 ? 'success' : ($s >= 70 ? 'warning' : 'danger')))
|
||||||
|
->alignRight()
|
||||||
|
->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('avg_delivery_days')
|
||||||
|
->label('Avg zile')
|
||||||
|
->state(fn (Supplier $r) => app(\App\Services\Warehouse\SupplierAnalytics::class)->avgDeliveryDays($r))
|
||||||
|
->formatStateUsing(fn ($s) => $s === null ? '—' : (string) $s)
|
||||||
|
->alignRight()
|
||||||
|
->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('spend_90d')
|
||||||
|
->label('Cheltuit 90d')
|
||||||
|
->state(fn (Supplier $r) => app(\App\Services\Warehouse\SupplierAnalytics::class)->spend($r))
|
||||||
|
->money('MDL')
|
||||||
|
->alignRight()
|
||||||
|
->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('delivery_days')->label('Livrare (zile)')->alignRight()->toggleable(),
|
||||||
Tables\Columns\TextColumn::make('discount_pct')->label('Discount')
|
Tables\Columns\TextColumn::make('discount_pct')->label('Discount')
|
||||||
->formatStateUsing(fn ($s) => $s . '%')->alignRight(),
|
->formatStateUsing(fn ($s) => $s . '%')->alignRight()->toggleable(),
|
||||||
Tables\Columns\IconColumn::make('is_active')->boolean(),
|
Tables\Columns\IconColumn::make('is_active')->boolean(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\TernaryFilter::make('is_active')->label('Activi'),
|
Tables\Filters\TernaryFilter::make('is_active')->label('Activi'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
|
Actions\Action::make('rate')
|
||||||
|
->label('Rerating')
|
||||||
|
->icon('heroicon-m-arrow-path')
|
||||||
|
->color('gray')
|
||||||
|
->action(function (Supplier $r) {
|
||||||
|
$score = app(\App\Services\Warehouse\SupplierAnalytics::class)
|
||||||
|
->computedRating($r);
|
||||||
|
if ($score === null) {
|
||||||
|
\Filament\Notifications\Notification::make()
|
||||||
|
->title('Date insuficiente')
|
||||||
|
->body('Necesită cel puțin 2 recepții complete cu data așteptată setată.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$r->rating = $score;
|
||||||
|
$r->saveQuietly();
|
||||||
|
\Filament\Notifications\Notification::make()
|
||||||
|
->title("Rating actualizat → {$score}★")
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make(),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\TireSetResource\Pages;
|
||||||
|
use App\Filament\Tenant\Resources\TireSetResource\RelationManagers;
|
||||||
|
use App\Models\Tenant\Client;
|
||||||
|
use App\Models\Tenant\TireSet;
|
||||||
|
use App\Models\Tenant\Vehicle;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class TireSetResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = TireSet::class;
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-lifebuoy';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Seturi anvelope';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Anvelope';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'set anvelope';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'seturi anvelope';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 60;
|
||||||
|
|
||||||
|
public static function getNavigationBadge(): ?string
|
||||||
|
{
|
||||||
|
$stored = \App\Models\Tenant\TireStorage::where('status', 'stored')->count();
|
||||||
|
return $stored > 0 ? (string) $stored : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationBadgeColor(): ?string
|
||||||
|
{
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Schemas\Components\Section::make('Proprietar')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Select::make('client_id')
|
||||||
|
->label('Client')
|
||||||
|
->options(fn () => Client::pluck('name', 'id'))
|
||||||
|
->searchable()
|
||||||
|
->live()
|
||||||
|
->required(),
|
||||||
|
Forms\Components\Select::make('vehicle_id')
|
||||||
|
->label('Auto')
|
||||||
|
->options(fn (Get $get) => $get('client_id')
|
||||||
|
? Vehicle::where('client_id', $get('client_id'))->get()
|
||||||
|
->mapWithKeys(fn ($v) => [$v->id => "{$v->make} {$v->model} {$v->plate}"])->toArray()
|
||||||
|
: [])
|
||||||
|
->searchable(),
|
||||||
|
Forms\Components\TextInput::make('label')->label('Etichetă')->placeholder('ex: Iarnă Michelin'),
|
||||||
|
Forms\Components\Select::make('season')->label('Sezon')->options(TireSet::SEASONS)->default('winter')->required(),
|
||||||
|
]),
|
||||||
|
Schemas\Components\Section::make('Specificații')
|
||||||
|
->columns(3)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('width')->label('Lățime')->numeric()->placeholder('205'),
|
||||||
|
Forms\Components\TextInput::make('profile')->label('Profil')->numeric()->placeholder('55'),
|
||||||
|
Forms\Components\TextInput::make('diameter')->label('Diametru R')->numeric()->placeholder('16'),
|
||||||
|
Forms\Components\TextInput::make('brand')->maxLength(64),
|
||||||
|
Forms\Components\TextInput::make('model')->maxLength(64),
|
||||||
|
Forms\Components\TextInput::make('dot_year')->label('DOT')->maxLength(8)->placeholder('3621'),
|
||||||
|
Forms\Components\Toggle::make('has_rims')->label('Cu jante'),
|
||||||
|
Forms\Components\Select::make('rim_type')->label('Tip jante')->options(['steel' => 'Tablă', 'alloy' => 'Aliaj']),
|
||||||
|
Forms\Components\Select::make('condition')->label('Stare')->options(TireSet::CONDITIONS),
|
||||||
|
]),
|
||||||
|
Schemas\Components\Section::make('Uzură (mm) per poziție')
|
||||||
|
->columns(4)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('tread.fl')->label('Față-Stânga')->numeric(),
|
||||||
|
Forms\Components\TextInput::make('tread.fr')->label('Față-Dreapta')->numeric(),
|
||||||
|
Forms\Components\TextInput::make('tread.rl')->label('Spate-Stânga')->numeric(),
|
||||||
|
Forms\Components\TextInput::make('tread.rr')->label('Spate-Dreapta')->numeric(),
|
||||||
|
]),
|
||||||
|
Schemas\Components\Section::make('TPMS & foto')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Toggle::make('tpms')->label('Senzori TPMS'),
|
||||||
|
Forms\Components\TextInput::make('notes')->label('Observații'),
|
||||||
|
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos')
|
||||||
|
->label('Fotografii')
|
||||||
|
->collection('photos')
|
||||||
|
->multiple()
|
||||||
|
->image()
|
||||||
|
->maxFiles(8)
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('client.name')->label('Client')->searchable()->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('label')->label('Etichetă')->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('size')
|
||||||
|
->label('Dimensiune')
|
||||||
|
->state(fn (TireSet $r) => $r->sizeLabel()),
|
||||||
|
Tables\Columns\TextColumn::make('season')
|
||||||
|
->label('Sezon')
|
||||||
|
->formatStateUsing(fn ($s) => TireSet::SEASONS[$s] ?? $s)
|
||||||
|
->badge()
|
||||||
|
->colors(['warning' => ['summer'], 'info' => ['winter'], 'gray' => ['allseason']]),
|
||||||
|
Tables\Columns\TextColumn::make('tread_min')->label('Uzură min')
|
||||||
|
->formatStateUsing(fn ($s) => $s ? $s . ' mm' : '—')
|
||||||
|
->color(fn ($s) => $s !== null && (float) $s < 3 ? 'danger' : null)
|
||||||
|
->alignRight(),
|
||||||
|
Tables\Columns\IconColumn::make('tpms')->label('TPMS')->boolean()->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('storage_status')
|
||||||
|
->label('Depozit')
|
||||||
|
->state(fn (TireSet $r) => $r->isStored() ? ($r->currentStorage()?->location ?? 'da') : '—')
|
||||||
|
->badge()
|
||||||
|
->color(fn ($state) => $state === '—' ? 'gray' : 'success'),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('season')->options(TireSet::SEASONS),
|
||||||
|
Tables\Filters\Filter::make('stored')
|
||||||
|
->label('În depozit')
|
||||||
|
->query(fn ($q) => $q->whereHas('storage', fn ($s) => $s->where('status', 'stored'))),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('check_in')
|
||||||
|
->label('Check-in depozit')
|
||||||
|
->icon('heroicon-m-arrow-down-on-square')
|
||||||
|
->color('success')
|
||||||
|
->visible(fn (TireSet $r) => ! $r->isStored())
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('location')->label('Locație (raft)')->required()->placeholder('A1-03'),
|
||||||
|
Forms\Components\TextInput::make('season_label')->label('Perioadă')->placeholder('Iarnă 2025-2026'),
|
||||||
|
Forms\Components\TextInput::make('fee')->label('Taxă depozitare')->numeric()->default(0),
|
||||||
|
])
|
||||||
|
->action(function (TireSet $r, array $data) {
|
||||||
|
\App\Models\Tenant\TireStorage::create([
|
||||||
|
'tire_set_id' => $r->id,
|
||||||
|
'location' => $data['location'],
|
||||||
|
'season_label' => $data['season_label'] ?? null,
|
||||||
|
'fee' => (float) ($data['fee'] ?? 0),
|
||||||
|
'status' => 'stored',
|
||||||
|
'checked_in_at' => now(),
|
||||||
|
]);
|
||||||
|
\Filament\Notifications\Notification::make()->title('Set primit în depozit')->success()->send();
|
||||||
|
}),
|
||||||
|
Actions\Action::make('check_out')
|
||||||
|
->label('Eliberează')
|
||||||
|
->icon('heroicon-m-arrow-up-on-square')
|
||||||
|
->color('warning')
|
||||||
|
->visible(fn (TireSet $r) => $r->isStored())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalDescription('Marchează setul ca ridicat de client.')
|
||||||
|
->action(function (TireSet $r) {
|
||||||
|
$storage = $r->currentStorage();
|
||||||
|
if ($storage) {
|
||||||
|
$storage->update(['status' => 'retrieved', 'checked_out_at' => now()]);
|
||||||
|
}
|
||||||
|
\Filament\Notifications\Notification::make()->title('Set eliberat din depozit')->success()->send();
|
||||||
|
}),
|
||||||
|
Actions\EditAction::make(),
|
||||||
|
Actions\DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->emptyStateHeading('Niciun set de anvelope')
|
||||||
|
->emptyStateDescription('Înregistrează seturile de anvelope ale clienților și gestionează depozitarea sezonieră (tire hotel). Urmărește uzura, TPMS și locația în depozit.')
|
||||||
|
->emptyStateIcon('heroicon-o-lifebuoy')
|
||||||
|
->defaultSort('created_at', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
RelationManagers\StorageRelationManager::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Seturi anvelope');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): ?string
|
||||||
|
{
|
||||||
|
return __('Anvelope');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('set anvelope');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPluralModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('seturi anvelope');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListTireSets::route('/'),
|
||||||
|
'create' => Pages\CreateTireSet::route('/create'),
|
||||||
|
'edit' => Pages\EditTireSet::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\TireSetResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\TireSetResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateTireSet extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = TireSetResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\TireSetResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\TireSetResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditTireSet extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = TireSetResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [Actions\DeleteAction::make()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\TireSetResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\TireSetResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListTireSets extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = TireSetResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [Actions\CreateAction::make()];
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\TireSetResource\RelationManagers;
|
||||||
|
|
||||||
|
use App\Models\Tenant\TireStorage;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class StorageRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'storage';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Istoric depozitare';
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('season_label')->label('Perioadă')->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('location')->label('Locație')->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('checked_in_at')->label('Primit')->dateTime('d.m.Y'),
|
||||||
|
Tables\Columns\TextColumn::make('checked_out_at')->label('Ridicat')->dateTime('d.m.Y')->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->formatStateUsing(fn ($s) => TireStorage::STATUSES[$s] ?? $s)
|
||||||
|
->badge()
|
||||||
|
->colors(['success' => ['stored'], 'gray' => ['retrieved']]),
|
||||||
|
Tables\Columns\TextColumn::make('fee')->money('MDL')->alignRight(),
|
||||||
|
Tables\Columns\IconColumn::make('paid')->label('Plătit')->boolean(),
|
||||||
|
])
|
||||||
|
->defaultSort('checked_in_at', 'desc')
|
||||||
|
->emptyStateHeading('Niciun istoric')
|
||||||
|
->emptyStateDescription('Folosește „Check-in depozit" pe set pentru a înregistra prima depozitare.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,10 @@ class VehicleResource extends Resource
|
|||||||
'Benzină' => 'Benzină', 'Diesel' => 'Diesel', 'Hybrid' => 'Hybrid',
|
'Benzină' => 'Benzină', 'Diesel' => 'Diesel', 'Hybrid' => 'Hybrid',
|
||||||
'EV' => 'Electric', 'GPL' => 'GPL', 'GNC' => 'GNC',
|
'EV' => 'Electric', 'GPL' => 'GPL', 'GNC' => 'GNC',
|
||||||
]),
|
]),
|
||||||
|
Forms\Components\Select::make('vehicle_class')
|
||||||
|
->label('Clasă (pentru pricing)')
|
||||||
|
->options(\App\Models\Tenant\PricingCoefficient::VEHICLE_CLASSES)
|
||||||
|
->helperText('Folosită de coeficienții de preț. Hibrid/EV se deduc și din combustibil.'),
|
||||||
Forms\Components\TextInput::make('mileage')->label('Kilometraj')->numeric()->default(0),
|
Forms\Components\TextInput::make('mileage')->label('Kilometraj')->numeric()->default(0),
|
||||||
Forms\Components\TextInput::make('color')->maxLength(40),
|
Forms\Components\TextInput::make('color')->maxLength(40),
|
||||||
]),
|
]),
|
||||||
@@ -93,6 +97,31 @@ class VehicleResource extends Resource
|
|||||||
Tables\Columns\TextColumn::make('created_at')->date()->sortable(),
|
Tables\Columns\TextColumn::make('created_at')->date()->sortable(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
|
Actions\Action::make('decode_vin')
|
||||||
|
->label('Decode VIN')
|
||||||
|
->icon('heroicon-m-cpu-chip')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (\App\Models\Tenant\Vehicle $r) => ! empty($r->vin) && strlen($r->vin) === 17)
|
||||||
|
->modalHeading(fn (\App\Models\Tenant\Vehicle $r) => 'Decode VIN: ' . $r->vin)
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelActionLabel('Închide')
|
||||||
|
->modalContent(function (\App\Models\Tenant\Vehicle $r) {
|
||||||
|
$info = app(\App\Services\Ai\VinDecoder::class)->decode($r->vin);
|
||||||
|
return view('filament.tenant.vin-decode', ['info' => $info, 'vehicle' => $r]);
|
||||||
|
}),
|
||||||
|
Actions\Action::make('ai_recommend')
|
||||||
|
->label('AI: recomandări')
|
||||||
|
->icon('heroicon-m-sparkles')
|
||||||
|
->color('primary')
|
||||||
|
->visible(fn (\App\Models\Tenant\Vehicle $r) => ! empty($r->vin))
|
||||||
|
->modalHeading('Recomandări AI')
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelActionLabel('Închide')
|
||||||
|
->modalContent(function (\App\Models\Tenant\Vehicle $r) {
|
||||||
|
[$reply, $meta] = app(\App\Services\Ai\AiAssistantService::class)
|
||||||
|
->vinRecommendations($r->vin, (int) $r->mileage);
|
||||||
|
return view('filament.tenant.ai-reply', ['reply' => $reply, 'meta' => $meta]);
|
||||||
|
}),
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make(),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\WarehouseResource\Pages;
|
||||||
|
use App\Models\Tenant\Warehouse;
|
||||||
|
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 WarehouseResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Warehouse::class;
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-storefront';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Depozite';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Depozit';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'depozit';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'depozite';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 38;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Schemas\Components\Section::make()->columns(2)->schema([
|
||||||
|
Forms\Components\TextInput::make('code')->label('Cod')->required()->maxLength(32),
|
||||||
|
Forms\Components\TextInput::make('name')->label('Denumire')->required()->maxLength(120),
|
||||||
|
Forms\Components\TextInput::make('address')->label('Adresă')->columnSpanFull()->maxLength(200),
|
||||||
|
Forms\Components\Toggle::make('is_default')->label('Depozit implicit'),
|
||||||
|
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('code')->searchable()->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('address')->placeholder('—')->toggleable(),
|
||||||
|
Tables\Columns\IconColumn::make('is_default')->label('Implicit')->boolean(),
|
||||||
|
Tables\Columns\IconColumn::make('is_active')->label('Activ')->boolean(),
|
||||||
|
Tables\Columns\TextColumn::make('batches_count')
|
||||||
|
->counts('batches')
|
||||||
|
->label('Loturi')
|
||||||
|
->alignRight(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\EditAction::make(),
|
||||||
|
Actions\DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->emptyStateHeading('Niciun depozit')
|
||||||
|
->emptyStateDescription('Un depozit implicit a fost creat la migrare. Adaugă altele dacă ai locații fizice separate (sucursală, hală, mobil).')
|
||||||
|
->emptyStateIcon('heroicon-o-building-storefront')
|
||||||
|
->defaultSort('code');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListWarehouses::route('/'),
|
||||||
|
'create' => Pages\CreateWarehouse::route('/create'),
|
||||||
|
'edit' => Pages\EditWarehouse::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\WarehouseResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\WarehouseResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateWarehouse extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = WarehouseResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\WarehouseResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\WarehouseResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditWarehouse extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = WarehouseResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [Actions\DeleteAction::make()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\WarehouseResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\WarehouseResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListWarehouses extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = WarehouseResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [Actions\CreateAction::make()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,11 @@ class WorkOrderResource extends Resource
|
|||||||
->options(WorkOrder::STATUSES)
|
->options(WorkOrder::STATUSES)
|
||||||
->default('new')
|
->default('new')
|
||||||
->required(),
|
->required(),
|
||||||
|
Forms\Components\Select::make('urgency')
|
||||||
|
->label('Urgență')
|
||||||
|
->options(\App\Models\Tenant\PricingCoefficient::URGENCY)
|
||||||
|
->default('normal')
|
||||||
|
->required(),
|
||||||
Forms\Components\Select::make('client_id')
|
Forms\Components\Select::make('client_id')
|
||||||
->label('Client')
|
->label('Client')
|
||||||
->options(fn () => Client::pluck('name', 'id'))
|
->options(fn () => Client::pluck('name', 'id'))
|
||||||
@@ -101,6 +106,35 @@ class WorkOrderResource extends Resource
|
|||||||
Forms\Components\Textarea::make('diagnosis')->label('Diagnostic')->rows(3)->columnSpanFull(),
|
Forms\Components\Textarea::make('diagnosis')->label('Diagnostic')->rows(3)->columnSpanFull(),
|
||||||
Forms\Components\Textarea::make('recommendations')->label('Recomandări')->rows(2)->columnSpanFull(),
|
Forms\Components\Textarea::make('recommendations')->label('Recomandări')->rows(2)->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
|
Schemas\Components\Section::make('Foto')
|
||||||
|
->collapsible()
|
||||||
|
->schema([
|
||||||
|
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos')
|
||||||
|
->label('Fotografii')
|
||||||
|
->collection('photos')
|
||||||
|
->multiple()
|
||||||
|
->reorderable()
|
||||||
|
->image()
|
||||||
|
->imageEditor()
|
||||||
|
->maxFiles(20)
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
Schemas\Components\Section::make('Tracking & ETA')
|
||||||
|
->columns(3)
|
||||||
|
->collapsible()
|
||||||
|
->schema([
|
||||||
|
Forms\Components\DateTimePicker::make('eta_at')
|
||||||
|
->label('Gata estimat (ETA)')
|
||||||
|
->seconds(false),
|
||||||
|
Forms\Components\TextInput::make('tracking_token')
|
||||||
|
->label('Token public')
|
||||||
|
->disabled()
|
||||||
|
->dehydrated(false)
|
||||||
|
->columnSpan(2)
|
||||||
|
->helperText(fn (?WorkOrder $record) => $record?->tracking_token
|
||||||
|
? 'Link client: ' . $record->trackingUrl()
|
||||||
|
: 'Se generează la salvare'),
|
||||||
|
]),
|
||||||
Schemas\Components\Section::make('Plată & total')
|
Schemas\Components\Section::make('Plată & total')
|
||||||
->columns(3)
|
->columns(3)
|
||||||
->schema([
|
->schema([
|
||||||
@@ -180,6 +214,7 @@ class WorkOrderResource extends Resource
|
|||||||
return [
|
return [
|
||||||
RelationManagers\WorksRelationManager::class,
|
RelationManagers\WorksRelationManager::class,
|
||||||
RelationManagers\PartsRelationManager::class,
|
RelationManagers\PartsRelationManager::class,
|
||||||
|
RelationManagers\SubcontractJobsRelationManager::class,
|
||||||
RelationManagers\PaymentsRelationManager::class,
|
RelationManagers\PaymentsRelationManager::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,49 @@ class EditWorkOrder extends EditRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
Actions\Action::make('apply_template')
|
||||||
|
->label('Aplică șablon')
|
||||||
|
->icon('heroicon-m-clipboard-document-list')
|
||||||
|
->color('gray')
|
||||||
|
->schema([
|
||||||
|
\Filament\Forms\Components\Select::make('template_id')
|
||||||
|
->label('Șablon serviciu')
|
||||||
|
->options(fn () => \App\Models\Tenant\ServiceTemplate::where('is_active', true)->pluck('name', 'id'))
|
||||||
|
->searchable()
|
||||||
|
->required(),
|
||||||
|
])
|
||||||
|
->action(function (array $data) {
|
||||||
|
$template = \App\Models\Tenant\ServiceTemplate::with('items')->find($data['template_id']);
|
||||||
|
if (! $template) return;
|
||||||
|
$r = app(\App\Services\ServiceComposer::class)->applyTemplate($this->record, $template);
|
||||||
|
$this->fillForm();
|
||||||
|
\Filament\Notifications\Notification::make()
|
||||||
|
->title("Șablon aplicat: {$r['labor']} manopere, {$r['parts']} piese")
|
||||||
|
->success()->send();
|
||||||
|
}),
|
||||||
|
Actions\Action::make('ai_diagnose')
|
||||||
|
->label('AI: sugerează diagnostic')
|
||||||
|
->icon('heroicon-m-sparkles')
|
||||||
|
->color('primary')
|
||||||
|
->visible(fn () => ! empty($this->record->complaint))
|
||||||
|
->modalHeading('Diagnostic AI bazat pe plângerea clientului')
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelActionLabel('Închide')
|
||||||
|
->modalContent(function () {
|
||||||
|
[$reply, $meta] = app(\App\Services\Ai\AiAssistantService::class)
|
||||||
|
->suggestDiagnosis($this->record);
|
||||||
|
return view('filament.tenant.ai-reply', ['reply' => $reply, 'meta' => $meta]);
|
||||||
|
}),
|
||||||
|
Actions\Action::make('tracking')
|
||||||
|
->label('Link client (QR)')
|
||||||
|
->icon('heroicon-m-qr-code')
|
||||||
|
->color('primary')
|
||||||
|
->modalHeading(fn () => 'Tracking client — WO #' . $this->record->number)
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelActionLabel('Închide')
|
||||||
|
->modalContent(fn () => view('filament.tenant.tracking-qr', [
|
||||||
|
'wo' => $this->record,
|
||||||
|
])),
|
||||||
Actions\Action::make('pdf')
|
Actions\Action::make('pdf')
|
||||||
->label('Descarcă PDF')
|
->label('Descarcă PDF')
|
||||||
->icon('heroicon-m-document-arrow-down')
|
->icon('heroicon-m-document-arrow-down')
|
||||||
|
|||||||
+71
@@ -3,9 +3,12 @@
|
|||||||
namespace App\Filament\Tenant\Resources\WorkOrderResource\RelationManagers;
|
namespace App\Filament\Tenant\Resources\WorkOrderResource\RelationManagers;
|
||||||
|
|
||||||
use App\Models\Tenant\Part;
|
use App\Models\Tenant\Part;
|
||||||
|
use App\Models\Tenant\PartReservation;
|
||||||
use App\Models\Tenant\WorkOrderPart;
|
use App\Models\Tenant\WorkOrderPart;
|
||||||
|
use App\Services\Warehouse\WarehouseService;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Schemas\Components\Utilities\Set;
|
use Filament\Schemas\Components\Utilities\Set;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
@@ -82,6 +85,74 @@ class PartsRelationManager extends RelationManager
|
|||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
|
Actions\Action::make('smart_price')
|
||||||
|
->label('Preț inteligent')
|
||||||
|
->icon('heroicon-m-sparkles')
|
||||||
|
->color('primary')
|
||||||
|
->visible(fn (WorkOrderPart $r) => (bool) $r->part_id)
|
||||||
|
->modalHeading('Preț contextual')
|
||||||
|
->modalSubmitActionLabel('Aplică prețul')
|
||||||
|
->modalContent(function (WorkOrderPart $r) {
|
||||||
|
$wo = $r->workOrder;
|
||||||
|
$part = $r->part;
|
||||||
|
$quote = app(\App\Services\Pricing\PricingEngine::class)->quote(
|
||||||
|
$part, $wo?->vehicle, $wo?->client, $wo?->urgency ?? 'normal'
|
||||||
|
);
|
||||||
|
return view('filament.tenant.smart-price', ['quote' => $quote, 'item' => $r]);
|
||||||
|
})
|
||||||
|
->action(function (WorkOrderPart $r) {
|
||||||
|
$wo = $r->workOrder;
|
||||||
|
$quote = app(\App\Services\Pricing\PricingEngine::class)->quote(
|
||||||
|
$r->part, $wo?->vehicle, $wo?->client, $wo?->urgency ?? 'normal'
|
||||||
|
);
|
||||||
|
$r->sell_price = $quote['final'];
|
||||||
|
$r->save();
|
||||||
|
Notification::make()
|
||||||
|
->title('Preț actualizat: ' . number_format($quote['final'], 2) . ' MDL')
|
||||||
|
->success()->send();
|
||||||
|
}),
|
||||||
|
Actions\Action::make('issue_now')
|
||||||
|
->label('Eliberează')
|
||||||
|
->icon('heroicon-m-arrow-up-on-square')
|
||||||
|
->color('warning')
|
||||||
|
->visible(fn (WorkOrderPart $r) => $r->part_id
|
||||||
|
&& PartReservation::where('work_order_part_id', $r->id)
|
||||||
|
->where('status', PartReservation::STATUS_ACTIVE)
|
||||||
|
->exists())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalDescription('Confirmă că mecanicul ia fizic piesa din depozit. Stocul scade acum, fără să aștepți închiderea fișei.')
|
||||||
|
->action(function (WorkOrderPart $r) {
|
||||||
|
$n = app(WarehouseService::class)->issueNow($r);
|
||||||
|
Notification::make()
|
||||||
|
->title("Eliberat: {$n} rezervări consumate")
|
||||||
|
->success()->send();
|
||||||
|
}),
|
||||||
|
Actions\Action::make('return_part')
|
||||||
|
->label('Restituire')
|
||||||
|
->icon('heroicon-m-arrow-uturn-left')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (WorkOrderPart $r) => $r->part_id
|
||||||
|
&& PartReservation::where('work_order_part_id', $r->id)
|
||||||
|
->where('status', PartReservation::STATUS_CONSUMED)
|
||||||
|
->exists())
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('qty')
|
||||||
|
->label('Cantitate restituită')
|
||||||
|
->numeric()
|
||||||
|
->required()
|
||||||
|
->minValue(0.001)
|
||||||
|
->default(fn (WorkOrderPart $r) => (float) $r->qty),
|
||||||
|
Forms\Components\Textarea::make('notes')->rows(2)->label('Observații'),
|
||||||
|
])
|
||||||
|
->action(function (WorkOrderPart $r, array $data) {
|
||||||
|
$batch = app(WarehouseService::class)->returnPart(
|
||||||
|
$r, (float) $data['qty'], $data['notes'] ?? null
|
||||||
|
);
|
||||||
|
Notification::make()
|
||||||
|
->title($batch ? 'Piesa returnată în stoc' : 'Nimic de restituit')
|
||||||
|
->{$batch ? 'success' : 'warning'}()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\WorkOrderResource\RelationManagers;
|
||||||
|
|
||||||
|
use App\Models\Tenant\Subcontractor;
|
||||||
|
use App\Models\Tenant\SubcontractJob;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class SubcontractJobsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'subcontractJobs';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Lucrări la terți';
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Forms\Components\Select::make('subcontractor_id')
|
||||||
|
->label('Subcontractor')
|
||||||
|
->options(fn () => Subcontractor::where('is_active', true)->pluck('name', 'id'))
|
||||||
|
->searchable()
|
||||||
|
->columnSpanFull(),
|
||||||
|
Forms\Components\Select::make('category')
|
||||||
|
->label('Categorie')
|
||||||
|
->options(array_combine(Subcontractor::SPECIALTIES, Subcontractor::SPECIALTIES))
|
||||||
|
->searchable(),
|
||||||
|
Forms\Components\Select::make('status')->options(SubcontractJob::STATUSES)->default('sent')->required(),
|
||||||
|
Forms\Components\Textarea::make('description')->label('Descriere')->rows(2)->columnSpanFull(),
|
||||||
|
Forms\Components\TextInput::make('cost')->label('Cost (terț)')->numeric()->default(0)->required(),
|
||||||
|
Forms\Components\TextInput::make('markup_pct')->label('Markup %')->numeric()->default(0),
|
||||||
|
Forms\Components\TextInput::make('client_price')->label('Preț client')->numeric()->default(0)
|
||||||
|
->helperText('Folosit dacă markup = 0.'),
|
||||||
|
Forms\Components\DatePicker::make('eta')->label('ETA'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->recordTitleAttribute('number')
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('number')->label('Nr.'),
|
||||||
|
Tables\Columns\TextColumn::make('subcontractor.name')->label('Terț')->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('category')->badge()->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('cost')->money('MDL')->alignRight(),
|
||||||
|
Tables\Columns\TextColumn::make('client_price')->label('Preț client')->money('MDL')->alignRight(),
|
||||||
|
Tables\Columns\TextColumn::make('margin')
|
||||||
|
->label('Marjă')
|
||||||
|
->state(fn (SubcontractJob $r) => $r->margin())
|
||||||
|
->money('MDL')->alignRight()
|
||||||
|
->color(fn ($s) => (float) $s > 0 ? 'success' : ((float) $s < 0 ? 'danger' : 'gray')),
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->formatStateUsing(fn ($s) => SubcontractJob::STATUSES[$s] ?? $s)
|
||||||
|
->badge()
|
||||||
|
->colors(['warning' => ['sent', 'in_progress'], 'success' => ['done', 'returned'], 'danger' => ['cancelled']]),
|
||||||
|
])
|
||||||
|
->headerActions([Actions\CreateAction::make()])
|
||||||
|
->actions([Actions\EditAction::make(), Actions\DeleteAction::make()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-1
@@ -69,7 +69,23 @@ class WorksRelationManager extends RelationManager
|
|||||||
->colors(['gray' => ['todo'], 'warning' => ['in_progress'], 'success' => ['done']]),
|
->colors(['gray' => ['todo'], 'warning' => ['in_progress'], 'success' => ['done']]),
|
||||||
])
|
])
|
||||||
->headerActions([
|
->headerActions([
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->after(function (WorkOrderWork $record) {
|
||||||
|
// Auto-add the labor's default parts to the parent WO.
|
||||||
|
if (! $record->labor_id) return;
|
||||||
|
$labor = Labor::with('laborParts.part')->find($record->labor_id);
|
||||||
|
$wo = $record->workOrder;
|
||||||
|
if (! $labor || ! $wo || $labor->laborParts->isEmpty()) return;
|
||||||
|
$composer = app(\App\Services\ServiceComposer::class);
|
||||||
|
foreach ($labor->laborParts as $lp) {
|
||||||
|
if ($lp->part) {
|
||||||
|
$composer->addPart($wo, $lp->part, (float) $lp->qty, $lp->unit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\Filament\Notifications\Notification::make()
|
||||||
|
->title('Piese implicite adăugate (' . $labor->laborParts->count() . ')')
|
||||||
|
->success()->send();
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
|
|||||||
@@ -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,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Tenant\Part;
|
||||||
|
use chillerlan\QRCode\QRCode;
|
||||||
|
use chillerlan\QRCode\QROptions;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PartLabelsController extends Controller
|
||||||
|
{
|
||||||
|
public function sheet(Request $request)
|
||||||
|
{
|
||||||
|
$ids = array_filter(array_map('intval', explode(',', (string) $request->query('ids', ''))));
|
||||||
|
if (empty($ids)) abort(400, 'No parts selected.');
|
||||||
|
|
||||||
|
$parts = Part::whereIn('id', $ids)->orderBy('name')->get();
|
||||||
|
|
||||||
|
$opts = new QROptions([
|
||||||
|
'outputType' => QRCode::OUTPUT_MARKUP_SVG,
|
||||||
|
'eccLevel' => QRCode::ECC_M,
|
||||||
|
'scale' => 4,
|
||||||
|
'imageBase64' => false,
|
||||||
|
'addQuietzone' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$labels = $parts->map(function (Part $p) use ($opts) {
|
||||||
|
$payload = 'PART:' . ($p->article ?: $p->id);
|
||||||
|
return [
|
||||||
|
'part' => $p,
|
||||||
|
'svg' => (new QRCode($opts))->render($payload),
|
||||||
|
'payload' => $payload,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return view('parts.labels', ['labels' => $labels]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Tenant\PushSubscription;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PushSubscriptionController extends Controller
|
||||||
|
{
|
||||||
|
public function subscribe(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'endpoint' => 'required|string|max:500',
|
||||||
|
'keys.p256dh' => 'required|string',
|
||||||
|
'keys.auth' => 'required|string',
|
||||||
|
'contentEncoding' => 'nullable|string|max:32',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
PushSubscription::updateOrCreate(
|
||||||
|
['endpoint' => $data['endpoint']],
|
||||||
|
[
|
||||||
|
'company_id' => $user?->company_id,
|
||||||
|
'user_id' => $user?->id,
|
||||||
|
'public_key' => $data['keys']['p256dh'],
|
||||||
|
'auth_token' => $data['keys']['auth'],
|
||||||
|
'content_encoding' => $data['contentEncoding'] ?? 'aesgcm',
|
||||||
|
'user_agent' => substr((string) $request->userAgent(), 0, 255),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unsubscribe(Request $request)
|
||||||
|
{
|
||||||
|
$endpoint = $request->input('endpoint');
|
||||||
|
if ($endpoint) {
|
||||||
|
PushSubscription::where('endpoint', $endpoint)->delete();
|
||||||
|
}
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Tenant\OnlineOrder;
|
||||||
|
use App\Models\Tenant\OnlineOrderItem;
|
||||||
|
use App\Models\Tenant\Part;
|
||||||
|
use App\Services\Ai\VinDecoder;
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
class ShopController extends Controller
|
||||||
|
{
|
||||||
|
private function tenantOrFail()
|
||||||
|
{
|
||||||
|
$tenant = app(TenantManager::class)->current();
|
||||||
|
if (! $tenant) {
|
||||||
|
throw new NotFoundHttpException('Magazinul e disponibil doar pe subdomeniul service-ului.');
|
||||||
|
}
|
||||||
|
if (! data_get($tenant->settings, 'shop.enabled')) {
|
||||||
|
throw new NotFoundHttpException('Magazinul online nu este activ.');
|
||||||
|
}
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function catalog(Request $request)
|
||||||
|
{
|
||||||
|
$tenant = $this->tenantOrFail();
|
||||||
|
|
||||||
|
$term = $request->query('q');
|
||||||
|
$category = $request->query('cat');
|
||||||
|
$inStock = $request->boolean('in_stock');
|
||||||
|
|
||||||
|
$query = Part::searchPublished($term);
|
||||||
|
if ($category) $query->where('category', $category);
|
||||||
|
if ($inStock) $query->where('qty', '>', 0);
|
||||||
|
|
||||||
|
$parts = $query->orderBy('name')->paginate(24)->withQueryString();
|
||||||
|
$categories = Part::published()->distinct()->pluck('category')->filter()->sort()->values();
|
||||||
|
|
||||||
|
return view('shop.catalog', [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'parts' => $parts,
|
||||||
|
'categories' => $categories,
|
||||||
|
'term' => $term,
|
||||||
|
'category' => $category,
|
||||||
|
'inStock' => $inStock,
|
||||||
|
'cartCount' => $this->cartCount(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function part(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$tenant = $this->tenantOrFail();
|
||||||
|
$part = Part::published()->with('crossRefs')->find($id);
|
||||||
|
if (! $part) throw new NotFoundHttpException('Piesa nu există sau nu e publicată.');
|
||||||
|
|
||||||
|
return view('shop.part', [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'part' => $part,
|
||||||
|
'cartCount' => $this->cartCount(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function vin(Request $request)
|
||||||
|
{
|
||||||
|
$tenant = $this->tenantOrFail();
|
||||||
|
$vin = strtoupper(trim((string) $request->query('vin', '')));
|
||||||
|
$decoded = null;
|
||||||
|
if ($vin !== '') {
|
||||||
|
$decoded = app(VinDecoder::class)->decode($vin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('shop.vin', [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'vin' => $vin,
|
||||||
|
'decoded' => $decoded,
|
||||||
|
'cartCount' => $this->cartCount(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cart (session) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private function cartKey(): string
|
||||||
|
{
|
||||||
|
$tenant = app(TenantManager::class)->current();
|
||||||
|
return 'shop_cart_' . ($tenant?->id ?? '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cart(): array
|
||||||
|
{
|
||||||
|
return (array) session($this->cartKey(), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cartCount(): int
|
||||||
|
{
|
||||||
|
return (int) collect($this->cart())->sum('qty');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addToCart(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$this->tenantOrFail();
|
||||||
|
$part = Part::published()->findOrFail($id);
|
||||||
|
$qty = max(1, (int) $request->input('qty', 1));
|
||||||
|
|
||||||
|
$cart = $this->cart();
|
||||||
|
$cart[$id] = [
|
||||||
|
'part_id' => $part->id,
|
||||||
|
'name' => $part->name,
|
||||||
|
'article' => $part->article,
|
||||||
|
'price' => (float) $part->sell_price,
|
||||||
|
'qty' => ($cart[$id]['qty'] ?? 0) + $qty,
|
||||||
|
];
|
||||||
|
session([$this->cartKey() => $cart]);
|
||||||
|
|
||||||
|
return redirect('/shop/cart');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateCart(Request $request)
|
||||||
|
{
|
||||||
|
$this->tenantOrFail();
|
||||||
|
$cart = $this->cart();
|
||||||
|
foreach ((array) $request->input('qty', []) as $id => $qty) {
|
||||||
|
$qty = (int) $qty;
|
||||||
|
if ($qty <= 0) {
|
||||||
|
unset($cart[$id]);
|
||||||
|
} elseif (isset($cart[$id])) {
|
||||||
|
$cart[$id]['qty'] = $qty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session([$this->cartKey() => $cart]);
|
||||||
|
return redirect('/shop/cart');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showCart()
|
||||||
|
{
|
||||||
|
$tenant = $this->tenantOrFail();
|
||||||
|
$cart = $this->cart();
|
||||||
|
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
|
||||||
|
|
||||||
|
return view('shop.cart', [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'cart' => $cart,
|
||||||
|
'subtotal' => $subtotal,
|
||||||
|
'cartCount' => $this->cartCount(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkout()
|
||||||
|
{
|
||||||
|
$tenant = $this->tenantOrFail();
|
||||||
|
$cart = $this->cart();
|
||||||
|
if (empty($cart)) return redirect('/shop');
|
||||||
|
|
||||||
|
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
|
||||||
|
$customer = \Illuminate\Support\Facades\Auth::guard('shop')->user();
|
||||||
|
|
||||||
|
return view('shop.checkout', [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'cart' => $cart,
|
||||||
|
'subtotal' => $subtotal,
|
||||||
|
'customer' => $customer,
|
||||||
|
'deliveryOptions' => (array) data_get($tenant->settings, 'shop.delivery_methods', ['pickup']),
|
||||||
|
'cartCount' => $this->cartCount(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function placeOrder(Request $request)
|
||||||
|
{
|
||||||
|
$tenant = $this->tenantOrFail();
|
||||||
|
$cart = $this->cart();
|
||||||
|
if (empty($cart)) return redirect('/shop');
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'customer_name' => 'required|string|max:160',
|
||||||
|
'customer_phone' => 'required|string|max:40',
|
||||||
|
'customer_email' => 'nullable|email|max:160',
|
||||||
|
'delivery_method' => 'required|in:pickup,courier,post',
|
||||||
|
'address' => 'nullable|string|max:255',
|
||||||
|
'notes' => 'nullable|string|max:1000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$deliveryFee = 0.0;
|
||||||
|
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
|
||||||
|
if ($data['delivery_method'] !== 'pickup') {
|
||||||
|
$fee = (float) data_get($tenant->settings, 'shop.delivery_fee', 0);
|
||||||
|
$freeOver = (float) data_get($tenant->settings, 'shop.free_delivery_over', 0);
|
||||||
|
$deliveryFee = ($freeOver > 0 && $subtotal >= $freeOver) ? 0.0 : $fee;
|
||||||
|
}
|
||||||
|
|
||||||
|
$shopCustomer = \Illuminate\Support\Facades\Auth::guard('shop')->user();
|
||||||
|
|
||||||
|
$order = DB::transaction(function () use ($tenant, $cart, $data, $deliveryFee, $shopCustomer) {
|
||||||
|
$order = OnlineOrder::create([
|
||||||
|
'number' => OnlineOrder::generateNumber($tenant->id),
|
||||||
|
'shop_customer_id' => $shopCustomer?->id,
|
||||||
|
'client_id' => $shopCustomer?->client_id,
|
||||||
|
'customer_name' => $data['customer_name'],
|
||||||
|
'customer_phone' => $data['customer_phone'],
|
||||||
|
'customer_email' => $data['customer_email'] ?? null,
|
||||||
|
'delivery_method' => $data['delivery_method'],
|
||||||
|
'address' => $data['address'] ?? null,
|
||||||
|
'notes' => $data['notes'] ?? null,
|
||||||
|
'status' => 'new',
|
||||||
|
'delivery_fee' => $deliveryFee,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($cart as $item) {
|
||||||
|
OnlineOrderItem::create([
|
||||||
|
'online_order_id' => $order->id,
|
||||||
|
'part_id' => $item['part_id'] ?? null,
|
||||||
|
'name' => $item['name'],
|
||||||
|
'article' => $item['article'] ?? null,
|
||||||
|
'qty' => $item['qty'],
|
||||||
|
'price' => $item['price'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$order->refresh()->recalcTotal();
|
||||||
|
return $order;
|
||||||
|
});
|
||||||
|
|
||||||
|
session()->forget($this->cartKey());
|
||||||
|
|
||||||
|
// Notify (best-effort): customer + shop staff.
|
||||||
|
try {
|
||||||
|
app(\App\Services\Notifications\ShopOrderNotifier::class)->placed($order);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Illuminate\Support\Facades\Log::debug('shop order notify skipped: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect('/shop/order/' . $order->tracking_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function orderStatus(Request $request, string $token)
|
||||||
|
{
|
||||||
|
$tenant = $this->tenantOrFail();
|
||||||
|
$order = OnlineOrder::with('items')->where('tracking_token', $token)->first();
|
||||||
|
if (! $order) throw new NotFoundHttpException('Comanda nu a fost găsită.');
|
||||||
|
|
||||||
|
return view('shop.order', [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'order' => $order,
|
||||||
|
'cartCount' => $this->cartCount(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Central\Company;
|
||||||
|
use App\Models\Tenant\Client;
|
||||||
|
use App\Services\Notifications\TelegramService;
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives Telegram updates per tenant. URL: /telegram/webhook/{slug}
|
||||||
|
*
|
||||||
|
* To link a Telegram account to a Client record, the bot expects the user
|
||||||
|
* to share their phone via Telegram's contact share button (Telegram lets
|
||||||
|
* users send their own phone with one tap). We match the shared phone (or
|
||||||
|
* the message text fallback) to clients.phone and persist chat_id.
|
||||||
|
*/
|
||||||
|
class TelegramWebhookController extends Controller
|
||||||
|
{
|
||||||
|
public function handle(Request $request, string $slug, TelegramService $telegram)
|
||||||
|
{
|
||||||
|
$company = Company::where('slug', $slug)->first();
|
||||||
|
if (! $company) return response()->json(['ok' => false], 404);
|
||||||
|
|
||||||
|
$expectedSecret = $telegram->webhookSecretFor($company);
|
||||||
|
$providedSecret = $request->header('X-Telegram-Bot-Api-Secret-Token');
|
||||||
|
if ($expectedSecret && $providedSecret !== $expectedSecret) {
|
||||||
|
Log::warning('telegram.webhook bad secret', ['tenant' => $slug]);
|
||||||
|
return response()->json(['ok' => false], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
app(TenantManager::class)->setCurrent($company);
|
||||||
|
|
||||||
|
$message = $request->input('message', []);
|
||||||
|
$chatId = (string) data_get($message, 'chat.id', '');
|
||||||
|
if (! $chatId) {
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$contact = data_get($message, 'contact');
|
||||||
|
$text = trim((string) data_get($message, 'text', ''));
|
||||||
|
|
||||||
|
$client = null;
|
||||||
|
$phoneRaw = null;
|
||||||
|
|
||||||
|
if ($contact) {
|
||||||
|
$phoneRaw = data_get($contact, 'phone_number');
|
||||||
|
} elseif (preg_match('/(\+?[0-9\-\s\(\)]{7,})/', $text, $m)) {
|
||||||
|
$phoneRaw = $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($phoneRaw) {
|
||||||
|
$needle = Client::normalizePhone($phoneRaw);
|
||||||
|
if ($needle) {
|
||||||
|
$client = Client::whereRaw(
|
||||||
|
"REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', '') LIKE ?",
|
||||||
|
['%' . substr($needle, -9) . '%']
|
||||||
|
)->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $client && $text === '/start') {
|
||||||
|
$telegram->sendMessage($company, $chatId,
|
||||||
|
'Salut! Pentru a primi notificări despre mașina ta, ' .
|
||||||
|
'apasă butonul „Share contact" sau trimite numărul tău de telefon.'
|
||||||
|
);
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($client) {
|
||||||
|
$client->telegram_chat_id = $chatId;
|
||||||
|
$client->saveQuietly();
|
||||||
|
|
||||||
|
$name = $company->display_name ?? $company->name;
|
||||||
|
$telegram->sendMessage($company, $chatId,
|
||||||
|
"Te-am identificat — <b>{$client->name}</b>.\n" .
|
||||||
|
"Vei primi aici notificări despre fișele tale de la <b>{$name}</b>.\n\n" .
|
||||||
|
"Trimite /stop oricând ca să oprești notificările."
|
||||||
|
);
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($text === '/stop') {
|
||||||
|
Client::where('telegram_chat_id', $chatId)->update(['telegram_chat_id' => null]);
|
||||||
|
$telegram->sendMessage($company, $chatId, 'Notificările au fost oprite.');
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($phoneRaw) {
|
||||||
|
$telegram->sendMessage($company, $chatId,
|
||||||
|
"Nu am găsit un client cu acest număr la {$company->name}. " .
|
||||||
|
"Verifică telefonul sau contactează service-ul."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user