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!
2686 lines
142 KiB
HTML
2686 lines
142 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 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>
|
|
|
|
<!-- 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.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.80; // 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),
|
|
|
|
|
|
// 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 = 15;
|
|
|
|
// Brand color
|
|
const brandColor = this.logoColor || '#3b82f6';
|
|
const rgb = this.hexToRgb(brandColor);
|
|
|
|
// Header with logo
|
|
if (this.azienda.logo) {
|
|
try {
|
|
doc.setFillColor(255, 255, 255);
|
|
doc.roundedRect(15, yPos, 30, 30, 3, 3, 'F');
|
|
doc.setDrawColor(230, 230, 230);
|
|
doc.setLineWidth(0.5);
|
|
doc.roundedRect(15, yPos, 30, 30, 3, 3, 'S');
|
|
doc.addImage(this.azienda.logo, 'PNG', 17, yPos + 2, 26, 26);
|
|
} catch (err) {
|
|
console.error('Error adding logo:', err);
|
|
}
|
|
}
|
|
|
|
// Header
|
|
doc.setFontSize(24);
|
|
doc.setTextColor(rgb.r, rgb.g, rgb.b);
|
|
doc.text(
|
|
this.azienda.nome || "Software Development",
|
|
this.azienda.logo ? 50 : 105,
|
|
yPos + 15,
|
|
{ align: this.azienda.logo ? "left" : "center" },
|
|
);
|
|
|
|
doc.setFontSize(12);
|
|
doc.setTextColor(100);
|
|
doc.text("PREVENTIVO SOFTWARE", this.azienda.logo ? 50 : 105, yPos + 25, {
|
|
align: this.azienda.logo ? "left" : "center",
|
|
});
|
|
|
|
yPos = 50;
|
|
|
|
// Il resto del codice per generare il PDF cliente...
|
|
// (codice identico a quello già fornito)
|
|
|
|
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 = 15;
|
|
|
|
// Brand color
|
|
const brandColor = this.logoColor || '#ff5722';
|
|
const rgb = this.hexToRgb(brandColor);
|
|
|
|
// Header with logo
|
|
if (this.azienda.logo) {
|
|
try {
|
|
doc.setFillColor(255, 255, 255);
|
|
doc.roundedRect(15, yPos, 30, 30, 3, 3, 'F');
|
|
doc.setDrawColor(230, 230, 230);
|
|
doc.setLineWidth(0.5);
|
|
doc.roundedRect(15, yPos, 30, 30, 3, 3, 'S');
|
|
doc.addImage(this.azienda.logo, 'PNG', 17, yPos + 2, 26, 26);
|
|
} catch (err) {
|
|
console.error('Error adding logo:', err);
|
|
}
|
|
}
|
|
|
|
// Header
|
|
doc.setFontSize(20);
|
|
doc.setTextColor(rgb.r, rgb.g, rgb.b);
|
|
doc.text("DOCUMENTO INTERNO - RISERVATO", this.azienda.logo ? 50 : 105, yPos + 15, {
|
|
align: this.azienda.logo ? "left" : "center",
|
|
});
|
|
|
|
yPos = 50;
|
|
|
|
// Il resto del codice per generare il PDF interno...
|
|
// (codice identico a quello già fornito)
|
|
|
|
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>
|