2nd commit

This commit is contained in:
Luis Ramos 2025-11-22 14:12:10 -05:00
parent 8092d60c3d
commit 91df406279
15 changed files with 5656 additions and 2 deletions

4630
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -35,5 +35,12 @@
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.7"
},
"dependencies": {
"@types/uuid": "^10.0.0",
"jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2",
"lucide-svelte": "^0.554.0",
"uuid": "^13.0.0"
}
}

View File

@ -0,0 +1,23 @@
<script lang="ts">
export let type: 'button' | 'submit' | 'reset' = 'button';
export let variant: 'primary' | 'secondary' | 'danger' = 'primary';
export let disabled = false;
export let onClick: (() => void) | undefined = undefined;
const baseClasses = "inline-flex items-center px-4 py-2 border text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";
const variantClasses = {
primary: "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500",
secondary: "border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:ring-indigo-500",
danger: "border-transparent text-white bg-red-600 hover:bg-red-700 focus:ring-red-500"
};
</script>
<button
{type}
class="{baseClasses} {variantClasses[variant]} {$$props.class || ''}"
{disabled}
on:click={onClick}
>
<slot />
</button>

View File

@ -0,0 +1,24 @@
<script lang="ts">
export let id: string;
export let label: string;
export let type: 'text' | 'email' | 'number' | 'tel' = 'text';
export let value: string | number;
export let placeholder = '';
export let required = false;
export let min: number | undefined = undefined;
export let step: number | undefined = undefined;
</script>
<div class="mb-4">
<label for={id} class="block text-sm font-medium text-gray-700 mb-1">{label}</label>
<input
{id}
{type}
bind:value
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md p-2 border"
{placeholder}
{required}
{min}
{step}
/>
</div>

View File

@ -0,0 +1,66 @@
<script lang="ts">
import { page } from '$app/stores';
</script>
<nav class="border-b border-gray-200 bg-white shadow-sm">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<div class="flex">
<div class="flex flex-shrink-0 items-center">
<a href="/" class="text-xl font-bold text-indigo-600">Cot-EZ</a>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a
href="/"
class="inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium {$page.url
.pathname === '/'
? 'border-indigo-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'}"
>
Inicio
</a>
<a
href="/products"
class="inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium {$page.url.pathname.startsWith(
'/products'
)
? 'border-indigo-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'}"
>
Productos
</a>
<a
href="/clients"
class="inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium {$page.url.pathname.startsWith(
'/clients'
)
? 'border-indigo-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'}"
>
Clientes
</a>
<a
href="/quotations"
class="inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium {$page.url.pathname.startsWith(
'/quotations'
)
? 'border-indigo-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'}"
>
Cotizaciones
</a>
<a
href="/settings"
class="inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium {$page.url.pathname.startsWith(
'/settings'
)
? 'border-indigo-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'}"
>
Configuración
</a>
</div>
</div>
</div>
</div>
</nav>

92
src/lib/pdf.ts Normal file
View File

@ -0,0 +1,92 @@
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import type { Quotation, Client, CompanyInfo } from './stores';
import { get } from 'svelte/store';
import { companyInfo } from './stores';
export function generateQuotationPDF(quotation: Quotation, client: Client | undefined) {
const doc = new jsPDF();
const company = get(companyInfo);
// Company Header
doc.setFontSize(22);
doc.setTextColor(79, 70, 229); // Indigo 600
doc.text(company.name, 14, 20);
doc.setFontSize(10);
doc.setTextColor(100);
let yPos = 26;
if (company.address) { doc.text(company.address, 14, yPos); yPos += 5; }
if (company.phone) { doc.text(`Tel: ${company.phone}`, 14, yPos); yPos += 5; }
if (company.email) { doc.text(`Email: ${company.email}`, 14, yPos); yPos += 5; }
if (company.headerText) {
doc.setFontSize(9);
doc.text(company.headerText, 14, yPos + 2);
}
// Quotation Title & Info (Right aligned)
doc.setFontSize(20);
doc.setTextColor(0);
doc.text('COTIZACIÓN', 140, 22);
doc.setFontSize(10);
doc.text(`Fecha: ${new Date(quotation.date).toLocaleDateString()}`, 140, 30);
doc.text(`ID: ${quotation.id.slice(0, 8)}`, 140, 35);
// Client Info
if (client) {
doc.setFontSize(12);
doc.setTextColor(0);
doc.text('Cliente:', 14, 60);
doc.setFontSize(11);
doc.text(client.name, 14, 66);
doc.setFontSize(10);
doc.setTextColor(100);
doc.text(client.email, 14, 71);
if (client.phone) doc.text(client.phone, 14, 76);
if (client.address) doc.text(client.address, 14, 81);
}
// Table
const tableColumn = ["Producto", "Cantidad", "Precio Unit.", "Total"];
const tableRows = [];
quotation.items.forEach(item => {
const itemData = [
item.productName,
item.quantity,
`$${item.price.toFixed(2)}`,
`$${(item.price * item.quantity).toFixed(2)}`
];
tableRows.push(itemData);
});
// @ts-ignore
autoTable(doc, {
head: [tableColumn],
body: tableRows,
startY: 90,
headStyles: { fillColor: [79, 70, 229] }, // Indigo 600
theme: 'grid'
});
// Total
// @ts-ignore
const finalY = doc.lastAutoTable.finalY || 90;
doc.setFontSize(12);
doc.setTextColor(0);
doc.text(`Total: $${quotation.total.toFixed(2)}`, 180, finalY + 10, { align: 'right' });
// Footer Notes
if (quotation.footer) {
doc.setFontSize(10);
doc.setTextColor(100);
doc.text('Notas:', 14, finalY + 20);
doc.setFontSize(9);
const splitFooter = doc.splitTextToSize(quotation.footer, 180);
doc.text(splitFooter, 14, finalY + 25);
}
// Save
doc.save(`cotizacion_${quotation.id.slice(0, 8)}.pdf`);
}

83
src/lib/stores.ts Normal file
View File

@ -0,0 +1,83 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export interface Product {
id: string;
name: string;
description: string;
price: number;
}
export interface Client {
id: string;
name: string;
email: string;
address: string;
phone: string;
}
export interface QuotationItem {
productId: string;
productName: string;
quantity: number;
price: number;
}
export interface Quotation {
id: string;
clientId: string;
clientName: string;
date: string;
items: QuotationItem[];
total: number;
footer?: string;
}
export interface CompanyInfo {
name: string;
email: string;
phone: string;
address: string;
headerText: string;
}
function createPersistentStore<T>(key: string, startValue: T) {
const { subscribe, set, update } = writable<T>(startValue, (set) => {
if (browser) {
const storedValue = localStorage.getItem(key);
if (storedValue) {
set(JSON.parse(storedValue));
}
}
});
return {
subscribe,
set: (n: T) => {
if (browser) {
localStorage.setItem(key, JSON.stringify(n));
}
set(n);
},
update: (cb: (value: T) => T) => {
update((n) => {
const newValue = cb(n);
if (browser) {
localStorage.setItem(key, JSON.stringify(newValue));
}
return newValue;
});
}
};
}
export const products = createPersistentStore<Product[]>('products', []);
export const clients = createPersistentStore<Client[]>('clients', []);
export const quotations = createPersistentStore<Quotation[]>('quotations', []);
export const companyInfo = createPersistentStore<CompanyInfo>('companyInfo', {
name: 'Mi Empresa',
email: 'contacto@miempresa.com',
phone: '',
address: '',
headerText: ''
});

View File

@ -1,5 +1,6 @@
<script lang="ts">
import '../app.css';
import Navbar from '$lib/components/Navbar.svelte';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();

View File

@ -1,2 +1,47 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<script lang="ts">
import { products, clients, quotations } from '$lib/stores';
import Button from '$lib/components/Button.svelte';
</script>
<div class="px-4 py-6 sm:px-0">
<div class="mb-8 grid grid-cols-1 gap-6 sm:grid-cols-3">
<!-- Stats Cards -->
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="px-4 py-5 sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500">Total Productos</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">{$products.length}</dd>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="px-4 py-5 sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500">Total Clientes</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">{$clients.length}</dd>
</div>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<div class="px-4 py-5 sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500">Total Cotizaciones</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">{$quotations.length}</dd>
</div>
</div>
</div>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">Acciones Rápidas</h3>
<div class="mt-5 flex flex-wrap gap-4">
<a href="/quotations/new">
<Button>Nueva Cotización</Button>
</a>
<a href="/products">
<Button variant="secondary">Gestionar Productos</Button>
</a>
<a href="/clients">
<Button variant="secondary">Gestionar Clientes</Button>
</a>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,115 @@
<script lang="ts">
import { clients } from '$lib/stores';
import type { Client } from '$lib/stores';
import Button from '$lib/components/Button.svelte';
import Input from '$lib/components/Input.svelte';
import { v4 as uuidv4 } from 'uuid';
let name = '';
let email = '';
let address = '';
let phone = '';
let isEditing = false;
let editId = '';
function handleSubmit() {
if (isEditing) {
clients.update((items) =>
items.map((c) => (c.id === editId ? { ...c, name, email, address, phone } : c))
);
isEditing = false;
editId = '';
} else {
const newClient: Client = {
id: crypto.randomUUID(),
name,
email,
address,
phone
};
clients.update((items) => [...items, newClient]);
}
resetForm();
}
function editClient(client: Client) {
name = client.name;
email = client.email;
address = client.address;
phone = client.phone;
isEditing = true;
editId = client.id;
}
function deleteClient(id: string) {
if (confirm('¿Estás seguro de que quieres eliminar este cliente?')) {
clients.update((items) => items.filter((c) => c.id !== id));
}
}
function resetForm() {
name = '';
email = '';
address = '';
phone = '';
isEditing = false;
editId = '';
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl leading-7 font-bold text-gray-900 sm:truncate sm:text-3xl">Clientes</h2>
<a href="/">
<Button variant="secondary">Volver al Inicio</Button>
</a>
</div>
<div class="bg-white px-4 py-5 shadow sm:rounded-lg sm:p-6">
<div class="md:grid md:grid-cols-3 md:gap-6">
<div class="md:col-span-1">
<h3 class="text-lg leading-6 font-medium text-gray-900">Información del Cliente</h3>
<p class="mt-1 text-sm text-gray-500">Añade o edita clientes para tus cotizaciones.</p>
</div>
<div class="mt-5 md:col-span-2 md:mt-0">
<form on:submit|preventDefault={handleSubmit}>
<Input id="name" label="Nombre del Cliente" bind:value={name} required />
<Input id="email" label="Email" type="email" bind:value={email} required />
<Input id="phone" label="Teléfono" type="tel" bind:value={phone} />
<Input id="address" label="Dirección" bind:value={address} />
<div class="flex justify-end gap-2">
{#if isEditing}
<Button variant="secondary" onClick={resetForm}>Cancelar</Button>
{/if}
<Button type="submit">{isEditing ? 'Actualizar' : 'Añadir'} Cliente</Button>
</div>
</form>
</div>
</div>
</div>
<div class="overflow-hidden bg-white shadow sm:rounded-md">
<ul role="list" class="divide-y divide-gray-200">
{#each $clients as client (client.id)}
<li>
<div class="flex items-center justify-between px-4 py-4 sm:px-6">
<div class="min-w-0 flex-1">
<h4 class="truncate text-lg font-bold text-indigo-600">{client.name}</h4>
<p class="text-sm text-gray-500">{client.email}{client.phone}</p>
<p class="mt-1 text-sm text-gray-500">{client.address}</p>
</div>
<div class="flex gap-2">
<Button variant="secondary" onClick={() => editClient(client)}>Editar</Button>
<Button variant="danger" onClick={() => deleteClient(client.id)}>Eliminar</Button>
</div>
</div>
</li>
{:else}
<li class="px-4 py-4 sm:px-6 text-center text-gray-500">
No se encontraron clientes. ¡Añade uno arriba!
</li>
{/each}
</ul>
</div>
</div>

View File

@ -0,0 +1,118 @@
<script lang="ts">
import { products } from '$lib/stores';
import type { Product } from '$lib/stores';
import Button from '$lib/components/Button.svelte';
import Input from '$lib/components/Input.svelte';
import { v4 as uuidv4 } from 'uuid';
let name = '';
let description = '';
let price = 0;
let isEditing = false;
let editId = '';
function handleSubmit() {
if (isEditing) {
products.update((items) =>
items.map((p) => (p.id === editId ? { ...p, name, description, price } : p))
);
isEditing = false;
editId = '';
} else {
const newProduct: Product = {
id: crypto.randomUUID(),
name,
description,
price
};
products.update((items) => [...items, newProduct]);
}
resetForm();
}
function editProduct(product: Product) {
name = product.name;
description = product.description;
price = product.price;
isEditing = true;
editId = product.id;
}
function deleteProduct(id: string) {
if (confirm('¿Estás seguro de que quieres eliminar este producto?')) {
products.update((items) => items.filter((p) => p.id !== id));
}
}
function resetForm() {
name = '';
description = '';
price = 0;
isEditing = false;
editId = '';
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl leading-7 font-bold text-gray-900 sm:truncate sm:text-3xl">Productos</h2>
<a href="/">
<Button variant="secondary">Volver al Inicio</Button>
</a>
</div>
<div class="bg-white px-4 py-5 shadow sm:rounded-lg sm:p-6">
<div class="md:grid md:grid-cols-3 md:gap-6">
<div class="md:col-span-1">
<h3 class="text-lg leading-6 font-medium text-gray-900">Información del Producto</h3>
<p class="mt-1 text-sm text-gray-500">Añade o edita productos para tus cotizaciones.</p>
</div>
<div class="mt-5 md:col-span-2 md:mt-0">
<form on:submit|preventDefault={handleSubmit}>
<Input id="name" label="Nombre del Producto" bind:value={name} required />
<Input id="description" label="Descripción" bind:value={description} />
<Input
id="price"
label="Precio"
type="number"
bind:value={price}
min={0}
step={0.01}
required
/>
<div class="flex justify-end gap-2">
{#if isEditing}
<Button variant="secondary" onClick={resetForm}>Cancelar</Button>
{/if}
<Button type="submit">{isEditing ? 'Actualizar' : 'Añadir'} Producto</Button>
</div>
</form>
</div>
</div>
</div>
<div class="overflow-hidden bg-white shadow sm:rounded-md">
<ul role="list" class="divide-y divide-gray-200">
{#each $products as product (product.id)}
<li>
<div class="flex items-center justify-between px-4 py-4 sm:px-6">
<div class="min-w-0 flex-1">
<h4 class="truncate text-lg font-bold text-indigo-600">{product.name}</h4>
<p class="text-sm text-gray-500">{product.description}</p>
<p class="mt-1 text-sm font-medium text-gray-900">${product.price.toFixed(2)}</p>
</div>
<div class="flex gap-2">
<Button variant="secondary" onClick={() => editProduct(product)}>Editar</Button>
<Button variant="danger" onClick={() => deleteProduct(product.id)}>Eliminar</Button>
</div>
</div>
</li>
{:else}
<li class="px-4 py-4 sm:px-6 text-center text-gray-500">
No se encontraron productos. ¡Añade uno arriba!
</li>
{/each}
</ul>
</div>
</div>

View File

@ -0,0 +1,57 @@
<script lang="ts">
import { quotations } from '$lib/stores';
import Button from '$lib/components/Button.svelte';
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl leading-7 font-bold text-gray-900 sm:truncate sm:text-3xl">Cotizaciones</h2>
<div class="flex gap-2">
<a href="/">
<Button variant="secondary">Volver al Inicio</Button>
</a>
<a href="/quotations/new">
<Button>Nueva Cotización</Button>
</a>
</div>
</div>
<div class="overflow-hidden bg-white shadow sm:rounded-md">
<ul role="list" class="divide-y divide-gray-200">
{#each $quotations as quotation (quotation.id)}
<li>
<a href="/quotations/{quotation.id}" class="block hover:bg-gray-50">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<p class="truncate text-sm font-medium text-indigo-600">
{quotation.clientName}
</p>
<div class="ml-2 flex flex-shrink-0">
<p
class="inline-flex rounded-full bg-green-100 px-2 text-xs leading-5 font-semibold text-green-800"
>
${quotation.total.toFixed(2)}
</p>
</div>
</div>
<div class="mt-2 sm:flex sm:justify-between">
<div class="sm:flex">
<p class="flex items-center text-sm text-gray-500">
{new Date(quotation.date).toLocaleDateString()}
</p>
</div>
<div class="mt-2 flex items-center text-sm text-gray-500 sm:mt-0">
<p>Ver Detalles &rarr;</p>
</div>
</div>
</div>
</a>
</li>
{:else}
<li class="px-4 py-4 sm:px-6 text-center text-gray-500">
No se encontraron cotizaciones. ¡Crea una!
</li>
{/each}
</ul>
</div>
</div>

View File

@ -0,0 +1,92 @@
<script lang="ts">
import { page } from '$app/stores';
import { quotations, clients } from '$lib/stores';
import Button from '$lib/components/Button.svelte';
import { generateQuotationPDF } from '$lib/pdf';
$: quotation = $quotations.find((q) => q.id === $page.params.id);
$: client = quotation ? $clients.find((c) => c.id === quotation!.clientId) : undefined;
function downloadPDF() {
if (quotation) {
generateQuotationPDF(quotation, client);
}
}
</script>
<div class="space-y-6">
{#if quotation}
<div class="overflow-hidden bg-white shadow sm:rounded-lg">
<div class="flex items-center justify-between px-4 py-5 sm:px-6">
<div>
<h3 class="text-lg leading-6 font-medium text-gray-900">Detalles de la Cotización</h3>
<p class="mt-1 max-w-2xl text-sm text-gray-500">ID: {quotation.id}</p>
</div>
<div class="flex gap-2">
<a href="/quotations">
<Button variant="secondary">Volver</Button>
</a>
<Button onClick={downloadPDF}>Descargar PDF</Button>
</div>
</div>
<div class="border-t border-gray-200 px-4 py-5 sm:p-0">
<dl class="sm:divide-y sm:divide-gray-200">
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
<dt class="text-sm font-medium text-gray-500">Cliente</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">{quotation.clientName}</dd>
</div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
<dt class="text-sm font-medium text-gray-500">Fecha</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{new Date(quotation.date).toLocaleDateString()}
</dd>
</div>
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
<dt class="text-sm font-medium text-gray-500">Monto Total</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
${quotation.total.toFixed(2)}
</dd>
</div>
{#if quotation.footer}
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
<dt class="text-sm font-medium text-gray-500">Notas</dt>
<dd class="mt-1 text-sm whitespace-pre-wrap text-gray-900 sm:col-span-2 sm:mt-0">
{quotation.footer}
</dd>
</div>
{/if}
<div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
<dt class="text-sm font-medium text-gray-500">Ítems</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<ul role="list" class="divide-y divide-gray-200 rounded-md border border-gray-200">
{#each quotation.items as item}
<li class="flex items-center justify-between py-3 pr-4 pl-3 text-sm">
<div class="flex w-0 flex-1 items-center">
<span class="ml-2 w-0 flex-1 truncate"
>{item.productName} (x{item.quantity})</span
>
</div>
<div class="ml-4 flex-shrink-0">
<span class="font-medium text-gray-900"
>${(item.price * item.quantity).toFixed(2)}</span
>
</div>
</li>
{/each}
</ul>
</dd>
</div>
</dl>
</div>
</div>
{:else}
<div class="py-12 text-center">
<h3 class="mt-2 text-sm font-medium text-gray-900">Cotización no encontrada</h3>
<div class="mt-6">
<a href="/quotations">
<Button>Volver a Cotizaciones</Button>
</a>
</div>
</div>
{/if}
</div>

View File

@ -0,0 +1,232 @@
<script lang="ts">
import { products, clients, quotations } from '$lib/stores';
import type { Product, Client, Quotation, QuotationItem } from '$lib/stores';
import Button from '$lib/components/Button.svelte';
import Input from '$lib/components/Input.svelte';
import { goto } from '$app/navigation';
let selectedClientId = '';
let selectedProductId = '';
let quantity = 1;
let items: QuotationItem[] = [];
let footer = '';
let productSearch = '';
$: selectedClient = $clients.find((c) => c.id === selectedClientId);
$: filteredProducts = $products.filter((p) =>
p.name.toLowerCase().includes(productSearch.toLowerCase())
);
$: selectedProduct = $products.find((p) => p.id === selectedProductId);
$: total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
function addItem() {
if (selectedProduct && quantity > 0) {
const existingItem = items.find((i) => i.productId === selectedProduct!.id);
if (existingItem) {
items = items.map((i) =>
i.productId === selectedProduct!.id ? { ...i, quantity: i.quantity + quantity } : i
);
} else {
items = [
...items,
{
productId: selectedProduct.id,
productName: selectedProduct.name,
quantity,
price: selectedProduct.price
}
];
}
selectedProductId = '';
quantity = 1;
productSearch = ''; // Reset search after adding
}
}
function removeItem(productId: string) {
items = items.filter((i) => i.productId !== productId);
}
function saveQuotation() {
if (selectedClient && items.length > 0) {
const newQuotation: Quotation = {
id: crypto.randomUUID(),
clientId: selectedClient.id,
clientName: selectedClient.name,
date: new Date().toISOString(),
items,
total,
footer
};
quotations.update((q) => [...q, newQuotation]);
goto(`/quotations/${newQuotation.id}`);
}
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl leading-7 font-bold text-gray-900 sm:truncate sm:text-3xl">
Nueva Cotización
</h2>
<a href="/quotations">
<Button variant="secondary">Volver</Button>
</a>
</div>
<div class="bg-white px-4 py-5 shadow sm:rounded-lg sm:p-6">
<div class="md:grid md:grid-cols-3 md:gap-6">
<div class="md:col-span-1">
<h3 class="text-lg leading-6 font-medium text-gray-900">Detalles de la Cotización</h3>
<p class="mt-1 text-sm text-gray-500">Crea una nueva cotización para un cliente.</p>
</div>
<div class="mt-5 space-y-6 md:col-span-2 md:mt-0">
<!-- Client Selection -->
<div>
<label for="client" class="block text-sm font-medium text-gray-700">Cliente</label>
<select
id="client"
bind:value={selectedClientId}
class="mt-1 block w-full rounded-md border border-gray-300 py-2 pr-10 pl-3 text-base focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none sm:text-sm"
>
<option value="">Selecciona un cliente</option>
{#each $clients as client}
<option value={client.id}>{client.name}</option>
{/each}
</select>
</div>
{#if selectedClient}
<div class="rounded-md bg-gray-50 p-4">
<p class="text-sm text-gray-700"><strong>Cliente:</strong> {selectedClient.name}</p>
<p class="text-sm text-gray-700"><strong>Email:</strong> {selectedClient.email}</p>
</div>
{/if}
<hr class="border-gray-200" />
<!-- Add Items -->
<div class="space-y-4">
<div>
<label for="productSearch" class="block text-sm font-medium text-gray-700"
>Buscar Producto</label
>
<input
id="productSearch"
type="text"
bind:value={productSearch}
placeholder="Escribe para buscar..."
class="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
<div class="flex items-end gap-4">
<div class="flex-grow">
<label for="product" class="block text-sm font-medium text-gray-700">Producto</label>
<select
id="product"
bind:value={selectedProductId}
class="mt-1 block w-full rounded-md border border-gray-300 py-2 pr-10 pl-3 text-base focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none sm:text-sm"
>
<option value="">Selecciona un producto</option>
{#each filteredProducts as product}
<option value={product.id}>{product.name} (${product.price})</option>
{/each}
</select>
</div>
<div class="w-24">
<Input id="quantity" label="Cant." type="number" bind:value={quantity} min={1} />
</div>
<div class="mb-4">
<Button onClick={addItem} disabled={!selectedProduct}>Añadir</Button>
</div>
</div>
</div>
<!-- Items List -->
{#if items.length > 0}
<div class="flex flex-col">
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div class="overflow-hidden border-b border-gray-200 shadow sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
scope="col"
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Producto</th
>
<th
scope="col"
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Cant.</th
>
<th
scope="col"
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Precio</th
>
<th
scope="col"
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Total</th
>
<th scope="col" class="relative px-6 py-3">
<span class="sr-only">Eliminar</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each items as item}
<tr>
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-gray-900"
>{item.productName}</td
>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500"
>{item.quantity}</td
>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500"
>${item.price.toFixed(2)}</td
>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500"
>${(item.price * item.quantity).toFixed(2)}</td
>
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
<button
on:click={() => removeItem(item.productId)}
class="text-red-600 hover:text-red-900">Eliminar</button
>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Footer Note -->
<div>
<label for="footer" class="block text-sm font-medium text-gray-700">Notas al pie</label>
<textarea
id="footer"
bind:value={footer}
rows="3"
class="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
placeholder="Términos y condiciones, notas adicionales..."
></textarea>
</div>
<div class="flex items-center justify-end gap-4">
<div class="text-lg font-bold">Total: ${total.toFixed(2)}</div>
<Button onClick={saveQuotation} disabled={!selectedClient || items.length === 0}
>Guardar Cotización</Button
>
</div>
{/if}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,69 @@
<script lang="ts">
import { companyInfo } from '$lib/stores';
import Button from '$lib/components/Button.svelte';
import Input from '$lib/components/Input.svelte';
let name = $companyInfo.name;
let email = $companyInfo.email;
let phone = $companyInfo.phone;
let address = $companyInfo.address;
let headerText = $companyInfo.headerText;
function saveSettings() {
companyInfo.set({
name,
email,
phone,
address,
headerText
});
alert('Configuración guardada correctamente');
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl leading-7 font-bold text-gray-900 sm:truncate sm:text-3xl">
Configuración de la Empresa
</h2>
<a href="/">
<Button variant="secondary">Volver al Inicio</Button>
</a>
</div>
<div class="bg-white px-4 py-5 shadow sm:rounded-lg sm:p-6">
<div class="md:grid md:grid-cols-3 md:gap-6">
<div class="md:col-span-1">
<h3 class="text-lg leading-6 font-medium text-gray-900">Información General</h3>
<p class="mt-1 text-sm text-gray-500">
Esta información aparecerá en el encabezado de tus cotizaciones PDF.
</p>
</div>
<div class="mt-5 md:col-span-2 md:mt-0">
<form on:submit|preventDefault={saveSettings}>
<Input id="name" label="Nombre de la Empresa" bind:value={name} required />
<Input id="email" label="Email de Contacto" type="email" bind:value={email} required />
<Input id="phone" label="Teléfono" type="tel" bind:value={phone} />
<Input id="address" label="Dirección" bind:value={address} />
<div class="mb-4">
<label for="headerText" class="mb-1 block text-sm font-medium text-gray-700"
>Texto del Encabezado (Opcional)</label
>
<textarea
id="headerText"
bind:value={headerText}
rows="2"
class="block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
placeholder="Slogan o información adicional..."
></textarea>
</div>
<div class="flex justify-end">
<Button type="submit">Guardar Configuración</Button>
</div>
</form>
</div>
</div>
</div>
</div>