Compare commits
10 Commits
7a5e8e5760
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ffdf6a83c | |||
|
|
e5a72183b5 | ||
|
|
d759bec45d | ||
|
|
720b926f21 | ||
|
|
fdc033f334 | ||
|
|
9ff03cd41a | ||
|
|
95f26e1bd4 | ||
|
|
546d7201b0 | ||
|
|
e1173a1fbc | ||
|
|
ea504d7b37 |
@@ -21,7 +21,7 @@ jobs:
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.commandware.com
|
||||
registry: ${{ vars.PACKAGES_REGISTRY }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.TOKEN }}
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.commandware.com/services/calcolatore_prezzi_software
|
||||
images: ${{ vars.PACKAGES_REGISTRY }}/${{ gitea.repository }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
|
||||
4945
project-mode.html
4945
project-mode.html
File diff suppressed because it is too large
Load Diff
810
shop-mode.html
810
shop-mode.html
@@ -82,18 +82,27 @@
|
||||
>
|
||||
<div class="container mx-auto p-4 max-w-7xl">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="glass rounded-2xl p-8 mb-6 animate-slide-in text-center"
|
||||
>
|
||||
<h1
|
||||
class="text-4xl font-bold text-gray-800 flex items-center justify-center gap-3"
|
||||
>
|
||||
<i class="fas fa-store text-emerald-600"></i>
|
||||
Calcolatore Prezzi - Modalità Negozio
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Sistema di preventivi basato su articoli e quantità
|
||||
</p>
|
||||
<div class="glass rounded-2xl p-8 mb-6 animate-slide-in">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<a
|
||||
href="index.html"
|
||||
class="bg-gradient-to-r from-gray-500 to-gray-600 text-white font-semibold py-2 px-4 rounded-lg hover:from-gray-600 hover:to-gray-700 transition-all flex items-center gap-2 shadow-lg"
|
||||
>
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>Torna al Menu</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<h1
|
||||
class="text-4xl font-bold text-gray-800 flex items-center justify-center gap-3"
|
||||
>
|
||||
<i class="fas fa-store text-emerald-600"></i>
|
||||
Calcolatore Prezzi - Modalità Negozio
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Sistema di preventivi basato su articoli e quantità
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifiche Toast -->
|
||||
@@ -132,6 +141,76 @@
|
||||
<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">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-semibold text-gray-700"
|
||||
>Colore Brand (media palette):</span
|
||||
>
|
||||
<div
|
||||
:style="'background-color: ' + logoColor"
|
||||
class="w-10 h-10 rounded-lg border-2 border-gray-300 shadow-sm"
|
||||
></div>
|
||||
<span
|
||||
class="text-xs font-mono font-bold"
|
||||
x-text="logoColor"
|
||||
></span>
|
||||
</div>
|
||||
<div x-show="colorPalette.length > 0" class="mt-2">
|
||||
<span class="text-xs text-gray-600"
|
||||
>Palette estratta:</span
|
||||
>
|
||||
<div class="flex gap-1 mt-1 flex-wrap">
|
||||
<template
|
||||
x-for="color in colorPalette"
|
||||
:key="color"
|
||||
>
|
||||
<div
|
||||
:style="'background-color: ' + color"
|
||||
:title="color"
|
||||
class="w-8 h-8 rounded border border-gray-300 shadow-sm cursor-pointer hover:scale-110 transition-transform"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="relative">
|
||||
<input
|
||||
@@ -349,13 +428,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 +696,12 @@
|
||||
piva: "",
|
||||
indirizzo: "",
|
||||
telefono: "",
|
||||
logo: "",
|
||||
},
|
||||
|
||||
logoColor: null,
|
||||
colorPalette: [],
|
||||
|
||||
cliente: {
|
||||
nome: "",
|
||||
email: "",
|
||||
@@ -623,6 +720,285 @@
|
||||
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 imgData = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
const data = imgData.data;
|
||||
|
||||
// Step 1: Collect all valid colors
|
||||
const colors = [];
|
||||
for (let i = 0; i < data.length; i += 16) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
// Skip transparent
|
||||
if (a < 125) continue;
|
||||
|
||||
// Skip whites (> 235)
|
||||
if (r > 235 && g > 235 && b > 235) continue;
|
||||
|
||||
// Skip blacks (< 20)
|
||||
if (r < 20 && g < 20 && b < 20) continue;
|
||||
|
||||
// Calculate saturation to skip grays
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const saturation =
|
||||
max === 0 ? 0 : (max - min) / max;
|
||||
if (saturation < 0.2) continue; // Skip grays
|
||||
|
||||
colors.push({ r, g, b });
|
||||
}
|
||||
|
||||
if (colors.length === 0) {
|
||||
this.logoColor = "#10b981";
|
||||
this.colorPalette = [];
|
||||
console.log(
|
||||
"No valid colors found, using fallback",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: K-means clustering to extract palette (5-7 colors)
|
||||
const numClusters = Math.min(
|
||||
6,
|
||||
Math.max(3, Math.floor(colors.length / 50)),
|
||||
);
|
||||
const palette = this.kMeansClustering(
|
||||
colors,
|
||||
numClusters,
|
||||
);
|
||||
|
||||
// Step 3: Filter palette colors by lightness
|
||||
const filteredPalette = palette.filter(
|
||||
(color) => {
|
||||
const max = Math.max(
|
||||
color.r,
|
||||
color.g,
|
||||
color.b,
|
||||
);
|
||||
const min = Math.min(
|
||||
color.r,
|
||||
color.g,
|
||||
color.b,
|
||||
);
|
||||
const l = (max + min) / 2 / 255;
|
||||
return l > 0.25 && l < 0.8; // Keep mid-range lightness
|
||||
},
|
||||
);
|
||||
|
||||
if (filteredPalette.length === 0) {
|
||||
this.logoColor = "#10b981";
|
||||
this.colorPalette = [];
|
||||
console.log(
|
||||
"No suitable colors in palette, using fallback",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Calculate average color from palette
|
||||
const avgR = Math.round(
|
||||
filteredPalette.reduce(
|
||||
(sum, c) => sum + c.r,
|
||||
0,
|
||||
) / filteredPalette.length,
|
||||
);
|
||||
const avgG = Math.round(
|
||||
filteredPalette.reduce(
|
||||
(sum, c) => sum + c.g,
|
||||
0,
|
||||
) / filteredPalette.length,
|
||||
);
|
||||
const avgB = Math.round(
|
||||
filteredPalette.reduce(
|
||||
(sum, c) => sum + c.b,
|
||||
0,
|
||||
) / filteredPalette.length,
|
||||
);
|
||||
|
||||
// Store results
|
||||
this.logoColor = this.rgbToHex(
|
||||
avgR,
|
||||
avgG,
|
||||
avgB,
|
||||
);
|
||||
this.colorPalette = filteredPalette.map((c) =>
|
||||
this.rgbToHex(c.r, c.g, c.b),
|
||||
);
|
||||
|
||||
console.log(
|
||||
"Extracted palette:",
|
||||
this.colorPalette,
|
||||
);
|
||||
console.log(
|
||||
"Average brand color:",
|
||||
this.logoColor,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error extracting color:", err);
|
||||
this.logoColor = "#10b981";
|
||||
this.colorPalette = [];
|
||||
}
|
||||
};
|
||||
img.src = imageData;
|
||||
},
|
||||
|
||||
kMeansClustering(colors, k) {
|
||||
// Initialize centroids randomly
|
||||
let centroids = [];
|
||||
const shuffled = [...colors].sort(
|
||||
() => Math.random() - 0.5,
|
||||
);
|
||||
for (let i = 0; i < k; i++) {
|
||||
centroids.push({
|
||||
...shuffled[i % shuffled.length],
|
||||
});
|
||||
}
|
||||
|
||||
// K-means iterations
|
||||
for (let iter = 0; iter < 10; iter++) {
|
||||
// Assign colors to nearest centroid
|
||||
const clusters = Array(k)
|
||||
.fill(null)
|
||||
.map(() => []);
|
||||
|
||||
colors.forEach((color) => {
|
||||
let minDist = Infinity;
|
||||
let closestIdx = 0;
|
||||
|
||||
centroids.forEach((centroid, idx) => {
|
||||
const dist = Math.sqrt(
|
||||
Math.pow(color.r - centroid.r, 2) +
|
||||
Math.pow(color.g - centroid.g, 2) +
|
||||
Math.pow(color.b - centroid.b, 2),
|
||||
);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
closestIdx = idx;
|
||||
}
|
||||
});
|
||||
|
||||
clusters[closestIdx].push(color);
|
||||
});
|
||||
|
||||
// Update centroids
|
||||
const newCentroids = clusters.map((cluster) => {
|
||||
if (cluster.length === 0) return centroids[0]; // Fallback
|
||||
|
||||
const avgR = Math.round(
|
||||
cluster.reduce((sum, c) => sum + c.r, 0) /
|
||||
cluster.length,
|
||||
);
|
||||
const avgG = Math.round(
|
||||
cluster.reduce((sum, c) => sum + c.g, 0) /
|
||||
cluster.length,
|
||||
);
|
||||
const avgB = Math.round(
|
||||
cluster.reduce((sum, c) => sum + c.b, 0) /
|
||||
cluster.length,
|
||||
);
|
||||
|
||||
return { r: avgR, g: avgG, b: avgB };
|
||||
});
|
||||
|
||||
// Check convergence
|
||||
const converged = centroids.every(
|
||||
(c, i) =>
|
||||
c.r === newCentroids[i].r &&
|
||||
c.g === newCentroids[i].g &&
|
||||
c.b === newCentroids[i].b,
|
||||
);
|
||||
|
||||
centroids = newCentroids;
|
||||
if (converged) break;
|
||||
}
|
||||
|
||||
// Sort by saturation (most saturated first)
|
||||
return centroids.sort((a, b) => {
|
||||
const satA =
|
||||
(Math.max(a.r, a.g, a.b) -
|
||||
Math.min(a.r, a.g, a.b)) /
|
||||
Math.max(a.r, a.g, a.b);
|
||||
const satB =
|
||||
(Math.max(b.r, b.g, b.b) -
|
||||
Math.min(b.r, b.g, b.b)) /
|
||||
Math.max(b.r, b.g, b.b);
|
||||
return satB - satA;
|
||||
});
|
||||
},
|
||||
|
||||
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 +1009,7 @@
|
||||
price: 0,
|
||||
quantity: 1,
|
||||
discount: 0,
|
||||
iva: this.taxRate || 22,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -646,6 +1023,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 +1050,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() {
|
||||
@@ -712,165 +1103,365 @@
|
||||
const { jsPDF } = window.jspdf;
|
||||
const doc = new jsPDF();
|
||||
|
||||
let yPos = 20;
|
||||
let yPos = 0;
|
||||
|
||||
// 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);
|
||||
const lightRgb = {
|
||||
r: Math.min(255, rgb.r + 180),
|
||||
g: Math.min(255, rgb.g + 180),
|
||||
b: Math.min(255, rgb.b + 180),
|
||||
};
|
||||
|
||||
// ===== HEADER SECTION with gradient effect =====
|
||||
doc.setFillColor(lightRgb.r, lightRgb.g, lightRgb.b);
|
||||
doc.rect(0, 0, 210, 55, "F");
|
||||
doc.setFillColor(rgb.r, rgb.g, rgb.b);
|
||||
doc.rect(0, 35, 210, 20, "F");
|
||||
|
||||
yPos = 18;
|
||||
|
||||
// Logo in header with white card background
|
||||
if (this.venditore.logo) {
|
||||
try {
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.roundedRect(
|
||||
15,
|
||||
yPos - 8,
|
||||
35,
|
||||
35,
|
||||
3,
|
||||
3,
|
||||
"F",
|
||||
);
|
||||
doc.setDrawColor(230, 230, 230);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.roundedRect(
|
||||
15,
|
||||
yPos - 8,
|
||||
35,
|
||||
35,
|
||||
3,
|
||||
3,
|
||||
"S",
|
||||
);
|
||||
doc.addImage(
|
||||
this.venditore.logo,
|
||||
"PNG",
|
||||
18,
|
||||
yPos - 5,
|
||||
29,
|
||||
29,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error adding logo to PDF:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Company name and title
|
||||
const xStart = this.venditore.logo ? 55 : 15;
|
||||
doc.setFontSize(26);
|
||||
doc.setFont(undefined, "bold");
|
||||
doc.setTextColor(60, 60, 60);
|
||||
doc.text(
|
||||
this.venditore.nome || "Venditore",
|
||||
105,
|
||||
xStart,
|
||||
yPos,
|
||||
{ align: "center" },
|
||||
);
|
||||
yPos += 10;
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setTextColor(100);
|
||||
doc.text("PREVENTIVO", 105, yPos, { align: "center" });
|
||||
yPos += 15;
|
||||
doc.setFont(undefined, "bold");
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text("PREVENTIVO", xStart, yPos + 23);
|
||||
|
||||
// Dati venditore e cliente
|
||||
// Date badge
|
||||
const dateStr = new Date(
|
||||
this.cliente.data,
|
||||
).toLocaleDateString("it-IT");
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.roundedRect(155, yPos + 15, 40, 12, 2, 2, "F");
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(0);
|
||||
doc.text("VENDITORE:", 20, yPos);
|
||||
doc.text("CLIENTE:", 120, yPos);
|
||||
yPos += 5;
|
||||
doc.setFont(undefined, "bold");
|
||||
doc.setTextColor(rgb.r, rgb.g, rgb.b);
|
||||
doc.text(dateStr, 175, yPos + 22, { align: "center" });
|
||||
|
||||
yPos = 65;
|
||||
|
||||
// ===== INFO CARDS SECTION =====
|
||||
// Venditore card
|
||||
doc.setFillColor(250, 250, 250);
|
||||
doc.roundedRect(15, yPos, 85, 28, 3, 3, "F");
|
||||
doc.setDrawColor(220, 220, 220);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.roundedRect(15, yPos, 85, 28, 3, 3, "S");
|
||||
|
||||
// Venditore header with brand color
|
||||
doc.setFillColor(rgb.r, rgb.g, rgb.b);
|
||||
doc.roundedRect(15, yPos, 85, 8, 3, 3, "F");
|
||||
doc.rect(15, yPos + 5, 85, 3, "F");
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.text(this.venditore.nome, 20, yPos);
|
||||
doc.text(this.cliente.nome, 120, yPos);
|
||||
yPos += 5;
|
||||
doc.text(`P.IVA: ${this.venditore.piva}`, 20, yPos);
|
||||
doc.text(this.cliente.email, 120, yPos);
|
||||
yPos += 5;
|
||||
doc.text(this.venditore.indirizzo, 20, yPos);
|
||||
doc.text(this.cliente.telefono, 120, yPos);
|
||||
yPos += 10;
|
||||
doc.setFont(undefined, "bold");
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text("VENDITORE", 57.5, yPos + 5.5, {
|
||||
align: "center",
|
||||
});
|
||||
|
||||
// Tabella articoli
|
||||
doc.setFontSize(10);
|
||||
doc.setFillColor(16, 185, 129);
|
||||
doc.rect(20, yPos, 170, 7, "F");
|
||||
doc.setTextColor(255);
|
||||
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);
|
||||
yPos += 10;
|
||||
// Venditore data
|
||||
doc.setFontSize(8);
|
||||
doc.setFont(undefined, "normal");
|
||||
doc.setTextColor(60, 60, 60);
|
||||
doc.text(this.venditore.nome || "-", 18, yPos + 13);
|
||||
doc.text(
|
||||
`P.IVA: ${this.venditore.piva || "-"}`,
|
||||
18,
|
||||
yPos + 18,
|
||||
);
|
||||
doc.text(
|
||||
this.venditore.indirizzo || "-",
|
||||
18,
|
||||
yPos + 23,
|
||||
);
|
||||
|
||||
doc.setTextColor(0);
|
||||
// Cliente card
|
||||
doc.setFillColor(250, 250, 250);
|
||||
doc.roundedRect(110, yPos, 85, 28, 3, 3, "F");
|
||||
doc.setDrawColor(220, 220, 220);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.roundedRect(110, yPos, 85, 28, 3, 3, "S");
|
||||
|
||||
// Cliente header with brand color
|
||||
doc.setFillColor(rgb.r, rgb.g, rgb.b);
|
||||
doc.roundedRect(110, yPos, 85, 8, 3, 3, "F");
|
||||
doc.rect(110, yPos + 5, 85, 3, "F");
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont(undefined, "bold");
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text("CLIENTE", 152.5, yPos + 5.5, {
|
||||
align: "center",
|
||||
});
|
||||
|
||||
// Cliente data
|
||||
doc.setFontSize(8);
|
||||
doc.setFont(undefined, "normal");
|
||||
doc.setTextColor(60, 60, 60);
|
||||
doc.text(this.cliente.nome || "-", 113, yPos + 13);
|
||||
doc.text(this.cliente.email || "-", 113, yPos + 18);
|
||||
doc.text(this.cliente.telefono || "-", 113, yPos + 23);
|
||||
|
||||
yPos += 38;
|
||||
|
||||
// ===== ITEMS TABLE =====
|
||||
// Table header with gradient
|
||||
doc.setFillColor(lightRgb.r, lightRgb.g, lightRgb.b);
|
||||
doc.roundedRect(15, yPos, 180, 10, 2, 2, "F");
|
||||
doc.setFillColor(rgb.r, rgb.g, rgb.b);
|
||||
doc.roundedRect(15, yPos, 180, 10, 2, 2, "F");
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setFont(undefined, "bold");
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text("ARTICOLO", 18, yPos + 6.5);
|
||||
doc.text("Q.TÀ", 110, yPos + 6.5, { align: "center" });
|
||||
doc.text("PREZZO", 130, yPos + 6.5, {
|
||||
align: "center",
|
||||
});
|
||||
doc.text("SC%", 150, yPos + 6.5, { align: "center" });
|
||||
doc.text("IVA%", 165, yPos + 6.5, { align: "center" });
|
||||
doc.text("TOTALE", 188, yPos + 6.5, { align: "right" });
|
||||
|
||||
yPos += 14;
|
||||
|
||||
// Items rows with alternating background
|
||||
let itemIndex = 0;
|
||||
this.items.forEach((item) => {
|
||||
if (yPos > 260) {
|
||||
if (yPos > 255) {
|
||||
doc.addPage();
|
||||
yPos = 20;
|
||||
itemIndex = 0;
|
||||
}
|
||||
|
||||
// Nome prodotto
|
||||
doc.setFontSize(9);
|
||||
// Alternating row background
|
||||
if (itemIndex % 2 === 0) {
|
||||
doc.setFillColor(250, 250, 250);
|
||||
doc.rect(15, yPos - 2, 180, 10, "F");
|
||||
}
|
||||
|
||||
// Item data
|
||||
doc.setFontSize(8);
|
||||
doc.setFont(undefined, "bold");
|
||||
doc.text(item.name.substring(0, 50), 25, yPos);
|
||||
doc.setTextColor(60, 60, 60);
|
||||
doc.text(item.name.substring(0, 45), 18, yPos + 2);
|
||||
|
||||
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), 110, yPos + 2, {
|
||||
align: "center",
|
||||
});
|
||||
doc.text(
|
||||
`€${this.calculateDiscountedTotal(item).toFixed(2)}`,
|
||||
175,
|
||||
yPos,
|
||||
`€${item.price.toFixed(2)}`,
|
||||
130,
|
||||
yPos + 2,
|
||||
{ align: "center" },
|
||||
);
|
||||
yPos += 5;
|
||||
doc.text(`${item.discount}%`, 150, yPos + 2, {
|
||||
align: "center",
|
||||
});
|
||||
doc.text(`${item.iva || 0}%`, 165, yPos + 2, {
|
||||
align: "center",
|
||||
});
|
||||
|
||||
// SKU e descrizione (se presenti)
|
||||
doc.setFont(undefined, "bold");
|
||||
doc.setTextColor(rgb.r, rgb.g, rgb.b);
|
||||
doc.text(
|
||||
`€${this.calculateItemTotal(item).toFixed(2)}`,
|
||||
188,
|
||||
yPos + 2,
|
||||
{ align: "right" },
|
||||
);
|
||||
|
||||
// SKU and description
|
||||
if (item.sku || item.description) {
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(100);
|
||||
yPos += 5;
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(120, 120, 120);
|
||||
doc.setFont(undefined, "italic");
|
||||
|
||||
if (item.sku) {
|
||||
doc.text(`SKU: ${item.sku}`, 25, yPos);
|
||||
yPos += 4;
|
||||
doc.text(`SKU: ${item.sku}`, 18, yPos);
|
||||
yPos += 3.5;
|
||||
}
|
||||
if (item.description) {
|
||||
const descLines = doc.splitTextToSize(
|
||||
item.description,
|
||||
160,
|
||||
165,
|
||||
);
|
||||
descLines.slice(0, 2).forEach((line) => {
|
||||
doc.text(line, 25, yPos);
|
||||
yPos += 4;
|
||||
doc.text(line, 18, yPos);
|
||||
yPos += 3.5;
|
||||
});
|
||||
}
|
||||
doc.setTextColor(0);
|
||||
doc.setFontSize(9);
|
||||
yPos += 2;
|
||||
} else {
|
||||
yPos += 5;
|
||||
yPos += 10;
|
||||
}
|
||||
|
||||
// Separator line
|
||||
doc.setDrawColor(230, 230, 230);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(15, yPos - 1, 195, yPos - 1);
|
||||
|
||||
itemIndex++;
|
||||
});
|
||||
|
||||
yPos += 5;
|
||||
|
||||
// Totali
|
||||
doc.line(120, yPos, 190, yPos);
|
||||
yPos += 7;
|
||||
// ===== TOTALS SECTION =====
|
||||
// Summary box
|
||||
doc.setFillColor(250, 250, 250);
|
||||
doc.roundedRect(115, yPos, 80, 45, 3, 3, "F");
|
||||
doc.setDrawColor(220, 220, 220);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.roundedRect(115, yPos, 80, 45, 3, 3, "S");
|
||||
|
||||
doc.text("Subtotale:", 120, yPos);
|
||||
let summaryY = yPos + 8;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont(undefined, "normal");
|
||||
doc.setTextColor(80, 80, 80);
|
||||
|
||||
// Subtotal
|
||||
doc.text("Subtotale:", 120, summaryY);
|
||||
doc.text(
|
||||
this.formatCurrency(this.calculateSubtotal()),
|
||||
175,
|
||||
yPos,
|
||||
190,
|
||||
summaryY,
|
||||
{ align: "right" },
|
||||
);
|
||||
yPos += 7;
|
||||
summaryY += 6;
|
||||
|
||||
// Discount
|
||||
if (this.calculateTotalDiscount() > 0) {
|
||||
doc.text("Sconto:", 120, yPos);
|
||||
doc.text("Sconto:", 120, summaryY);
|
||||
doc.setTextColor(220, 50, 50);
|
||||
doc.text(
|
||||
`-${this.formatCurrency(this.calculateTotalDiscount())}`,
|
||||
175,
|
||||
yPos,
|
||||
190,
|
||||
summaryY,
|
||||
{ align: "right" },
|
||||
);
|
||||
yPos += 7;
|
||||
doc.setTextColor(80, 80, 80);
|
||||
summaryY += 6;
|
||||
}
|
||||
|
||||
// Shipping
|
||||
if (this.shippingCost > 0) {
|
||||
doc.text("Spedizione:", 120, yPos);
|
||||
doc.text("Spedizione:", 120, summaryY);
|
||||
doc.text(
|
||||
this.formatCurrency(this.shippingCost),
|
||||
175,
|
||||
yPos,
|
||||
190,
|
||||
summaryY,
|
||||
{ align: "right" },
|
||||
);
|
||||
yPos += 7;
|
||||
summaryY += 6;
|
||||
}
|
||||
|
||||
doc.text("Imponibile:", 120, yPos);
|
||||
// Taxable amount
|
||||
doc.text("Imponibile:", 120, summaryY);
|
||||
doc.text(
|
||||
this.formatCurrency(this.calculateTaxableAmount()),
|
||||
175,
|
||||
yPos,
|
||||
190,
|
||||
summaryY,
|
||||
{ align: "right" },
|
||||
);
|
||||
yPos += 7;
|
||||
summaryY += 6;
|
||||
|
||||
if (this.taxRate > 0) {
|
||||
doc.text(`IVA (${this.taxRate}%):`, 120, yPos);
|
||||
// VAT
|
||||
if (this.calculateTax() > 0) {
|
||||
doc.text("IVA:", 120, summaryY);
|
||||
doc.text(
|
||||
this.formatCurrency(this.calculateTax()),
|
||||
175,
|
||||
yPos,
|
||||
190,
|
||||
summaryY,
|
||||
{ align: "right" },
|
||||
);
|
||||
yPos += 7;
|
||||
summaryY += 8;
|
||||
} else {
|
||||
summaryY += 8;
|
||||
}
|
||||
|
||||
doc.setFontSize(12);
|
||||
// Total with brand color background
|
||||
doc.setFillColor(rgb.r, rgb.g, rgb.b);
|
||||
doc.roundedRect(115, summaryY - 4, 80, 10, 2, 2, "F");
|
||||
|
||||
doc.setFontSize(11);
|
||||
doc.setFont(undefined, "bold");
|
||||
doc.text("TOTALE:", 120, yPos);
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text("TOTALE:", 120, summaryY + 2.5);
|
||||
doc.setFontSize(13);
|
||||
doc.text(
|
||||
this.formatCurrency(this.calculateTotal()),
|
||||
175,
|
||||
yPos,
|
||||
190,
|
||||
summaryY + 2.5,
|
||||
{ align: "right" },
|
||||
);
|
||||
|
||||
// Footer
|
||||
yPos += 55;
|
||||
if (yPos < 270) {
|
||||
doc.setFontSize(7);
|
||||
doc.setFont(undefined, "italic");
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(
|
||||
`Documento generato il ${new Date().toLocaleString("it-IT")}`,
|
||||
105,
|
||||
285,
|
||||
{ align: "center" },
|
||||
);
|
||||
}
|
||||
|
||||
// Save PDF
|
||||
const filename = `Preventivo_${this.cliente.nome || "Cliente"}_${new Date().toISOString().split("T")[0]}.pdf`;
|
||||
doc.save(filename);
|
||||
|
||||
@@ -879,7 +1470,6 @@
|
||||
"success",
|
||||
);
|
||||
},
|
||||
|
||||
salvaDati() {
|
||||
const data = {
|
||||
venditore: this.venditore,
|
||||
@@ -888,6 +1478,7 @@
|
||||
taxRate: this.taxRate,
|
||||
shippingCost: this.shippingCost,
|
||||
invoiceNotes: this.invoiceNotes,
|
||||
logoColor: this.logoColor,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -931,6 +1522,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