Compare commits

...

10 Commits

Author SHA1 Message Date
5ffdf6a83c Update .gitea/workflows/build.yml
All checks were successful
Build and Deploy / build (push) Successful in 2m41s
2025-10-14 11:19:06 +02:00
d.viti
e5a72183b5 feat: Add back button to return to index.html in both modes
All checks were successful
Build and Deploy / build (push) Successful in 32s
2025-10-14 00:46:43 +02:00
d.viti
d759bec45d fix: Complete PDF generation with professional graphics in project-mode
All checks were successful
Build and Deploy / build (push) Successful in 41s
- Fixed syntax errors: hexToRgb function completion and this.azienda.logo reference
- Implemented complete generaPDFCliente() with shop-mode style graphics
  * Professional gradient header with brand colors from logo
  * Info cards for company and client with colored headers
  * Milestone table with MVP and additional milestones
  * Summary totals box with INPS, IVA, and withholding tax support
  * KPI stats section for hours breakdown
- Implemented complete generaPDFInterno() for internal analysis
  * Reserved document header with orange theme
  * Active team members section with rates
  * Two-column financial analysis (revenue vs costs)
  * KPI dashboard with margin and hours metrics
  * Tax regime information section
- Both PDFs now feature rounded corners, alternating backgrounds, and professional typography
- PDFs adapt colors from uploaded logo for brand consistency
2025-10-14 00:43:46 +02:00
d.viti
720b926f21 fix: Remove trailing comma in generaPDFInterno function
All checks were successful
Build and Deploy / build (push) Successful in 32s
Fixed JavaScript syntax error:
- Removed extra comma after generaPDFInterno() closing brace (line 2679)
- Error: 'missing : in conditional expression' at line 2681:30

The comma was causing the parser to expect another property in the
object literal, but instead found the closing brace.

Before: },    (extra comma caused syntax error)
After:  }     (correct - last method in object)
2025-10-14 00:31:55 +02:00
d.viti
fdc033f334 fix: Remove corrupted HTML in shop-mode logo section
Fixed duplicate and malformed HTML tags in logo upload section:
- Removed duplicate <div x-show="logoColor"> opening tag (line 171-172)
- Removed orphan ></span> closing tag (line 190-191)
- Cleaned up color palette display section

The corrupted lines were causing layout issues where the logo section
wasn't properly closed before the vendor input fields.

Verification:
- project-mode.html: Already correct, div balance = 0
- shop-mode.html: Fixed, removed lines 171-172 and 190-191
- Docker build: Successful
- Both files now have properly structured HTML
2025-10-14 00:30:05 +02:00
d.viti
9ff03cd41a feat: Add logo and color palette support to project-mode
Features added to project-mode.html:
- Logo upload section in 'Dati Azienda' (matches shop-mode design)
- Base64 logo encoding and preview
- K-means color palette extraction (5-7 colors)
- Average color calculation as brand color
- Visual palette display with color chips

Functions copied from shop-mode:
- handleLogoUpload() - File upload with size validation (2MB max)
- extractDominantColor() - K-means clustering algorithm
- kMeansClustering() - Palette extraction with convergence
- rgbToHex() / hexToRgb() - Color conversion utilities

PDF improvements:
- generaPDFCliente() now includes logo in header
- generaPDFInterno() now includes logo in header
- Dynamic brand colors from extracted palette
- Responsive layout (centered without logo, left-aligned with logo)
- White card background for logo with subtle border

Data persistence:
- logoColor and colorPalette added to salvaDati()
- Logo and palette restored in caricaPreventivoSalvato()

UI features:
- Drag & drop logo upload
- Real-time color extraction on upload
- Palette visualization (8x8px color chips with hover effect)
- Brand color display with hex value
- Delete logo button

Algorithm specs:
- Skip whites (RGB > 235), blacks (< 20), grays (sat < 0.2)
- 3-6 clusters based on image complexity
- 10 iterations with early convergence
- Lightness filter (0.25-0.80 range)
- Sort by saturation (most vibrant first)

Both modes now have identical logo/branding features!
2025-10-14 00:27:00 +02:00
d.viti
95f26e1bd4 feat: K-means color palette extraction with average brand color
Algorithm improvements:
- Implemented K-means clustering (3-6 clusters based on image size)
- Extract complete color palette from logo (5-7 representative colors)
- Calculate average RGB of palette colors as final brand color
- Sort palette by saturation (most vibrant colors first)

Filters applied:
- Skip whites (RGB > 235), blacks (RGB < 20), grays (saturation < 0.2)
- Filter palette by lightness (0.25-0.80 range)
- 10 iterations K-means with convergence check

UI improvements:
- Show extracted color palette as color chips
- Display final brand color (average of palette)
- Labeled as 'Colore Brand (media palette)'
- Hover effect on palette colors with tooltips
- Console logging for debugging

Benefits:
- More accurate color extraction from complex logos
- Avoids picking background colors (white/black)
- Represents true brand identity (not just most common)
- Works better with gradients and multi-color logos
- Average creates balanced, professional brand color
2025-10-14 00:22:47 +02:00
d.viti
546d7201b0 feat: Advanced color palette extraction algorithm
- Build complete color palette instead of single dominant color
- Filter out whites (RGB > 240), blacks (RGB < 15), and grays (saturation < 0.15)
- Calculate HSL values for better color analysis
- Quantize colors to group similar shades together
- Score colors based on:
  * Saturation (prefer vibrant colors)
  * Frequency (weight by occurrence)
  * Optimal lightness (0.4-0.6 range preferred)
- Select best scoring color from palette
- Skip too light (> 0.85) or too dark (< 0.20) colors
- Improved sampling rate (every 4 pixels instead of 10)
- Console logging for debugging extracted palette
- Fallback to emerald green if no suitable colors found
2025-10-14 00:16:37 +02:00
d.viti
e1173a1fbc feat: Modernized PDF design with web panel style and dynamic branding
- Added logo upload functionality with base64 encoding
- Automatic dominant color extraction from logo
- Complete PDF redesign with modern card-based layout
- Gradient header with company logo and name
- Information cards for vendor and client data
- Table with alternating row backgrounds
- Summary box with brand color highlights
- Per-item IVA support in calculations and PDF
- Dynamic accent colors based on logo
- Responsive layout with rounded corners and shadows
- Professional footer with generation timestamp
2025-10-14 00:14:55 +02:00
d.viti
ea504d7b37 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
2025-10-14 00:12:07 +02:00
3 changed files with 4186 additions and 1573 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",