Batch 3: Integrări placeholder + Backup tenant
═══ Integrări (Marketing → Integrări) ═══ - /app/integrations Page cu 6 carduri (Telegram/WhatsApp/Google Ads/FB/SMS/Webhook) - Toggle on/off per integrare; salvare în settings.integrations JSON - Câmpuri specifice per integrare (token/key/id/secret) - Banner explicativ: 'placeholder UI — implementare separată' ═══ Backup tenant ═══ - TenantBackupService::export($company) → ZIP cu: • data/ (1 JSON per tabel: clients/vehicles/leads/deals/work_orders cu sub-relații/...) • media/ (logo + favicon) • manifest.json (metadata + counts) - /app/backup Page cu buton 'Descarcă backup acum' - Streaming download cu deleteFileAfterSend - Util pentru: backup local, migrare, audit, GDPR right-to-erasure Total tenant routes: 104. Toate cele ~26 module din prototip implementate (sau echivalent funcțional).
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Pages;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Services\TenantBackupService;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class Backup extends Page
|
||||
{
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-archive-box-arrow-down';
|
||||
|
||||
protected static ?string $navigationLabel = 'Backup';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Admin';
|
||||
|
||||
protected static ?int $navigationSort = 96;
|
||||
|
||||
protected static ?string $title = 'Backup & Export date';
|
||||
|
||||
protected string $view = 'filament.tenant.pages.backup';
|
||||
|
||||
public function downloadBackup()
|
||||
{
|
||||
$tenant = app(TenantManager::class)->current();
|
||||
if (! $tenant) abort(404);
|
||||
|
||||
// Re-fetch via central scope to avoid issues.
|
||||
$company = Company::withoutGlobalScopes()->find($tenant->id);
|
||||
if (! $company) abort(404);
|
||||
|
||||
$svc = app(TenantBackupService::class);
|
||||
$file = $svc->export($company);
|
||||
$filename = $svc->filename($company);
|
||||
|
||||
return response()->download($file, $filename, [
|
||||
'Content-Type' => 'application/zip',
|
||||
])->deleteFileAfterSend();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Pages;
|
||||
|
||||
use App\Tenancy\TenantManager;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class Integrations extends Page
|
||||
{
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-puzzle-piece';
|
||||
|
||||
protected static ?string $navigationLabel = 'Integrări';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Marketing';
|
||||
|
||||
protected static ?int $navigationSort = 63;
|
||||
|
||||
protected static ?string $title = 'Integrări externe';
|
||||
|
||||
protected string $view = 'filament.tenant.pages.integrations';
|
||||
|
||||
public array $config = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = app(TenantManager::class)->current();
|
||||
$this->config = (array) ($tenant?->settings['integrations'] ?? []);
|
||||
}
|
||||
|
||||
public function setStatus(string $key, bool $enabled): void
|
||||
{
|
||||
$this->config[$key]['enabled'] = $enabled;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function updateField(string $key, string $field, ?string $value): void
|
||||
{
|
||||
$this->config[$key][$field] = $value;
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$tenant = app(TenantManager::class)->current();
|
||||
if (! $tenant) return;
|
||||
$tenant->update([
|
||||
'settings' => array_merge((array) $tenant->settings, [
|
||||
'integrations' => $this->config,
|
||||
]),
|
||||
]);
|
||||
Notification::make()->title('Integrări salvate')->success()->send();
|
||||
}
|
||||
|
||||
public function integrations(): array
|
||||
{
|
||||
return [
|
||||
'telegram_bot' => [
|
||||
'name' => 'Telegram Bot',
|
||||
'icon' => '✈️',
|
||||
'color' => '#229ED9',
|
||||
'description' => 'Primește cereri din canalul Telegram + trimite confirmări automate.',
|
||||
'fields' => [
|
||||
'bot_token' => ['label' => 'Bot Token', 'type' => 'password', 'placeholder' => 'din @BotFather'],
|
||||
'chat_id' => ['label' => 'Channel/Chat ID', 'type' => 'text', 'placeholder' => '@psauto_md'],
|
||||
],
|
||||
],
|
||||
'whatsapp_business' => [
|
||||
'name' => 'WhatsApp Business',
|
||||
'icon' => '💬',
|
||||
'color' => '#25D366',
|
||||
'description' => 'Trimite mesaje template către clienți (programări, confirmări).',
|
||||
'fields' => [
|
||||
'phone_id' => ['label' => 'Phone Number ID', 'type' => 'text'],
|
||||
'token' => ['label' => 'Permanent Access Token', 'type' => 'password'],
|
||||
],
|
||||
],
|
||||
'google_ads' => [
|
||||
'name' => 'Google Ads',
|
||||
'icon' => '🔍',
|
||||
'color' => '#EA4335',
|
||||
'description' => 'Tracking conversii — leadurile generate de campanii Google.',
|
||||
'fields' => [
|
||||
'customer_id' => ['label' => 'Customer ID', 'type' => 'text', 'placeholder' => '123-456-7890'],
|
||||
'conversion_id' => ['label' => 'Conversion ID', 'type' => 'text'],
|
||||
],
|
||||
],
|
||||
'facebook' => [
|
||||
'name' => 'Facebook / Instagram Lead Ads',
|
||||
'icon' => '📘',
|
||||
'color' => '#1877F2',
|
||||
'description' => 'Importă lead-uri direct din Lead Ads forms.',
|
||||
'fields' => [
|
||||
'page_id' => ['label' => 'Page ID', 'type' => 'text'],
|
||||
'access_token' => ['label' => 'Access Token', 'type' => 'password'],
|
||||
],
|
||||
],
|
||||
'sms_gateway' => [
|
||||
'name' => 'SMS Gateway',
|
||||
'icon' => '📱',
|
||||
'color' => '#6366F1',
|
||||
'description' => 'Trimite SMS-uri prin gateway local (Moldcell/Orange API).',
|
||||
'fields' => [
|
||||
'provider' => ['label' => 'Provider', 'type' => 'text', 'placeholder' => 'moldcell / orange / twilio'],
|
||||
'api_key' => ['label' => 'API Key', 'type' => 'password'],
|
||||
'sender_id' => ['label' => 'Sender ID', 'type' => 'text'],
|
||||
],
|
||||
],
|
||||
'webhook_in' => [
|
||||
'name' => 'Webhook intrări (formulare site)',
|
||||
'icon' => '🪝',
|
||||
'color' => '#8B5CF6',
|
||||
'description' => 'URL secret pentru POST direct cu lead-uri din site-ul tău.',
|
||||
'fields' => [
|
||||
'secret' => ['label' => 'Secret token', 'type' => 'text', 'placeholder' => 'random string'],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Tenant\Appointment;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Models\Tenant\Deal;
|
||||
use App\Models\Tenant\EmployeeProfile;
|
||||
use App\Models\Tenant\Expense;
|
||||
use App\Models\Tenant\Labor;
|
||||
use App\Models\Tenant\Lead;
|
||||
use App\Models\Tenant\Part;
|
||||
use App\Models\Tenant\Payment;
|
||||
use App\Models\Tenant\Purchase;
|
||||
use App\Models\Tenant\PurchaseItem;
|
||||
use App\Models\Tenant\Supplier;
|
||||
use App\Models\Tenant\User;
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use App\Models\Tenant\WorkOrder;
|
||||
use App\Models\Tenant\WorkOrderPart;
|
||||
use App\Models\Tenant\WorkOrderWork;
|
||||
use ZipArchive;
|
||||
|
||||
class TenantBackupService
|
||||
{
|
||||
/**
|
||||
* Export all tenant data to a temporary ZIP file. Returns absolute path.
|
||||
* Caller is responsible for streaming + cleanup.
|
||||
*/
|
||||
public function export(Company $company): string
|
||||
{
|
||||
$tmp = storage_path('app/backups');
|
||||
if (! is_dir($tmp)) @mkdir($tmp, 0775, true);
|
||||
$file = $tmp . '/tenant-' . $company->slug . '-' . date('Ymd-His') . '.zip';
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($file, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException('Nu pot crea fișierul ZIP la ' . $file);
|
||||
}
|
||||
|
||||
// Tables to dump.
|
||||
$tables = [
|
||||
'company' => Company::withoutGlobalScopes()->where('id', $company->id)->get(),
|
||||
'users' => User::all(),
|
||||
'clients' => Client::all(),
|
||||
'vehicles' => Vehicle::all(),
|
||||
'leads' => Lead::all(),
|
||||
'deals' => Deal::all(),
|
||||
'appointments' => Appointment::all(),
|
||||
'work_orders' => WorkOrder::with(['works', 'parts'])->get(),
|
||||
'wo_works' => WorkOrderWork::all(),
|
||||
'wo_parts' => WorkOrderPart::all(),
|
||||
'labors' => Labor::all(),
|
||||
'parts' => Part::all(),
|
||||
'suppliers' => Supplier::all(),
|
||||
'purchases' => Purchase::with('items')->get(),
|
||||
'purchase_items' => PurchaseItem::all(),
|
||||
'payments' => Payment::all(),
|
||||
'expenses' => Expense::all(),
|
||||
'employee_profiles' => EmployeeProfile::all(),
|
||||
];
|
||||
|
||||
$manifest = [
|
||||
'tenant' => $company->only(['id', 'slug', 'name', 'city', 'phone', 'email']),
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
'app_version' => 'AutoCRM-1.0',
|
||||
'counts' => [],
|
||||
];
|
||||
|
||||
foreach ($tables as $name => $rows) {
|
||||
$json = $rows->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$zip->addFromString("data/{$name}.json", $json);
|
||||
$manifest['counts'][$name] = $rows->count();
|
||||
}
|
||||
|
||||
$zip->addFromString('manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
// Optionally embed media files (logo + favicon).
|
||||
foreach (['logo', 'favicon'] as $col) {
|
||||
$m = $company->getFirstMedia($col);
|
||||
if ($m && file_exists($m->getPath())) {
|
||||
$zip->addFile($m->getPath(), 'media/' . $col . '.' . pathinfo($m->getPath(), PATHINFO_EXTENSION));
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
return $file;
|
||||
}
|
||||
|
||||
public function filename(Company $company): string
|
||||
{
|
||||
return 'tenant-' . $company->slug . '-' . date('Ymd-His') . '.zip';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<x-filament-panels::page>
|
||||
<style>
|
||||
.bk-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 24px; max-width: 720px; }
|
||||
.dark .bk-card { background: #1f2937; border-color: #374151; }
|
||||
.bk-h2 { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
|
||||
.bk-p { font-size: 13px; color: #6b7280; line-height: 1.6; margin-bottom: 12px; }
|
||||
.bk-list { list-style: none; padding: 0; margin: 16px 0; }
|
||||
.bk-list li { padding: 6px 0; font-size: 13px; }
|
||||
.bk-list li::before { content: '✓'; color: #059669; font-weight: 700; margin-right: 8px; }
|
||||
.bk-btn {
|
||||
display: inline-block; padding: 12px 24px; background: #3b82f6; color: #fff;
|
||||
border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
.bk-btn:hover { background: #2563eb; }
|
||||
</style>
|
||||
|
||||
<div class="bk-card">
|
||||
<h2 class="bk-h2">📦 Export complet date tenant</h2>
|
||||
<p class="bk-p">
|
||||
Exportă toate datele companiei tale într-un fișier ZIP. Util pentru:
|
||||
</p>
|
||||
<ul class="bk-list">
|
||||
<li>Backup periodic (descarcă local sau sincronizează cu cloud)</li>
|
||||
<li>Migrare la alt sistem CRM</li>
|
||||
<li>Audit / verificări fiscale</li>
|
||||
<li>Conformitate GDPR (la cererea ștergerii datelor)</li>
|
||||
</ul>
|
||||
<p class="bk-p">
|
||||
<b>Conține:</b> clienți, mașini, lead-uri, deal-uri, programări, fișe de lucru cu manopere și piese,
|
||||
depozit, furnizori, achiziții, plăți, cheltuieli, salarii, useri, logo + favicon, manifest cu metadata.
|
||||
</p>
|
||||
<p class="bk-p">
|
||||
<b>Format:</b> ZIP cu fișiere JSON (1 per tabel) + media + manifest.json.
|
||||
</p>
|
||||
|
||||
<button type="button" class="bk-btn" wire:click="downloadBackup" wire:loading.attr="disabled">
|
||||
<span wire:loading.remove wire:target="downloadBackup">⬇ Descarcă backup acum</span>
|
||||
<span wire:loading wire:target="downloadBackup">Generez ZIP...</span>
|
||||
</button>
|
||||
|
||||
<p class="bk-p" style="margin-top:24px;font-size:11px;color:#9ca3af;">
|
||||
⚠ Backup-urile pot conține date sensibile (telefoane, emailuri, plăți). Stochează-le în siguranță.
|
||||
</p>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@@ -0,0 +1,78 @@
|
||||
<x-filament-panels::page>
|
||||
<style>
|
||||
.ig-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); }
|
||||
.ig-card {
|
||||
background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;
|
||||
padding: 20px; position: relative;
|
||||
}
|
||||
.dark .ig-card { background: #1f2937; border-color: #374151; }
|
||||
.ig-head { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||
.ig-icon {
|
||||
width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 8px; font-size: 22px; color: #fff;
|
||||
}
|
||||
.ig-name { font-size: 15px; font-weight: 600; flex: 1; }
|
||||
.ig-desc { font-size: 12px; color: #6b7280; margin-bottom: 16px; line-height: 1.5; }
|
||||
|
||||
.ig-toggle { display: flex; align-items: center; gap: 8px; }
|
||||
.ig-toggle input[type=checkbox] { transform: scale(1.2); cursor: pointer; }
|
||||
.ig-status { font-size: 11px; color: #6b7280; }
|
||||
.ig-status.on { color: #059669; font-weight: 600; }
|
||||
|
||||
.ig-fields { margin-top: 12px; }
|
||||
.ig-fld { margin-bottom: 10px; }
|
||||
.ig-lbl { font-size: 11px; color: #6b7280; display: block; margin-bottom: 4px; }
|
||||
.ig-input {
|
||||
width: 100%; padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 6px;
|
||||
font-size: 12px; font-family: inherit;
|
||||
}
|
||||
.dark .ig-input { background: #111827; border-color: #374151; color: #f9fafb; }
|
||||
|
||||
.ig-save {
|
||||
margin-top: 8px; padding: 6px 16px; font-size: 12px;
|
||||
background: #3b82f6; color: #fff; border: none; border-radius: 6px; cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div style="background: #fef3c7; border-left: 4px solid #f59e0b; padding: 12px 16px; border-radius: 6px; margin-bottom: 16px; font-size: 13px;">
|
||||
ℹ️ <b>Status:</b> aceste integrări sunt placeholder UI — le configurezi aici, dar implementarea actuală a fiecărui canal e proiect separat.
|
||||
Pentru moment, valorile salvate sunt accesibile în cod via <code>$tenant->settings['integrations']</code>.
|
||||
</div>
|
||||
|
||||
<div class="ig-grid">
|
||||
@foreach ($this->integrations() as $key => $meta)
|
||||
@php
|
||||
$cfg = $config[$key] ?? [];
|
||||
$enabled = $cfg['enabled'] ?? false;
|
||||
@endphp
|
||||
<div class="ig-card">
|
||||
<div class="ig-head">
|
||||
<div class="ig-icon" style="background:{{ $meta['color'] }};">{{ $meta['icon'] }}</div>
|
||||
<div class="ig-name">{{ $meta['name'] }}</div>
|
||||
<label class="ig-toggle">
|
||||
<input type="checkbox" {{ $enabled ? 'checked' : '' }}
|
||||
wire:click="setStatus('{{ $key }}', {{ $enabled ? 'false' : 'true' }})">
|
||||
<span class="ig-status {{ $enabled ? 'on' : '' }}">{{ $enabled ? 'Activ' : 'Inactiv' }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ig-desc">{{ $meta['description'] }}</div>
|
||||
|
||||
<div class="ig-fields">
|
||||
@foreach ($meta['fields'] as $fkey => $fmeta)
|
||||
<div class="ig-fld">
|
||||
<label class="ig-lbl">{{ $fmeta['label'] }}</label>
|
||||
<input
|
||||
type="{{ $fmeta['type'] }}"
|
||||
class="ig-input"
|
||||
placeholder="{{ $fmeta['placeholder'] ?? '' }}"
|
||||
wire:change="updateField('{{ $key }}', '{{ $fkey }}', $event.target.value)"
|
||||
value="{{ $cfg[$fkey] ?? '' }}"
|
||||
>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<button type="button" class="ig-save" wire:click="save">Salvează</button>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
Reference in New Issue
Block a user