Files
calcolatore_prezzi_software/project-mode.html
d.viti e5a72183b5
All checks were successful
Build and Deploy / build (push) Successful in 32s
feat: Add back button to return to index.html in both modes
2025-10-14 00:46:43 +02:00

3521 lines
168 KiB
HTML

<!doctype html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Calcolatore Prezzi Software Pro - Italia</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Alpine.js -->
<script
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
></script>
<!-- jsPDF per generazione PDF -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<!-- Font Awesome per icone -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
/>
<!-- Google Fonts -->
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<style>
body {
font-family: "Inter", sans-serif;
}
[x-cloak] {
display: none !important;
}
/* Animazioni personalizzate */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-slide-in {
animation: slideIn 0.5s ease-out;
}
/* Effetto glass morphism */
.glass {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
border-radius: 10px;
}
/* Loading animation */
.loader {
border: 3px solid #f3f3f3;
border-top: 3px solid #3b82f6;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body
class="bg-gradient-to-br from-blue-400 via-purple-500 to-pink-500 min-h-screen"
x-data="calcolatoreApp()"
x-init="init()"
>
<div class="container mx-auto p-4 max-w-7xl">
<!-- Header -->
<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-code text-blue-600"></i>
Calcolatore Prezzi Software Pro
</h1>
<p class="text-gray-600 mt-2">
Sistema professionale per preventivi sviluppo software
in Italia
</p>
</div>
</div>
<!-- Notifiche Toast -->
<div
x-show="notification.show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed top-4 right-4 z-50"
>
<div
:class="'p-4 rounded-lg shadow-lg flex items-center gap-3 ' +
(notification.type === 'success' ? 'bg-green-500' :
notification.type === 'error' ? 'bg-red-500' :
notification.type === 'warning' ? 'bg-orange-500' : 'bg-blue-500') +
' text-white'"
>
<i
:class="'fas fa-' +
(notification.type === 'success' ? 'check-circle' :
notification.type === 'error' ? 'exclamation-circle' :
notification.type === 'warning' ? 'exclamation-triangle' : 'info-circle')"
></i>
<span x-text="notification.message"></span>
</div>
</div>
<!-- Progress Bar -->
<div class="glass rounded-full h-2 mb-6 overflow-hidden">
<div
class="h-full bg-gradient-to-r from-blue-500 to-purple-500 transition-all duration-500"
:style="'width: ' + calculateProgress() + '%'"
></div>
</div>
<!-- Dati Azienda -->
<div class="glass rounded-2xl p-6 mb-6 animate-slide-in">
<h2
class="text-2xl font-bold text-gray-800 mb-4 flex items-center gap-2"
>
<i class="fas fa-building text-blue-600"></i>
Dati Azienda
</h2>
<!-- Logo Upload -->
<div
class="mb-6 p-4 bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg border-2 border-blue-200"
>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-image text-blue-600 mr-2"></i>
Logo Aziendale
</label>
<div class="flex items-center gap-4">
<div x-show="azienda.logo" class="flex-shrink-0">
<img
:src="azienda.logo"
alt="Logo"
class="h-20 w-20 object-contain border-2 border-blue-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-blue-500 file:text-white hover:file:bg-blue-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="azienda.logo"
@click="azienda.logo = ''; logoColor = null; colorPalette = []"
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
type="text"
x-model="azienda.nome"
class="w-full p-3 pl-10 border-2 border-blue-200 rounded-lg focus:border-blue-500 focus:outline-none transition-all"
placeholder="Nome Azienda"
/>
<i
class="fas fa-signature absolute left-3 top-4 text-blue-400"
></i>
</div>
<div class="relative">
<input
type="text"
x-model="azienda.piva"
class="w-full p-3 pl-10 border-2 border-blue-200 rounded-lg focus:border-blue-500 focus:outline-none transition-all"
placeholder="P.IVA"
/>
<i
class="fas fa-id-card absolute left-3 top-4 text-blue-400"
></i>
</div>
<div class="relative">
<input
type="text"
x-model="azienda.indirizzo"
class="w-full p-3 pl-10 border-2 border-blue-200 rounded-lg focus:border-blue-500 focus:outline-none transition-all"
placeholder="Indirizzo"
/>
<i
class="fas fa-map-marker-alt absolute left-3 top-4 text-blue-400"
></i>
</div>
<div class="relative">
<input
type="text"
x-model="azienda.telefono"
class="w-full p-3 pl-10 border-2 border-blue-200 rounded-lg focus:border-blue-500 focus:outline-none transition-all"
placeholder="Telefono"
/>
<i
class="fas fa-phone absolute left-3 top-4 text-blue-400"
></i>
</div>
</div>
</div>
<!-- Dati Cliente -->
<div class="glass rounded-2xl p-6 mb-6 animate-slide-in">
<h2
class="text-2xl font-bold text-gray-800 mb-4 flex items-center gap-2"
>
<i class="fas fa-user text-blue-600"></i>
Dati Cliente
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="relative">
<input
type="text"
x-model="cliente.nome"
class="w-full p-3 pl-10 border-2 border-blue-200 rounded-lg focus:border-blue-500 focus:outline-none transition-all"
placeholder="Nome Cliente"
/>
<i
class="fas fa-user-tie absolute left-3 top-4 text-blue-400"
></i>
</div>
<div class="relative">
<input
type="text"
x-model="cliente.azienda"
class="w-full p-3 pl-10 border-2 border-blue-200 rounded-lg focus:border-blue-500 focus:outline-none transition-all"
placeholder="Azienda Cliente"
/>
<i
class="fas fa-briefcase absolute left-3 top-4 text-blue-400"
></i>
</div>
<div class="relative">
<input
type="email"
x-model="cliente.email"
class="w-full p-3 pl-10 border-2 border-blue-200 rounded-lg focus:border-blue-500 focus:outline-none transition-all"
placeholder="Email"
/>
<i
class="fas fa-envelope absolute left-3 top-4 text-blue-400"
></i>
</div>
<div class="relative">
<input
type="text"
x-model="cliente.progetto"
class="w-full p-3 pl-10 border-2 border-blue-200 rounded-lg focus:border-blue-500 focus:outline-none transition-all"
placeholder="Nome Progetto"
/>
<i
class="fas fa-project-diagram absolute left-3 top-4 text-blue-400"
></i>
</div>
<div class="relative md:col-span-2">
<input
type="date"
x-model="cliente.data"
class="w-full p-3 pl-10 border-2 border-blue-200 rounded-lg focus:border-blue-500 focus:outline-none transition-all"
/>
<i
class="fas fa-calendar absolute left-3 top-4 text-blue-400"
></i>
</div>
</div>
</div>
<!-- Team Members -->
<div class="glass rounded-2xl p-6 mb-6 animate-slide-in">
<h2
class="text-2xl font-bold text-gray-800 mb-4 flex items-center gap-2"
>
<i class="fas fa-users text-blue-600"></i>
Membri del Team
<span
class="bg-orange-500 text-white text-xs px-2 py-1 rounded-full ml-2"
>INTERNO</span
>
</h2>
<!-- Team member list -->
<div class="space-y-3 mb-4">
<template
x-for="(member, index) in teamMembers"
:key="member.id"
>
<div
class="p-4 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg border-2 border-indigo-200"
>
<div class="flex items-start justify-between">
<div
class="flex-1 grid grid-cols-1 md:grid-cols-4 gap-3"
>
<div>
<label
class="block text-xs font-medium text-gray-700 mb-1"
>Nome</label
>
<input
type="text"
x-model="member.name"
class="w-full p-2 text-sm border-2 border-indigo-200 rounded-lg focus:border-indigo-500 focus:outline-none"
placeholder="Nome membro"
/>
</div>
<div>
<label
class="block text-xs font-medium text-gray-700 mb-1"
>Ruolo</label
>
<select
x-model="member.role"
class="w-full p-2 text-sm border-2 border-indigo-200 rounded-lg focus:border-indigo-500 focus:outline-none"
>
<option value="developer">
Developer
</option>
<option value="senior">
Senior Developer
</option>
<option value="architect">
Software Architect
</option>
<option value="designer">
UI/UX Designer
</option>
<option value="pm">
Project Manager
</option>
<option value="analyst">
Business Analyst
</option>
<option value="tester">
QA Tester
</option>
<option value="support">
Support
</option>
</select>
</div>
<div>
<label
class="block text-xs font-medium text-gray-700 mb-1"
>Tariffa Sviluppo (€/h)</label
>
<input
type="number"
x-model.number="member.devRate"
class="w-full p-2 text-sm border-2 border-indigo-200 rounded-lg focus:border-indigo-500 focus:outline-none"
step="5"
/>
</div>
<div>
<label
class="block text-xs font-medium text-gray-700 mb-1"
>Tariffa Supporto (€/h)</label
>
<input
type="number"
x-model.number="member.supportRate"
class="w-full p-2 text-sm border-2 border-indigo-200 rounded-lg focus:border-indigo-500 focus:outline-none"
step="5"
/>
</div>
</div>
<button
@click="removeTeamMember(index)"
class="ml-3 text-red-500 hover:text-red-700 transition-colors"
>
<i class="fas fa-trash"></i>
</button>
</div>
<div class="mt-3 flex items-center gap-4">
<div class="flex items-center">
<input
type="checkbox"
:id="'active-' + member.id"
x-model="member.active"
class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
<label
:for="'active-' + member.id"
class="ml-2 text-sm text-gray-700"
>Attivo nel progetto</label
>
</div>
<div class="text-sm text-gray-600">
<i
class="fas fa-calculator text-indigo-500"
></i>
<span
x-text="'Giornata (8h): €' + (member.devRate * 8)"
></span>
</div>
</div>
</div>
</template>
</div>
<button
@click="addTeamMember()"
class="w-full bg-gradient-to-r from-indigo-500 to-purple-500 text-white font-bold py-3 px-6 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all flex items-center justify-center gap-2"
>
<i class="fas fa-user-plus"></i>
Aggiungi Membro del Team
</button>
<!-- Team summary -->
<div
x-show="teamMembers.filter(m => m.active).length > 0"
class="mt-4 p-4 bg-indigo-50 rounded-lg"
>
<p class="text-sm text-gray-700 mb-2">
<i class="fas fa-users text-indigo-600 mr-2"></i>
Team attivo:
<span
class="font-bold"
x-text="teamMembers.filter(m => m.active).length + ' membri'"
></span>
</p>
<p class="text-xs text-gray-600">
Tariffa media sviluppo:
<span
class="font-bold text-indigo-600"
x-text="'€' + calculateAverageDevRate().toFixed(2) + '/h'"
></span>
| Tariffa media supporto:
<span
class="font-bold text-purple-600"
x-text="'€' + calculateAverageSupportRate().toFixed(2) + '/h'"
></span>
</p>
</div>
</div>
<!-- Tabs Configurazione -->
<div class="glass rounded-2xl p-6 mb-6 animate-slide-in">
<!-- Tab Headers -->
<div
class="flex flex-wrap gap-2 mb-6 border-b-2 border-blue-200"
>
<button
@click="activeTab = 'regime'"
:class="activeTab === 'regime' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-4 py-2 rounded-t-lg font-semibold transition-all flex items-center gap-2"
>
<i class="fas fa-balance-scale"></i>
Regime Fiscale
</button>
<button
@click="activeTab = 'tariffe'"
:class="activeTab === 'tariffe' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-4 py-2 rounded-t-lg font-semibold transition-all flex items-center gap-2"
>
<i class="fas fa-euro-sign"></i>
Tariffe
</button>
<button
@click="activeTab = 'milestone'"
:class="activeTab === 'milestone' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-4 py-2 rounded-t-lg font-semibold transition-all flex items-center gap-2"
>
<i class="fas fa-tasks"></i>
Milestone
</button>
</div>
<!-- Tab Content -->
<div class="tab-content">
<!-- Regime Fiscale Tab -->
<div
x-show="activeTab === 'regime'"
x-cloak
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
>
<h3
class="text-xl font-bold text-gray-800 mb-4 flex items-center gap-2"
>
<i class="fas fa-balance-scale text-blue-600"></i>
Configurazione Regime Fiscale
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label
class="block text-sm font-medium text-gray-700 mb-2"
>Tipologia Fiscale</label
>
<select
x-model="taxRegime"
class="w-full p-3 border-2 border-blue-200 rounded-lg focus:border-blue-500 focus:outline-none"
>
<option value="forfettario">
Regime Forfettario (Ditta Individuale)
</option>
<option value="ordinario">
Regime Ordinario Semplificato (Ditta
Individuale)
</option>
<option value="occasionale">
Lavoro Autonomo Occasionale
</option>
<option value="minimi">
Regime dei Minimi (ad esaurimento)
</option>
<option value="srl">
SRL - Società a Responsabilità Limitata
</option>
<option value="srls">
SRLS - SRL Semplificata
</option>
</select>
</div>
<!-- Opzioni Forfettario -->
<template x-if="taxRegime === 'forfettario'">
<div
class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-2"
>
Coefficiente di Redditività (%)
<span
class="bg-orange-500 text-white text-xs px-2 py-1 rounded-full ml-2"
>INTERNO</span
>
</label>
<input
type="number"
x-model.number="coeffRedditivita"
class="w-full p-3 border-2 border-blue-200 rounded-lg focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-2"
>
Aliquota Imposta Sostitutiva
<span
class="bg-orange-500 text-white text-xs px-2 py-1 rounded-full ml-2"
>INTERNO</span
>
</label>
<select
x-model.number="impostaSostitutiva"
class="w-full p-3 border-2 border-blue-200 rounded-lg focus:border-blue-500 focus:outline-none"
>
<option value="0.05">
5% (Startup - primi 5 anni)
</option>
<option value="0.15">
15% (Standard)
</option>
</select>
</div>
</div>
</template>
<!-- Opzioni Ordinario/Occasionale -->
<template
x-if="taxRegime === 'ordinario' || taxRegime === 'occasionale'"
>
<div class="md:col-span-2">
<label
class="block text-sm font-medium text-gray-700 mb-2"
>
Altri Redditi IRPEF Stimati (€)
<span
class="bg-orange-500 text-white text-xs px-2 py-1 rounded-full ml-2"
>INTERNO</span
>
</label>
<input
type="number"
x-model.number="estimatedAnnualTaxable"
class="w-full p-3 border-2 border-blue-200 rounded-lg focus:border-blue-500 focus:outline-none"
/>
</div>
</template>
<!-- Opzioni SRL/SRLS -->
<template
x-if="taxRegime === 'srl' || taxRegime === 'srls'"
>
<div class="md:col-span-2 space-y-4">
<div
class="p-4 bg-purple-50 rounded-lg border-2 border-purple-200"
>
<h4
class="font-semibold text-purple-800 mb-3 flex items-center gap-2"
>
<i
class="fas fa-building text-purple-600"
></i>
Parametri Societari
<span
class="bg-orange-500 text-white text-xs px-2 py-1 rounded-full ml-2"
>INTERNO</span
>
</h4>
<div
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-2"
>
Aliquota IRES (%)
</label>
<input
type="number"
x-model.number="iresRate"
class="w-full p-3 border-2 border-purple-200 rounded-lg focus:border-purple-500 focus:outline-none"
step="0.1"
min="0"
max="100"
/>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-2"
>
Aliquota IRAP (%)
</label>
<input
type="number"
x-model.number="irapRate"
class="w-full p-3 border-2 border-purple-200 rounded-lg focus:border-purple-500 focus:outline-none"
step="0.1"
min="0"
max="10"
/>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-2"
>
Costi Deducibili Stimati (€)
</label>
<input
type="number"
x-model.number="deductibleCosts"
class="w-full p-3 border-2 border-purple-200 rounded-lg focus:border-purple-500 focus:outline-none"
placeholder="es. 5000"
/>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-2"
>
Compenso Amministratore (€)
</label>
<input
type="number"
x-model.number="adminCompensation"
class="w-full p-3 border-2 border-purple-200 rounded-lg focus:border-purple-500 focus:outline-none"
placeholder="es. 2000"
/>
</div>
</div>
</div>
<div
class="p-3 bg-yellow-50 rounded-lg border border-yellow-300"
>
<p class="text-sm text-yellow-800">
<i
class="fas fa-info-circle mr-2"
></i>
<span x-show="taxRegime === 'srl'">
<strong>SRL:</strong> Società
con capitale minimo €10.000,
costi notarili circa €2.000
</span>
<span x-show="taxRegime === 'srls'">
<strong>SRLS:</strong> Capitale
da €1 a €9.999, costi notarili
ridotti (circa €600)
</span>
</p>
</div>
</div>
</template>
<!-- Rivalsa INPS -->
<div
class="md:col-span-2"
x-show="taxRegime !== 'occasionale'"
>
<div
class="flex items-center justify-between p-4 bg-blue-50 rounded-lg"
>
<label
for="includeINPS"
class="text-gray-700 font-medium flex items-center"
>
<i
class="fas fa-info-circle text-blue-500 mr-2"
></i>
Includere rivalsa INPS 4% in fattura?
</label>
<input
type="checkbox"
id="includeINPS"
x-model="includeINPS"
class="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
</div>
</div>
</div>
</div>
<!-- Tariffe Tab -->
<div
x-show="activeTab === 'tariffe'"
x-cloak
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
>
<h3
class="text-xl font-bold text-gray-800 mb-4 flex items-center gap-2"
>
<i class="fas fa-euro-sign text-blue-600"></i>
Tariffe Orarie
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
class="block text-sm font-medium text-gray-700 mb-2"
>
Tariffa Oraria Sviluppo (€/h)
</label>
<input
type="number"
x-model.number="devRate"
class="w-full p-3 border-2 border-blue-200 rounded-lg focus:border-blue-500 focus:outline-none"
step="5"
/>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-2"
>
Tariffa Oraria Supporto (€/h)
</label>
<input
type="number"
x-model.number="supportRate"
class="w-full p-3 border-2 border-blue-200 rounded-lg focus:border-blue-500 focus:outline-none"
step="5"
/>
</div>
</div>
<!-- Riepilogo tariffe -->
<div
class="mt-6 p-4 bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg"
>
<p class="text-gray-700">
<i
class="fas fa-calculator text-blue-600 mr-2"
></i>
Con le tariffe attuali, una giornata di 8 ore
vale:
</p>
<div class="grid grid-cols-2 gap-4 mt-3">
<div class="text-center">
<p class="text-sm text-gray-600">
Sviluppo
</p>
<p
class="text-xl font-bold text-blue-600"
x-text="'€ ' + (devRate * 8)"
></p>
</div>
<div class="text-center">
<p class="text-sm text-gray-600">
Supporto
</p>
<p
class="text-xl font-bold text-purple-600"
x-text="'€ ' + (supportRate * 8)"
></p>
</div>
</div>
</div>
</div>
<!-- Milestone Tab -->
<div
x-show="activeTab === 'milestone'"
x-cloak
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
>
<h3
class="text-xl font-bold text-gray-800 mb-4 flex items-center gap-2"
>
<i class="fas fa-tasks text-blue-600"></i>
Gestione Milestone Progetto
</h3>
<!-- Prima Milestone (MVP) -->
<div
class="mb-6 p-4 bg-green-50 rounded-lg border-2 border-green-200"
>
<h4
class="font-bold text-green-800 mb-3 flex items-center gap-2"
>
<i class="fas fa-flag text-green-600"></i>
Prima Milestone (MVP)
</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label
class="block text-sm font-medium text-gray-700 mb-2"
>Ore Sviluppo</label
>
<input
type="number"
x-model.number="mvpDevHours"
class="w-full p-3 border-2 border-green-200 rounded-lg focus:border-green-500 focus:outline-none"
step="1"
/>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-2"
>Ore Supporto</label
>
<input
type="number"
x-model.number="mvpSupportHours"
class="w-full p-3 border-2 border-green-200 rounded-lg focus:border-green-500 focus:outline-none"
step="1"
/>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-2"
>Team Leader</label
>
<select
x-model="mvpTeamLeader"
class="w-full p-3 border-2 border-green-200 rounded-lg focus:border-green-500 focus:outline-none"
>
<option value="">
Tariffa Standard
</option>
<template
x-for="member in teamMembers.filter(m => m.active)"
:key="member.id"
>
<option
:value="member.id"
x-text="member.name || 'Membro ' + member.id"
></option>
</template>
</select>
</div>
</div>
<div class="mt-3 flex justify-between items-center">
<div class="text-green-700">
<p class="font-semibold">
Costo MVP:
<span
class="text-xl"
x-text="'€ ' + calculateMvpCost().toFixed(2)"
></span>
</p>
</div>
<div
x-show="mvpTeamLeader"
class="text-sm text-green-600"
>
<i class="fas fa-user-tie mr-1"></i>
<span
x-text="'Assegnato a: ' + getTeamMemberName(mvpTeamLeader)"
></span>
</div>
</div>
</div>
<!-- Milestone Aggiuntive -->
<div class="space-y-4">
<template
x-for="(milestone, index) in milestones"
:key="milestone.id"
>
<div
class="p-4 bg-blue-50 rounded-lg border-2 border-blue-200 relative"
>
<button
@click="removeMilestone(index)"
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full h-8 w-8 flex items-center justify-center hover:bg-red-600 transition-colors"
>
<i class="fas fa-times"></i>
</button>
<div
class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-3"
>
<div class="md:col-span-2">
<label
class="block text-sm font-medium text-gray-700 mb-2"
>Nome Milestone</label
>
<input
type="text"
x-model="milestone.name"
class="w-full p-3 border-2 border-blue-200 rounded-lg focus:border-blue-500 focus:outline-none"
placeholder="es. Integrazione API"
/>
</div>
</div>
<!-- Task assignments for milestone -->
<div class="space-y-2">
<p
class="text-sm font-medium text-gray-700 mb-2"
>
<i
class="fas fa-user-friends text-blue-500 mr-1"
></i>
Assegnazione Task
</p>
<div
class="grid grid-cols-1 md:grid-cols-2 gap-3"
>
<!-- Sviluppo -->
<div
class="p-3 bg-white rounded-lg border border-blue-200"
>
<p
class="text-xs font-semibold text-blue-700 mb-2"
>
SVILUPPO
</p>
<div class="space-y-2">
<template
x-for="(task, taskIndex) in milestone.devTasks"
:key="task.id"
>
<div
class="flex items-center gap-2"
>
<select
x-model="task.memberId"
class="flex-1 p-2 text-sm border border-gray-300 rounded"
>
<option
value=""
>
Tariffa
Standard
</option>
<template
x-for="member in teamMembers.filter(m => m.active)"
:key="member.id"
>
<option
:value="member.id"
x-text="member.name || 'Membro ' + member.id"
></option>
</template>
</select>
<input
type="number"
x-model.number="task.hours"
class="w-20 p-2 text-sm border border-gray-300 rounded"
placeholder="Ore"
step="1"
/>
<button
@click="milestone.devTasks.splice(taskIndex, 1)"
class="text-red-500 hover:text-red-700"
>
<i
class="fas fa-minus-circle"
></i>
</button>
</div>
</template>
<button
@click="addDevTask(milestone)"
class="w-full py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors"
>
<i
class="fas fa-plus mr-1"
></i>
Aggiungi Task Sviluppo
</button>
</div>
</div>
<!-- Supporto -->
<div
class="p-3 bg-white rounded-lg border border-purple-200"
>
<p
class="text-xs font-semibold text-purple-700 mb-2"
>
SUPPORTO
</p>
<div class="space-y-2">
<template
x-for="(task, taskIndex) in milestone.supportTasks"
:key="task.id"
>
<div
class="flex items-center gap-2"
>
<select
x-model="task.memberId"
class="flex-1 p-2 text-sm border border-gray-300 rounded"
>
<option
value=""
>
Tariffa
Standard
</option>
<template
x-for="member in teamMembers.filter(m => m.active)"
:key="member.id"
>
<option
:value="member.id"
x-text="member.name || 'Membro ' + member.id"
></option>
</template>
</select>
<input
type="number"
x-model.number="task.hours"
class="w-20 p-2 text-sm border border-gray-300 rounded"
placeholder="Ore"
step="1"
/>
<button
@click="milestone.supportTasks.splice(taskIndex, 1)"
class="text-red-500 hover:text-red-700"
>
<i
class="fas fa-minus-circle"
></i>
</button>
</div>
</template>
<button
@click="addSupportTask(milestone)"
class="w-full py-1 text-xs bg-purple-100 text-purple-700 rounded hover:bg-purple-200 transition-colors"
>
<i
class="fas fa-plus mr-1"
></i>
Aggiungi Task Supporto
</button>
</div>
</div>
</div>
</div>
<!-- Milestone summary -->
<div
class="mt-3 pt-3 border-t border-blue-200"
>
<div
class="flex justify-between items-center"
>
<div>
<span
class="text-sm text-gray-600"
>Totale ore:
</span>
<span
class="font-bold text-blue-600"
x-text="calculateMilestoneHours(milestone) + 'h'"
></span>
</div>
<div>
<span
class="text-sm text-gray-600"
>Costo:
</span>
<span
class="font-bold text-blue-600 text-lg"
x-text="'€ ' + calculateMilestoneCost(milestone).toFixed(2)"
></span>
</div>
</div>
</div>
</div>
</template>
</div>
<button
@click="addMilestone()"
class="mt-4 w-full bg-gradient-to-r from-blue-500 to-purple-500 text-white font-bold py-3 px-6 rounded-lg hover:from-blue-600 hover:to-purple-600 transition-all flex items-center justify-center gap-2"
>
<i class="fas fa-plus"></i>
Aggiungi Milestone
</button>
</div>
</div>
</div>
<!-- Riepilogo Totale -->
<div class="glass rounded-2xl p-6 mb-6 animate-slide-in">
<h2
class="text-2xl font-bold text-gray-800 mb-6 flex items-center gap-2"
>
<i class="fas fa-chart-line text-blue-600"></i>
Riepilogo Preventivo e Analisi
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Box Preventivo Cliente -->
<div
class="bg-gradient-to-br from-blue-50 to-blue-100 p-6 rounded-xl shadow-lg"
>
<h3
class="text-xl font-bold text-blue-800 mb-4 flex items-center gap-2"
>
<i class="fas fa-file-invoice text-blue-600"></i>
Preventivo Cliente
<span
class="bg-blue-600 text-white text-xs px-2 py-1 rounded-full ml-auto"
>CLIENTE</span
>
</h3>
<div class="space-y-3">
<div
class="flex justify-between p-2 bg-white/70 rounded"
>
<span
class="text-gray-700"
x-text="labels.subtotal"
></span>
<span
class="font-bold"
x-text="formatCurrency(results.subtotal)"
></span>
</div>
<div
class="flex justify-between p-2 bg-white/70 rounded"
x-show="showInpsCharge && results.inpsCharge > 0"
>
<span class="text-gray-700"
>Rivalsa INPS (4%)</span
>
<span
class="font-bold"
x-text="formatCurrency(results.inpsCharge)"
></span>
</div>
<div
class="flex justify-between p-2 bg-white/70 rounded"
x-show="showIva"
>
<span class="text-gray-700">IVA (22%)</span>
<span
class="font-bold"
x-text="formatCurrency(results.iva)"
></span>
</div>
<div
class="flex justify-between p-2 bg-white/70 rounded"
x-show="showWithholdingTax"
>
<span class="text-red-600"
>Ritenuta d'acconto (20%)</span
>
<span
class="font-bold text-red-600"
x-text="'- ' + formatCurrency(results.withholdingTax)"
></span>
</div>
<div class="border-t-2 border-blue-300 pt-3 mt-3">
<div class="flex justify-between items-center">
<span
class="text-lg font-bold text-blue-800"
x-text="labels.amountDue"
></span>
<span
class="text-2xl font-bold text-blue-600"
x-text="formatCurrency(results.amountDue)"
></span>
</div>
</div>
</div>
</div>
<!-- Box Analisi Interna -->
<div
class="bg-gradient-to-br from-orange-50 to-orange-100 p-6 rounded-xl shadow-lg"
>
<h3
class="text-xl font-bold text-orange-800 mb-4 flex items-center gap-2"
>
<i class="fas fa-chart-pie text-orange-600"></i>
Analisi Interna
<span
class="bg-orange-600 text-white text-xs px-2 py-1 rounded-full ml-auto"
>INTERNO</span
>
</h3>
<div class="space-y-3">
<div
class="flex justify-between p-2 bg-white/70 rounded"
>
<span
class="text-gray-700"
x-text="labels.grossRevenue"
></span>
<span
class="font-bold"
x-text="formatCurrency(results.grossRevenue)"
></span>
</div>
<div
class="flex justify-between p-2 bg-white/70 rounded"
>
<span class="text-red-600"
>Commissione Rete (10%)</span
>
<span
class="font-bold text-red-600"
x-text="'- ' + formatCurrency(results.sellerFee)"
></span>
</div>
<div
class="flex justify-between p-2 bg-white/70 rounded"
x-show="showInpsDue"
>
<span class="text-red-600"
>Contributi INPS (26.07%)</span
>
<span
class="font-bold text-red-600"
x-text="'- ' + formatCurrency(results.inpsDue)"
></span>
</div>
<div
class="flex justify-between p-2 bg-white/70 rounded"
>
<span
class="text-red-600"
x-text="labels.taxDue"
></span>
<span
class="font-bold text-red-600"
x-text="'- ' + formatCurrency(results.taxDue)"
></span>
</div>
<div class="border-t-2 border-orange-300 pt-3 mt-3">
<div class="flex justify-between items-center">
<span
class="text-lg font-bold text-orange-800"
>Reddito Netto Stimato</span
>
<span
class="text-2xl font-bold text-green-600"
x-text="formatCurrency(results.netIncome)"
></span>
</div>
</div>
</div>
</div>
</div>
<!-- Indicatori KPI -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
<div
class="text-center p-4 bg-purple-50 rounded-lg hover:shadow-lg transition-shadow"
>
<i
class="fas fa-percentage text-2xl text-purple-600 mb-2"
></i>
<p class="text-sm text-gray-600">Margine</p>
<p
class="text-xl font-bold text-purple-600"
x-text="calcolaMargine() + '%'"
></p>
</div>
<div
class="text-center p-4 bg-green-50 rounded-lg hover:shadow-lg transition-shadow"
>
<i
class="fas fa-clock text-2xl text-green-600 mb-2"
></i>
<p class="text-sm text-gray-600">Ore Totali</p>
<p
class="text-xl font-bold text-green-600"
x-text="calcolaOreTotali() + 'h'"
></p>
</div>
<div
class="text-center p-4 bg-blue-50 rounded-lg hover:shadow-lg transition-shadow"
>
<i class="fas fa-code text-2xl text-blue-600 mb-2"></i>
<p class="text-sm text-gray-600">Ore Sviluppo</p>
<p
class="text-xl font-bold text-blue-600"
x-text="calcolaOreSviluppo() + 'h'"
></p>
</div>
<div
class="text-center p-4 bg-orange-50 rounded-lg hover:shadow-lg transition-shadow"
>
<i
class="fas fa-hands-helping text-2xl text-orange-600 mb-2"
></i>
<p class="text-sm text-gray-600">Ore Supporto</p>
<p
class="text-xl font-bold text-orange-600"
x-text="calcolaOreSupporto() + 'h'"
></p>
</div>
</div>
</div>
<!-- Pulsanti Azione -->
<div class="glass rounded-2xl p-6 animate-slide-in">
<div class="flex flex-wrap gap-3 justify-center">
<button
@click="generaPDFCliente()"
class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg shadow-lg transform hover:scale-105 transition-all flex items-center gap-2"
>
<i class="fas fa-file-pdf"></i>
PDF Cliente
</button>
<button
@click="generaPDFInterno()"
class="bg-orange-500 hover:bg-orange-600 text-white font-bold py-3 px-6 rounded-lg shadow-lg transform hover:scale-105 transition-all flex items-center gap-2"
>
<i class="fas fa-archive"></i>
PDF Interno
</button>
<button
@click="salvaDati()"
class="bg-green-500 hover:bg-green-600 text-white font-bold py-3 px-6 rounded-lg shadow-lg transform hover:scale-105 transition-all flex items-center gap-2"
>
<i class="fas fa-save"></i>
Salva Dati
</button>
<button
@click="caricaDati()"
class="bg-purple-500 hover:bg-purple-600 text-white font-bold py-3 px-6 rounded-lg shadow-lg transform hover:scale-105 transition-all flex items-center gap-2"
>
<i class="fas fa-upload"></i>
Carica Dati
</button>
<button
@click="resetForm()"
class="bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-6 rounded-lg shadow-lg transform hover:scale-105 transition-all flex items-center gap-2"
>
<i class="fas fa-redo"></i>
Reset
</button>
</div>
</div>
<!-- Modal Carica Dati -->
<div
x-show="showLoadModal"
x-cloak
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
@click.away="showLoadModal = false"
>
<div
class="bg-white rounded-2xl p-6 max-w-2xl max-h-96 overflow-y-auto shadow-2xl"
@click.stop
>
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
<i class="fas fa-folder-open text-purple-600"></i>
Carica Preventivo Salvato
</h3>
<div class="space-y-2">
<template
x-for="(preventivo, index) in preventiviSalvati"
:key="index"
>
<div
class="p-3 bg-gray-50 rounded-lg hover:bg-purple-50 cursor-pointer transition-all"
@click="caricaPreventivoSalvato(index)"
>
<div class="flex justify-between items-center">
<div>
<p
class="font-bold"
x-text="preventivo.cliente.nome || 'Senza nome'"
></p>
<p
class="text-sm text-gray-600"
x-text="preventivo.cliente.progetto || 'Senza progetto'"
></p>
<p
class="text-xs text-gray-500"
x-text="formatDate(preventivo.timestamp)"
></p>
</div>
<div class="flex items-center gap-3">
<span
class="text-lg font-bold text-green-600"
x-text="'€ ' + (preventivo.totali?.totaleFinale || preventivo.totali?.amountDue || 0).toFixed(2)"
></span>
<button
@click.stop="eliminaPreventivo(index)"
class="text-red-500 hover:text-red-700"
>
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</template>
</div>
<div
x-show="preventiviSalvati.length === 0"
class="text-center py-8 text-gray-500"
>
<i class="fas fa-inbox text-4xl mb-2"></i>
<p>Nessun preventivo salvato</p>
</div>
<div class="mt-4 flex justify-end">
<button
@click="showLoadModal = false"
class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded-lg"
>
Chiudi
</button>
</div>
</div>
</div>
</div>
<script>
function calcolatoreApp() {
return {
activeTab: "regime",
showLoadModal: false,
preventiviSalvati: [],
notification: {
show: false,
message: "",
type: "info",
},
azienda: {
nome: "",
piva: "",
indirizzo: "",
telefono: "",
logo: "",
},
logoColor: null,
colorPalette: [],
cliente: {
nome: "",
azienda: "",
email: "",
progetto: "",
data: new Date().toISOString().split("T")[0],
},
// Team members
teamMembers: [],
teamMemberCounter: 0,
taxRegime: "forfettario",
coeffRedditivita: 78,
impostaSostitutiva: 0.15,
devRate: 50,
supportRate: 40,
estimatedAnnualTaxable: 25000,
includeINPS: true,
// SRL/SRLS specific
iresRate: 24, // IRES standard 24%
irapRate: 3.9, // IRAP standard 3.9%
deductibleCosts: 5000, // Costi deducibili stimati
adminCompensation: 0, // Compenso amministratore
mvpDevHours: 100,
mvpSupportHours: 20,
mvpTeamLeader: "",
milestones: [],
milestoneCounter: 0,
taskCounter: 0,
// Constants
INPS_RATE: 0.2607,
init() {
this.caricaPreventiviDaStorage();
// Initialize with one default team member
this.addTeamMember();
},
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.azienda.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: 59, g: 130, b: 246 }; // Fallback blue
},
// Team member management
addTeamMember() {
this.teamMemberCounter++;
this.teamMembers.push({
id: this.teamMemberCounter,
name: "",
role: "developer",
devRate: this.devRate,
supportRate: this.supportRate,
active: true,
});
},
removeTeamMember(index) {
this.teamMembers.splice(index, 1);
},
getTeamMemberName(memberId) {
const member = this.teamMembers.find(
(m) => m.id == memberId,
);
return member
? member.name || `Membro ${member.id}`
: "Non assegnato";
},
getTeamMemberRate(memberId, type = "dev") {
if (!memberId) {
return type === "dev"
? this.devRate
: this.supportRate;
}
const member = this.teamMembers.find(
(m) => m.id == memberId,
);
if (member) {
return type === "dev"
? member.devRate
: member.supportRate;
}
return type === "dev" ? this.devRate : this.supportRate;
},
calculateAverageDevRate() {
const activeMembers = this.teamMembers.filter(
(m) => m.active,
);
if (activeMembers.length === 0) return this.devRate;
const sum = activeMembers.reduce(
(acc, m) => acc + m.devRate,
0,
);
return sum / activeMembers.length;
},
calculateAverageSupportRate() {
const activeMembers = this.teamMembers.filter(
(m) => m.active,
);
if (activeMembers.length === 0) return this.supportRate;
const sum = activeMembers.reduce(
(acc, m) => acc + m.supportRate,
0,
);
return sum / activeMembers.length;
},
calculateMvpCost() {
const devRate = this.getTeamMemberRate(
this.mvpTeamLeader,
"dev",
);
const supportRate = this.getTeamMemberRate(
this.mvpTeamLeader,
"support",
);
return (
this.mvpDevHours * devRate +
this.mvpSupportHours * supportRate
);
},
// Task management for milestones
addDevTask(milestone) {
this.taskCounter++;
if (!milestone.devTasks) milestone.devTasks = [];
milestone.devTasks.push({
id: this.taskCounter,
memberId: "",
hours: 0,
});
},
addSupportTask(milestone) {
this.taskCounter++;
if (!milestone.supportTasks)
milestone.supportTasks = [];
milestone.supportTasks.push({
id: this.taskCounter,
memberId: "",
hours: 0,
});
},
calculateMilestoneHours(milestone) {
let totalHours = 0;
if (milestone.devTasks) {
totalHours += milestone.devTasks.reduce(
(sum, task) => sum + (task.hours || 0),
0,
);
}
if (milestone.supportTasks) {
totalHours += milestone.supportTasks.reduce(
(sum, task) => sum + (task.hours || 0),
0,
);
}
// Legacy support for old milestone format
if (milestone.devHours)
totalHours += milestone.devHours;
if (milestone.supportHours)
totalHours += milestone.supportHours;
return totalHours;
},
calculateMilestoneCost(milestone) {
let totalCost = 0;
// Calculate dev tasks cost
if (milestone.devTasks) {
milestone.devTasks.forEach((task) => {
const rate = this.getTeamMemberRate(
task.memberId,
"dev",
);
totalCost += (task.hours || 0) * rate;
});
}
// Calculate support tasks cost
if (milestone.supportTasks) {
milestone.supportTasks.forEach((task) => {
const rate = this.getTeamMemberRate(
task.memberId,
"support",
);
totalCost += (task.hours || 0) * rate;
});
}
// Legacy support for old milestone format
if (milestone.devHours) {
totalCost += milestone.devHours * this.devRate;
}
if (milestone.supportHours) {
totalCost +=
milestone.supportHours * this.supportRate;
}
return totalCost;
},
calculateProgress() {
let filled = 0;
let total = 10;
if (this.azienda.nome) filled++;
if (this.azienda.piva) filled++;
if (this.cliente.nome) filled++;
if (this.cliente.progetto) filled++;
if (this.mvpDevHours > 0) filled++;
if (this.mvpSupportHours > 0) filled++;
if (this.devRate > 0) filled++;
if (this.supportRate > 0) filled++;
if (this.milestones.length > 0) filled++;
if (this.results.subtotal > 0) filled++;
return (filled / total) * 100;
},
showNotification(message, type = "info") {
this.notification = {
show: true,
message: message,
type: type,
};
setTimeout(() => {
this.notification.show = false;
}, 3000);
},
// Computed properties for UI
get showInpsCharge() {
return (
this.taxRegime !== "occasionale" &&
this.taxRegime !== "srl" &&
this.taxRegime !== "srls"
);
},
get showIva() {
return (
this.taxRegime === "ordinario" ||
this.taxRegime === "srl" ||
this.taxRegime === "srls"
);
},
get showWithholdingTax() {
return (
this.taxRegime === "ordinario" ||
this.taxRegime === "occasionale"
);
},
get showTotalInvoice() {
return this.taxRegime !== "occasionale";
},
get showInpsDue() {
return (
this.taxRegime !== "occasionale" &&
this.taxRegime !== "srl" &&
this.taxRegime !== "srls"
);
},
// Labels
get labels() {
const baseLabels = {
subtotal: "Imponibile",
amountDue: "Importo dovuto",
grossRevenue: "Fatturato",
netRevenue: "Ricavo Netto",
taxDue: "Imposta Dovuta",
};
switch (this.taxRegime) {
case "ordinario":
return {
...baseLabels,
grossRevenue:
"Fatturato (Imponibile + Rivalsa)",
taxDue: "IRPEF Stimato",
};
case "forfettario":
const taxRate = (
this.impostaSostitutiva * 100
).toFixed(0);
return {
...baseLabels,
netRevenue: "Ricavo Post-Commissioni",
taxDue: `Imposta Sostitutiva (${taxRate}%)`,
};
case "occasionale":
return {
...baseLabels,
subtotal: "Compenso Lordo",
amountDue: "Netto a pagare",
grossRevenue: "Compenso Lordo",
taxDue: "IRPEF Stimata",
};
case "minimi":
return {
...baseLabels,
netRevenue: "Reddito Imponibile",
taxDue: "Imposta Sostitutiva (5%)",
};
case "srl":
case "srls":
return {
...baseLabels,
subtotal: "Imponibile",
grossRevenue: "Fatturato Societario",
netRevenue: "Utile Lordo",
taxDue: `IRES (${this.iresRate}%) + IRAP (${this.irapRate}%)`,
};
default:
return baseLabels;
}
},
// Calculation results
get results() {
// Calculate MVP cost with team member rates
const mvpCost = this.calculateMvpCost();
// Calculate milestones cost with team member rates
const totalCustomCost = this.milestones.reduce(
(sum, milestone) => {
return (
sum + this.calculateMilestoneCost(milestone)
);
},
0,
);
const subtotal = mvpCost + totalCustomCost;
const inpsCharge =
this.taxRegime !== "occasionale" &&
this.taxRegime !== "srl" &&
this.taxRegime !== "srls" &&
this.includeINPS
? subtotal * 0.04
: 0;
switch (this.taxRegime) {
case "ordinario":
return this.calculateOrdinario(
subtotal,
inpsCharge,
);
case "forfettario":
return this.calculateForfettario(
subtotal,
inpsCharge,
);
case "occasionale":
return this.calculateOccasionale(subtotal);
case "minimi":
return this.calculateMinimi(
subtotal,
inpsCharge,
);
case "srl":
return this.calculateSRL(subtotal);
case "srls":
return this.calculateSRLS(subtotal);
default:
return this.getEmptyResults();
}
},
calculateIrpef(income) {
if (income <= 0) return 0;
let tax = 0;
const bracket1 = 28000;
const bracket2 = 50000;
if (income <= bracket1) {
tax = income * 0.23;
} else if (income <= bracket2) {
tax = bracket1 * 0.23 + (income - bracket1) * 0.35;
} else {
tax =
bracket1 * 0.23 +
(bracket2 - bracket1) * 0.35 +
(income - bracket2) * 0.43;
}
return tax;
},
calculateOrdinario(subtotal, inpsCharge) {
const ivaBase = subtotal + inpsCharge;
const iva = ivaBase * 0.22;
const totalInvoice = ivaBase + iva;
const withholdingTax = subtotal * 0.2;
const amountDue = totalInvoice - withholdingTax;
const grossRevenue = subtotal + inpsCharge;
const sellerFee = grossRevenue * 0.1;
const netRevenue = grossRevenue - sellerFee;
const inpsDue = netRevenue * this.INPS_RATE;
const taxableForIrpef = netRevenue - inpsDue;
const totalIrpefWithProject = this.calculateIrpef(
this.estimatedAnnualTaxable + taxableForIrpef,
);
const irpefOnPreviousIncome = this.calculateIrpef(
this.estimatedAnnualTaxable,
);
const taxDue =
totalIrpefWithProject - irpefOnPreviousIncome;
const netIncome = netRevenue - inpsDue - taxDue;
return {
subtotal,
inpsCharge,
iva,
totalInvoice,
withholdingTax,
amountDue,
grossRevenue,
sellerFee,
netRevenue,
inpsDue,
taxDue,
netIncome,
};
},
calculateForfettario(subtotal, inpsCharge) {
const totalInvoice = subtotal + inpsCharge;
const grossRevenue = totalInvoice;
const sellerFee = grossRevenue * 0.1;
const revenueAfterFee = grossRevenue - sellerFee;
const taxableIncome =
revenueAfterFee * (this.coeffRedditivita / 100);
const inpsDue = taxableIncome * this.INPS_RATE;
const taxDue = taxableIncome * this.impostaSostitutiva;
const netIncome = revenueAfterFee - inpsDue - taxDue;
return {
subtotal,
inpsCharge,
iva: 0,
totalInvoice,
withholdingTax: 0,
amountDue: totalInvoice,
grossRevenue,
sellerFee,
netRevenue: revenueAfterFee,
inpsDue,
taxDue,
netIncome,
};
},
calculateOccasionale(subtotal) {
const totalReceipt = subtotal;
const withholdingTax = subtotal * 0.2;
const amountDue = totalReceipt - withholdingTax;
const grossRevenue = subtotal;
const sellerFee = grossRevenue * 0.1;
const netRevenue = grossRevenue - sellerFee;
const inpsDue = 0;
const taxableForIrpef = netRevenue;
const totalIrpefWithProject = this.calculateIrpef(
this.estimatedAnnualTaxable + taxableForIrpef,
);
const irpefOnPreviousIncome = this.calculateIrpef(
this.estimatedAnnualTaxable,
);
const taxDue =
totalIrpefWithProject - irpefOnPreviousIncome;
const netIncome = netRevenue - taxDue;
return {
subtotal,
inpsCharge: 0,
iva: 0,
totalInvoice: totalReceipt,
withholdingTax,
amountDue,
grossRevenue,
sellerFee,
netRevenue,
inpsDue,
taxDue,
netIncome,
};
},
calculateMinimi(subtotal, inpsCharge) {
const TAX_RATE = 0.05;
const totalInvoice = subtotal + inpsCharge;
const grossRevenue = totalInvoice;
const sellerFee = grossRevenue * 0.1;
const taxableIncome = grossRevenue - sellerFee;
const inpsDue = taxableIncome * this.INPS_RATE;
const taxDue = taxableIncome * TAX_RATE;
const netIncome = taxableIncome - inpsDue - taxDue;
return {
subtotal,
inpsCharge,
iva: 0,
totalInvoice,
withholdingTax: 0,
amountDue: totalInvoice,
grossRevenue,
sellerFee,
netRevenue: taxableIncome,
inpsDue,
taxDue,
netIncome,
};
},
calculateSRL(subtotal) {
// SRL calculation
const iva = subtotal * 0.22;
const totalInvoice = subtotal + iva;
const amountDue = totalInvoice; // No ritenuta per SRL
const grossRevenue = subtotal; // Fatturato senza IVA (IVA è neutra)
const sellerFee = grossRevenue * 0.1;
const revenueAfterFee = grossRevenue - sellerFee;
// Deduzione costi e compenso amministratore
const totalDeductions =
this.deductibleCosts + this.adminCompensation;
const taxableIncome = Math.max(
0,
revenueAfterFee - totalDeductions,
);
// Calcolo IRES e IRAP
const iresAmount =
taxableIncome * (this.iresRate / 100);
const irapBase = revenueAfterFee; // IRAP si calcola sul valore della produzione
const irapAmount = irapBase * (this.irapRate / 100);
const totalTax = iresAmount + irapAmount;
const netIncome =
revenueAfterFee - totalDeductions - totalTax;
return {
subtotal,
inpsCharge: 0,
iva,
totalInvoice,
withholdingTax: 0,
amountDue,
grossRevenue,
sellerFee,
netRevenue: revenueAfterFee,
inpsDue: 0, // Le SRL non hanno INPS gestione separata
taxDue: totalTax,
netIncome,
// Additional SRL specific values for reporting
iresAmount,
irapAmount,
deductions: totalDeductions,
};
},
calculateSRLS(subtotal) {
// SRLS calculation (same as SRL but with potentially lower setup costs)
return this.calculateSRL(subtotal);
},
getEmptyResults() {
return {
subtotal: 0,
inpsCharge: 0,
iva: 0,
totalInvoice: 0,
withholdingTax: 0,
amountDue: 0,
grossRevenue: 0,
sellerFee: 0,
netRevenue: 0,
inpsDue: 0,
taxDue: 0,
netIncome: 0,
};
},
formatCurrency(value) {
return `${value.toFixed(2)}`;
},
formatDate(timestamp) {
if (!timestamp) return "";
return new Date(timestamp).toLocaleString("it-IT", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
},
calcolaMargine() {
if (this.results.subtotal === 0) return 0;
const margine =
(this.results.netIncome / this.results.subtotal) *
100;
return margine.toFixed(1);
},
calcolaOreTotali() {
let totale = this.mvpDevHours + this.mvpSupportHours;
this.milestones.forEach((m) => {
totale += this.calculateMilestoneHours(m);
});
return totale;
},
calcolaOreSviluppo() {
let totale = this.mvpDevHours;
this.milestones.forEach((m) => {
if (m.devTasks) {
totale += m.devTasks.reduce(
(sum, task) => sum + (task.hours || 0),
0,
);
}
if (m.devHours) totale += m.devHours; // Legacy support
});
return totale;
},
calcolaOreSupporto() {
let totale = this.mvpSupportHours;
this.milestones.forEach((m) => {
if (m.supportTasks) {
totale += m.supportTasks.reduce(
(sum, task) => sum + (task.hours || 0),
0,
);
}
if (m.supportHours) totale += m.supportHours; // Legacy support
});
return totale;
},
addMilestone() {
this.milestoneCounter++;
this.milestones.push({
id: this.milestoneCounter,
name: "",
devTasks: [],
supportTasks: [],
});
// Add default tasks
this.addDevTask(
this.milestones[this.milestones.length - 1],
);
this.addSupportTask(
this.milestones[this.milestones.length - 1],
);
},
removeMilestone(index) {
this.milestones.splice(index, 1);
},
salvaDati() {
const dati = {
azienda: this.azienda,
cliente: this.cliente,
teamMembers: this.teamMembers,
taxRegime: this.taxRegime,
coeffRedditivita: this.coeffRedditivita,
impostaSostitutiva: this.impostaSostitutiva,
devRate: this.devRate,
supportRate: this.supportRate,
estimatedAnnualTaxable: this.estimatedAnnualTaxable,
includeINPS: this.includeINPS,
// SRL/SRLS fields
iresRate: this.iresRate,
irapRate: this.irapRate,
deductibleCosts: this.deductibleCosts,
adminCompensation: this.adminCompensation,
mvpDevHours: this.mvpDevHours,
mvpSupportHours: this.mvpSupportHours,
mvpTeamLeader: this.mvpTeamLeader,
milestones: this.milestones,
logoColor: this.logoColor,
colorPalette: this.colorPalette,
totali: this.results,
timestamp: new Date().toISOString(),
};
const preventivi = JSON.parse(
localStorage.getItem("preventiviSoftware") || "[]",
);
preventivi.push(dati);
localStorage.setItem(
"preventiviSoftware",
JSON.stringify(preventivi),
);
const dataStr = JSON.stringify(dati, null, 2);
const dataUri =
"data:application/json;charset=utf-8," +
encodeURIComponent(dataStr);
const clientName = this.cliente.nome || "backup";
const exportFileDefaultName = `preventivo_software_${clientName}_${new Date().toISOString().split("T")[0]}.json`;
const linkElement = document.createElement("a");
linkElement.setAttribute("href", dataUri);
linkElement.setAttribute(
"download",
exportFileDefaultName,
);
linkElement.click();
this.showNotification(
"Preventivo salvato con successo!",
"success",
);
this.caricaPreventiviDaStorage();
},
caricaDati() {
this.caricaPreventiviDaStorage();
this.showLoadModal = true;
},
caricaPreventiviDaStorage() {
this.preventiviSalvati = JSON.parse(
localStorage.getItem("preventiviSoftware") || "[]",
);
},
caricaPreventivoSalvato(index) {
const preventivo = this.preventiviSalvati[index];
if (preventivo) {
this.azienda = preventivo.azienda || this.azienda;
this.cliente = preventivo.cliente || this.cliente;
this.teamMembers = preventivo.teamMembers || [];
this.taxRegime =
preventivo.taxRegime || this.taxRegime;
this.coeffRedditivita =
preventivo.coeffRedditivita ||
this.coeffRedditivita;
this.impostaSostitutiva =
preventivo.impostaSostitutiva ||
this.impostaSostitutiva;
this.devRate = preventivo.devRate || this.devRate;
this.supportRate =
preventivo.supportRate || this.supportRate;
this.estimatedAnnualTaxable =
preventivo.estimatedAnnualTaxable ||
this.estimatedAnnualTaxable;
this.includeINPS =
preventivo.includeINPS !== undefined
? preventivo.includeINPS
: this.includeINPS;
// SRL/SRLS fields
this.iresRate =
preventivo.iresRate || this.iresRate;
this.irapRate =
preventivo.irapRate || this.irapRate;
this.deductibleCosts =
preventivo.deductibleCosts ||
this.deductibleCosts;
this.adminCompensation =
preventivo.adminCompensation ||
this.adminCompensation;
this.mvpDevHours =
preventivo.mvpDevHours || this.mvpDevHours;
this.mvpSupportHours =
preventivo.mvpSupportHours ||
this.mvpSupportHours;
this.mvpTeamLeader = preventivo.mvpTeamLeader || "";
this.milestones = preventivo.milestones || [];
this.logoColor = preventivo.logoColor || null;
this.colorPalette = preventivo.colorPalette || [];
this.showLoadModal = false;
this.showNotification(
"Preventivo caricato con successo!",
"success",
);
}
},
eliminaPreventivo(index) {
if (
confirm(
"Sei sicuro di voler eliminare questo preventivo?",
)
) {
this.preventiviSalvati.splice(index, 1);
localStorage.setItem(
"preventiviSoftware",
JSON.stringify(this.preventiviSalvati),
);
this.showNotification(
"Preventivo eliminato!",
"error",
);
}
},
resetForm() {
if (
confirm(
"Sei sicuro di voler resettare tutti i campi?",
)
) {
this.cliente = {
nome: "",
azienda: "",
email: "",
progetto: "",
data: new Date().toISOString().split("T")[0],
};
this.teamMembers = [];
this.mvpDevHours = 100;
this.mvpSupportHours = 20;
this.mvpTeamLeader = "";
this.milestones = [];
this.estimatedAnnualTaxable = 25000;
// Re-add default team member
this.addTeamMember();
this.showNotification("Form resettato!", "info");
}
},
generaPDFCliente() {
const { jsPDF } = window.jspdf;
const doc = new jsPDF();
let yPos = 0;
// Brand color
const brandColor = this.logoColor || "#3b82f6";
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.azienda.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.azienda.logo,
"PNG",
18,
yPos - 5,
29,
29,
);
} catch (err) {
console.error("Error adding logo:", err);
}
}
// Company name and title
const xStart = this.azienda.logo ? 55 : 15;
doc.setFontSize(26);
doc.setFont(undefined, "bold");
doc.setTextColor(60, 60, 60);
doc.text(
this.azienda.nome || "Software Development",
xStart,
yPos,
);
doc.setFontSize(14);
doc.setFont(undefined, "bold");
doc.setTextColor(255, 255, 255);
doc.text("PREVENTIVO SOFTWARE", xStart, yPos + 23);
// 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.setFont(undefined, "bold");
doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text(dateStr, 175, yPos + 22, { align: "center" });
yPos = 65;
// ===== INFO CARDS SECTION =====
// Azienda 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");
// Azienda header
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.setFont(undefined, "bold");
doc.setTextColor(255, 255, 255);
doc.text("FORNITORE", 57.5, yPos + 5.5, {
align: "center",
});
// Azienda data
doc.setFontSize(8);
doc.setFont(undefined, "normal");
doc.setTextColor(60, 60, 60);
doc.text(this.azienda.nome || "-", 18, yPos + 13);
doc.text(
`P.IVA: ${this.azienda.piva || "-"}`,
18,
yPos + 18,
);
doc.text(this.azienda.indirizzo || "-", 18, yPos + 23);
// 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
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.azienda || "-", 113, yPos + 18);
doc.text(this.cliente.email || "-", 113, yPos + 23);
yPos += 38;
// ===== PROJECT INFO =====
doc.setFillColor(lightRgb.r, lightRgb.g, lightRgb.b);
doc.roundedRect(15, yPos, 180, 12, 2, 2, "F");
doc.setFontSize(11);
doc.setFont(undefined, "bold");
doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text("PROGETTO:", 18, yPos + 8);
doc.setFont(undefined, "normal");
doc.setTextColor(60, 60, 60);
doc.text(this.cliente.progetto || "N/A", 45, yPos + 8);
yPos += 18;
// ===== MILESTONE TABLE =====
// Table header
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("MILESTONE", 18, yPos + 6.5);
doc.text("ORE SVILUPPO", 100, yPos + 6.5, {
align: "center",
});
doc.text("ORE SUPPORTO", 140, yPos + 6.5, {
align: "center",
});
doc.text("TOTALE", 188, yPos + 6.5, { align: "right" });
yPos += 14;
// MVP Row
doc.setFillColor(250, 250, 250);
doc.rect(15, yPos - 2, 180, 10, "F");
doc.setFontSize(8);
doc.setFont(undefined, "bold");
doc.setTextColor(60, 60, 60);
doc.text("MVP (Minimum Viable Product)", 18, yPos + 2);
doc.setFont(undefined, "normal");
doc.text(
String(this.mvpDevHours) + "h",
100,
yPos + 2,
{ align: "center" },
);
doc.text(
String(this.mvpSupportHours) + "h",
140,
yPos + 2,
{ align: "center" },
);
doc.setFont(undefined, "bold");
doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text(
`${this.calculateMvpCost().toFixed(2)}`,
188,
yPos + 2,
{ align: "right" },
);
yPos += 10;
// Separator
doc.setDrawColor(230, 230, 230);
doc.setLineWidth(0.3);
doc.line(15, yPos - 1, 195, yPos - 1);
// Additional milestones
let milestoneIndex = 0;
this.milestones.forEach((milestone) => {
if (yPos > 255) {
doc.addPage();
yPos = 20;
milestoneIndex = 0;
}
// Alternating background
if (milestoneIndex % 2 === 1) {
doc.setFillColor(250, 250, 250);
doc.rect(15, yPos - 2, 180, 10, "F");
}
const devHours = milestone.devTasks
? milestone.devTasks.reduce(
(sum, t) => sum + (t.hours || 0),
0,
)
: milestone.devHours || 0;
const supportHours = milestone.supportTasks
? milestone.supportTasks.reduce(
(sum, t) => sum + (t.hours || 0),
0,
)
: milestone.supportHours || 0;
doc.setFontSize(8);
doc.setFont(undefined, "bold");
doc.setTextColor(60, 60, 60);
doc.text(
milestone.name.substring(0, 45) ||
`Milestone ${milestoneIndex + 1}`,
18,
yPos + 2,
);
doc.setFont(undefined, "normal");
doc.text(String(devHours) + "h", 100, yPos + 2, {
align: "center",
});
doc.text(
String(supportHours) + "h",
140,
yPos + 2,
{ align: "center" },
);
doc.setFont(undefined, "bold");
doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text(
`${this.calculateMilestoneCost(milestone).toFixed(2)}`,
188,
yPos + 2,
{ align: "right" },
);
yPos += 10;
// Separator
doc.setDrawColor(230, 230, 230);
doc.setLineWidth(0.3);
doc.line(15, yPos - 1, 195, yPos - 1);
milestoneIndex++;
});
yPos += 5;
// ===== TOTALS SECTION =====
// Summary box
doc.setFillColor(250, 250, 250);
doc.roundedRect(115, yPos, 80, 35, 3, 3, "F");
doc.setDrawColor(220, 220, 220);
doc.setLineWidth(0.5);
doc.roundedRect(115, yPos, 80, 35, 3, 3, "S");
let summaryY = yPos + 8;
doc.setFontSize(9);
doc.setFont(undefined, "normal");
doc.setTextColor(80, 80, 80);
// Subtotal
doc.text(this.labels.subtotal + ":", 120, summaryY);
doc.text(
this.formatCurrency(this.results.subtotal),
190,
summaryY,
{ align: "right" },
);
summaryY += 6;
// INPS Charge (if applicable)
if (
this.showInpsCharge &&
this.results.inpsCharge > 0
) {
doc.text("Rivalsa INPS (4%):", 120, summaryY);
doc.text(
this.formatCurrency(this.results.inpsCharge),
190,
summaryY,
{ align: "right" },
);
summaryY += 6;
}
// IVA (if applicable)
if (this.showIva && this.results.iva > 0) {
doc.text("IVA (22%):", 120, summaryY);
doc.text(
this.formatCurrency(this.results.iva),
190,
summaryY,
{ align: "right" },
);
summaryY += 6;
}
// Withholding tax (if applicable)
if (
this.showWithholdingTax &&
this.results.withholdingTax > 0
) {
doc.setTextColor(220, 50, 50);
doc.text("Ritenuta (20%):", 120, summaryY);
doc.text(
`-${this.formatCurrency(this.results.withholdingTax)}`,
190,
summaryY,
{ align: "right" },
);
doc.setTextColor(80, 80, 80);
summaryY += 8;
} else {
summaryY += 8;
}
// 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.setTextColor(255, 255, 255);
doc.text(
this.labels.amountDue + ":",
120,
summaryY + 2.5,
);
doc.setFontSize(13);
doc.text(
this.formatCurrency(this.results.amountDue),
190,
summaryY + 2.5,
{ align: "right" },
);
// Stats section
yPos += 45;
if (yPos < 240) {
doc.setFillColor(
lightRgb.r,
lightRgb.g,
lightRgb.b,
);
doc.roundedRect(15, yPos, 180, 18, 2, 2, "F");
doc.setFontSize(8);
doc.setFont(undefined, "bold");
doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text("ORE TOTALI:", 20, yPos + 8);
doc.setFont(undefined, "normal");
doc.setTextColor(60, 60, 60);
doc.text(
this.calcolaOreTotali() + "h",
20,
yPos + 13,
);
doc.setFont(undefined, "bold");
doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text("ORE SVILUPPO:", 70, yPos + 8);
doc.setFont(undefined, "normal");
doc.setTextColor(60, 60, 60);
doc.text(
this.calcolaOreSviluppo() + "h",
70,
yPos + 13,
);
doc.setFont(undefined, "bold");
doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text("ORE SUPPORTO:", 120, yPos + 8);
doc.setFont(undefined, "normal");
doc.setTextColor(60, 60, 60);
doc.text(
this.calcolaOreSupporto() + "h",
120,
yPos + 13,
);
}
// Footer
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" },
);
const filename = `Preventivo_${this.cliente.nome || "Cliente"}_${this.cliente.progetto || "Progetto"}_${new Date().toISOString().split("T")[0]}.pdf`;
doc.save(filename);
this.showNotification(
"PDF Cliente generato con successo!",
"success",
);
},
generaPDFInterno() {
const { jsPDF } = window.jspdf;
const doc = new jsPDF();
let yPos = 0;
// Brand color (orange for internal)
const brandColor = this.logoColor || "#ff5722";
const rgb = this.hexToRgb(brandColor);
const lightRgb = {
r: Math.min(255, rgb.r + 100),
g: Math.min(255, rgb.g + 150),
b: Math.min(255, rgb.b + 150),
};
// ===== HEADER SECTION with gradient effect =====
doc.setFillColor(255, 220, 200);
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
if (this.azienda.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.azienda.logo,
"PNG",
18,
yPos - 5,
29,
29,
);
} catch (err) {
console.error("Error adding logo:", err);
}
}
// Title
const xStart = this.azienda.logo ? 55 : 15;
doc.setFontSize(22);
doc.setFont(undefined, "bold");
doc.setTextColor(60, 60, 60);
doc.text("DOCUMENTO INTERNO", xStart, yPos);
doc.setFontSize(14);
doc.setFont(undefined, "bold");
doc.setTextColor(255, 255, 255);
doc.text(
"ANALISI FINANZIARIA - RISERVATO",
xStart,
yPos + 23,
);
// 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.setFont(undefined, "bold");
doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text(dateStr, 175, yPos + 22, { align: "center" });
yPos = 65;
// ===== PROJECT INFO =====
doc.setFillColor(250, 250, 250);
doc.roundedRect(15, yPos, 180, 20, 3, 3, "F");
doc.setDrawColor(220, 220, 220);
doc.setLineWidth(0.5);
doc.roundedRect(15, yPos, 180, 20, 3, 3, "S");
doc.setFontSize(10);
doc.setFont(undefined, "bold");
doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text("PROGETTO:", 20, yPos + 8);
doc.setFont(undefined, "normal");
doc.setTextColor(60, 60, 60);
doc.text(this.cliente.progetto || "N/A", 20, yPos + 14);
doc.setFont(undefined, "bold");
doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text("CLIENTE:", 100, yPos + 8);
doc.setFont(undefined, "normal");
doc.setTextColor(60, 60, 60);
doc.text(this.cliente.nome || "N/A", 100, yPos + 14);
yPos += 28;
// ===== TEAM MEMBERS SECTION =====
if (
this.teamMembers.filter((m) => m.active).length > 0
) {
doc.setFillColor(rgb.r, rgb.g, rgb.b);
doc.roundedRect(15, yPos, 180, 10, 2, 2, "F");
doc.setFontSize(9);
doc.setFont(undefined, "bold");
doc.setTextColor(255, 255, 255);
doc.text("TEAM ATTIVO", 18, yPos + 6.5);
doc.text("RUOLO", 80, yPos + 6.5);
doc.text("TARIFFA DEV", 125, yPos + 6.5, {
align: "center",
});
doc.text("TARIFFA SUPP", 170, yPos + 6.5, {
align: "center",
});
yPos += 14;
this.teamMembers
.filter((m) => m.active)
.forEach((member, idx) => {
if (yPos > 260) {
doc.addPage();
yPos = 20;
}
if (idx % 2 === 0) {
doc.setFillColor(250, 250, 250);
doc.rect(15, yPos - 2, 180, 8, "F");
}
doc.setFontSize(8);
doc.setFont(undefined, "normal");
doc.setTextColor(60, 60, 60);
doc.text(
member.name || `Membro ${member.id}`,
18,
yPos + 2,
);
doc.text(member.role, 80, yPos + 2);
doc.text(
`${member.devRate}/h`,
125,
yPos + 2,
{ align: "center" },
);
doc.text(
`${member.supportRate}/h`,
170,
yPos + 2,
{ align: "center" },
);
yPos += 8;
});
yPos += 5;
}
// ===== FINANCIAL ANALYSIS =====
doc.setFillColor(lightRgb.r, lightRgb.g, lightRgb.b);
doc.roundedRect(15, yPos, 180, 12, 2, 2, "F");
doc.setFontSize(11);
doc.setFont(undefined, "bold");
doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text("ANALISI FINANZIARIA", 105, yPos + 8, {
align: "center",
});
yPos += 18;
// Two columns layout for financial data
// Left column: Revenue side
doc.setFillColor(250, 250, 250);
doc.roundedRect(15, yPos, 85, 60, 3, 3, "F");
doc.setDrawColor(220, 220, 220);
doc.setLineWidth(0.5);
doc.roundedRect(15, yPos, 85, 60, 3, 3, "S");
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.setFont(undefined, "bold");
doc.setTextColor(255, 255, 255);
doc.text("RICAVI", 57.5, yPos + 5.5, {
align: "center",
});
let leftY = yPos + 14;
doc.setFontSize(8);
doc.setFont(undefined, "normal");
doc.setTextColor(60, 60, 60);
doc.text(this.labels.grossRevenue + ":", 18, leftY);
doc.text(
this.formatCurrency(this.results.grossRevenue),
95,
leftY,
{ align: "right" },
);
leftY += 6;
doc.setTextColor(220, 50, 50);
doc.text("Commissione (10%):", 18, leftY);
doc.text(
`-${this.formatCurrency(this.results.sellerFee)}`,
95,
leftY,
{ align: "right" },
);
leftY += 6;
doc.setTextColor(60, 60, 60);
doc.setFont(undefined, "bold");
doc.text(this.labels.netRevenue + ":", 18, leftY);
doc.text(
this.formatCurrency(this.results.netRevenue),
95,
leftY,
{ align: "right" },
);
// Right column: Costs side
doc.setFillColor(250, 250, 250);
doc.roundedRect(110, yPos, 85, 60, 3, 3, "F");
doc.setDrawColor(220, 220, 220);
doc.setLineWidth(0.5);
doc.roundedRect(110, yPos, 85, 60, 3, 3, "S");
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("COSTI E IMPOSTE", 152.5, yPos + 5.5, {
align: "center",
});
let rightY = yPos + 14;
doc.setFontSize(8);
doc.setFont(undefined, "normal");
doc.setTextColor(220, 50, 50);
if (this.showInpsDue && this.results.inpsDue > 0) {
doc.text("INPS (26.07%):", 113, rightY);
doc.text(
`-${this.formatCurrency(this.results.inpsDue)}`,
190,
rightY,
{ align: "right" },
);
rightY += 6;
}
doc.text(this.labels.taxDue + ":", 113, rightY);
doc.text(
`-${this.formatCurrency(this.results.taxDue)}`,
190,
rightY,
{ align: "right" },
);
rightY += 6;
doc.setTextColor(60, 60, 60);
doc.setFont(undefined, "bold");
doc.text("Reddito Netto:", 113, rightY);
doc.setTextColor(34, 197, 94); // green
doc.text(
this.formatCurrency(this.results.netIncome),
190,
rightY,
{ align: "right" },
);
yPos += 70;
// ===== KPI SECTION =====
doc.setFillColor(lightRgb.r, lightRgb.g, lightRgb.b);
doc.roundedRect(15, yPos, 180, 25, 2, 2, "F");
doc.setFontSize(8);
doc.setFont(undefined, "bold");
doc.setTextColor(rgb.r, rgb.g, rgb.b);
// Margine
doc.text("MARGINE:", 20, yPos + 8);
doc.setFontSize(12);
doc.setFont(undefined, "bold");
doc.setTextColor(60, 60, 60);
doc.text(this.calcolaMargine() + "%", 20, yPos + 16);
// Ore totali
doc.setFontSize(8);
doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text("ORE TOTALI:", 70, yPos + 8);
doc.setFontSize(12);
doc.setTextColor(60, 60, 60);
doc.text(this.calcolaOreTotali() + "h", 70, yPos + 16);
// Ore sviluppo
doc.setFontSize(8);
doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text("ORE SVILUPPO:", 120, yPos + 8);
doc.setFontSize(12);
doc.setTextColor(60, 60, 60);
doc.text(
this.calcolaOreSviluppo() + "h",
120,
yPos + 16,
);
// Ore supporto
doc.setFontSize(8);
doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text("ORE SUPPORTO:", 160, yPos + 8);
doc.setFontSize(12);
doc.setTextColor(60, 60, 60);
doc.text(
this.calcolaOreSupporto() + "h",
160,
yPos + 16,
);
yPos += 35;
// ===== TAX REGIME INFO =====
if (yPos < 245) {
doc.setFillColor(250, 250, 250);
doc.roundedRect(15, yPos, 180, 15, 2, 2, "F");
doc.setFontSize(8);
doc.setFont(undefined, "bold");
doc.setTextColor(rgb.r, rgb.g, rgb.b);
doc.text("REGIME FISCALE:", 20, yPos + 6);
doc.setFont(undefined, "normal");
doc.setTextColor(60, 60, 60);
let regimeName = "";
switch (this.taxRegime) {
case "forfettario":
regimeName = `Forfettario (${(this.impostaSostitutiva * 100).toFixed(0)}%)`;
break;
case "ordinario":
regimeName = "Ordinario Semplificato";
break;
case "occasionale":
regimeName = "Lavoro Occasionale";
break;
case "srl":
regimeName = `SRL (IRES ${this.iresRate}% + IRAP ${this.irapRate}%)`;
break;
case "srls":
regimeName = `SRLS (IRES ${this.iresRate}% + IRAP ${this.irapRate}%)`;
break;
default:
regimeName = this.taxRegime;
}
doc.text(regimeName, 20, yPos + 11);
}
// Footer
doc.setFontSize(7);
doc.setFont(undefined, "italic");
doc.setTextColor(150, 150, 150);
doc.text(
`DOCUMENTO RISERVATO - Generato il ${new Date().toLocaleString("it-IT")}`,
105,
285,
{ align: "center" },
);
const filename = `INTERNO_${this.cliente.progetto || "Progetto"}_${new Date().toISOString().split("T")[0]}.pdf`;
doc.save(filename);
this.showNotification(
"PDF Interno generato con successo!",
"success",
);
},
};
}
</script>
</body>
</html>