Add logo upload and color extraction for vendor
- Allow uploading a company logo and extract its dominant color - Show logo preview and extracted color in the UI - Use logo and color for PDF branding and styling - Add per-item IVA field and calculation - Update PDF export to show logo, brand color, and IVA per item
This commit is contained in:
315
shop-mode.html
315
shop-mode.html
@@ -132,6 +132,60 @@
|
||||
<i class="fas fa-building text-emerald-600"></i>
|
||||
Dati Venditore
|
||||
</h2>
|
||||
|
||||
<!-- Logo Upload -->
|
||||
<div
|
||||
class="mb-6 p-4 bg-gradient-to-r from-emerald-50 to-teal-50 rounded-lg border-2 border-emerald-200"
|
||||
>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<i class="fas fa-image text-emerald-600 mr-2"></i>
|
||||
Logo Aziendale
|
||||
</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<div x-show="venditore.logo" class="flex-shrink-0">
|
||||
<img
|
||||
:src="venditore.logo"
|
||||
alt="Logo"
|
||||
class="h-20 w-20 object-contain border-2 border-emerald-300 rounded-lg bg-white p-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="file"
|
||||
@change="handleLogoUpload($event)"
|
||||
accept="image/*"
|
||||
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-emerald-500 file:text-white hover:file:bg-emerald-600 file:cursor-pointer cursor-pointer"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
PNG, JPG, SVG fino a 2MB
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
x-show="venditore.logo"
|
||||
@click="venditore.logo = ''; logoColor = null"
|
||||
class="px-3 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
x-show="logoColor"
|
||||
class="mt-3 flex items-center gap-2"
|
||||
>
|
||||
<span class="text-xs text-gray-600"
|
||||
>Colore estratto:</span
|
||||
>
|
||||
<div
|
||||
:style="'background-color: ' + logoColor"
|
||||
class="w-8 h-8 rounded border-2 border-gray-300"
|
||||
></div>
|
||||
<span
|
||||
class="text-xs font-mono"
|
||||
x-text="logoColor"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="relative">
|
||||
<input
|
||||
@@ -349,13 +403,27 @@
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-medium text-gray-700 mb-1"
|
||||
>IVA (%)</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="item.iva"
|
||||
class="w-24 p-2 text-sm border-2 border-emerald-200 rounded-lg focus:border-emerald-500 focus:outline-none"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 text-right">
|
||||
<span class="text-xs text-gray-600"
|
||||
>Prezzo scontato:
|
||||
>Totale (IVA incl.):
|
||||
</span>
|
||||
<span
|
||||
class="font-bold text-emerald-600"
|
||||
x-text="'€' + calculateDiscountedTotal(item).toFixed(2)"
|
||||
x-text="'€' + calculateItemTotal(item).toFixed(2)"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -603,8 +671,11 @@
|
||||
piva: "",
|
||||
indirizzo: "",
|
||||
telefono: "",
|
||||
logo: "",
|
||||
},
|
||||
|
||||
logoColor: null,
|
||||
|
||||
cliente: {
|
||||
nome: "",
|
||||
email: "",
|
||||
@@ -623,6 +694,127 @@
|
||||
this.addItem();
|
||||
},
|
||||
|
||||
handleLogoUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Check file size (max 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
this.showNotification(
|
||||
"Il file è troppo grande. Max 2MB",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.venditore.logo = e.target.result;
|
||||
this.extractDominantColor(e.target.result);
|
||||
this.showNotification(
|
||||
"Logo caricato con successo!",
|
||||
"success",
|
||||
);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
this.showNotification(
|
||||
"Errore nel caricamento del logo",
|
||||
"error",
|
||||
);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
|
||||
extractDominantColor(imageData) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
try {
|
||||
const imageData = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
const data = imageData.data;
|
||||
|
||||
// Sample colors (every 10 pixels for performance)
|
||||
const colorMap = {};
|
||||
for (let i = 0; i < data.length; i += 40) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
// Skip transparent pixels
|
||||
if (a < 125) continue;
|
||||
|
||||
// Skip very light colors (likely background)
|
||||
if (r > 240 && g > 240 && b > 240) continue;
|
||||
|
||||
const rgb = `${r},${g},${b}`;
|
||||
colorMap[rgb] = (colorMap[rgb] || 0) + 1;
|
||||
}
|
||||
|
||||
// Find most common color
|
||||
let maxCount = 0;
|
||||
let dominantColor = null;
|
||||
for (const [rgb, count] of Object.entries(
|
||||
colorMap,
|
||||
)) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
dominantColor = rgb;
|
||||
}
|
||||
}
|
||||
|
||||
if (dominantColor) {
|
||||
const [r, g, b] = dominantColor
|
||||
.split(",")
|
||||
.map(Number);
|
||||
this.logoColor = this.rgbToHex(r, g, b);
|
||||
} else {
|
||||
this.logoColor = "#10b981"; // Fallback emerald
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error extracting color:", err);
|
||||
this.logoColor = "#10b981"; // Fallback emerald
|
||||
}
|
||||
};
|
||||
img.src = imageData;
|
||||
},
|
||||
|
||||
rgbToHex(r, g, b) {
|
||||
return (
|
||||
"#" +
|
||||
[r, g, b]
|
||||
.map((x) => {
|
||||
const hex = x.toString(16);
|
||||
return hex.length === 1 ? "0" + hex : hex;
|
||||
})
|
||||
.join("")
|
||||
);
|
||||
},
|
||||
|
||||
hexToRgb(hex) {
|
||||
const result =
|
||||
/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(
|
||||
hex,
|
||||
);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: { r: 16, g: 185, b: 129 }; // Fallback emerald
|
||||
},
|
||||
|
||||
addItem() {
|
||||
this.itemCounter++;
|
||||
this.items.push({
|
||||
@@ -633,6 +825,7 @@
|
||||
price: 0,
|
||||
quantity: 1,
|
||||
discount: 0,
|
||||
iva: this.taxRate || 22,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -646,6 +839,13 @@
|
||||
return subtotal - discount;
|
||||
},
|
||||
|
||||
calculateItemTotal(item) {
|
||||
const discountedTotal =
|
||||
this.calculateDiscountedTotal(item);
|
||||
const iva = discountedTotal * ((item.iva || 0) / 100);
|
||||
return discountedTotal + iva;
|
||||
},
|
||||
|
||||
calculateSubtotal() {
|
||||
return this.items.reduce((sum, item) => {
|
||||
return sum + item.price * item.quantity;
|
||||
@@ -666,15 +866,22 @@
|
||||
},
|
||||
|
||||
calculateTax() {
|
||||
return (
|
||||
this.calculateTaxableAmount() * (this.taxRate / 100)
|
||||
);
|
||||
// Calcola l'IVA sommando l'IVA di ogni item
|
||||
return this.items.reduce((sum, item) => {
|
||||
const discountedTotal =
|
||||
this.calculateDiscountedTotal(item);
|
||||
const iva =
|
||||
discountedTotal * ((item.iva || 0) / 100);
|
||||
return sum + iva;
|
||||
}, 0);
|
||||
},
|
||||
|
||||
calculateTotal() {
|
||||
return (
|
||||
this.calculateTaxableAmount() + this.calculateTax()
|
||||
);
|
||||
// Somma tutti i totali degli item (già con IVA) + spese di spedizione
|
||||
const itemsTotal = this.items.reduce((sum, item) => {
|
||||
return sum + this.calculateItemTotal(item);
|
||||
}, 0);
|
||||
return itemsTotal + this.shippingCost;
|
||||
},
|
||||
|
||||
calculateTotalQuantity() {
|
||||
@@ -714,21 +921,54 @@
|
||||
|
||||
let yPos = 20;
|
||||
|
||||
// Header
|
||||
doc.setFontSize(20);
|
||||
doc.setTextColor(16, 185, 129);
|
||||
// Determine brand color (from logo or default)
|
||||
const brandColor = this.logoColor || "#10b981";
|
||||
const rgb = this.hexToRgb(brandColor);
|
||||
|
||||
// Header with logo
|
||||
if (this.venditore.logo) {
|
||||
try {
|
||||
doc.addImage(
|
||||
this.venditore.logo,
|
||||
"PNG",
|
||||
15,
|
||||
yPos,
|
||||
30,
|
||||
30,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error adding logo to PDF:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Company name
|
||||
doc.setFontSize(22);
|
||||
doc.setFont(undefined, "bold");
|
||||
doc.setTextColor(rgb.r, rgb.g, rgb.b);
|
||||
doc.text(
|
||||
this.venditore.nome || "Venditore",
|
||||
105,
|
||||
yPos,
|
||||
{ align: "center" },
|
||||
this.venditore.logo ? 50 : 105,
|
||||
yPos + 10,
|
||||
{ align: this.venditore.logo ? "left" : "center" },
|
||||
);
|
||||
yPos += 10;
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setFontSize(16);
|
||||
doc.setFont(undefined, "bold");
|
||||
doc.setTextColor(100);
|
||||
doc.text("PREVENTIVO", 105, yPos, { align: "center" });
|
||||
yPos += 15;
|
||||
doc.text(
|
||||
"PREVENTIVO",
|
||||
this.venditore.logo ? 50 : 105,
|
||||
yPos + 20,
|
||||
{ align: this.venditore.logo ? "left" : "center" },
|
||||
);
|
||||
|
||||
yPos += this.venditore.logo ? 40 : 30;
|
||||
|
||||
// Decorative line with brand color
|
||||
doc.setDrawColor(rgb.r, rgb.g, rgb.b);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(20, yPos, 190, yPos);
|
||||
yPos += 5;
|
||||
|
||||
// Dati venditore e cliente
|
||||
doc.setFontSize(10);
|
||||
@@ -748,17 +988,20 @@
|
||||
doc.text(this.cliente.telefono, 120, yPos);
|
||||
yPos += 10;
|
||||
|
||||
// Tabella articoli
|
||||
// Tabella articoli con brand color
|
||||
doc.setFontSize(10);
|
||||
doc.setFillColor(16, 185, 129);
|
||||
doc.setFillColor(rgb.r, rgb.g, rgb.b);
|
||||
doc.rect(20, yPos, 170, 7, "F");
|
||||
doc.setTextColor(255);
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFont(undefined, "bold");
|
||||
doc.text("Articolo", 25, yPos + 5);
|
||||
doc.text("Q.tà", 120, yPos + 5);
|
||||
doc.text("Prezzo", 140, yPos + 5);
|
||||
doc.text("Sconto", 160, yPos + 5);
|
||||
doc.text("Totale", 175, yPos + 5);
|
||||
doc.text("Q.tà", 110, yPos + 5);
|
||||
doc.text("Prezzo", 130, yPos + 5);
|
||||
doc.text("Sc%", 150, yPos + 5);
|
||||
doc.text("IVA%", 165, yPos + 5);
|
||||
doc.text("Totale", 180, yPos + 5);
|
||||
yPos += 10;
|
||||
doc.setFont(undefined, "normal");
|
||||
|
||||
doc.setTextColor(0);
|
||||
this.items.forEach((item) => {
|
||||
@@ -774,12 +1017,13 @@
|
||||
doc.setFont(undefined, "normal");
|
||||
|
||||
// Dati numerici
|
||||
doc.text(String(item.quantity), 125, yPos);
|
||||
doc.text(`€${item.price.toFixed(2)}`, 140, yPos);
|
||||
doc.text(`${item.discount}%`, 163, yPos);
|
||||
doc.text(String(item.quantity), 113, yPos);
|
||||
doc.text(`€${item.price.toFixed(2)}`, 130, yPos);
|
||||
doc.text(`${item.discount}%`, 153, yPos);
|
||||
doc.text(`${item.iva || 0}%`, 168, yPos);
|
||||
doc.text(
|
||||
`€${this.calculateDiscountedTotal(item).toFixed(2)}`,
|
||||
175,
|
||||
`€${this.calculateItemTotal(item).toFixed(2)}`,
|
||||
180,
|
||||
yPos,
|
||||
);
|
||||
yPos += 5;
|
||||
@@ -862,8 +1106,9 @@
|
||||
yPos += 7;
|
||||
}
|
||||
|
||||
doc.setFontSize(12);
|
||||
doc.setFontSize(14);
|
||||
doc.setFont(undefined, "bold");
|
||||
doc.setTextColor(rgb.r, rgb.g, rgb.b);
|
||||
doc.text("TOTALE:", 120, yPos);
|
||||
doc.text(
|
||||
this.formatCurrency(this.calculateTotal()),
|
||||
@@ -871,6 +1116,12 @@
|
||||
yPos,
|
||||
);
|
||||
|
||||
// Footer decorative line
|
||||
yPos += 5;
|
||||
doc.setDrawColor(rgb.r, rgb.g, rgb.b);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(20, yPos, 190, yPos);
|
||||
|
||||
const filename = `Preventivo_${this.cliente.nome || "Cliente"}_${new Date().toISOString().split("T")[0]}.pdf`;
|
||||
doc.save(filename);
|
||||
|
||||
@@ -888,6 +1139,7 @@
|
||||
taxRate: this.taxRate,
|
||||
shippingCost: this.shippingCost,
|
||||
invoiceNotes: this.invoiceNotes,
|
||||
logoColor: this.logoColor,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -931,6 +1183,7 @@
|
||||
this.taxRate = data.taxRate || 22;
|
||||
this.shippingCost = data.shippingCost || 0;
|
||||
this.invoiceNotes = data.invoiceNotes || "";
|
||||
this.logoColor = data.logoColor || null;
|
||||
this.showNotification(
|
||||
"Dati caricati con successo!",
|
||||
"success",
|
||||
|
||||
Reference in New Issue
Block a user