Compare commits

..

6 Commits

Author SHA1 Message Date
HalbeBruno
1434523bab Corrige aspas do atributo x-data que quebravam o html com json_encode 2026-03-12 16:12:18 -03:00
HalbeBruno
af9ce9a8fb Corrige syntax error inline do modal do AlpineJS 2026-03-12 16:10:36 -03:00
HalbeBruno
cb09163d7d Implementa modal de validacao de tamanho no frontend no lugar do toast 2026-03-12 16:09:01 -03:00
HalbeBruno
9a2536932f Correcao de syntax error no script do toast 2026-03-12 16:01:15 -03:00
HalbeBruno
a58805a7dc Aumento tempo alerta toast.php 2026-03-12 15:50:18 -03:00
HalbeBruno
fa96ec4aea Implementacao upload anexos de ordens 2026-03-12 15:20:38 -03:00
13 changed files with 466 additions and 22 deletions

View File

@@ -12,3 +12,4 @@ database/
.vscode/ .vscode/
vendor/ vendor/
node_modules/ node_modules/
storage/

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ agent/dist/*
.idea .idea
.DS_Store .DS_Store
mysql_data/ mysql_data/
storage/

View File

@@ -3,6 +3,8 @@
namespace App\Controllers; namespace App\Controllers;
use App\Models\Order; use App\Models\Order;
use App\Models\OrderAttachment;
use App\Services\AttachmentService;
use App\Services\OrderProcessor; use App\Services\OrderProcessor;
use App\Services\TelegramService; use App\Services\TelegramService;
use App\Utils\View; use App\Utils\View;
@@ -70,12 +72,38 @@ class OrderController
public function store() public function store()
{ {
// Detect post_max_size overflow
if (empty($_POST) && empty($_FILES) && isset($_SERVER['CONTENT_LENGTH']) && (int)$_SERVER['CONTENT_LENGTH'] > 0) {
$_SESSION['flash_error'] = "O tamanho total dos arquivos enviados excede o limite permitido pelo servidor.";
View::redirect('/admin/orders/create');
return;
}
if (!isset($_FILES['csv_file']) || $_FILES['csv_file']['error'] !== UPLOAD_ERR_OK) { if (!isset($_FILES['csv_file']) || $_FILES['csv_file']['error'] !== UPLOAD_ERR_OK) {
$_SESSION['flash_error'] = "Erro no upload do arquivo CSV."; $_SESSION['flash_error'] = "Erro no upload do arquivo CSV.";
View::redirect('/admin/orders/create'); View::redirect('/admin/orders/create');
return; return;
} }
// Validate attachments before inserting the order
if (isset($_FILES['attachments']) && !empty($_FILES['attachments']['name'][0])) {
$attachments = $_FILES['attachments'];
$count = count($attachments['name']);
for ($i = 0; $i < $count; $i++) {
$error = $attachments['error'][$i];
if ($error !== UPLOAD_ERR_OK && $error !== UPLOAD_ERR_NO_FILE) {
$fileName = $attachments['name'][$i];
if ($error === UPLOAD_ERR_INI_SIZE || $error === UPLOAD_ERR_FORM_SIZE) {
$_SESSION['flash_error'] = "O anexo '{$fileName}' excede o tamanho máximo permitido.";
} else {
$_SESSION['flash_error'] = "Erro no upload do anexo '{$fileName}': código {$error}.";
}
View::redirect('/admin/orders/create');
return;
}
}
}
$title = $_POST['title']; $title = $_POST['title'];
$type = $_POST['type']; $type = $_POST['type'];
$content = \App\Utils\TextFormatter::normalizeLineBreaks($_POST['content']); $content = \App\Utils\TextFormatter::normalizeLineBreaks($_POST['content']);
@@ -96,10 +124,16 @@ class OrderController
$orderId = $conn->lastInsertId(); $orderId = $conn->lastInsertId();
// Process CSV // Process CSV (unchanged)
$processor = new OrderProcessor(); $processor = new OrderProcessor();
$count = $processor->process($orderId, $type, $_FILES['csv_file']['tmp_name']); $count = $processor->process($orderId, $type, $_FILES['csv_file']['tmp_name']);
// Store attachments (if any were sent)
if (isset($_FILES['attachments']) && !empty($_FILES['attachments']['name'][0])) {
$attachmentService = new AttachmentService();
$attachmentService->storeFiles((int) $orderId, $_FILES['attachments']);
}
// Send Telegram Notification // Send Telegram Notification
$telegramService = new TelegramService(); $telegramService = new TelegramService();
$typeLabel = ($type === 'block') ? 'Bloqueio' : (($type === 'unblock') ? 'Desbloqueio' : $type); $typeLabel = ($type === 'block') ? 'Bloqueio' : (($type === 'unblock') ? 'Desbloqueio' : $type);
@@ -134,11 +168,47 @@ class OrderController
$stmt->execute(['id' => $id]); $stmt->execute(['id' => $id]);
$items = $stmt->fetchAll(); $items = $stmt->fetchAll();
// Get Attachments
$attachmentModel = new OrderAttachment();
$attachments = $attachmentModel->findByOrderId((int) $id);
View::render('layouts.admin', [ View::render('layouts.admin', [
'title' => 'Detalhes da Ordem #' . $id, 'title' => 'Detalhes da Ordem #' . $id,
'content' => __DIR__ . '/../../resources/views/admin/orders/view.php', 'content' => __DIR__ . '/../../resources/views/admin/orders/view.php',
'order' => $order, 'order' => $order,
'items' => $items 'items' => $items,
'attachments' => $attachments,
]); ]);
} }
public function downloadAttachment($id)
{
$attachmentModel = new OrderAttachment();
$attachment = $attachmentModel->find($id);
if (!$attachment) {
$_SESSION['flash_error'] = "Anexo não encontrado.";
View::redirect('/admin/orders');
return;
}
$attachmentService = new AttachmentService();
$filePath = $attachmentService->getFilePath((int) $attachment['order_id'], $attachment['stored_name']);
if (!file_exists($filePath)) {
$_SESSION['flash_error'] = "Arquivo físico não encontrado no servidor.";
View::redirect('/admin/orders/view/' . $attachment['order_id']);
return;
}
// Serve o arquivo para download
header('Content-Type: ' . ($attachment['mime_type'] ?? 'application/octet-stream'));
header('Content-Disposition: attachment; filename="' . addslashes($attachment['original_name']) . '"');
header('Content-Length: ' . filesize($filePath));
header('Cache-Control: private, no-cache');
header('Pragma: no-cache');
readfile($filePath);
exit;
}
} }

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use App\Core\Model;
class OrderAttachment extends Model
{
protected $table = 'order_attachments';
/**
* Retorna todos os anexos de uma ordem específica.
*/
public function findByOrderId(int $orderId): array
{
$stmt = $this->conn->prepare(
"SELECT * FROM {$this->table} WHERE order_id = :order_id ORDER BY created_at ASC"
);
$stmt->execute(['order_id' => $orderId]);
return $stmt->fetchAll();
}
/**
* Insere um registro de anexo no banco.
*/
public function create(array $data): int
{
$stmt = $this->conn->prepare(
"INSERT INTO {$this->table} (order_id, original_name, stored_name, mime_type, size)
VALUES (:order_id, :original_name, :stored_name, :mime_type, :size)"
);
$stmt->execute([
'order_id' => $data['order_id'],
'original_name' => $data['original_name'],
'stored_name' => $data['stored_name'],
'mime_type' => $data['mime_type'] ?? null,
'size' => $data['size'] ?? null,
]);
return (int) $this->conn->lastInsertId();
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Services;
use App\Models\OrderAttachment;
class AttachmentService
{
/**
* Tipos de arquivo permitidos para anexo.
* Mapeamento mime_type => extensões aceitas.
*/
private const ALLOWED_TYPES = [
'application/pdf' => 'pdf',
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/gif' => 'gif',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.ms-excel' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'text/plain' => 'txt',
];
/** Tamanho máximo por arquivo: 20 MB */
private const MAX_SIZE = 20 * 1024 * 1024;
/**
* Raiz do diretório de uploads (fora do public/).
*/
private function getBaseUploadDir(): string
{
return dirname(__DIR__, 2) . '/storage/uploads/orders';
}
/**
* Pega o caminho do diretório de upload de uma ordem específica.
*/
private function getOrderUploadDir(int $orderId): string
{
return $this->getBaseUploadDir() . '/' . $orderId;
}
/**
* Garante que o diretório de upload da ordem exista.
*/
private function ensureDirectory(string $path): void
{
if (!is_dir($path)) {
mkdir($path, 0775, true);
}
}
/**
* Processa e armazena os arquivos do campo de upload.
* Retorna a quantidade de arquivos salvos com sucesso.
*
* @param int $orderId
* @param array $filesInput Entry do $_FILES (já normalizado como array de arquivos)
* @return int
* @throws \Exception em caso de erro crítico
*/
public function storeFiles(int $orderId, array $filesInput): int
{
$uploadDir = $this->getOrderUploadDir($orderId);
$this->ensureDirectory($uploadDir);
$model = new OrderAttachment();
$saved = 0;
// Normaliza o $_FILES para iterar arquivo a arquivo
$files = $this->normalizeFilesArray($filesInput);
foreach ($files as $file) {
// Ignora entradas vazias (campo não preenchido)
if ($file['error'] === UPLOAD_ERR_NO_FILE) {
continue;
}
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new \Exception("Erro no upload do arquivo '{$file['name']}': código {$file['error']}");
}
if ($file['size'] > self::MAX_SIZE) {
throw new \Exception("O arquivo '{$file['name']}' excede o tamanho máximo permitido de 20 MB.");
}
// Detecta o tipo MIME real do arquivo (não confiar só no header HTTP)
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
if (!array_key_exists($mimeType, self::ALLOWED_TYPES)) {
throw new \Exception("Tipo de arquivo não permitido: '{$file['name']}' ({$mimeType}).");
}
$extension = self::ALLOWED_TYPES[$mimeType];
$storedName = uniqid('attach_', true) . '.' . $extension;
$destination = $uploadDir . '/' . $storedName;
if (!move_uploaded_file($file['tmp_name'], $destination)) {
throw new \Exception("Falha ao mover o arquivo '{$file['name']}' para o servidor.");
}
$model->create([
'order_id' => $orderId,
'original_name' => $file['name'],
'stored_name' => $storedName,
'mime_type' => $mimeType,
'size' => $file['size'],
]);
$saved++;
}
return $saved;
}
/**
* Retorna o caminho absoluto do arquivo no disco.
*/
public function getFilePath(int $orderId, string $storedName): string
{
return $this->getOrderUploadDir($orderId) . '/' . $storedName;
}
/**
* Normaliza o array $_FILES para uma lista linear de arquivos,
* independente de ser upload único ou múltiplo (name[]).
*/
private function normalizeFilesArray(array $files): array
{
// Se é um único arquivo, encapsula em array
if (!is_array($files['name'])) {
return [$files];
}
$normalized = [];
$count = count($files['name']);
for ($i = 0; $i < $count; $i++) {
$normalized[] = [
'name' => $files['name'][$i],
'type' => $files['type'][$i],
'tmp_name' => $files['tmp_name'][$i],
'error' => $files['error'][$i],
'size' => $files['size'][$i],
];
}
return $normalized;
}
}

View File

@@ -22,7 +22,7 @@ class OrderProcessor
$domains = []; $domains = [];
while (($data = fgetcsv($handle, 1000, ",")) !== FALSE) { while (($data = fgetcsv($handle, 1000, ",")) !== FALSE) {
$raw = trim($data[0]); $raw = trim($data[0] ?? '');
if (empty($raw)) if (empty($raw))
continue; continue;

View File

@@ -84,6 +84,9 @@ $router->addMiddleware(\App\Middleware\AdminMiddleware::class);
$router->get('/admin/orders/view/{id}', [\App\Controllers\OrderController::class, 'view']); $router->get('/admin/orders/view/{id}', [\App\Controllers\OrderController::class, 'view']);
$router->addMiddleware(\App\Middleware\AdminMiddleware::class); $router->addMiddleware(\App\Middleware\AdminMiddleware::class);
$router->get('/admin/orders/attachments/{id}/download', [\App\Controllers\OrderController::class, 'downloadAttachment']);
$router->addMiddleware(\App\Middleware\AdminMiddleware::class);
// Settings // Settings
$router->get('/admin/settings', [\App\Controllers\SettingsController::class, 'index']); $router->get('/admin/settings', [\App\Controllers\SettingsController::class, 'index']);
$router->addMiddleware(\App\Middleware\AdminMiddleware::class); $router->addMiddleware(\App\Middleware\AdminMiddleware::class);

View File

@@ -0,0 +1,17 @@
-- Migration: Criação da tabela order_attachments
-- Data: 2026-03-12
-- Descrição: Armazena metadados de arquivos anexados às ordens judiciais.
-- Os arquivos físicos ficam em storage/uploads/orders/{order_id}/
CREATE TABLE IF NOT EXISTS `order_attachments` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`order_id` int(11) NOT NULL,
`original_name` varchar(255) NOT NULL COMMENT 'Nome original do arquivo enviado pelo usuário',
`stored_name` varchar(255) NOT NULL COMMENT 'Nome gerado para armazenamento no disco',
`mime_type` varchar(100) DEFAULT NULL,
`size` int(11) DEFAULT NULL COMMENT 'Tamanho em bytes',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_attachments_order` (`order_id`),
CONSTRAINT `fk_attachments_order` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -143,4 +143,21 @@ CREATE TABLE `settings` (
PRIMARY KEY (`key`) PRIMARY KEY (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
--
-- Tabela `order_attachments`
--
CREATE TABLE IF NOT EXISTS `order_attachments` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`order_id` int(11) NOT NULL,
`original_name` varchar(255) NOT NULL COMMENT 'Nome original do arquivo enviado pelo usuário',
`stored_name` varchar(255) NOT NULL COMMENT 'Nome gerado para armazenamento no disco',
`mime_type` varchar(100) DEFAULT NULL,
`size` int(11) DEFAULT NULL COMMENT 'Tamanho em bytes',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_attachments_order` (`order_id`),
CONSTRAINT `fk_attachments_order` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
COMMIT; COMMIT;

View File

@@ -1,5 +1,3 @@
version: '3.8'
services: services:
app: app:
build: build:

View File

@@ -1,4 +1,46 @@
<div class="max-w-3xl mx-auto bg-white rounded-xl shadow-sm border border-gray-100 p-6"> <?php
$hasError = isset($_SESSION['flash_error']);
$errorMsg = $_SESSION['flash_error'] ?? '';
if ($hasError) {
unset($_SESSION['flash_error']); // Prevent toast from showing
}
?>
<div class="max-w-3xl mx-auto bg-white rounded-xl shadow-sm border border-gray-100 p-6" x-data='orderForm(<?= $hasError ? 'true' : 'false' ?>, <?= json_encode($errorMsg) ?>)'>
<!-- Modal de Erro -->
<div x-show="showModal" class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true" x-cloak>
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- Background overlay -->
<div x-show="showModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" @click="showModal = false"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<!-- Painel do Modal -->
<div x-show="showModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100" x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" class="inline-block align-bottom bg-white rounded-xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg leading-6 font-bold text-gray-900" id="modal-title" x-text="modalTitle"></h3>
<button @click="showModal = false" type="button" class="text-gray-400 hover:text-gray-500 focus:outline-none">
<span class="sr-only">Fechar</span>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="mt-2">
<p class="text-sm text-gray-600" x-text="modalMessage"></p>
</div>
<div class="mt-6 flex justify-end">
<button type="button" @click="showModal = false" class="inline-flex justify-center w-full rounded-lg border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-200 sm:w-auto sm:text-sm transition-colors">
Fechar
</button>
</div>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-800 mb-6">Nova Ordem Judicial</h3> <h3 class="text-lg font-semibold text-gray-800 mb-6">Nova Ordem Judicial</h3>
@@ -6,7 +48,7 @@
<!-- Quill Styles --> <!-- Quill Styles -->
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet"> <link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
<form action="/admin/orders/store" method="POST" enctype="multipart/form-data" class="space-y-6" id="orderForm"> <form action="/admin/orders/store" method="POST" enctype="multipart/form-data" class="space-y-6" id="orderForm" @submit="validateForm($event)">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="col-span-2"> <div class="col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">Título / Identificação da Ordem</label> <label class="block text-sm font-medium text-gray-700 mb-1">Título / Identificação da Ordem</label>
@@ -68,6 +110,38 @@
</label> </label>
</div> </div>
</div> </div>
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">
Anexos <span class="text-gray-400 font-normal">(PDF, imagens, documentos — opcional)</span>
</label>
<p class="text-xs text-gray-500 mb-2">Arquivos da ordem judicial (decisão, ofícios, etc.). Tipos aceitos: PDF, PNG, JPG, GIF, DOC, DOCX, XLS, XLSX, TXT — máx. 20 MB por arquivo.</p>
<div x-data="{ attachNames: [] }" class="mt-1">
<label class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer hover:border-primary-500 transition-colors bg-white">
<div class="space-y-1 text-center w-full">
<svg class="mx-auto h-10 w-10 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13" />
</svg>
<div class="flex text-sm text-gray-600 justify-center">
<span class="font-medium text-primary-600 hover:text-primary-500">Selecionar arquivos</span>
<p class="pl-1">ou arraste e solte</p>
</div>
<template x-if="attachNames.length > 0">
<ul class="text-xs text-gray-700 mt-2 text-left list-disc list-inside">
<template x-for="name in attachNames" :key="name">
<li x-text="name"></li>
</template>
</ul>
</template>
<p x-show="attachNames.length === 0" class="text-xs text-gray-400 mt-1">Nenhum arquivo selecionado</p>
</div>
<input type="file" name="attachments[]" class="sr-only"
accept=".pdf,.png,.jpg,.jpeg,.gif,.doc,.docx,.xls,.xlsx,.txt"
multiple
@change="attachNames = Array.from($event.target.files).map(f => f.name)">
</label>
</div>
</div>
</div> </div>
<div class="flex justify-end space-x-3 pt-4 border-t border-gray-100"> <div class="flex justify-end space-x-3 pt-4 border-t border-gray-100">
@@ -106,9 +180,46 @@
} }
}); });
var form = document.getElementById('orderForm'); document.addEventListener('alpine:init', () => {
form.onsubmit = function () { Alpine.data('orderForm', (initialShow, initialMsg) => ({
var content = document.querySelector('input[name=content]'); showModal: initialShow,
content.value = quill.root.innerHTML; modalTitle: 'Aviso',
}; modalMessage: initialMsg || '',
validateForm(e) {
let totalSize = 0;
let maxSize = 20 * 1024 * 1024; // 20 MB
let attachments = document.querySelector('input[name=\'attachments[]\']').files;
for(let i = 0; i < attachments.length; i++) {
if (attachments[i].size > maxSize) {
this.showError('Arquivo Muito Grande', 'O anexo \'' + attachments[i].name + '\' excede o limite permitido de 20 MB.');
e.preventDefault();
return false;
}
totalSize += attachments[i].size;
}
let csvFile = document.querySelector('input[name=\'csv_file\']').files[0];
if (csvFile) {
totalSize += csvFile.size;
}
if (totalSize > 40 * 1024 * 1024) { // 40 MB limite de segurança frontend
this.showError('Tamanho Total Excedido', 'O tamanho total de todos os arquivos excede o limite de submissão do servidor.');
e.preventDefault();
return false;
}
// Popula o campo oculto do Quill
document.querySelector('input[name=content]').value = window.quill.root.innerHTML;
return true;
},
showError(title, message) {
this.modalTitle = title;
this.modalMessage = message;
this.showModal = true;
}
}));
});
</script> </script>

View File

@@ -29,6 +29,40 @@
</div> </div>
</div> </div>
<?php if (!empty($attachments)): ?>
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100 bg-gray-50 flex items-center gap-2">
<svg class="w-4 h-4 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 002.112 2.13" />
</svg>
<h3 class="text-sm font-semibold text-gray-800">Anexos (<?= count($attachments) ?>)</h3>
</div>
<ul class="divide-y divide-gray-100">
<?php foreach ($attachments as $attachment): ?>
<li class="flex items-center justify-between px-6 py-3 hover:bg-gray-50 transition-colors">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<span class="text-sm text-gray-800 truncate"><?= htmlspecialchars($attachment['original_name']) ?></span>
<?php if ($attachment['size']): ?>
<span class="text-xs text-gray-400 flex-shrink-0"><?= number_format($attachment['size'] / 1024, 1) ?> KB</span>
<?php endif; ?>
</div>
<a href="/admin/orders/attachments/<?= (int) $attachment['id'] ?>/download"
class="flex items-center gap-1 text-xs text-primary-600 hover:text-primary-800 font-medium flex-shrink-0 ml-4"
title="Baixar <?= htmlspecialchars($attachment['original_name']) ?>">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Baixar
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden"> <div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100 bg-gray-50"> <div class="px-6 py-4 border-b border-gray-100 bg-gray-50">
<h3 class="text-lg font-semibold text-gray-800">Domínios Afetados (<?= count($items) ?>)</h3> <h3 class="text-lg font-semibold text-gray-800">Domínios Afetados (<?= count($items) ?>)</h3>

View File

@@ -8,9 +8,9 @@
type = $event.detail.type; type = $event.detail.type;
title = $event.detail.title; title = $event.detail.title;
message = $event.detail.message; message = $event.detail.message;
setTimeout(() => show = false, 5000) setTimeout(() => show = false, type === 'error' ? 15000 : 8000)
" x-init=" " x-init="
if (show) setTimeout(() => show = false, 5000); if (show) setTimeout(() => show = false, type === 'error' ? 15000 : 8000);
window.notify = (type, title, message) => { window.notify = (type, title, message) => {
$dispatch('notify', { type, title, message }); $dispatch('notify', { type, title, message });
} }