Import UI, mobile workflows and ticket context fixes

This commit is contained in:
michele 2026-04-08 19:13:29 +00:00
parent 432f0565ab
commit 478c0a120b
17 changed files with 912 additions and 99 deletions

View File

@ -678,6 +678,43 @@ public function aggiornaAnagraficheDaCondomin(): void
->send();
}
public function risincronizzaRelazioniImportate(): void
{
$user = Auth::user();
if (! $user instanceof User) {
abort(403);
}
$state = $this->getSchema('form')?->getState() ?? [];
$code = trim((string) ($state['stabile_code'] ?? ''));
if ($code === '') {
Notification::make()->title('Seleziona uno stabile')->danger()->send();
return;
}
$stabile = $this->resolveSelectedStabileByCode($code);
if (! $stabile instanceof Stabile) {
Notification::make()->title('Stabile locale non trovato')->body('Prima crea o apri lo stabile selezionato.')->danger()->send();
return;
}
$result = $this->runRelationSyncSequence($code, (int) $stabile->id, (bool) ($state['dry_run'] ?? false));
if (! $result['ok']) {
Notification::make()
->title('Risincronizzazione relazioni fallita')
->body((string) ($result['message'] ?? 'Errore non specificato.'))
->danger()
->send();
return;
}
Notification::make()
->title((bool) ($state['dry_run'] ?? false) ? 'Dry-run relazioni completato' : 'Risincronizzazione relazioni completata')
->body((string) ($result['message'] ?? 'Operazione completata'))
->success()
->send();
}
public function aggiornaFornitoriGescon(): void
{
$user = Auth::user();
@ -1134,6 +1171,28 @@ public function eseguiImportSelezionato(EssentialImportService $service): void
$outputs[] = trim((string) Artisan::output());
}
$shouldSyncRelations = ! empty($options['with_unita'])
|| ! empty($options['with_soggetti'])
|| ! empty($options['with_diritti']);
if ($shouldSyncRelations) {
$relationSync = $this->runRelationSyncSequence($code, (int) $stabile->id, (bool) $options['dry']);
if (! $relationSync['ok']) {
Notification::make()
->title('Import completato con errore nel re-sync relazioni')
->body((string) ($relationSync['message'] ?? 'Errore non specificato.'))
->danger()
->send();
return;
}
foreach ((array) ($relationSync['outputs'] ?? []) as $syncOutput) {
if (is_string($syncOutput) && trim($syncOutput) !== '') {
$outputs[] = trim($syncOutput);
}
}
}
$tail = collect($outputs)
->filter()
->map(fn(string $o) => trim((string) collect(preg_split('/\R/', $o) ?: [])->last()))
@ -1250,6 +1309,100 @@ public function caricaAnniDaGenerale(): void
->send();
}
/** @return array<string,mixed> */
public function getCommandPreviewData(): array
{
$state = is_array($this->data) ? $this->data : [];
$code = trim((string) ($state['stabile_code'] ?? ''));
if ($code !== '') {
$code = str_pad($code, 4, '0', STR_PAD_LEFT);
}
$path = trim((string) ($state['path_root'] ?? '/mnt/gescon-archives/gescon'));
$annoDir = trim((string) ($state['anno'] ?? ''));
$years = implode(',', array_values(array_filter(array_map(
fn($value) => trim((string) $value),
(array) ($state['align_years_selected'] ?? [])
))));
$waterYears = implode(',', array_values(array_filter(array_map(
fn($value) => trim((string) $value),
(array) ($state['align_water_years_selected'] ?? [])
))));
$openYears = implode(',', array_values(array_filter(array_map(
fn($value) => trim((string) $value),
(array) ($state['align_open_years_selected'] ?? [])
))));
$adminId = (int) ($state['amministratore_id'] ?? 0);
$stabile = $this->resolveSelectedStabileByCode($code);
$stabileId = $stabile instanceof Stabile ? (int) $stabile->id : null;
$fornitoriMdb = trim((string) ($state['fornitori_mdb'] ?? ''));
if ($fornitoriMdb === '' && $path !== '') {
$fornitoriMdb = rtrim($path, '/') . '/dbc/Fornitori.mdb';
}
$stabiliMdb = trim((string) ($state['stabili_mdb'] ?? ''));
if ($stabiliMdb === '' && $path !== '') {
$stabiliMdb = rtrim($path, '/') . '/dbc/Stabili.mdb';
}
$generaleMdb = ($path !== '' && $code !== '')
? rtrim($path, '/') . '/' . $code . '/generale_stabile.mdb'
: '';
$singoloMdb = ($path !== '' && $code !== '' && $annoDir !== '')
? rtrim($path, '/') . '/' . $code . '/' . $annoDir . '/singolo_anno.mdb'
: '';
return [
'source_mode' => (string) ($state['source_mode'] ?? 'linux_server'),
'stabile_code' => $code,
'stabile_id' => $stabileId,
'amministratore_id' => $adminId > 0 ? $adminId : null,
'anno_dir' => $annoDir,
'years_csv' => $years,
'water_years_csv' => $waterYears,
'open_years_csv' => $openYears,
'paths' => [
'root' => $path,
'stabili_mdb' => $stabiliMdb,
'fornitori_mdb' => $fornitoriMdb,
'generale_mdb' => $generaleMdb,
'singolo_mdb' => $singoloMdb,
],
'commands' => [
'load_stabili' => $stabiliMdb !== ''
? 'php artisan import:gescon-fornitori ' . max(1, $adminId) . ' --mdb="' . $fornitoriMdb . '" --preview --limit=5'
: null,
'fornitori_sync' => ($adminId > 0 && $fornitoriMdb !== '')
? 'php artisan gescon:sync-fornitori-legacy ' . $adminId . ' --mdb="' . $fornitoriMdb . '"'
: null,
'allineamento' => ($code !== '' && $path !== '' && $years !== '')
? 'php artisan gescon:import-align --stabile=' . $code . ' --path="' . $path . '" --years=' . $years . ' --water-years=' . ($waterYears !== '' ? $waterYears : $years) . ' --open-years=' . ($openYears !== '' ? $openYears : $years)
: null,
'unita' => ($code !== '' && $path !== '' && $annoDir !== '')
? 'php artisan gescon:import-full --stabile=' . $code . ' --solo=unita --anno=' . $annoDir . ' --path="' . $path . '"'
: null,
'soggetti' => ($code !== '' && $path !== '' && $annoDir !== '')
? 'php artisan gescon:import-full --stabile=' . $code . ' --solo=soggetti --anno=' . $annoDir . ' --path="' . $path . '"'
: null,
'diritti' => ($code !== '' && $path !== '' && $annoDir !== '')
? 'php artisan gescon:import-full --stabile=' . $code . ' --solo=diritti --anno=' . $annoDir . ' --path="' . $path . '"'
: null,
'anagrafiche_sync' => ($code !== '')
? 'php artisan gescon:auto-sync-anagrafiche --stabile=' . $code . ' --only=both --link-ruolo --include-comproprietari --apply'
: null,
'ruoli_sync' => ($stabileId !== null)
? 'php artisan gescon:sync-rubrica-ruoli --stabile=' . $stabileId . ' --apply'
: null,
'repair_contacts' => ($code !== '')
? 'php artisan gescon:repair-nominativi-contacts --stabile=' . $code . ' --apply'
: null,
],
];
}
private function refreshAnniOptionsForStabile(string $code, string $root, ?Set $set = null, mixed $get = null): int
{
$code = trim($code);
@ -1348,6 +1501,88 @@ private function refreshAnniOptionsForStabile(string $code, string $root, ?Set $
return count($opts);
}
private function resolveSelectedStabileByCode(?string $code): ?Stabile
{
$normalized = trim((string) $code);
if ($normalized === '') {
return null;
}
$trimmed = ltrim($normalized, '0');
return Stabile::query()
->where(function ($query) use ($normalized, $trimmed): void {
$query->where('codice_stabile', $normalized);
if ($trimmed !== '' && $trimmed !== $normalized) {
$query->orWhere('codice_stabile', $trimmed);
}
})
->first();
}
/** @return array{ok:bool,message:string,outputs:array<int,string>} */
private function runRelationSyncSequence(string $code, int $stabileId, bool $dryRun): array
{
$outputs = [];
$statuses = [];
try {
$syncParams = [
'--stabile' => $code,
'--limit' => 20000,
'--only' => 'both',
'--link-ruolo' => true,
'--include-comproprietari' => true,
];
if (! $dryRun) {
$syncParams['--apply'] = true;
}
$statuses[] = Artisan::call('gescon:auto-sync-anagrafiche', $syncParams);
$outputs[] = trim((string) Artisan::output());
$ruoliParams = [
'--stabile' => (string) $stabileId,
'--limit' => 5000,
];
if (! $dryRun) {
$ruoliParams['--apply'] = true;
}
$statuses[] = Artisan::call('gescon:sync-rubrica-ruoli', $ruoliParams);
$outputs[] = trim((string) Artisan::output());
$repairParams = [
'--stabile' => $code,
'--limit' => 5000,
];
if (! $dryRun) {
$repairParams['--apply'] = true;
}
$statuses[] = Artisan::call('gescon:repair-nominativi-contacts', $repairParams);
$outputs[] = trim((string) Artisan::output());
} catch (\Throwable $e) {
return [
'ok' => false,
'message' => $e->getMessage(),
'outputs' => $outputs,
];
}
$tail = collect($outputs)
->filter(fn($value) => is_string($value) && trim($value) !== '')
->map(fn(string $output) => trim((string) collect(preg_split('/\R/', $output) ?: [])->last()))
->filter(fn($value) => is_string($value) && $value !== '')
->implode("\n");
return [
'ok' => collect($statuses)->every(fn($status) => (int) $status === 0),
'message' => $tail !== '' ? $tail : 'Operazione completata.',
'outputs' => $outputs,
];
}
private function firstNonEmpty(array $row, array $keys): mixed
{
foreach ($keys as $k) {

View File

@ -28,6 +28,7 @@
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
@ -1108,6 +1109,16 @@ protected function getHeaderActions(): array
$this->startInlineEdit();
}),
Action::make('abilita_accesso_netgescon')
->label('Abilita accesso')
->icon('heroicon-o-key')
->requiresConfirmation()
->modalHeading('Abilita accesso NetGescon')
->modalDescription('Crea o collega un utente per questo nominativo e invia il link impostazione password all\'email disponibile in rubrica.')
->action(function (): void {
$this->abilitaAccessoRubrica();
}),
Action::make('email_multiple')
->label('Email aggiuntive')
->icon('heroicon-o-envelope')
@ -1859,6 +1870,61 @@ private function getStudioCollaboratoreEmail(): ?string
return null;
}
private function getRubricaAccessEmail(): ?string
{
return $this->getStudioCollaboratoreEmail();
}
public function abilitaAccessoRubrica(): void
{
$email = $this->getRubricaAccessEmail();
if ($email === null) {
Notification::make()
->title('Email rubrica mancante')
->warning()
->body('Per abilitare l\'accesso serve almeno un indirizzo email principale o aggiuntivo attivo nella scheda rubrica.')
->send();
return;
}
$name = trim((string) ($this->rubrica->nome_completo ?: $this->rubrica->ragione_sociale ?: 'Utente NetGescon'));
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
if (! $user) {
$user = User::query()->create([
'name' => $name,
'email' => $email,
'password' => Hash::make(Str::random(32)),
'email_verified_at' => null,
'is_active' => true,
'registration_status' => 'approved',
]);
}
if (! $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore', 'fornitore', 'condomino'])) {
$user->assignRole('condomino');
} elseif (! $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore', 'fornitore']) && ! $user->hasRole('condomino')) {
$user->assignRole('condomino');
}
$status = Password::sendResetLink(['email' => $email]);
if ($status === Password::RESET_LINK_SENT) {
Notification::make()
->title('Accesso abilitato')
->success()
->body('Utente collegato alla rubrica. Link impostazione password inviato a ' . $email . '.')
->send();
return;
}
Notification::make()
->title('Utente creato o collegato, ma invio mail non confermato')
->warning()
->body('Controlla il mailer attivo. Stato Laravel: ' . __($status))
->send();
}
public function canRequestClickToCall(): bool
{
$user = Auth::user();

View File

@ -0,0 +1,33 @@
<?php
namespace App\Filament\Pages\Impostazioni;
use App\Models\User;
use BackedEnum;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
class GoogleDashboard extends Page
{
protected static ?string $navigationLabel = 'Google';
protected static ?string $title = 'Google';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-cloud';
protected static UnitEnum|string|null $navigationGroup = 'Impostazioni';
protected static ?int $navigationSort = 35;
protected static ?string $slug = 'impostazioni/google';
protected string $view = 'filament.pages.impostazioni.google-dashboard';
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Filament\Pages\Mobile;
use App\Models\User;
use BackedEnum;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
class MobileDashboard extends Page
{
protected static ?string $navigationLabel = 'Dashboard Mobile';
protected static ?string $title = 'Dashboard Mobile';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-device-phone-mobile';
protected static UnitEnum|string|null $navigationGroup = 'Mobile';
protected static ?int $navigationSort = 1;
protected static ?string $slug = 'mobile/dashboard';
protected string $view = 'filament.pages.mobile.dashboard';
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
}

View File

@ -39,7 +39,7 @@ class TicketMobile extends Page
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-device-phone-mobile';
protected static UnitEnum|string|null $navigationGroup = 'Supporto';
protected static UnitEnum|string|null $navigationGroup = 'Mobile';
protected static ?int $navigationSort = 25;
@ -1131,14 +1131,14 @@ private function selectCallerRepresentative(Collection $group, int $selectedCall
$representative = $selected instanceof RubricaUniversale
? $selected
: $group
->sortByDesc(fn(RubricaUniversale $match): array=> [
(int) (! empty($match->amministratore_id)),
(int) (! empty($match->riferimento_stabile)),
(int) (! empty($match->riferimento_unita)),
(int) (($match->categoria ?? '') === 'condomino'),
-1 * (int) $match->id,
])
->first();
->sortByDesc(fn(RubricaUniversale $match): array => [
(int) (! empty($match->amministratore_id)),
(int) (! empty($match->riferimento_stabile)),
(int) (! empty($match->riferimento_unita)),
(int) (($match->categoria ?? '') === 'condomino'),
-1 * (int) $match->id,
])
->first();
$duplicateCategories = $group
->pluck('categoria')
@ -1195,20 +1195,33 @@ public function getCallerStabileLabel(int $rubricaId): ?string
*/
private function resolveCallerTicketContext(?RubricaUniversale $caller, ?int $fallbackStabileId = null): array
{
$stabile = $caller instanceof RubricaUniversale
? $this->resolveCallerStabile($caller, $fallbackStabileId)
: ($fallbackStabileId ? Stabile::query()->find($fallbackStabileId) : null);
$fallbackStabile = $fallbackStabileId ? Stabile::query()->find($fallbackStabileId) : null;
$callerStabile = $caller instanceof RubricaUniversale
? $this->resolveCallerStabile($caller)
: null;
$soggetto = $caller instanceof RubricaUniversale
? $this->resolveCallerSoggetto($caller, $stabile?->id)
? $this->resolveCallerSoggetto($caller, $callerStabile?->id)
: null;
$unita = $soggetto instanceof Soggetto
? $this->resolveCallerUnita($soggetto, $stabile?->id, (string) ($caller?->riferimento_unita ?? ''))
? $this->resolveCallerUnita($soggetto, $callerStabile?->id, (string) ($caller?->riferimento_unita ?? ''))
: null;
if (! $stabile && $unita instanceof UnitaImmobiliare) {
$stabile = $unita->stabile;
$stabile = $unita instanceof UnitaImmobiliare
? $unita->stabile
: $callerStabile;
if (! $stabile && $soggetto instanceof Soggetto) {
$unita = $this->resolveCallerUnita($soggetto, null, (string) ($caller?->riferimento_unita ?? ''));
if ($unita instanceof UnitaImmobiliare) {
$stabile = $unita->stabile;
}
}
if (! $stabile && ! $unita && ! $soggetto && $fallbackStabile instanceof Stabile) {
$stabile = $fallbackStabile;
}
return [
@ -1261,6 +1274,7 @@ private function resolveCallerStabile(RubricaUniversale $caller, ?int $fallbackS
return $fallbackStabileId ? Stabile::query()->find($fallbackStabileId) : null;
}
private function resolveCallerSoggetto(RubricaUniversale $caller, ?int $stabileId = null): ?Soggetto
{
$queries = [];

View File

@ -0,0 +1,102 @@
<?php
namespace App\Filament\Widgets;
use App\Filament\Pages\Supporto\TicketGestione;
use App\Filament\Pages\Supporto\TicketMobile;
use App\Filament\Pages\Supporto\TicketMobileHelp;
use App\Models\Ticket;
use App\Models\User;
use App\Support\StabileContext;
use Filament\Widgets\Widget;
use Illuminate\Support\Facades\Auth;
class TicketMobileOverview extends Widget
{
protected string $view = 'filament.widgets.ticket-mobile-overview';
protected int|string|array $columnSpan = 'full';
/** @var array<string,int> */
public array $ticketCounters = [
'open' => 0,
'urgent' => 0,
'closed' => 0,
'all' => 0,
];
public static function canView(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
public function mount(): void
{
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$stabileIds = StabileContext::accessibleStabili($user)
->pluck('id')
->map(fn($value) => (int) $value)
->filter(fn(int $value) => $value > 0)
->values()
->all();
if ($stabileIds === []) {
return;
}
$base = Ticket::query()->whereIn('stabile_id', $stabileIds);
$this->ticketCounters = [
'open' => (clone $base)->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione'])->count(),
'urgent' => (clone $base)->where('priorita', 'Urgente')->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione'])->count(),
'closed' => (clone $base)->whereIn('stato', ['Risolto', 'Chiuso'])->count(),
'all' => (clone $base)->count(),
];
}
public function getDashboardFilamentUrl(): string
{
return route('filament.admin-filament.pages.dashboard');
}
public function getRubricaFilamentUrl(): string
{
return route('filament.admin-filament.pages.gescon.anagrafica.rubrica-universale');
}
public function getAdminMobileHubUrl(): string
{
return route('admin.mobile');
}
public function getCondominoMobileTicketUrl(): string
{
return route('condomino.tickets.mobile');
}
public function getTicketArchivioUrl(): string
{
return TicketGestione::getUrl(panel: 'admin-filament', parameters: ['status' => 'all']);
}
public function getTicketMobileUrl(): string
{
return TicketMobile::getUrl(panel: 'admin-filament');
}
public function getGestioneTicketUrl(): string
{
return TicketGestione::getUrl(panel: 'admin-filament');
}
public function getHelpUrl(): string
{
return TicketMobileHelp::getUrl(panel: 'admin-filament');
}
}

View File

@ -11,6 +11,7 @@
use App\Filament\Pages\Supporto\SupportoDashboard;
use App\Filament\Pages\Supporto\TicketMobile;
use App\Filament\Widgets\GoogleScadenziarioOverview;
use App\Filament\Widgets\TicketMobileOverview;
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\User;
@ -116,6 +117,7 @@ public function panel(Panel $panel): Panel
->widgets([
AccountWidget::class,
GoogleScadenziarioOverview::class,
TicketMobileOverview::class,
])
->middleware([
EncryptCookies::class,

View File

@ -37,6 +37,8 @@ public function store(object $file, string $directory, array $options = []): arr
$displayName = $originalName !== '' ? $originalName : ($storedName !== '' ? $storedName : 'file');
}
$sourceMetadata = $this->extractImageMetadataFromFileObject($file);
$mime = TicketAttachment::normalizeMimeType(
method_exists($file, 'getClientMimeType') ? (string) $file->getClientMimeType() : '',
$originalName,
@ -46,7 +48,10 @@ public function store(object $file, string $directory, array $options = []): arr
$metadata = [];
if (str_starts_with($mime, 'image/')) {
$metadata = $this->extractImageMetadata($path);
$metadata = $this->mergeImageMetadata(
$this->extractImageMetadata($path),
$sourceMetadata,
);
if ($this->shouldOptimizeImage($metadata, $options)) {
$this->optimizeImage($path, $mime, $metadata);
}
@ -80,6 +85,30 @@ private function shouldOptimizeImage(array $metadata, array $options = []): bool
return ! $this->hasEmbeddedImageMetadata($metadata);
}
private function mergeImageMetadata(array $storedMetadata, array $sourceMetadata): array
{
$merged = $storedMetadata;
foreach ($sourceMetadata as $key => $value) {
if (is_array($value)) {
if ($value === []) {
continue;
}
$merged[$key] = array_replace(is_array($merged[$key] ?? null) ? $merged[$key] : [], $value);
continue;
}
if ($value === null || $value === '') {
continue;
}
$merged[$key] = $value;
}
return $merged;
}
private function hasEmbeddedImageMetadata(array $metadata): bool
{
return filled($metadata['exif_datetime'] ?? null)
@ -183,6 +212,30 @@ private function applyOrientation($source, int $orientation)
private function extractImageMetadata(string $path): array
{
$absolutePath = Storage::disk('public')->path($path);
return $this->extractImageMetadataFromAbsolutePath($absolutePath);
}
private function extractImageMetadataFromFileObject(object $file): array
{
$absolutePath = null;
if (method_exists($file, 'getRealPath')) {
$absolutePath = $file->getRealPath();
}
if ((! is_string($absolutePath) || $absolutePath === '') && method_exists($file, 'path')) {
$absolutePath = $file->path();
}
if (! is_string($absolutePath) || $absolutePath === '' || ! is_file($absolutePath)) {
return [];
}
return $this->extractImageMetadataFromAbsolutePath($absolutePath);
}
private function extractImageMetadataFromAbsolutePath(string $absolutePath): array
{
$metadata = [];
$imageSize = @getimagesize($absolutePath);

View File

@ -1,66 +1,98 @@
<x-filament-panels::page>
<div class="space-y-4">
@php
$sourceMode = data_get($data ?? [], 'source_mode', 'linux_server');
@endphp
@php
$sourceMode = data_get($data ?? [], 'source_mode', 'linux_server');
$preview = $this->getCommandPreviewData();
$selectedLabel = data_get($stabiliOptions ?? [], data_get($preview, 'stabile_code', ''), data_get($preview, 'stabile_code', ''));
@endphp
<div class="flex flex-wrap items-center gap-2">
<x-filament::button
size="sm"
:color="$sourceMode === 'linux_server' ? 'primary' : 'gray'"
wire:click="$set('data.source_mode','linux_server')"
>Linux / Server</x-filament::button>
<x-filament::button
size="sm"
:color="$sourceMode === 'windows_upload' ? 'primary' : 'gray'"
wire:click="$set('data.source_mode','windows_upload')"
>Windows / Upload</x-filament::button>
</div>
<div class="space-y-6" x-data="{ tab: @js($sourceMode === 'windows_upload' ? 'windows' : 'linux') }">
<section class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-sm font-semibold text-slate-900">Configurazione sorgente e import</h2>
<p class="mt-1 text-xs text-slate-600">Seleziona la sorgente, imposta stabile e anni, poi usa la tab operativa per import, aggiornamenti e re-sync.</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<x-filament::button
size="sm"
:color="$sourceMode === 'linux_server' ? 'primary' : 'gray'"
x-on:click="tab = 'linux'"
wire:click="$set('data.source_mode','linux_server')"
>Linux / Server</x-filament::button>
<x-filament::button
size="sm"
:color="$sourceMode === 'windows_upload' ? 'primary' : 'gray'"
x-on:click="tab = 'windows'"
wire:click="$set('data.source_mode','windows_upload')"
>Windows / Upload</x-filament::button>
<x-filament::button
size="sm"
color="gray"
x-on:click="tab = 'operativa'"
>Tab operativa</x-filament::button>
</div>
</div>
<div class="space-y-3">
{{ $this->getSchema('form') }}
</div>
<div class="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="grid gap-3 md:grid-cols-3">
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Stabile selezionato</div>
<div class="mt-1 text-sm text-slate-800">{{ $selectedLabel !== '' ? $selectedLabel : 'Nessuno selezionato' }}</div>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Amministratore</div>
<div class="mt-1 text-sm text-slate-800">{{ data_get($preview, 'amministratore_id') ?: '-' }}</div>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Anno legacy diretto</div>
<div class="mt-1 text-sm text-slate-800">{{ data_get($preview, 'anno_dir') ?: '-' }}</div>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<x-filament::button type="button" wire:click="loadStabili">
Carica elenco stabili da Stabili.mdb
</x-filament::button>
<div class="mt-4">
{{ $this->getSchema('form') }}
</div>
</section>
<x-filament::button type="button" color="gray" wire:click="caricaAnniDaGenerale" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">
Carica anni da generale_stabile.mdb
</x-filament::button>
<section class="rounded-2xl border border-slate-200 bg-white shadow-sm">
<div class="border-b border-slate-200 px-4 py-3">
<div class="flex flex-wrap items-center gap-2">
<button type="button" class="rounded-lg px-3 py-1.5 text-sm font-medium transition"
:class="tab === 'linux' ? 'bg-sky-600 text-white' : 'bg-slate-100 text-slate-700'"
x-on:click="tab = 'linux'">
Connessione Linux
</button>
<button type="button" class="rounded-lg px-3 py-1.5 text-sm font-medium transition"
:class="tab === 'windows' ? 'bg-sky-600 text-white' : 'bg-slate-100 text-slate-700'"
x-on:click="tab = 'windows'">
Caricamento Windows
</button>
<button type="button" class="rounded-lg px-3 py-1.5 text-sm font-medium transition"
:class="tab === 'operativa' ? 'bg-sky-600 text-white' : 'bg-slate-100 text-slate-700'"
x-on:click="tab = 'operativa'">
Operativa / Re-sync
</button>
</div>
</div>
<x-filament::button type="button" color="success" wire:click="creaStabileDaMdb" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">
Crea / apri stabile selezionato
</x-filament::button>
<div class="p-4">
<div x-show="tab === 'linux'">
@include('filament.pages.gescon.partials.importazione-archivi-linux', ['preview' => $preview])
</div>
<x-filament::button type="button" color="gray" wire:click="eseguiImportSelezionato" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">
Importa solo dati selezionati
</x-filament::button>
<div x-show="tab === 'windows'">
@include('filament.pages.gescon.partials.importazione-archivi-windows', ['preview' => $preview])
</div>
<x-filament::button type="button" color="primary" wire:click="eseguiImportAllineamentoCompleto" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">
Import + allineamento (comando unico)
</x-filament::button>
<x-filament::button type="button" color="warning" wire:click="applicaEnrichmentStabile" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">
Applica enrichment (banca/posta/rate/catasto)
</x-filament::button>
<x-filament::button type="button" color="info" wire:click="aggiornaFornitoriGescon" :disabled="blank(data_get($data ?? [], 'path_root'))">
Aggiorna fornitori (GESCON)
</x-filament::button>
<x-filament::button type="button" color="warning" wire:click="refreshImportTagLegacyFornitoriGlobale" :disabled="blank(data_get($data ?? [], 'path_root'))">
Refresh + Import TAG legacy (tutti i fornitori)
</x-filament::button>
<x-filament::button type="button" color="info" wire:click="aggiornaAnagraficheDaCondomin" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">
Aggiorna anagrafiche (condomin Rubrica)
</x-filament::button>
<x-filament::button type="button" color="danger" wire:click="applicaEnrichmentTuttiStabili" :disabled="blank(data_get($data ?? [], 'path_root'))">
Enrichment massivo stabili
</x-filament::button>
</div>
<div x-show="tab === 'operativa'">
@include('filament.pages.gescon.partials.importazione-archivi-operativa', [
'preview' => $preview,
'data' => $data ?? [],
'stabiliOptions' => $stabiliOptions ?? [],
])
</div>
</div>
</section>
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,43 @@
<div class="space-y-4">
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="text-sm font-semibold text-slate-900">Percorsi attesi sul server Linux</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Root archivio</div>
<pre class="mt-1 overflow-x-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">{{ data_get($preview, 'paths.root', '/mnt/gescon-archives/gescon') }}</pre>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Stabili.mdb / Fornitori.mdb</div>
<pre class="mt-1 overflow-x-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">{{ data_get($preview, 'paths.stabili_mdb', '-') }}
{{ data_get($preview, 'paths.fornitori_mdb', '-') }}</pre>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">generale_stabile.mdb</div>
<pre class="mt-1 overflow-x-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">{{ data_get($preview, 'paths.generale_mdb', '-') }}</pre>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">singolo_anno.mdb</div>
<pre class="mt-1 overflow-x-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">{{ data_get($preview, 'paths.singolo_mdb', '-') }}</pre>
</div>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Verifica rapida archivio</div>
<p class="mt-1 text-xs text-slate-600">Usa questi comandi se vuoi controllare a terminale che i file legacy siano leggibili dal server staging.</p>
<pre class="mt-3 overflow-x-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">ls -la "{{ data_get($preview, 'paths.root', '/mnt/gescon-archives/gescon') }}"
ls -la "{{ data_get($preview, 'paths.generale_mdb', '') }}"
ls -la "{{ data_get($preview, 'paths.singolo_mdb', '') }}"
ls -la "{{ data_get($preview, 'paths.fornitori_mdb', '') }}"</pre>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Comandi rapidi legacy</div>
<p class="mt-1 text-xs text-slate-600">Comandi compatti già valorizzati con stabile, anni e path selezionati nella pagina.</p>
<pre class="mt-3 overflow-x-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">{{ data_get($preview, 'commands.allineamento', 'Seleziona stabile e anni per generare il comando.') }}
{{ data_get($preview, 'commands.fornitori_sync', 'Seleziona amministratore e Fornitori.mdb per generare il comando.') }}</pre>
</div>
</div>
</div>

View File

@ -0,0 +1,82 @@
@php
$selectedLabel = data_get($stabiliOptions ?? [], data_get($preview, 'stabile_code', ''), data_get($preview, 'stabile_code', ''));
@endphp
<div class="space-y-4">
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">
Se importi `unità`, `soggetti` o `diritti`, la pagina ora lancia anche il re-sync di anagrafiche, ruoli rubrica e bonifica recapiti/contatti collegati allo stabile selezionato.
</div>
<div class="grid gap-4 xl:grid-cols-3">
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-sm font-semibold text-slate-900">1. Setup archivio</div>
<div class="mt-3 flex flex-wrap gap-2">
<x-filament::button type="button" size="sm" wire:click="loadStabili">Carica elenco stabili</x-filament::button>
<x-filament::button type="button" size="sm" color="gray" wire:click="caricaAnniDaGenerale" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">Carica anni</x-filament::button>
<x-filament::button type="button" size="sm" color="success" wire:click="creaStabileDaMdb" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">Crea / apri stabile</x-filament::button>
</div>
<div class="mt-3 text-xs text-slate-600">Stabile attuale: {{ $selectedLabel !== '' ? $selectedLabel : 'nessuno' }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-sm font-semibold text-slate-900">2. Import / aggiornamento</div>
<div class="mt-3 flex flex-wrap gap-2">
<x-filament::button type="button" size="sm" color="gray" wire:click="eseguiImportSelezionato" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">Importa dati selezionati</x-filament::button>
<x-filament::button type="button" size="sm" color="primary" wire:click="eseguiImportAllineamentoCompleto" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">Import + allineamento</x-filament::button>
<x-filament::button type="button" size="sm" color="info" wire:click="aggiornaFornitoriGescon" :disabled="blank(data_get($data ?? [], 'path_root'))">Aggiorna fornitori</x-filament::button>
</div>
<div class="mt-3 text-xs text-slate-600">Usa `Import + allineamento` come flusso principale; `Importa dati selezionati` serve per slice mirate.</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-sm font-semibold text-slate-900">3. Re-sync relazioni</div>
<div class="mt-3 flex flex-wrap gap-2">
<x-filament::button type="button" size="sm" color="warning" wire:click="risincronizzaRelazioniImportate" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">Risincronizza relazioni</x-filament::button>
<x-filament::button type="button" size="sm" color="info" wire:click="aggiornaAnagraficheDaCondomin" :disabled="empty($stabiliOptions) || blank(data_get($data ?? [], 'stabile_code'))">Sync anagrafiche</x-filament::button>
<x-filament::button type="button" size="sm" color="warning" wire:click="refreshImportTagLegacyFornitoriGlobale" :disabled="blank(data_get($data ?? [], 'path_root'))">Refresh TAG fornitori</x-filament::button>
</div>
<div class="mt-3 text-xs text-slate-600">Serve quando i dati locali esistono già e vuoi riallineare rubrica, ruoli e recapiti senza reimportare tutto.</div>
</div>
</div>
<div class="grid gap-4 xl:grid-cols-2">
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Comandi pronti</div>
<div class="mt-3 space-y-3 text-xs text-slate-600">
<div>
<div class="mb-1 font-semibold uppercase tracking-wide text-slate-500">Flusso principale</div>
<pre class="overflow-x-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">{{ data_get($preview, 'commands.allineamento', 'Seleziona stabile e anni per generare il comando.') }}</pre>
</div>
<div>
<div class="mb-1 font-semibold uppercase tracking-wide text-slate-500">Fornitori</div>
<pre class="overflow-x-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">{{ data_get($preview, 'commands.fornitori_sync', 'Seleziona amministratore e Fornitori.mdb per generare il comando.') }}</pre>
</div>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Relazioni e riallineamento</div>
<div class="mt-3 space-y-3 text-xs text-slate-600">
<div>
<div class="mb-1 font-semibold uppercase tracking-wide text-slate-500">Sync anagrafiche</div>
<pre class="overflow-x-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">{{ data_get($preview, 'commands.anagrafiche_sync', 'Seleziona uno stabile per generare il comando.') }}</pre>
</div>
<div>
<div class="mb-1 font-semibold uppercase tracking-wide text-slate-500">Sync ruoli / repair contatti</div>
<pre class="overflow-x-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">{{ data_get($preview, 'commands.ruoli_sync', 'Crea o apri prima lo stabile locale per ottenere lo stabile_id.') }}
{{ data_get($preview, 'commands.repair_contacts', 'Seleziona uno stabile per generare il comando.') }}</pre>
</div>
</div>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="text-sm font-semibold text-slate-900">Import modulare</div>
<div class="mt-3 grid gap-3 xl:grid-cols-3">
<pre class="overflow-x-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">{{ data_get($preview, 'commands.unita', 'Serve stabile + anno legacy per generare il comando.') }}</pre>
<pre class="overflow-x-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">{{ data_get($preview, 'commands.soggetti', 'Serve stabile + anno legacy per generare il comando.') }}</pre>
<pre class="overflow-x-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">{{ data_get($preview, 'commands.diritti', 'Serve stabile + anno legacy per generare il comando.') }}</pre>
</div>
</div>
</div>

View File

@ -0,0 +1,32 @@
<div class="space-y-4">
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
In modalità Windows la pagina non legge i file direttamente dal PC: carichi ZIP, Stabili.mdb e Fornitori.mdb, poi il server staging li usa come sorgente temporanea.
</div>
<div class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Cosa caricare</div>
<ul class="mt-3 space-y-2 text-sm text-slate-700">
<li>ZIP dell'archivio GESCON con cartella `dbc` e cartelle stabili.</li>
<li>`Stabili.mdb` se vuoi caricare o rileggere l'elenco stabili da file singolo.</li>
<li>`Fornitori.mdb` se vuoi aggiornare solo i fornitori dal legacy.</li>
</ul>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-sm font-semibold text-slate-900">PowerShell rapido</div>
<p class="mt-1 text-xs text-slate-600">Se devi preparare uno ZIP dal PC Windows.</p>
<pre class="mt-3 overflow-x-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">Compress-Archive -Path "C:\\GESCON\\*" -DestinationPath "C:\\temp\\gescon-archive.zip" -Force</pre>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="text-sm font-semibold text-slate-900">Uso consigliato</div>
<ol class="mt-3 space-y-2 text-sm text-slate-700">
<li>Carica ZIP o file MDB nella parte alta della pagina.</li>
<li>Premi `Carica elenco stabili da Stabili.mdb`.</li>
<li>Seleziona stabile e anni, poi vai in `Operativa / Re-sync`.</li>
<li>Esegui prima `Import + allineamento` oppure `Aggiorna fornitori` a seconda del blocco dati che vuoi riallineare.</li>
</ol>
</div>
</div>

View File

@ -0,0 +1,10 @@
<x-filament-panels::page>
<div class="space-y-4">
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-base font-semibold text-slate-900">Google</div>
<div class="mt-1 text-xs text-slate-600">Area dedicata per collegamento Google, sincronizzazioni e template Drive per stabile.</div>
</div>
@livewire(\App\Filament\Widgets\GoogleWorkspaceOverview::class)
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,26 @@
<x-filament-panels::page>
<div class="space-y-4">
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-base font-semibold text-slate-900">Mobile</div>
<div class="mt-1 text-xs text-slate-600">Contenitore unico per ticket, letture e prossimi flussi mobile ottimizzati.</div>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Ticket Mobile</div>
<div class="mt-1 text-xs text-slate-600">Inserimento rapido ticket dal telefono con foto, contesto chiamata e nominativo rubrica.</div>
<div class="mt-3">
<a href="{{ \App\Filament\Pages\Supporto\TicketMobile::getUrl(panel: 'admin-filament') }}" class="inline-flex items-center rounded-md bg-sky-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-600">Apri Ticket Mobile</a>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="text-sm font-semibold text-slate-900">Letture servizi</div>
<div class="mt-1 text-xs text-slate-600">Base amministrativa per le letture. Qui agganceremo il flusso dedicato lettura acqua con GPS, EXIF e accesso nominativo.</div>
<div class="mt-3">
<a href="{{ \App\Filament\Pages\Condomini\LettureServiziArchivio::getUrl(panel: 'admin-filament') }}" class="inline-flex items-center rounded-md bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600">Apri Letture Servizi</a>
</div>
</div>
</div>
</div>
</x-filament-panels::page>

View File

@ -5,6 +5,18 @@
<div class="mt-1 text-xs text-gray-500">Area tecnica per manutenzione, sincronizzazione Google e debug operativo.</div>
</div>
@livewire(\App\Filament\Widgets\GoogleWorkspaceOverview::class)
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-900">Google e scadenzario</div>
<div class="mt-1 text-xs text-slate-600">Il pannello completo Google ora ha una pagina dedicata sotto Impostazioni.</div>
</div>
<a href="{{ \App\Filament\Pages\Impostazioni\GoogleDashboard::getUrl(panel: 'admin-filament') }}" class="inline-flex items-center rounded-md bg-slate-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-800">
Apri pagina Google
</a>
</div>
</div>
@livewire(\App\Filament\Widgets\GoogleScadenziarioOverview::class)
</div>
</x-filament-panels::page>

View File

@ -56,20 +56,8 @@
</div>
@endif
<div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-center gap-2">
<a href="{{ $this->getDashboardFilamentUrl() }}" class="inline-flex items-center rounded-md bg-gray-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Dashboard Filament</a>
<a href="{{ $this->getRubricaFilamentUrl() }}" class="inline-flex items-center rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-500">Rubrica Unica Filament</a>
<a href="{{ $this->getAdminMobileHubUrl() }}" class="inline-flex items-center rounded-md bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600">Hub Cellulare / WebApp</a>
<a href="{{ $this->getCondominoMobileTicketUrl() }}" class="inline-flex items-center rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-500" target="_blank" rel="noopener noreferrer">WebApp Ticket Condomino</a>
<a href="{{ $this->getTicketArchivioUrl() }}" class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500">Archivio Ticket Completo</a>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
<div class="rounded-lg border bg-gray-50 px-3 py-2 text-xs">Aperti: <span class="font-semibold">{{ $ticketCounters['open'] ?? 0 }}</span></div>
<div class="rounded-lg border bg-gray-50 px-3 py-2 text-xs">Urgenti: <span class="font-semibold">{{ $ticketCounters['urgent'] ?? 0 }}</span></div>
<div class="rounded-lg border bg-gray-50 px-3 py-2 text-xs">Chiusi: <span class="font-semibold">{{ $ticketCounters['closed'] ?? 0 }}</span></div>
<div class="rounded-lg border bg-gray-50 px-3 py-2 text-xs">Totali: <span class="font-semibold">{{ $ticketCounters['all'] ?? 0 }}</span></div>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
Il riepilogo operativo Ticket Mobile ora e nella dashboard principale, cosi i link rapidi e lo stato ticket restano visibili appena entri in Filament.
</div>
<div class="rounded-xl border bg-white p-4">
@ -292,11 +280,31 @@
url: (file.type || '').startsWith('image/') ? URL.createObjectURL(file) : null,
}));
},
appendPreviews(target, items) {
const existing = new Map((this[target] || []).map((item) => [item.key, item]));
items.forEach((item) => {
existing.set(item.key, item);
});
this[target] = Array.from(existing.values());
},
setPreviews(event, kind) {
const target = kind === 'camera' ? 'localCameraPreviews' : 'localAttachmentPreviews';
this.revoke(this[target]);
this[target] = this.mapFiles(event?.target?.files || [], kind);
const batchId = Date.now();
const mapped = Array.from(event?.target?.files || []).map((file, index) => ({
key: `${kind}-${batchId}-${index}-${file.name}-${file.size}`,
name: file.name,
draftName: this.buildDraftName(file, this.draftSequence + index),
size: file.size || 0,
isImage: (file.type || '').startsWith('image/'),
url: (file.type || '').startsWith('image/') ? URL.createObjectURL(file) : null,
}));
this.appendPreviews(target, mapped);
this.persist();
if (event?.target) {
event.target.value = null;
}
},
clearPreviews(kind = null) {
if (kind === null || kind === 'camera') {
@ -415,6 +423,11 @@
<div class="grid gap-3 sm:grid-cols-2">
@foreach($this->selectedUploads as $upload)
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm">
@if($upload['is_image'] && $upload['preview_url'])
<div class="mb-2 aspect-[4/3] overflow-hidden rounded-lg border bg-slate-50">
<img src="{{ $upload['preview_url'] }}" alt="{{ $upload['name'] }}" class="h-full w-full object-cover" />
</div>
@endif
<div class="flex flex-wrap items-center gap-2 rounded-lg border bg-gray-50 px-3 py-2">
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold {{ $upload['is_image'] ? 'bg-sky-100 text-sky-700' : 'bg-slate-200 text-slate-700' }}">
{{ $upload['is_image'] ? 'Immagine' : (strtoupper(pathinfo($upload['name'], PATHINFO_EXTENSION) ?: 'FILE')) }}
@ -450,15 +463,6 @@
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">Gestione operativa separata</div>
<div class="mt-1 text-xs text-gray-500">Per lavorazione, presa in carico, risoluzione e chiusura usa la pagina dedicata.</div>
<div class="mt-3 flex flex-wrap gap-2">
<a href="{{ \App\Filament\Pages\Supporto\TicketGestione::getUrl(panel: 'admin-filament') }}" class="inline-flex items-center rounded-md bg-indigo-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-600">Vai a Gestione Ticket</a>
<a href="{{ $this->getHelpUrl() }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Apri Help Ticket Mobile</a>
</div>
</div>
<div class="text-xs text-gray-500">
Suggerimento: aggiungi questa pagina ai preferiti del browser sul telefono per usarla come webapp rapida.
</div>

View File

@ -0,0 +1,34 @@
<x-filament-widgets::widget>
<x-filament::section>
<x-slot name="heading">Ticket e Hub Mobile</x-slot>
<x-slot name="description">Accesso rapido a inserimento ticket, archivio operativo e flusso mobile/webapp.</x-slot>
<div class="space-y-4">
<div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-center gap-2">
<a href="{{ $this->getDashboardFilamentUrl() }}" class="inline-flex items-center rounded-md bg-gray-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Dashboard Filament</a>
<a href="{{ $this->getRubricaFilamentUrl() }}" class="inline-flex items-center rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-500">Rubrica Unica Filament</a>
<a href="{{ $this->getAdminMobileHubUrl() }}" class="inline-flex items-center rounded-md bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600">Hub Cellulare / WebApp</a>
<a href="{{ $this->getCondominoMobileTicketUrl() }}" class="inline-flex items-center rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-500" target="_blank" rel="noopener noreferrer">WebApp Ticket Condomino</a>
<a href="{{ $this->getTicketMobileUrl() }}" class="inline-flex items-center rounded-md bg-sky-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-600">Apri Ticket Mobile</a>
<a href="{{ $this->getTicketArchivioUrl() }}" class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500">Archivio Ticket Completo</a>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
<div class="rounded-lg border bg-gray-50 px-3 py-2 text-xs">Aperti: <span class="font-semibold">{{ $ticketCounters['open'] ?? 0 }}</span></div>
<div class="rounded-lg border bg-gray-50 px-3 py-2 text-xs">Urgenti: <span class="font-semibold">{{ $ticketCounters['urgent'] ?? 0 }}</span></div>
<div class="rounded-lg border bg-gray-50 px-3 py-2 text-xs">Chiusi: <span class="font-semibold">{{ $ticketCounters['closed'] ?? 0 }}</span></div>
<div class="rounded-lg border bg-gray-50 px-3 py-2 text-xs">Totali: <span class="font-semibold">{{ $ticketCounters['all'] ?? 0 }}</span></div>
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">Gestione operativa separata</div>
<div class="mt-1 text-xs text-gray-500">Per lavorazione, presa in carico, risoluzione e chiusura usa la pagina dedicata.</div>
<div class="mt-3 flex flex-wrap gap-2">
<a href="{{ $this->getGestioneTicketUrl() }}" class="inline-flex items-center rounded-md bg-indigo-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-600">Vai a Gestione Ticket</a>
<a href="{{ $this->getHelpUrl() }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Apri Help Ticket Mobile</a>
</div>
</div>
</div>
</x-filament::section>
</x-filament-widgets::widget>