first commit

This commit is contained in:
Serreau Jovann
2026-01-17 22:29:04 +01:00
parent 0709988305
commit 52eecfda03
12 changed files with 444 additions and 20 deletions

View File

@@ -3,6 +3,8 @@ import './admin.scss'
import * as Sentry from "@sentry/browser";
import * as Turbo from "@hotwired/turbo";
import TomSelect from "tom-select";
import {RepeatLine} from "./libs/RepeatLine.js";
import {DevisManager} from "./libs/DevisManager.js";
// --- INITIALISATION SENTRY (En premier !) ---
Sentry.init({
dsn: "https://803814be6540031b1c37bf92ba9c0f79@sentry.esy-web.dev/24",
@@ -22,7 +24,10 @@ Sentry.init({
* Initialise les composants de l'interface d'administration.
*/
function initAdminLayout() {
if (!customElements.get('copy-text')) {
customElements.define('repeat-line', RepeatLine, {extends: 'div'})
customElements.define('devis-manager', DevisManager, {extends: 'div'})
}
document.querySelectorAll('select').forEach((el) => {
if (!el.tomselect) { // Éviter la double initialisation avec Turbo
new TomSelect(el, {

View File

@@ -0,0 +1,46 @@
// SUPPRIME l'import de postcss ici
export class DevisManager extends HTMLDivElement {
connectedCallback() {
this.customerSelect = this.querySelector('select');
// On remonte au parent pour trouver les selects d'adresse hors du bloc customer
this.billAddress = this.parentElement.querySelector('#billAddress');
this.shipAddress = this.parentElement.querySelector('#shipAddress');
if (this.customerSelect) {
this.customerSelect.addEventListener('change', (e) => this.updateCustomerInfo(e.target.value));
}
}
async updateCustomerInfo(customerId) {
if (!customerId) return;
try {
const resp = await fetch("/crm/customer/address/" + customerId);
const data = await resp.json();
// Vider les anciens selects
data.addressList.forEach(itemList => {
this.billAddress.tomselect.addOption({
value: itemList.id,
text :itemList.label
})
this.shipAddress.tomselect.addOption({
value: itemList.id,
text :itemList.label
})
});
} catch (error) {
console.error("Erreur lors de la récupération des adresses:", error);
}
}
createOption(itemList) {
// Pas besoin d'import, 'document' est global dans le navigateur
const option = document.createElement('option');
option.value = itemList.id;
option.textContent = itemList.label;
return option;
}
}

119
assets/libs/RepeatLine.js Normal file
View File

@@ -0,0 +1,119 @@
export class RepeatLine extends HTMLDivElement{
connectedCallback(){
this.$props = this.getProps(this, { maxRows: 20 });
this.$refs = this.getRefs(this);
this.rowHTML = this.$refs.rows.children[0].outerHTML;
this.init();
}
// Hook up events for the row.
setUpRow(row) {
const rowRefs = this.getRefs(row);
rowRefs.removeButton.onclick = (e) => {
e.preventDefault();
this.removeRow(row);
};
}
// Enable or disable addButton as necessary.
updateAddButton() {
if (this.$refs.rows.children.length >= this.$props.maxRows) {
this.$refs.addButton.setAttribute('disabled', '');
return;
}
this.$refs.addButton.removeAttribute('disabled');
}
// Update array key values to the row number
updateFieldNames() {
[...this.$refs.rows.children]
.forEach((el, index) => {
el.querySelectorAll('[name]')
.forEach(el => {
const newName = el.getAttribute('name').replace(/\[\d\]/gm, `[${index}]`);
el.setAttribute('name', newName);
});
});
}
addRow() {
if (
!this.rowHTML ||
this.$refs.rows.children.length >= this.$props.maxRows
) return;
let newRow = this.createFromHTML(this.rowHTML);
newRow.removeAttribute('id');
this.setUpRow(newRow);
this.$refs.rows.appendChild(newRow);
newRow.querySelector('input,textarea,select').focus();
newRow.querySelectorAll('input,textarea,select').forEach(el=>{
el.setAttribute('value','');
el.value = "";
})
this.updateFieldNames();
this.updateAddButton();
}
removeRow(row) {
if (this.$refs.rows.children.length <= 1) return;
row.remove();
this.$refs.rows.focus();
this.updateFieldNames();
this.updateFieldNames();
}
init() {
this.setUpRow(this.$refs.rows.children[0]);
this.$refs.addButton.onclick = (e) => {
e.preventDefault();
this.addRow();
}
this.updateFieldNames();
}
// Return an object that contains references to DOM objects.
getRefs(el) {
let result = {};
[...el.querySelectorAll('[data-ref]')]
.forEach(ref => {
result[ref.dataset.ref] = ref;
});
return result;
}
setDefaults(obj, defaults) {
let results = obj;
for (const prop in defaults) {
if (!obj.hasOwnProperty(prop)) {
results[prop] = defaults[prop];
}
}
return results;
}
getProps(el, defaults={}) {
return this.setDefaults(
JSON.parse(el.dataset.props ?? '{}'),
defaults
);
}
createFromHTML(html='') {
let element = document.createElement(null);
element.innerHTML = html;
return element.firstElementChild;
}
}

View File

@@ -19,7 +19,7 @@
"docusealco/docuseal-php": "^1.0.5",
"endroid/qr-code": "^6.0.9",
"exbil/mailcow-php-api": ">=0.15.0",
"fpdf/fpdf": ">=1.86.1",
"fpdf/fpdf": "^1.86",
"google/apiclient": "^2.19.0",
"google/cloud": "^0.296.0",
"healey/robots": "^1.0.1",

2
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "42643761eadf7db812cdd14bca4e4106",
"content-hash": "4d8f04690344698e5031e5b37786c9f9",
"packages": [
{
"name": "async-aws/core",

View File

@@ -335,7 +335,7 @@ class AccountController extends AbstractController
// --- SÉCURITÉ : Interdiction suppression ROOT ou CLIENT_MAIN ---
// On vérifie si l'un des rôles protégés est présent dans le tableau des rôles du compte
$protectedRoles = ['ROLE_ROOT', 'ROLE_CLIENT_MAIN'];
$protectedRoles = ['ROLE_ROOT'];
$accountRoles = $account->getRoles();
// Si l'intersection entre les rôles du compte et les rôles protégés n'est pas vide

View File

@@ -25,6 +25,21 @@ class CustomerController extends AbstractController
private readonly EntityManagerInterface $entityManager
) {}
#[Route(path: '/crm/customer/address/{id}', name: 'app_crm_customer_address', methods: ['GET'])]
public function customerAddress(?Customer $customer)
{
$addressList = [];
foreach ($customer->getCustomerAddresses() as $address) {
$addressList[] = [
'id' => $address->getId(),
'label' => $address->getAddress()." ".$address->getZipcode()." ".$address->getCity(),
];
}
return $this->json([
'addressList' => $addressList,
]);
}
#[Route(path: '/crm/customer', name: 'app_crm_customer', methods: ['GET'])]
public function index(
PaginatorInterface $paginator,

View File

@@ -3,15 +3,20 @@
namespace App\Controller\Dashboard;
use App\Entity\Devis;
use App\Entity\DevisLine;
use App\Form\NewDevisType;
use App\Logger\AppLogger;
use App\Repository\AccountRepository;
use App\Repository\CustomerRepository;
use App\Repository\DevisRepository;
use App\Service\Pdf\DevisPdfService;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Bundle\PaginatorBundle\KnpPaginatorBundle;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
class DevisController extends AbstractController
@@ -20,15 +25,20 @@ class DevisController extends AbstractController
* Liste des administrateurs
*/
#[Route(path: '/crm/devis', name: 'app_crm_devis', options: ['sitemap' => false], methods: ['GET'])]
public function devis(DevisRepository $devisRepository,AppLogger $appLogger,PaginatorInterface $paginator,Request $request): Response
public function devis(KernelInterface $kernel,DevisRepository $devisRepository,AppLogger $appLogger,PaginatorInterface $paginator,Request $request): Response
{
$devis = $devisRepository->findAll()[0];
$df = new DevisPdfService($kernel,$devis);
$df->generate();
$df->Output('I');
$appLogger->record('VIEW', 'Consultation de la liste des devis');
return $this->render('dashboard/devis/list.twig',[
'quotes' => $paginator->paginate($devisRepository->findBy([],['createA'=>'asc']),$request->get('page', 1),20),
]);
}
#[Route(path: '/crm/devis/add', name: 'app_crm_devis_add', options: ['sitemap' => false], methods: ['GET'])]
public function devisAdd(DevisRepository $devisRepository, AppLogger $appLogger): Response
#[Route(path: '/crm/devis/add', name: 'app_crm_devis_add', options: ['sitemap' => false], methods: ['GET','POST'])]
public function devisAdd(EntityManagerInterface $entityManager,CustomerRepository $customerRepository,DevisRepository $devisRepository, AppLogger $appLogger,Request $request): Response
{
$devisNumber ="DEVIS-".sprintf('%05d',$devisRepository->count()+1);
$appLogger->record('VIEW', 'Consultation de la création d\'un devis');
@@ -40,6 +50,21 @@ class DevisController extends AbstractController
$devis->setUpdateAt(new \DateTimeImmutable());
$form = $this->createForm(NewDevisType::class,$devis);
if($request->isMethod('POST')){
$devis->setCustomer($customerRepository->find($_POST['new_devis']['customer']));
foreach ($_POST['lines'] as $cd=>$line) {
$rLine = new DevisLine();
$rLine->setDevi($devis);
$rLine->setPos($cd);
$rLine->setTitle($line['title']);
$rLine->setContent($line['description']);
$rLine->setPriceHt(floatval($line['price']));
$entityManager->persist($rLine);
}
$entityManager->persist($devis);
$entityManager->flush();
}
return $this->render('dashboard/devis/add.twig',[
'form' => $form->createView(),
]);

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Service\Pdf;
use App\Entity\Devis;
use Fpdf\Fpdf;
use Symfony\Component\HttpKernel\KernelInterface;
class DevisPdfService extends Fpdf
{
private Devis $devis;
private string $logo;
public function __construct(KernelInterface $kernel, Devis $devis, $orientation = 'P', $unit = 'mm', $size = 'A4')
{
parent::__construct($orientation, $unit, $size);
$this->devis = $devis;
$this->logo = $kernel->getProjectDir()."/public/provider/images/favicon.png";
$this->AliasNbPages();
$this->SetAutoPageBreak(true, 35);
}
private function clean(?string $text): string
{
if (!$text) return '';
// On s'assure que l'entrée est bien vue comme de l'UTF-8
// //TRANSLIT : tente de remplacer par un caractère proche (ex: é -> e)
// //IGNORE : supprime purement et simplement les caractères impossibles à convertir
return iconv('UTF-8', 'windows-1252//TRANSLIT//IGNORE', $text);
}
public function Header()
{
$this->SetY(10);
if (file_exists($this->logo)) {
$this->Image($this->logo, 10, 10, 12);
$this->SetX(25);
}
$this->SetFont('Arial', 'B', 14);
$this->Cell(0, 7, $this->clean('LUDIKEVENT'), 0, 1, 'L');
$this->SetX(25);
$this->SetFont('Arial', '', 8);
$this->SetTextColor(80, 80, 80);
$this->Cell(0, 4, $this->clean('SIRET : 930 488 408 00012 | RCS : 930 488 408'), 0, 1, 'L');
$this->SetX(25);
$this->Cell(0, 4, $this->clean('Tél. : 06 14 17 24 47'), 0, 1, 'L');
$this->SetX(25);
$this->SetTextColor(37, 99, 235);
$this->Cell(0, 4, $this->clean('contact@ludikevent.fr | www.ludikevent.fr'), 0, 1, 'L', false, 'https://www.ludikevent.fr');
$this->SetY(40);
$this->SetFont('Arial', 'B', 16);
$this->SetTextColor(37, 99, 235);
$this->Cell(0, 10, $this->clean('DEVIS N° ' . $this->devis->getNum()), 0, 1, 'L');
$this->SetDrawColor(37, 99, 235);
$this->SetLineWidth(0.5);
$this->Line(10, $this->GetY(), 200, $this->GetY());
$this->Ln(10);
}
public function generate(): string
{
$this->AddPage();
$customer = $this->devis->getCustomer();
// BLOC CLIENT À DROITE
$this->SetY(55);
$this->SetFont('Arial', 'B', 9);
$this->SetTextColor(100, 100, 100);
$this->Cell(0, 5, $this->clean('DESTINATAIRE'), 0, 1, 'R');
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', 'B', 12);
$this->Cell(0, 7, $this->clean($customer->getName()), 0, 1, 'R');
$this->SetFont('Arial', '', 10);
$this->SetTextColor(50, 50, 50);
$surname = method_exists($customer, 'getSurname') ? $customer->getSurname() : '';
if ($surname) {
$this->Cell(0, 5, $this->clean($customer->getName() . ' ' . $surname), 0, 1, 'R');
}
if ($customer->getPhone()) {
$this->Cell(0, 5, $this->clean('Tél : ' . $customer->getPhone()), 0, 1, 'R');
}
if ($customer->getEmail()) {
$this->SetTextColor(37, 99, 235);
$this->Cell(0, 5, $this->clean($customer->getEmail()), 0, 1, 'R');
}
$this->Ln(15);
// --- TABLEAU (Sans colonne Quantité) ---
$this->SetFont('Arial', 'B', 10);
$this->SetFillColor(245, 247, 250);
$this->SetDrawColor(200, 200, 200);
$this->SetTextColor(0, 0, 0);
// Largeurs : Désignation (150) + Total HT (40) = 190mm
$this->Cell(150, 10, $this->clean('Désignation'), 1, 0, 'L', true);
$this->Cell(40, 10, $this->clean('Total HT'), 1, 1, 'R', true);
$this->SetFont('Arial', '', 10);
$totalHT = 0;
foreach ($this->devis->getDevisLines() as $line) {
$ht = $line->getPriceHt();
$totalHT += $ht;
$currentY = $this->GetY();
// MultiCell pour la description longue
$this->MultiCell(150, 8, $this->clean($line->getTitle()), 1, 'L');
$endY = $this->GetY();
$h = $endY - $currentY;
// On se replace à droite de la MultiCell pour le prix
$this->SetXY(160, $currentY);
$this->Cell(40, $h, number_format($ht, 2, ',', ' ') . $this->clean(' €'), 1, 1, 'R');
}
// --- TOTAUX ---
$this->Ln(5);
$this->SetFont('Arial', 'B', 10);
$this->Cell(120);
$this->Cell(30, 8, $this->clean('TOTAL HT'), 0, 0, 'L');
$this->Cell(40, 8, number_format($totalHT, 2, ',', ' ') . $this->clean(' €'), 0, 1, 'R');
$this->Cell(120);
$this->SetTextColor(37, 99, 235);
$this->SetFont('Arial', 'B', 12);
$this->Cell(30, 10, $this->clean('TOTAL TTC'), 0, 0, 'L');
$this->Cell(40, 10, number_format($totalHT * 1.20, 2, ',', ' ') . $this->clean(' €'), 0, 1, 'R');
return $this->Output('S');
}
public function Footer()
{
$this->SetY(-15);
$this->SetFont('Arial', 'I', 8);
$this->SetTextColor(128, 128, 128);
$num = 'Devis N' . chr(176) . ' ' . $this->devis->getNum();
$date = 'Date : ' . ($this->devis->getCreateA() ? $this->devis->getCreateA()->format('d/m/Y') : date('d/m/Y'));
$page = 'Page ' . $this->PageNo() . '/{nb}';
$this->Cell(63, 10, $this->clean($num), 0, 0, 'L');
$this->Cell(63, 10, $this->clean($date), 0, 0, 'C');
$this->Cell(64, 10, $this->clean($page), 0, 0, 'R');
}
}

View File

@@ -106,7 +106,7 @@
</a>
{# Bouton Supprimer avec protection ROOT/CLIENT_MAIN #}
{% if 'ROLE_ROOT' not in admin.roles and 'ROLE_CLIENT_MAIN' not in admin.roles %}
{% if 'ROLE_ROOT' not in admin.roles %}
<a href="{{ path('app_crm_administrateur_delete', {id: admin.id}) }}?_token={{ csrf_token('delete' ~ admin.id) }}"
data-turbo-method="post"
data-turbo-confirm="Confirmer la suppression définitive de l'Administrateur {{ admin.firstName }} {{ admin.name }} ?"

View File

@@ -15,7 +15,7 @@
{% block body %}
<div class="w-full animate-in fade-in zoom-in-95 duration-500">
{{ form_start(form) }}
{{ form_start(form,{attr:{'data-turbo':'false'}}) }}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[3rem] p-10 shadow-2xl relative overflow-hidden w-full">
@@ -58,15 +58,69 @@
</div>
{# COLONNE 3 : CLIENT #}
<div class="space-y-3">
<div class="space-y-3" is="devis-manager">
{{ form_label(form.customer) }}
<div class="relative">
{{ form_widget(form.customer) }}
</div>
{{ form_widget(form.customer) }}
</div>
<div>
<label for="billAddress">Adresse de facturation</label>
<select id="billAddress" name="devis[ship_address]">
</select>
</div>
<div>
<label for="shipAddress">Adresse de livraison</label>
<select id="shipAddress" name="devis[ship_address]">
</select>
</div>
</div>
<div class="form-repeater" data-component="repeater" is="repeat-line">
<ol class="form-repeater__rows" data-ref="rows" tabindex="0">
<li class="form-repeater__row" style="border-bottom: 1px solid white">
<fieldset class="form-group form-group--horizontal">
<div class="flex space-x-4">
<div class="flex-1">
<div class="form-field">
<div class="mb-1">
<label for="titre" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Titre</label>
<input type="text" name="lines[0][title]" id="lines[0][title]" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required />
</div>
<div class="mb-1">
<label for="price" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Prix HT</label>
<input type="number" step="0.1" name="lines[0][price]" id="lines[0][price]" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required />
</div>
<button
class="w-full form-repeater__remove-button bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded"
data-ref="removeButton"
type="button"
>
Supprimer la ligne
</button>
</div>
</div>
<div class="flex-1">
<div class="form-field">
<div class="mb-5">
<label for="description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
<textarea rows="7" type="text" name="lines[0][description]" id="lines[0][description]" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required></textarea>
</div>
</div>
</div>
</div>
</fieldset>
</li>
</ol>
<button
class="mt-2 form-repeater__add-button w-full bg-purple-600 hover:bg-purple-700 text-white px-3 py-1 rounded"
data-ref="addButton"
type="button"
>
+ Ajouter une ligne
</button>
</div>
{# BOUTON LARGEUR TOTALE #}
<div class="pt-4">
<button type="submit" class="w-full py-6 bg-blue-600 hover:bg-blue-500 text-white text-[11px] font-black uppercase tracking-[0.5em] rounded-2xl shadow-2xl shadow-blue-600/40 transition-all hover:scale-[1.005] active:scale-95 flex items-center justify-center group">

View File

@@ -56,7 +56,7 @@
{# DATE #}
<td class="px-6 py-4">
<span class="text-xs text-slate-400 font-medium">
{{ quote.createdAt|date('d/m/Y') }}
{{ quote.createa|date('d/m/Y') }}
</span>
</td>
@@ -80,7 +80,7 @@
'signée': 'Signé'
} %}
{% set currentStatus = quote.status|lower %}
{% set currentStatus = quote.state|lower %}
<span class="px-3 py-1.5 rounded-lg border text-[8px] font-black uppercase tracking-[0.15em] whitespace-nowrap {{ statusClasses[currentStatus] ?? 'text-slate-400 bg-slate-500/10 border-slate-500/20' }}">
{% if currentStatus == 'en attends de signature' %}
@@ -95,7 +95,7 @@
{# MONTANT #}
<td class="px-6 py-4 text-center">
<span class="text-sm font-black text-white">
{{ quote.totalTtc|number_format(2, ',', ' ') }}
{{ 0|number_format(2, ',', ' ') }}
</span>
</td>
@@ -103,19 +103,19 @@
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end space-x-2">
{# Modifier #}
<a href="{{ path('app_crm_devis_edit', {id: quote.id}) }}" class="p-2 bg-blue-600/10 hover:bg-blue-600 text-blue-500 hover:text-white rounded-xl transition-all border border-blue-500/20 shadow-lg shadow-blue-600/5">
<a href="{{ path('app_crm_devis_add', {id: quote.id}) }}" class="p-2 bg-blue-600/10 hover:bg-blue-600 text-blue-500 hover:text-white rounded-xl transition-all border border-blue-500/20 shadow-lg shadow-blue-600/5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
</a>
{# PDF #}
<a href="{{ path('app_crm_devis_pdf', {id: quote.id}) }}" target="_blank" class="p-2 bg-emerald-600/10 hover:bg-emerald-600 text-emerald-500 hover:text-white rounded-xl transition-all border border-emerald-500/20 shadow-lg shadow-emerald-600/5">
<a href="{{ path('app_crm_devis_add', {id: quote.id}) }}" target="_blank" class="p-2 bg-emerald-600/10 hover:bg-emerald-600 text-emerald-500 hover:text-white rounded-xl transition-all border border-emerald-500/20 shadow-lg shadow-emerald-600/5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
</a>
{# Delete #}
<a href="{{ path('app_crm_devis_delete', {id: quote.id}) }}?_token={{ csrf_token('delete' ~ quote.id) }}"
<a href="{{ path('app_crm_devis_add', {id: quote.id}) }}?_token={{ csrf_token('delete' ~ quote.id) }}"
data-turbo-method="post"
data-turbo-confirm="Confirmer la suppression du devis {{ quote.ref }} ?"
data-turbo-confirm="Confirmer la suppression du devis {{ quote.num }} ?"
class="p-2 bg-rose-500/10 hover:bg-rose-600 text-rose-500 hover:text-white rounded-xl transition-all border border-rose-500/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</a>