Browse Source

initial clients info implementation

Matthew Trejo 1 month ago
parent
commit
079bb0a9e5

+ 2 - 1
.gitignore

@@ -41,4 +41,5 @@ yarn-error.log*
 next-env.d.ts
 
 # why is this here lmfao
-/docs
+/docs
+*.db

+ 38 - 9
README.md

@@ -40,18 +40,20 @@ cd sumire
 npm install
 ```
 
-3. Configurar la base de datos:
-```bash
-npx prisma generate
-npx prisma db push
-```
-
-4. Configurar variables de entorno:
+3. Configurar variables de entorno:
 Crear un archivo `.env` en la raíz del proyecto:
 ```env
 DATABASE_URL="file:./dev.db"
 ```
 
+4. Configurar la base de datos:
+```bash
+# Aplicar todas las migraciones existentes
+npm run db:migrate
+
+# Esto creará la base de datos y aplicará todas las migraciones
+```
+
 ## Uso
 
 ### Desarrollo
@@ -118,10 +120,37 @@ sumire/
 
 ## Base de Datos
 
-El proyecto utiliza SQLite para el almacenamiento local. Para modificar el esquema:
+El proyecto utiliza SQLite con Prisma ORM para el almacenamiento local.
+
+### Comandos Disponibles
+
+```bash
+# Crear/aplicar nueva migración (después de editar schema.prisma)
+npm run db:migrate -- --name descripcion_del_cambio
+
+# Ver la base de datos visualmente
+npm run db:studio
+
+# Reset completo de la base de datos (⚠️ borra todos los datos)
+npm run db:reset
+
+# Regenerar el cliente de Prisma
+npm run db:generate
+```
+
+### Modificar el Esquema
 
 1. Editar `prisma/schema.prisma`
-2. Ejecutar `npx prisma db push`
+2. Ejecutar `npm run db:migrate -- --name descripcion_del_cambio`
+3. Commit tanto `schema.prisma` como la carpeta `migrations/`
+
+### Para Nuevos Desarrolladores
+
+Al clonar el proyecto por primera vez:
+```bash
+npm install                    # Instalar dependencias
+npm run db:migrate            # Aplicar migraciones existentes
+```
 
 ## Licencia
 

+ 6 - 1
package.json

@@ -6,7 +6,12 @@
     "dev": "next dev",
     "build": "next build",
     "start": "next start",
-    "lint": "eslint"
+    "lint": "eslint",
+    "db:migrate": "prisma migrate dev",
+    "db:reset": "prisma migrate reset",
+    "db:push": "prisma db push",
+    "db:studio": "prisma studio",
+    "db:generate": "prisma generate"
   },
   "dependencies": {
     "@prisma/client": "^6.18.0",

BIN
prisma/dev.db


+ 37 - 0
prisma/migrations/20251101024804_initial_setup/migration.sql

@@ -0,0 +1,37 @@
+-- CreateTable
+CREATE TABLE "configuraciones_tributarias" (
+    "id" TEXT NOT NULL PRIMARY KEY,
+    "ambiente" TEXT NOT NULL,
+    "tipoEmision" TEXT NOT NULL,
+    "razonSocial" TEXT NOT NULL,
+    "nombreComercial" TEXT NOT NULL,
+    "ruc" TEXT NOT NULL,
+    "dirMatriz" TEXT NOT NULL,
+    "estab" TEXT NOT NULL,
+    "ptoEmi" TEXT NOT NULL,
+    "secuencial" TEXT NOT NULL,
+    "obligadoContabilidad" TEXT NOT NULL DEFAULT 'NO',
+    "activo" BOOLEAN NOT NULL DEFAULT true,
+    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" DATETIME NOT NULL
+);
+
+-- CreateTable
+CREATE TABLE "clientes" (
+    "id" TEXT NOT NULL PRIMARY KEY,
+    "tipoIdentificacion" TEXT NOT NULL,
+    "identificacion" TEXT NOT NULL,
+    "razonSocial" TEXT NOT NULL,
+    "direccion" TEXT NOT NULL,
+    "email" TEXT,
+    "telefono" TEXT,
+    "activo" BOOLEAN NOT NULL DEFAULT true,
+    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" DATETIME NOT NULL
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "configuraciones_tributarias_ruc_key" ON "configuraciones_tributarias"("ruc");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "clientes_tipoIdentificacion_identificacion_key" ON "clientes"("tipoIdentificacion", "identificacion");

+ 3 - 0
prisma/migrations/migration_lock.toml

@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (e.g., Git)
+provider = "sqlite"

+ 16 - 0
prisma/schema.prisma

@@ -28,3 +28,19 @@ model ConfiguracionTributaria {
 
   @@map("configuraciones_tributarias")
 }
+
+model Cliente {
+  id                        String @id @default(cuid())
+  tipoIdentificacion        String // "04" RUC, "05" Cédula, "06" Pasaporte, "07" Consumidor Final
+  identificacion            String
+  razonSocial               String // Nombre o razón social del cliente
+  direccion                 String
+  email                     String?
+  telefono                  String?
+  activo                    Boolean @default(true)
+  createdAt                 DateTime @default(now())
+  updatedAt                 DateTime @updatedAt
+
+  @@unique([tipoIdentificacion, identificacion])
+  @@map("clientes")
+}

+ 82 - 0
src/app/api/clientes/[id]/route.ts

@@ -0,0 +1,82 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { PrismaClient } from '@prisma/client'
+
+const prisma = new PrismaClient()
+
+// GET - Obtener un cliente por ID
+export async function GET(
+  request: NextRequest,
+  { params }: { params: { id: string } }
+) {
+  try {
+    const cliente = await prisma.cliente.findUnique({
+      where: { id: params.id }
+    })
+
+    if (!cliente) {
+      return NextResponse.json(
+        { error: 'Cliente no encontrado' },
+        { status: 404 }
+      )
+    }
+
+    return NextResponse.json(cliente)
+  } catch (error) {
+    console.error('Error fetching cliente:', error)
+    return NextResponse.json(
+      { error: 'Error al obtener cliente' },
+      { status: 500 }
+    )
+  }
+}
+
+// PUT - Actualizar cliente
+export async function PUT(
+  request: NextRequest,
+  { params }: { params: { id: string } }
+) {
+  try {
+    const body = await request.json()
+
+    const cliente = await prisma.cliente.update({
+      where: { id: params.id },
+      data: {
+        tipoIdentificacion: body.tipoIdentificacion,
+        identificacion: body.identificacion,
+        razonSocial: body.razonSocial,
+        direccion: body.direccion,
+        email: body.email || null,
+        telefono: body.telefono || null,
+        activo: body.activo
+      }
+    })
+
+    return NextResponse.json(cliente)
+  } catch (error) {
+    console.error('Error updating cliente:', error)
+    return NextResponse.json(
+      { error: 'Error al actualizar cliente' },
+      { status: 500 }
+    )
+  }
+}
+
+// DELETE - Eliminar cliente
+export async function DELETE(
+  request: NextRequest,
+  { params }: { params: { id: string } }
+) {
+  try {
+    await prisma.cliente.delete({
+      where: { id: params.id }
+    })
+
+    return NextResponse.json({ success: true })
+  } catch (error) {
+    console.error('Error deleting cliente:', error)
+    return NextResponse.json(
+      { error: 'Error al eliminar cliente' },
+      { status: 500 }
+    )
+  }
+}

+ 66 - 0
src/app/api/clientes/route.ts

@@ -0,0 +1,66 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { PrismaClient } from '@prisma/client'
+
+const prisma = new PrismaClient()
+
+// GET - Obtener todos los clientes
+export async function GET() {
+  try {
+    const clientes = await prisma.cliente.findMany({
+      orderBy: {
+        createdAt: 'desc'
+      }
+    })
+    return NextResponse.json(clientes)
+  } catch (error) {
+    console.error('Error fetching clientes:', error)
+    return NextResponse.json(
+      { error: 'Error al obtener clientes' },
+      { status: 500 }
+    )
+  }
+}
+
+// POST - Crear nuevo cliente
+export async function POST(request: NextRequest) {
+  try {
+    const body = await request.json()
+
+    // Verificar si ya existe un cliente con el mismo tipo e identificación
+    const existente = await prisma.cliente.findUnique({
+      where: {
+        tipoIdentificacion_identificacion: {
+          tipoIdentificacion: body.tipoIdentificacion,
+          identificacion: body.identificacion
+        }
+      }
+    })
+
+    if (existente) {
+      return NextResponse.json(
+        { error: 'Ya existe un cliente con esta identificación' },
+        { status: 400 }
+      )
+    }
+
+    const cliente = await prisma.cliente.create({
+      data: {
+        tipoIdentificacion: body.tipoIdentificacion,
+        identificacion: body.identificacion,
+        razonSocial: body.razonSocial,
+        direccion: body.direccion,
+        email: body.email || null,
+        telefono: body.telefono || null,
+        activo: body.activo ?? true
+      }
+    })
+
+    return NextResponse.json(cliente)
+  } catch (error) {
+    console.error('Error creating cliente:', error)
+    return NextResponse.json(
+      { error: 'Error al crear cliente' },
+      { status: 500 }
+    )
+  }
+}

+ 9 - 0
src/app/clientes/page.tsx

@@ -0,0 +1,9 @@
+import { ClienteManager } from "@/components/clientes/ClienteManager"
+
+export default function ClientesPage() {
+  return (
+    <div className="container mx-auto py-6">
+      <ClienteManager />
+    </div>
+  )
+}

+ 29 - 1
src/components/app-sidebar.tsx

@@ -1,6 +1,6 @@
 "use client"
 
-import { ChevronUp, Home, FileText, Settings, FileSignature, Send } from "lucide-react"
+import { ChevronUp, Home, FileText, Settings, FileSignature, Send, Users } from "lucide-react"
 
 import {
   DropdownMenu,
@@ -52,6 +52,15 @@ const invoiceItems = [
   },
 ]
 
+// Menu items - Gestión
+const managementItems = [
+  {
+    title: "Clientes",
+    url: "/clientes",
+    icon: Users,
+  },
+]
+
 // Menu items - Configuración
 const settingsItems = [
   {
@@ -118,6 +127,25 @@ export function AppSidebar() {
           </SidebarGroupContent>
         </SidebarGroup>
 
+        {/* Gestión */}
+        <SidebarGroup>
+          <SidebarGroupLabel>Gestión</SidebarGroupLabel>
+          <SidebarGroupContent>
+            <SidebarMenu>
+              {managementItems.map((item) => (
+                <SidebarMenuItem key={item.title}>
+                  <SidebarMenuButton asChild>
+                    <a href={item.url}>
+                      <item.icon />
+                      <span>{item.title}</span>
+                    </a>
+                  </SidebarMenuButton>
+                </SidebarMenuItem>
+              ))}
+            </SidebarMenu>
+          </SidebarGroupContent>
+        </SidebarGroup>
+
         {/* Configuración */}
         <SidebarGroup>
           <SidebarGroupLabel>Sistema</SidebarGroupLabel>

+ 120 - 0
src/components/clientes/ClienteForm.tsx

@@ -0,0 +1,120 @@
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Switch } from "@/components/ui/switch"
+
+interface ClienteFormData {
+  tipoIdentificacion: string
+  identificacion: string
+  razonSocial: string
+  direccion: string
+  email: string
+  telefono: string
+  activo: boolean
+}
+
+interface ClienteFormProps {
+  formData: ClienteFormData
+  setFormData: (data: ClienteFormData) => void
+  onSave: () => void
+  onCancel: () => void
+  isEdit?: boolean
+}
+
+export function ClienteForm({ formData, setFormData, onSave, onCancel, isEdit = false }: ClienteFormProps) {
+  return (
+    <div className="space-y-4">
+      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+        <div className="space-y-2">
+          <Label htmlFor="tipoIdentificacion">Tipo Identificación *</Label>
+          <Select
+            value={formData.tipoIdentificacion}
+            onValueChange={(value) => setFormData({ ...formData, tipoIdentificacion: value })}
+          >
+            <SelectTrigger>
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="04">04 - RUC</SelectItem>
+              <SelectItem value="05">05 - Cédula</SelectItem>
+              <SelectItem value="06">06 - Pasaporte</SelectItem>
+              <SelectItem value="07">07 - Consumidor Final</SelectItem>
+            </SelectContent>
+          </Select>
+        </div>
+
+        <div className="space-y-2">
+          <Label htmlFor="identificacion">Identificación *</Label>
+          <Input
+            id="identificacion"
+            value={formData.identificacion}
+            onChange={(e) => setFormData({ ...formData, identificacion: e.target.value })}
+            placeholder="Según tipo seleccionado"
+          />
+        </div>
+
+        <div className="space-y-2 md:col-span-2">
+          <Label htmlFor="razonSocial">Razón Social / Nombre *</Label>
+          <Input
+            id="razonSocial"
+            value={formData.razonSocial}
+            onChange={(e) => setFormData({ ...formData, razonSocial: e.target.value })}
+            placeholder="Nombre completo o razón social"
+          />
+        </div>
+
+        <div className="space-y-2 md:col-span-2">
+          <Label htmlFor="direccion">Dirección *</Label>
+          <Input
+            id="direccion"
+            value={formData.direccion}
+            onChange={(e) => setFormData({ ...formData, direccion: e.target.value })}
+            placeholder="Dirección completa"
+          />
+        </div>
+
+        <div className="space-y-2">
+          <Label htmlFor="email">Email</Label>
+          <Input
+            id="email"
+            type="email"
+            value={formData.email}
+            onChange={(e) => setFormData({ ...formData, email: e.target.value })}
+            placeholder="email@ejemplo.com"
+          />
+        </div>
+
+        <div className="space-y-2">
+          <Label htmlFor="telefono">Teléfono</Label>
+          <Input
+            id="telefono"
+            value={formData.telefono}
+            onChange={(e) => setFormData({ ...formData, telefono: e.target.value })}
+            placeholder="0999999999"
+          />
+        </div>
+
+        {isEdit && (
+          <div className="flex items-center space-x-2 md:col-span-2">
+            <Switch
+              id="activo"
+              checked={formData.activo}
+              onCheckedChange={(checked) => setFormData({ ...formData, activo: checked })}
+            />
+            <Label htmlFor="activo">Cliente Activo</Label>
+          </div>
+        )}
+      </div>
+
+      <div className="flex justify-end gap-2 pt-4">
+        <Button variant="outline" onClick={onCancel}>
+          Cancelar
+        </Button>
+        <Button onClick={onSave}>
+          {isEdit ? 'Actualizar' : 'Guardar'}
+        </Button>
+      </div>
+    </div>
+  )
+}

+ 277 - 0
src/components/clientes/ClienteManager.tsx

@@ -0,0 +1,277 @@
+"use client"
+
+import { useState } from 'react'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { Label } from "@/components/ui/label"
+import { Input } from "@/components/ui/input"
+import { useClientes, type Cliente } from '@/hooks/useClientes'
+import { ClienteForm } from './ClienteForm'
+import { Plus, Edit, Trash2, Save, Search } from 'lucide-react'
+
+export function ClienteManager() {
+  const {
+    clientes,
+    loading,
+    error,
+    createCliente,
+    updateCliente,
+    deleteCliente,
+  } = useClientes()
+
+  const [editingCliente, setEditingCliente] = useState<string | null>(null)
+  const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
+  const [searchTerm, setSearchTerm] = useState('')
+  const [formData, setFormData] = useState({
+    tipoIdentificacion: '05',
+    identificacion: '',
+    razonSocial: '',
+    direccion: '',
+    email: '',
+    telefono: '',
+    activo: true,
+  })
+
+  const resetForm = () => {
+    setFormData({
+      tipoIdentificacion: '05',
+      identificacion: '',
+      razonSocial: '',
+      direccion: '',
+      email: '',
+      telefono: '',
+      activo: true,
+    })
+  }
+
+  const handleCreate = async () => {
+    try {
+      await createCliente(formData)
+      setIsCreateDialogOpen(false)
+      resetForm()
+    } catch (error) {
+      console.error('Error creating cliente:', error)
+    }
+  }
+
+  const handleUpdate = async (id: string) => {
+    try {
+      await updateCliente(id, formData)
+      setEditingCliente(null)
+      resetForm()
+    } catch (error) {
+      console.error('Error updating cliente:', error)
+    }
+  }
+
+  const handleDelete = async (id: string) => {
+    if (confirm('¿Está seguro de que desea eliminar este cliente?')) {
+      try {
+        await deleteCliente(id)
+      } catch (error) {
+        console.error('Error deleting cliente:', error)
+      }
+    }
+  }
+
+  const startEdit = (cliente: Cliente) => {
+    setEditingCliente(cliente.id)
+    setFormData({
+      tipoIdentificacion: cliente.tipoIdentificacion,
+      identificacion: cliente.identificacion,
+      razonSocial: cliente.razonSocial,
+      direccion: cliente.direccion,
+      email: cliente.email || '',
+      telefono: cliente.telefono || '',
+      activo: cliente.activo,
+    })
+  }
+
+  const cancelEdit = () => {
+    setEditingCliente(null)
+    resetForm()
+  }
+
+  const getTipoIdentificacionLabel = (tipo: string) => {
+    const tipos: Record<string, string> = {
+      '04': 'RUC',
+      '05': 'Cédula',
+      '06': 'Pasaporte',
+      '07': 'Consumidor Final',
+    }
+    return tipos[tipo] || tipo
+  }
+
+  // Filtrar clientes por búsqueda
+  const filteredClientes = clientes.filter(cliente =>
+    cliente.razonSocial.toLowerCase().includes(searchTerm.toLowerCase()) ||
+    cliente.identificacion.includes(searchTerm) ||
+    (cliente.email && cliente.email.toLowerCase().includes(searchTerm.toLowerCase()))
+  )
+
+  if (loading && clientes.length === 0) {
+    return <div className="flex justify-center items-center h-64">Cargando...</div>
+  }
+
+  if (error) {
+    return <div className="text-red-500 text-center p-4">Error: {error}</div>
+  }
+
+  return (
+    <div className="space-y-6">
+      <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
+        <div>
+          <h2 className="text-2xl font-bold">Clientes</h2>
+          <p className="mt-2 text-muted-foreground">Gestiona la información de tus clientes</p>
+        </div>
+
+        <div className="flex gap-2 w-full md:w-auto">
+          <div className="relative flex-1 md:flex-initial">
+            <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
+            <Input
+              placeholder="Buscar cliente..."
+              value={searchTerm}
+              onChange={(e) => setSearchTerm(e.target.value)}
+              className="pl-8 w-full md:w-[300px]"
+            />
+          </div>
+
+          <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
+            <DialogTrigger asChild>
+              <Button onClick={() => { resetForm(); setIsCreateDialogOpen(true); }}>
+                <Plus className="w-4 h-4 mr-2" />
+                Nuevo Cliente
+              </Button>
+            </DialogTrigger>
+            <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
+              <DialogHeader>
+                <DialogTitle>Nuevo Cliente</DialogTitle>
+                <DialogDescription>
+                  Ingresa los datos del cliente
+                </DialogDescription>
+              </DialogHeader>
+              <ClienteForm
+                formData={formData}
+                setFormData={setFormData}
+                onSave={handleCreate}
+                onCancel={() => setIsCreateDialogOpen(false)}
+              />
+            </DialogContent>
+          </Dialog>
+        </div>
+      </div>
+
+      <div className="grid gap-4">
+        {filteredClientes.map((cliente) => (
+          <Card key={cliente.id}>
+            <CardHeader>
+              <div className="flex justify-between items-start">
+                <div>
+                  <CardTitle className="flex items-center gap-2">
+                    {cliente.razonSocial}
+                    <Badge variant={cliente.activo ? "default" : "secondary"}>
+                      {cliente.activo ? "Activo" : "Inactivo"}
+                    </Badge>
+                  </CardTitle>
+                  <CardDescription>
+                    {getTipoIdentificacionLabel(cliente.tipoIdentificacion)}: {cliente.identificacion}
+                  </CardDescription>
+                </div>
+                <div className="flex gap-2">
+                  {editingCliente === cliente.id ? (
+                    <>
+                      <Button
+                        size="sm"
+                        onClick={() => handleUpdate(cliente.id)}
+                        disabled={loading}
+                      >
+                        <Save className="w-4 h-4" />
+                      </Button>
+                      <Button
+                        size="sm"
+                        variant="outline"
+                        onClick={cancelEdit}
+                        disabled={loading}
+                      >
+                        Cancelar
+                      </Button>
+                    </>
+                  ) : (
+                    <>
+                      <Button
+                        size="sm"
+                        variant="outline"
+                        onClick={() => startEdit(cliente)}
+                      >
+                        <Edit className="w-4 h-4" />
+                      </Button>
+                      <Button
+                        size="sm"
+                        variant="destructive"
+                        onClick={() => handleDelete(cliente.id)}
+                        disabled={loading}
+                      >
+                        <Trash2 className="w-4 h-4" />
+                      </Button>
+                    </>
+                  )}
+                </div>
+              </div>
+            </CardHeader>
+            <CardContent>
+              {editingCliente === cliente.id ? (
+                <ClienteForm
+                  formData={formData}
+                  setFormData={setFormData}
+                  onSave={() => handleUpdate(cliente.id)}
+                  onCancel={cancelEdit}
+                  isEdit
+                />
+              ) : (
+                <div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
+                  <div>
+                    <Label className="text-muted-foreground">Dirección</Label>
+                    <p>{cliente.direccion}</p>
+                  </div>
+                  {cliente.email && (
+                    <div>
+                      <Label className="text-muted-foreground">Email</Label>
+                      <p>{cliente.email}</p>
+                    </div>
+                  )}
+                  {cliente.telefono && (
+                    <div>
+                      <Label className="text-muted-foreground">Teléfono</Label>
+                      <p>{cliente.telefono}</p>
+                    </div>
+                  )}
+                </div>
+              )}
+            </CardContent>
+          </Card>
+        ))}
+
+        {filteredClientes.length === 0 && !loading && (
+          <Card>
+            <CardContent className="text-center py-8">
+              <p className="text-gray-500">
+                {searchTerm ? 'No se encontraron clientes con ese criterio de búsqueda' : 'No hay clientes registrados'}
+              </p>
+              {!searchTerm && (
+                <Button
+                  className="mt-4"
+                  onClick={() => setIsCreateDialogOpen(true)}
+                >
+                  <Plus className="w-4 h-4 mr-2" />
+                  Crear Primer Cliente
+                </Button>
+              )}
+            </CardContent>
+          </Card>
+        )}
+      </div>
+    </div>
+  )
+}

+ 178 - 0
src/components/factura/ClienteSelector.tsx

@@ -0,0 +1,178 @@
+"use client"
+
+import { useState } from 'react'
+import { Label } from "@/components/ui/label"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Button } from "@/components/ui/button"
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
+import { Input } from "@/components/ui/input"
+import { useClientes } from '@/hooks/useClientes'
+import { ClienteForm } from '@/components/clientes/ClienteForm'
+import { Plus, Search } from 'lucide-react'
+import type { InfoFactura } from '@/types/factura'
+
+interface ClienteSelectorProps {
+  infoFactura: InfoFactura
+  onChange: (field: keyof InfoFactura, value: string) => void
+}
+
+export function ClienteSelector({ infoFactura, onChange }: ClienteSelectorProps) {
+  const { clientes, createCliente, loading } = useClientes()
+  const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
+  const [searchTerm, setSearchTerm] = useState('')
+  const [formData, setFormData] = useState({
+    tipoIdentificacion: '05',
+    identificacion: '',
+    razonSocial: '',
+    direccion: '',
+    email: '',
+    telefono: '',
+    activo: true,
+  })
+
+  const resetForm = () => {
+    setFormData({
+      tipoIdentificacion: '05',
+      identificacion: '',
+      razonSocial: '',
+      direccion: '',
+      email: '',
+      telefono: '',
+      activo: true,
+    })
+  }
+
+  const handleCreateCliente = async () => {
+    try {
+      const newCliente = await createCliente(formData)
+
+      // Auto-cargar el cliente recién creado en el formulario
+      onChange('tipoIdentificacionComprador', newCliente.tipoIdentificacion)
+      onChange('identificacionComprador', newCliente.identificacion)
+      onChange('razonSocialComprador', newCliente.razonSocial)
+      onChange('direccionComprador', newCliente.direccion)
+      onChange('emailComprador', newCliente.email || '')
+      onChange('telefonoComprador', newCliente.telefono || '')
+
+      setIsCreateDialogOpen(false)
+      resetForm()
+    } catch (error) {
+      console.error('Error creating cliente:', error)
+    }
+  }
+
+  const handleSelectCliente = (clienteId: string) => {
+    const cliente = clientes.find(c => c.id === clienteId)
+    if (cliente) {
+      onChange('tipoIdentificacionComprador', cliente.tipoIdentificacion)
+      onChange('identificacionComprador', cliente.identificacion)
+      onChange('razonSocialComprador', cliente.razonSocial)
+      onChange('direccionComprador', cliente.direccion)
+      onChange('emailComprador', cliente.email || '')
+      onChange('telefonoComprador', cliente.telefono || '')
+    }
+  }
+
+  const getTipoIdentificacionLabel = (tipo: string) => {
+    const tipos: Record<string, string> = {
+      '04': 'RUC',
+      '05': 'Cédula',
+      '06': 'Pasaporte',
+      '07': 'Consumidor Final',
+    }
+    return tipos[tipo] || tipo
+  }
+
+  // Filtrar clientes activos
+  const clientesActivos = clientes.filter(c => c.activo)
+
+  // Filtrar por búsqueda si hay término de búsqueda
+  const filteredClientes = searchTerm
+    ? clientesActivos.filter(c =>
+        c.razonSocial.toLowerCase().includes(searchTerm.toLowerCase()) ||
+        c.identificacion.includes(searchTerm)
+      )
+    : clientesActivos
+
+  // Encontrar cliente seleccionado actual
+  const clienteSeleccionado = clientes.find(
+    c => c.identificacion === infoFactura.identificacionComprador &&
+         c.tipoIdentificacion === infoFactura.tipoIdentificacionComprador
+  )
+
+  return (
+    <div className="space-y-2">
+      <div className="flex items-center justify-between">
+        <Label>Seleccionar Cliente</Label>
+        <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
+          <DialogTrigger asChild>
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              onClick={() => { resetForm(); setIsCreateDialogOpen(true); }}
+            >
+              <Plus className="w-4 h-4 mr-2" />
+              Nuevo Cliente
+            </Button>
+          </DialogTrigger>
+          <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
+            <DialogHeader>
+              <DialogTitle>Nuevo Cliente</DialogTitle>
+              <DialogDescription>
+                Ingresa los datos del cliente. Se agregará automáticamente a la factura.
+              </DialogDescription>
+            </DialogHeader>
+            <ClienteForm
+              formData={formData}
+              setFormData={setFormData}
+              onSave={handleCreateCliente}
+              onCancel={() => setIsCreateDialogOpen(false)}
+            />
+          </DialogContent>
+        </Dialog>
+      </div>
+
+      <Select
+        value={clienteSeleccionado?.id || ''}
+        onValueChange={handleSelectCliente}
+      >
+        <SelectTrigger>
+          <SelectValue placeholder="Selecciona un cliente o ingresa manualmente abajo">
+            {clienteSeleccionado ? (
+              <span>
+                {clienteSeleccionado.razonSocial} - {getTipoIdentificacionLabel(clienteSeleccionado.tipoIdentificacion)}: {clienteSeleccionado.identificacion}
+              </span>
+            ) : (
+              "Selecciona un cliente o ingresa manualmente abajo"
+            )}
+          </SelectValue>
+        </SelectTrigger>
+        <SelectContent>
+          <div className="p-2">
+            <div className="relative">
+              <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
+              <Input
+                placeholder="Buscar cliente..."
+                value={searchTerm}
+                onChange={(e) => setSearchTerm(e.target.value)}
+                className="pl-8"
+              />
+            </div>
+          </div>
+          {filteredClientes.length > 0 ? (
+            filteredClientes.map((cliente) => (
+              <SelectItem key={cliente.id} value={cliente.id}>
+                {cliente.razonSocial} - {getTipoIdentificacionLabel(cliente.tipoIdentificacion)}: {cliente.identificacion}
+              </SelectItem>
+            ))
+          ) : (
+            <div className="p-4 text-center text-sm text-muted-foreground">
+              {searchTerm ? 'No se encontraron clientes' : 'No hay clientes registrados'}
+            </div>
+          )}
+        </SelectContent>
+      </Select>
+    </div>
+  )
+}

+ 45 - 30
src/components/factura/InfoFacturaForm.tsx

@@ -2,6 +2,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
 import { Input } from "@/components/ui/input"
 import { Label } from "@/components/ui/label"
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { ClienteSelector } from "@/components/factura/ClienteSelector"
 import type { InfoFactura } from "@/types/factura"
 
 interface InfoFacturaFormProps {
@@ -17,39 +18,52 @@ export function InfoFacturaForm({ infoFactura, onChange }: InfoFacturaFormProps)
         <CardDescription>Datos de la factura y comprador</CardDescription>
       </CardHeader>
       <CardContent>
-        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
-          <div className="space-y-2">
-            <Label htmlFor="fechaEmision">Fecha Emisión *</Label>
-            <Input
-              id="fechaEmision"
-              type="date"
-              value={infoFactura.fechaEmision}
-              onChange={(e) => onChange('fechaEmision', e.target.value)}
-            />
+        <div className="space-y-6">
+          {/* Selector de Cliente */}
+          <div className="lg:col-span-3">
+            <ClienteSelector infoFactura={infoFactura} onChange={onChange} />
           </div>
-          
-          <div className="space-y-2">
-            <Label htmlFor="dirEstablecimiento">Dirección Establecimiento *</Label>
-            <Input
-              id="dirEstablecimiento"
-              value={infoFactura.dirEstablecimiento}
-              onChange={(e) => onChange('dirEstablecimiento', e.target.value)}
-              placeholder="Av. Secundaria 456"
-            />
-          </div>
-          
-          <div className="space-y-2">
-            <Label htmlFor="telefonoComprador">Teléfono Comprador</Label>
-            <Input
-              id="telefonoComprador"
-              value={infoFactura.telefonoComprador}
-              onChange={(e) => onChange('telefonoComprador', e.target.value)}
-              placeholder="Teléfono del comprador"
-            />
+
+          {/* Separador visual */}
+          <div className="border-t pt-4">
+            <p className="text-sm text-muted-foreground mb-4">
+              Información de la factura y datos del comprador (puedes editar manualmente si es necesario)
+            </p>
           </div>
 
-          <div className="space-y-2">
-            <Label htmlFor="tipoIdentificacionComprador">Tipo Identificación Comprador</Label>
+          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+            <div className="space-y-2">
+              <Label htmlFor="fechaEmision">Fecha Emisión *</Label>
+              <Input
+                id="fechaEmision"
+                type="date"
+                value={infoFactura.fechaEmision}
+                onChange={(e) => onChange('fechaEmision', e.target.value)}
+              />
+            </div>
+
+            <div className="space-y-2">
+              <Label htmlFor="dirEstablecimiento">Dirección Establecimiento *</Label>
+              <Input
+                id="dirEstablecimiento"
+                value={infoFactura.dirEstablecimiento}
+                onChange={(e) => onChange('dirEstablecimiento', e.target.value)}
+                placeholder="Av. Secundaria 456"
+              />
+            </div>
+
+            <div className="space-y-2">
+              <Label htmlFor="telefonoComprador">Teléfono Comprador</Label>
+              <Input
+                id="telefonoComprador"
+                value={infoFactura.telefonoComprador}
+                onChange={(e) => onChange('telefonoComprador', e.target.value)}
+                placeholder="Teléfono del comprador"
+              />
+            </div>
+
+            <div className="space-y-2">
+              <Label htmlFor="tipoIdentificacionComprador">Tipo Identificación Comprador</Label>
             <Select value={infoFactura.tipoIdentificacionComprador} onValueChange={(value) => onChange('tipoIdentificacionComprador', value)}>
               <SelectTrigger>
                 <SelectValue />
@@ -119,6 +133,7 @@ export function InfoFacturaForm({ infoFactura, onChange }: InfoFacturaFormProps)
               placeholder="email@ejemplo.com"
             />
           </div>
+          </div>
         </div>
       </CardContent>
     </Card>

+ 66 - 27
src/components/factura/InfoTributariaForm.tsx

@@ -1,5 +1,6 @@
 "use client"
 
+import { useState } from 'react'
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
 import { Input } from "@/components/ui/input"
 import { Label } from "@/components/ui/label"
@@ -7,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
 import type { InfoTributaria } from "@/types/factura"
 import { useConfiguracionesTributarias } from '@/hooks/useConfiguracionesTributarias'
 import { Button } from "@/components/ui/button"
-import { ExternalLink } from 'lucide-react'
+import { Lock, Unlock } from 'lucide-react'
 
 interface InfoTributariaFormProps {
   infoTributaria: InfoTributaria
@@ -16,6 +17,8 @@ interface InfoTributariaFormProps {
 
 export function InfoTributariaForm({ infoTributaria, onChange }: InfoTributariaFormProps) {
   const { configuraciones } = useConfiguracionesTributarias()
+  const [isLocked, setIsLocked] = useState(false)
+  const [selectedConfigId, setSelectedConfigId] = useState<string>('')
 
   const loadConfiguracion = (config: any) => {
     onChange('ambiente', config.ambiente)
@@ -28,25 +31,39 @@ export function InfoTributariaForm({ infoTributaria, onChange }: InfoTributariaF
     onChange('ptoEmi', config.ptoEmi)
     onChange('secuencial', config.secuencial)
     onChange('obligadoContabilidad', config.obligadoContabilidad || 'NO')
+    setIsLocked(true)
     // Nota: claveAcceso se genera automáticamente al crear el XML
   }
 
+  const toggleLock = () => {
+    setIsLocked(!isLocked)
+  }
+
   return (
     <Card>
       <CardHeader>
         <div className="flex justify-between items-start">
           <div>
             <CardTitle>Información Tributaria</CardTitle>
-            <CardDescription>Datos del emisor de la factura</CardDescription>
+            <CardDescription>
+              {isLocked
+                ? "Datos del emisor (bloqueados - click en el candado para editar)"
+                : "Datos del emisor de la factura"}
+            </CardDescription>
           </div>
-          {configuraciones.length > 0 && (
-            <div className="flex gap-2">
-              <Select onValueChange={(value) => {
-                const config = configuraciones.find(c => c.id === value)
-                if (config) {
-                  loadConfiguracion(config)
-                }
-              }}>
+          <div className="flex gap-2">
+            {configuraciones.length > 0 && (
+              <Select
+                value={selectedConfigId}
+                onValueChange={(value) => {
+                  const config = configuraciones.find(c => c.id === value)
+                  if (config) {
+                    setSelectedConfigId(value)
+                    loadConfiguracion(config)
+                  }
+                }}
+                disabled={isLocked}
+              >
                 <SelectTrigger className="w-[200px]">
                   <SelectValue placeholder="Cargar configuración" />
                 </SelectTrigger>
@@ -58,22 +75,37 @@ export function InfoTributariaForm({ infoTributaria, onChange }: InfoTributariaF
                   ))}
                 </SelectContent>
               </Select>
-              {/* <Button
+            )}
+            {isLocked && (
+              <Button
+                variant="outline"
+                size="sm"
+                onClick={toggleLock}
+                className="flex items-center gap-2"
+              >
+                <Lock className="w-4 h-4" />
+                Desbloquear
+              </Button>
+            )}
+            {!isLocked && selectedConfigId && (
+              <Button
                 variant="outline"
                 size="sm"
-                onClick={() => window.open('/configuracion', '_blank')}
+                onClick={toggleLock}
+                className="flex items-center gap-2"
               >
-                <ExternalLink className="w-4 h-4" />
-              </Button> */}
-            </div>
-          )}
+                <Unlock className="w-4 h-4" />
+                Bloquear
+              </Button>
+            )}
+          </div>
         </div>
       </CardHeader>
       <CardContent>
         <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
           <div className="space-y-2">
             <Label htmlFor="ambiente">Ambiente</Label>
-            <Select value={infoTributaria.ambiente} onValueChange={(value) => onChange('ambiente', value)}>
+            <Select value={infoTributaria.ambiente} onValueChange={(value) => onChange('ambiente', value)} disabled={isLocked}>
               <SelectTrigger>
                 <SelectValue />
               </SelectTrigger>
@@ -83,10 +115,10 @@ export function InfoTributariaForm({ infoTributaria, onChange }: InfoTributariaF
               </SelectContent>
             </Select>
           </div>
-          
+
           <div className="space-y-2">
             <Label htmlFor="tipoEmision">Tipo Emisión</Label>
-            <Select value={infoTributaria.tipoEmision} onValueChange={(value) => onChange('tipoEmision', value)}>
+            <Select value={infoTributaria.tipoEmision} onValueChange={(value) => onChange('tipoEmision', value)} disabled={isLocked}>
               <SelectTrigger>
                 <SelectValue />
               </SelectTrigger>
@@ -95,7 +127,7 @@ export function InfoTributariaForm({ infoTributaria, onChange }: InfoTributariaF
               </SelectContent>
             </Select>
           </div>
-          
+
           <div className="space-y-2">
             <Label htmlFor="ruc">RUC *</Label>
             <Input
@@ -104,9 +136,10 @@ export function InfoTributariaForm({ infoTributaria, onChange }: InfoTributariaF
               onChange={(e) => onChange('ruc', e.target.value)}
               placeholder="13 dígitos"
               maxLength={13}
+              disabled={isLocked}
             />
           </div>
-          
+
           <div className="space-y-2">
             <Label htmlFor="razonSocial">Razón Social *</Label>
             <Input
@@ -114,9 +147,10 @@ export function InfoTributariaForm({ infoTributaria, onChange }: InfoTributariaF
               value={infoTributaria.razonSocial}
               onChange={(e) => onChange('razonSocial', e.target.value)}
               placeholder="Empresa S.A."
+              disabled={isLocked}
             />
           </div>
-          
+
           <div className="space-y-2">
             <Label htmlFor="nombreComercial">Nombre Comercial *</Label>
             <Input
@@ -124,9 +158,10 @@ export function InfoTributariaForm({ infoTributaria, onChange }: InfoTributariaF
               value={infoTributaria.nombreComercial}
               onChange={(e) => onChange('nombreComercial', e.target.value)}
               placeholder="Nombre Comercial"
+              disabled={isLocked}
             />
           </div>
-          
+
           <div className="space-y-2">
             <Label htmlFor="estab">Establecimiento</Label>
             <Input
@@ -135,9 +170,10 @@ export function InfoTributariaForm({ infoTributaria, onChange }: InfoTributariaF
               onChange={(e) => onChange('estab', e.target.value)}
               placeholder="001"
               maxLength={3}
+              disabled={isLocked}
             />
           </div>
-          
+
           <div className="space-y-2">
             <Label htmlFor="ptoEmi">Punto Emisión</Label>
             <Input
@@ -146,9 +182,10 @@ export function InfoTributariaForm({ infoTributaria, onChange }: InfoTributariaF
               onChange={(e) => onChange('ptoEmi', e.target.value)}
               placeholder="001"
               maxLength={3}
+              disabled={isLocked}
             />
           </div>
-          
+
           <div className="space-y-2">
             <Label htmlFor="secuencial">Secuencial</Label>
             <Input
@@ -157,9 +194,10 @@ export function InfoTributariaForm({ infoTributaria, onChange }: InfoTributariaF
               onChange={(e) => onChange('secuencial', e.target.value)}
               placeholder="000000001"
               maxLength={9}
+              disabled={isLocked}
             />
           </div>
-          
+
           <div className="space-y-2 lg:col-span-2">
             <Label htmlFor="dirMatriz">Dirección Matriz *</Label>
             <Input
@@ -167,12 +205,13 @@ export function InfoTributariaForm({ infoTributaria, onChange }: InfoTributariaF
               value={infoTributaria.dirMatriz}
               onChange={(e) => onChange('dirMatriz', e.target.value)}
               placeholder="Av. Principal 123 y Secundaria"
+              disabled={isLocked}
             />
           </div>
 
           <div className="space-y-2">
             <Label htmlFor="obligadoContabilidad">Obligado a Llevar Contabilidad *</Label>
-            <Select value={infoTributaria.obligadoContabilidad} onValueChange={(value) => onChange('obligadoContabilidad', value)}>
+            <Select value={infoTributaria.obligadoContabilidad} onValueChange={(value) => onChange('obligadoContabilidad', value)} disabled={isLocked}>
               <SelectTrigger>
                 <SelectValue />
               </SelectTrigger>

+ 143 - 0
src/hooks/useClientes.ts

@@ -0,0 +1,143 @@
+import { useState, useEffect, useCallback } from 'react'
+import { toast } from 'sonner'
+
+export interface Cliente {
+  id: string
+  tipoIdentificacion: string
+  identificacion: string
+  razonSocial: string
+  direccion: string
+  email: string | null
+  telefono: string | null
+  activo: boolean
+  createdAt: string
+  updatedAt: string
+}
+
+export interface ClienteInput {
+  tipoIdentificacion: string
+  identificacion: string
+  razonSocial: string
+  direccion: string
+  email?: string
+  telefono?: string
+  activo?: boolean
+}
+
+export function useClientes() {
+  const [clientes, setClientes] = useState<Cliente[]>([])
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const fetchClientes = useCallback(async () => {
+    setLoading(true)
+    setError(null)
+    try {
+      const response = await fetch('/api/clientes')
+      if (!response.ok) {
+        throw new Error('Error al cargar clientes')
+      }
+      const data = await response.json()
+      setClientes(data)
+    } catch (err) {
+      const errorMessage = err instanceof Error ? err.message : 'Error desconocido'
+      setError(errorMessage)
+      toast.error(errorMessage)
+    } finally {
+      setLoading(false)
+    }
+  }, [])
+
+  useEffect(() => {
+    fetchClientes()
+  }, [fetchClientes])
+
+  const createCliente = async (clienteData: ClienteInput) => {
+    setLoading(true)
+    try {
+      const response = await fetch('/api/clientes', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+        },
+        body: JSON.stringify(clienteData),
+      })
+
+      if (!response.ok) {
+        const errorData = await response.json()
+        throw new Error(errorData.error || 'Error al crear cliente')
+      }
+
+      const newCliente = await response.json()
+      setClientes(prev => [newCliente, ...prev])
+      toast.success('Cliente creado exitosamente')
+      return newCliente
+    } catch (err) {
+      const errorMessage = err instanceof Error ? err.message : 'Error desconocido'
+      toast.error(errorMessage)
+      throw err
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const updateCliente = async (id: string, clienteData: ClienteInput) => {
+    setLoading(true)
+    try {
+      const response = await fetch(`/api/clientes/${id}`, {
+        method: 'PUT',
+        headers: {
+          'Content-Type': 'application/json',
+        },
+        body: JSON.stringify(clienteData),
+      })
+
+      if (!response.ok) {
+        throw new Error('Error al actualizar cliente')
+      }
+
+      const updatedCliente = await response.json()
+      setClientes(prev => prev.map(c => c.id === id ? updatedCliente : c))
+      toast.success('Cliente actualizado exitosamente')
+      return updatedCliente
+    } catch (err) {
+      const errorMessage = err instanceof Error ? err.message : 'Error desconocido'
+      toast.error(errorMessage)
+      throw err
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const deleteCliente = async (id: string) => {
+    setLoading(true)
+    try {
+      const response = await fetch(`/api/clientes/${id}`, {
+        method: 'DELETE',
+      })
+
+      if (!response.ok) {
+        throw new Error('Error al eliminar cliente')
+      }
+
+      setClientes(prev => prev.filter(c => c.id !== id))
+      toast.success('Cliente eliminado exitosamente')
+    } catch (err) {
+      const errorMessage = err instanceof Error ? err.message : 'Error desconocido'
+      toast.error(errorMessage)
+      throw err
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  return {
+    clientes,
+    loading,
+    error,
+    fetchClientes,
+    createCliente,
+    updateCliente,
+    deleteCliente,
+  }
+}