Browse Source

initial services info loading and saving

Matthew Trejo 1 month ago
parent
commit
1d4a5fc4db

BIN
prisma/dev.db


+ 13 - 0
prisma/migrations/20251103084441_add_servicio_model/migration.sql

@@ -0,0 +1,13 @@
+-- CreateTable
+CREATE TABLE "servicios" (
+    "id" TEXT NOT NULL PRIMARY KEY,
+    "codigoPrincipal" TEXT NOT NULL,
+    "codigoAuxiliar" TEXT,
+    "descripcion" TEXT NOT NULL,
+    "precioUnitario" TEXT NOT NULL,
+    "codigoPorcentaje" TEXT NOT NULL DEFAULT '2',
+    "tarifa" TEXT NOT NULL DEFAULT '15',
+    "activo" BOOLEAN NOT NULL DEFAULT true,
+    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" DATETIME NOT NULL
+);

+ 15 - 0
prisma/schema.prisma

@@ -45,3 +45,18 @@ model Cliente {
   @@unique([tipoIdentificacion, identificacion])
   @@map("clientes")
 }
+
+model Servicio {
+  id                  String @id @default(cuid())
+  codigoPrincipal     String  // Código principal del producto/servicio
+  codigoAuxiliar      String? // Código auxiliar opcional
+  descripcion         String  // Descripción detallada del servicio
+  precioUnitario      String  // Precio unitario base (como string para precisión)
+  codigoPorcentaje    String  @default("2") // "0" No objeto de IVA, "2" IVA
+  tarifa              String  @default("15") // Tarifa de IVA (0, 5, 12, 15)
+  activo              Boolean @default(true)
+  createdAt           DateTime @default(now())
+  updatedAt           DateTime @updatedAt
+
+  @@map("servicios")
+}

+ 135 - 0
src/app/api/servicios/[id]/route.ts

@@ -0,0 +1,135 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { PrismaClient } from '@prisma/client'
+
+const prisma = new PrismaClient()
+
+// GET - Obtener servicio por ID
+export async function GET(
+  request: NextRequest,
+  { params }: { params: { id: string } }
+) {
+  try {
+    const servicio = await prisma.servicio.findUnique({
+      where: {
+        id: params.id
+      }
+    })
+
+    if (!servicio) {
+      return NextResponse.json(
+        { error: 'Servicio no encontrado' },
+        { status: 404 }
+      )
+    }
+
+    return NextResponse.json(servicio)
+  } catch (error) {
+    console.error('Error fetching servicio:', error)
+    return NextResponse.json(
+      { error: 'Error al obtener servicio' },
+      { status: 500 }
+    )
+  }
+}
+
+// PUT - Actualizar servicio
+export async function PUT(
+  request: NextRequest,
+  { params }: { params: { id: string } }
+) {
+  try {
+    const body = await request.json()
+
+    // Verificar si el servicio existe
+    const servicioExistente = await prisma.servicio.findUnique({
+      where: {
+        id: params.id
+      }
+    })
+
+    if (!servicioExistente) {
+      return NextResponse.json(
+        { error: 'Servicio no encontrado' },
+        { status: 404 }
+      )
+    }
+
+    // Si se está cambiando el código principal, verificar que no exista otro servicio con ese código
+    if (body.codigoPrincipal && body.codigoPrincipal !== servicioExistente.codigoPrincipal) {
+      const codigoExistente = await prisma.servicio.findFirst({
+        where: {
+          codigoPrincipal: body.codigoPrincipal,
+          id: {
+            not: params.id
+          }
+        }
+      })
+
+      if (codigoExistente) {
+        return NextResponse.json(
+          { error: 'Ya existe otro servicio con este código principal' },
+          { status: 400 }
+        )
+      }
+    }
+
+    const servicio = await prisma.servicio.update({
+      where: {
+        id: params.id
+      },
+      data: {
+        codigoPrincipal: body.codigoPrincipal,
+        codigoAuxiliar: body.codigoAuxiliar || null,
+        descripcion: body.descripcion,
+        precioUnitario: body.precioUnitario,
+        codigoPorcentaje: body.codigoPorcentaje,
+        tarifa: body.tarifa,
+        activo: body.activo
+      }
+    })
+
+    return NextResponse.json(servicio)
+  } catch (error) {
+    console.error('Error updating servicio:', error)
+    return NextResponse.json(
+      { error: 'Error al actualizar servicio' },
+      { status: 500 }
+    )
+  }
+}
+
+// DELETE - Eliminar servicio
+export async function DELETE(
+  request: NextRequest,
+  { params }: { params: { id: string } }
+) {
+  try {
+    // Verificar si el servicio existe
+    const servicioExistente = await prisma.servicio.findUnique({
+      where: {
+        id: params.id
+      }
+    })
+
+    if (!servicioExistente) {
+      return NextResponse.json(
+        { error: 'Servicio no encontrado' },
+        { status: 404 }
+      )
+    }
+
+    await prisma.servicio.delete({
+      where: {
+        id: params.id
+      }
+    })
+
+    return NextResponse.json({ message: 'Servicio eliminado exitosamente' })
+  } catch (error) {
+    console.error('Error deleting servicio:', error)
+    return NextResponse.json(
+      { error: 'Error al eliminar servicio' },
+      { status: 500 }
+    )
+  }
+}

+ 63 - 0
src/app/api/servicios/route.ts

@@ -0,0 +1,63 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { PrismaClient } from '@prisma/client'
+
+const prisma = new PrismaClient()
+
+// GET - Obtener todos los servicios
+export async function GET() {
+  try {
+    const servicios = await prisma.servicio.findMany({
+      orderBy: {
+        createdAt: 'desc'
+      }
+    })
+    return NextResponse.json(servicios)
+  } catch (error) {
+    console.error('Error fetching servicios:', error)
+    return NextResponse.json(
+      { error: 'Error al obtener servicios' },
+      { status: 500 }
+    )
+  }
+}
+
+// POST - Crear nuevo servicio
+export async function POST(request: NextRequest) {
+  try {
+    const body = await request.json()
+
+    // Verificar si ya existe un servicio con el mismo código principal
+    const existente = await prisma.servicio.findFirst({
+      where: {
+        codigoPrincipal: body.codigoPrincipal
+      }
+    })
+
+    if (existente) {
+      return NextResponse.json(
+        { error: 'Ya existe un servicio con este código principal' },
+        { status: 400 }
+      )
+    }
+
+    const servicio = await prisma.servicio.create({
+      data: {
+        codigoPrincipal: body.codigoPrincipal,
+        codigoAuxiliar: body.codigoAuxiliar || null,
+        descripcion: body.descripcion,
+        precioUnitario: body.precioUnitario,
+        codigoPorcentaje: body.codigoPorcentaje || '2',
+        tarifa: body.tarifa || '15',
+        activo: body.activo ?? true
+      }
+    })
+
+    return NextResponse.json(servicio)
+  } catch (error) {
+    console.error('Error creating servicio:', error)
+    return NextResponse.json(
+      { error: 'Error al crear servicio' },
+      { status: 500 }
+    )
+  }
+}

+ 1 - 1
src/app/layout.tsx

@@ -18,7 +18,7 @@ const geistMono = Geist_Mono({
 });
 
 export const metadata: Metadata = {
-  title: "Sumire - shadcn/ui Demo",
+  title: "Sumire",
   description: "yoshizawa se merecía mejor",
 };
 

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

@@ -0,0 +1,9 @@
+import { ServicioManager } from '@/components/servicios/ServicioManager'
+
+export default function ServiciosPage() {
+  return (
+    <div className="container mx-auto p-6">
+      <ServicioManager />
+    </div>
+  )
+}

+ 7 - 2
src/components/app-sidebar.tsx

@@ -1,6 +1,6 @@
 "use client"
 
-import { ChevronUp, Home, FileText, Settings, FileSignature, Send, Users } from "lucide-react"
+import { ChevronUp, Home, FileText, Settings, FileSignature, Send, Users, Package } from "lucide-react"
 
 import {
   DropdownMenu,
@@ -59,6 +59,11 @@ const managementItems = [
     url: "/clientes",
     icon: Users,
   },
+  {
+    title: "Servicios",
+    url: "/servicios",
+    icon: Package,
+  },
 ]
 
 // Menu items - Configuración
@@ -110,7 +115,7 @@ export function AppSidebar() {
 
         {/* Facturación Electrónica */}
         <SidebarGroup>
-          <SidebarGroupLabel>Facturación Electrónica</SidebarGroupLabel>
+          <SidebarGroupLabel>Facturación Electrónica (manual)</SidebarGroupLabel>
           <SidebarGroupContent>
             <SidebarMenu>
               {invoiceItems.map((item) => (

+ 48 - 0
src/components/factura/DetalleItemForm.tsx

@@ -5,6 +5,9 @@ import { Label } from "@/components/ui/label"
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
 import { Trash2 } from "lucide-react"
 import { toast } from "sonner"
+import { useState } from "react"
+import { ServicioSelector } from "@/components/servicios/ServicioSelector"
+import type { Servicio } from '@/hooks/useServicios'
 import type { DetalleItem } from "@/types/factura"
 
 interface DetalleItemFormProps { 
@@ -14,6 +17,39 @@ interface DetalleItemFormProps {
 }
 
 export function DetalleItemForm({ item, onChange, onRemove }: DetalleItemFormProps) {
+  const handleServicioSelect = (servicio: Servicio) => {
+    const updatedItem = {
+      ...item,
+      servicioId: servicio.id,
+      codigoPrincipal: servicio.codigoPrincipal,
+      codigoAuxiliar: servicio.codigoAuxiliar || '',
+      descripcion: servicio.descripcion,
+      precioUnitario: servicio.precioUnitario,
+      codigoPorcentaje: servicio.codigoPorcentaje,
+      tarifa: servicio.tarifa,
+    }
+    
+    // Calcular totales automáticamente
+    const cantidad = parseFloat(updatedItem.cantidad) || 1
+    const precioUnitario = parseFloat(updatedItem.precioUnitario) || 0
+    const subtotal = cantidad * precioUnitario
+    
+    updatedItem.precioTotalSinImpuesto = subtotal.toFixed(2)
+    updatedItem.baseImponible = subtotal.toFixed(2)
+    
+    // Calcular IVA
+    const tarifa = parseFloat(updatedItem.tarifa) || 0
+    const codigoPorcentaje = updatedItem.codigoPorcentaje
+    
+    if (codigoPorcentaje === '0' || tarifa === 0) {
+      updatedItem.valorImpuesto = '0.00'
+    } else {
+      updatedItem.valorImpuesto = (subtotal * tarifa / 100).toFixed(2)
+    }
+    
+    onChange(updatedItem)
+  }
+
   const handleChange = (field: keyof DetalleItem, value: string) => {
     const updatedItem = { ...item, [field]: value }
     
@@ -70,6 +106,18 @@ export function DetalleItemForm({ item, onChange, onRemove }: DetalleItemFormPro
           <Trash2 className="h-4 w-4" />
         </Button>
       </div>
+
+      {/* Selector de Servicios */}
+      <div className="mb-6">
+        <ServicioSelector
+          value={item.servicioId || ''}
+          onServicioSelect={handleServicioSelect}
+          placeholder="Seleccionar servicio (opcional)..."
+        />
+        <p className="text-xs text-muted-foreground mt-2">
+          Puedes seleccionar un servicio registrado o ingresar manualmente los datos
+        </p>
+      </div>
       
       <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
         <div className="space-y-2">

+ 134 - 0
src/components/servicios/ServicioForm.tsx

@@ -0,0 +1,134 @@
+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"
+import type { ServicioInput } from '@/types/servicio'
+
+interface ServicioFormData {
+  codigoPrincipal: string
+  codigoAuxiliar: string
+  descripcion: string
+  precioUnitario: string
+  codigoPorcentaje: string
+  tarifa: string
+  activo: boolean
+}
+
+interface ServicioFormProps {
+  formData: ServicioFormData
+  setFormData: (data: ServicioFormData) => void
+  onSave: () => void
+  onCancel: () => void
+  isEdit?: boolean
+}
+
+export function ServicioForm({ formData, setFormData, onSave, onCancel, isEdit = false }: ServicioFormProps) {
+  return (
+    <div className="space-y-6">
+      <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
+        <div className="space-y-2">
+          <Label htmlFor="codigoPrincipal">Código Principal *</Label>
+          <Input
+            id="codigoPrincipal"
+            value={formData.codigoPrincipal}
+            onChange={(e) => setFormData({ ...formData, codigoPrincipal: e.target.value })}
+            placeholder="PROD001"
+          />
+        </div>
+
+        <div className="space-y-2">
+          <Label htmlFor="codigoAuxiliar">Código Auxiliar</Label>
+          <Input
+            id="codigoAuxiliar"
+            value={formData.codigoAuxiliar || ''}
+            onChange={(e) => setFormData({ ...formData, codigoAuxiliar: e.target.value })}
+            placeholder="AUX001"
+          />
+        </div>
+
+        <div className="space-y-2 sm:col-span-2">
+          <Label htmlFor="descripcion">Descripción *</Label>
+          <Input
+            id="descripcion"
+            value={formData.descripcion}
+            onChange={(e) => setFormData({ ...formData, descripcion: e.target.value })}
+            placeholder="Descripción detallada del servicio"
+          />
+        </div>
+
+        <div className="space-y-2">
+          <Label htmlFor="precioUnitario">Precio Unitario *</Label>
+          <Input
+            id="precioUnitario"
+            type="number"
+            step="0.01"
+            value={formData.precioUnitario}
+            onChange={(e) => setFormData({ ...formData, precioUnitario: e.target.value })}
+            placeholder="10.00"
+          />
+        </div>
+
+        <div className="space-y-2">
+          <Label htmlFor="codigoPorcentaje">Código IVA *</Label>
+          <Select
+            value={formData.codigoPorcentaje || '2'}
+            onValueChange={(value) => setFormData({ ...formData, codigoPorcentaje: value })}
+          >
+            <SelectTrigger>
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="2">2 - IVA</SelectItem>
+              <SelectItem value="0">0 - No objeto de IVA</SelectItem>
+            </SelectContent>
+          </Select>
+        </div>
+
+        <div className="space-y-2">
+          <Label htmlFor="tarifa">Tarifa IVA (%) *</Label>
+          <Select
+            value={formData.tarifa || '15'}
+            onValueChange={(value) => setFormData({ ...formData, tarifa: value })}
+            disabled={formData.codigoPorcentaje === '0'}
+          >
+            <SelectTrigger>
+              <SelectValue placeholder={formData.codigoPorcentaje === '0' ? 'No aplica' : 'Seleccionar'} />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="15">15%</SelectItem>
+              <SelectItem value="13">13%</SelectItem>
+              <SelectItem value="5">5%</SelectItem>
+              <SelectItem value="0">0%</SelectItem>
+            </SelectContent>
+          </Select>
+          {formData.codigoPorcentaje === '0' && (
+            <p className="text-xs text-muted-foreground mt-1">
+              No aplica para servicios no objeto de IVA
+            </p>
+          )}
+        </div>
+
+        {isEdit && (
+          <div className="flex items-center space-x-2 sm:col-span-2">
+            <Switch
+              id="activo"
+              checked={formData.activo ?? true}
+              onCheckedChange={(checked) => setFormData({ ...formData, activo: checked })}
+            />
+            <Label htmlFor="activo">Servicio 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>
+  )
+}

+ 285 - 0
src/components/servicios/ServicioManager.tsx

@@ -0,0 +1,285 @@
+"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 { useServicios, type Servicio } from '@/hooks/useServicios'
+import { ServicioForm } from './ServicioForm'
+import { Plus, Edit, Trash2, Save, Search } from 'lucide-react'
+
+export function ServicioManager() {
+  const {
+    servicios,
+    loading,
+    error,
+    createServicio,
+    updateServicio,
+    deleteServicio,
+  } = useServicios()
+
+  const [editingServicio, setEditingServicio] = useState<string | null>(null)
+  const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
+  const [searchTerm, setSearchTerm] = useState('')
+  const [formData, setFormData] = useState({
+    codigoPrincipal: '',
+    codigoAuxiliar: '',
+    descripcion: '',
+    precioUnitario: '',
+    codigoPorcentaje: '2',
+    tarifa: '15',
+    activo: true,
+  })
+
+  const resetForm = () => {
+    setFormData({
+      codigoPrincipal: '',
+      codigoAuxiliar: '',
+      descripcion: '',
+      precioUnitario: '',
+      codigoPorcentaje: '2',
+      tarifa: '15',
+      activo: true,
+    })
+  }
+
+  const handleCreate = async () => {
+    try {
+      const dataToSubmit = {
+        ...formData,
+        codigoAuxiliar: formData.codigoAuxiliar || undefined
+      }
+      await createServicio(dataToSubmit)
+      setIsCreateDialogOpen(false)
+      resetForm()
+    } catch (error) {
+      console.error('Error creating servicio:', error)
+    }
+  }
+
+  const handleUpdate = async (id: string) => {
+    try {
+      const dataToSubmit = {
+        ...formData,
+        codigoAuxiliar: formData.codigoAuxiliar || undefined
+      }
+      await updateServicio(id, dataToSubmit)
+      setEditingServicio(null)
+      resetForm()
+    } catch (error) {
+      console.error('Error updating servicio:', error)
+    }
+  }
+
+  const handleDelete = async (id: string) => {
+    if (confirm('¿Está seguro de que desea eliminar este servicio?')) {
+      try {
+        await deleteServicio(id)
+      } catch (error) {
+        console.error('Error deleting servicio:', error)
+      }
+    }
+  }
+
+  const startEdit = (servicio: Servicio) => {
+    setEditingServicio(servicio.id)
+    setFormData({
+      codigoPrincipal: servicio.codigoPrincipal,
+      codigoAuxiliar: servicio.codigoAuxiliar || '',
+      descripcion: servicio.descripcion,
+      precioUnitario: servicio.precioUnitario,
+      codigoPorcentaje: servicio.codigoPorcentaje,
+      tarifa: servicio.tarifa,
+      activo: servicio.activo,
+    })
+  }
+
+  const cancelEdit = () => {
+    setEditingServicio(null)
+    resetForm()
+  }
+
+  const getCodigoPorcentajeLabel = (codigo: string) => {
+    const codigos: Record<string, string> = {
+      '0': 'No objeto de IVA',
+      '2': 'IVA',
+    }
+    return codigos[codigo] || codigo
+  }
+
+  // Filtrar servicios por búsqueda
+  const filteredServicios = servicios.filter(servicio =>
+    servicio.descripcion.toLowerCase().includes(searchTerm.toLowerCase()) ||
+    servicio.codigoPrincipal.toLowerCase().includes(searchTerm.toLowerCase()) ||
+    (servicio.codigoAuxiliar && servicio.codigoAuxiliar.toLowerCase().includes(searchTerm.toLowerCase()))
+  )
+
+  if (loading && servicios.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">Servicios</h2>
+          <p className="mt-2 text-muted-foreground">Gestiona la información de tus servicios y productos</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 servicio..."
+              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 Servicio
+              </Button>
+            </DialogTrigger>
+            <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
+              <DialogHeader>
+                <DialogTitle>Nuevo Servicio</DialogTitle>
+                <DialogDescription>
+                  Ingresa los datos del servicio o producto
+                </DialogDescription>
+              </DialogHeader>
+              <ServicioForm
+                formData={formData}
+                setFormData={setFormData}
+                onSave={handleCreate}
+                onCancel={() => setIsCreateDialogOpen(false)}
+              />
+            </DialogContent>
+          </Dialog>
+        </div>
+      </div>
+
+      <div className="grid gap-4">
+        {filteredServicios.map((servicio) => (
+          <Card key={servicio.id}>
+            <CardHeader>
+              <div className="flex justify-between items-start">
+                <div>
+                  <CardTitle className="flex items-center gap-2">
+                    {servicio.codigoPrincipal} - {servicio.descripcion}
+                    <Badge variant={servicio.activo ? "default" : "secondary"}>
+                      {servicio.activo ? "Activo" : "Inactivo"}
+                    </Badge>
+                  </CardTitle>
+                  <CardDescription>
+                    ${parseFloat(servicio.precioUnitario).toFixed(2)} • {getCodigoPorcentajeLabel(servicio.codigoPorcentaje)}
+                    {servicio.codigoPorcentaje !== '0' && ` (${servicio.tarifa}%)`}
+                  </CardDescription>
+                </div>
+                <div className="flex gap-2">
+                  {editingServicio === servicio.id ? (
+                    <>
+                      <Button
+                        size="sm"
+                        onClick={() => handleUpdate(servicio.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(servicio)}
+                      >
+                        <Edit className="w-4 h-4" />
+                      </Button>
+                      <Button
+                        size="sm"
+                        variant="destructive"
+                        onClick={() => handleDelete(servicio.id)}
+                        disabled={loading}
+                      >
+                        <Trash2 className="w-4 h-4" />
+                      </Button>
+                    </>
+                  )}
+                </div>
+              </div>
+            </CardHeader>
+            <CardContent>
+              {editingServicio === servicio.id ? (
+                <ServicioForm
+                  formData={formData}
+                  setFormData={setFormData}
+                  onSave={() => handleUpdate(servicio.id)}
+                  onCancel={cancelEdit}
+                  isEdit
+                />
+              ) : (
+                <div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
+                  {servicio.codigoAuxiliar && (
+                    <div>
+                      <Label className="text-muted-foreground">Código Auxiliar</Label>
+                      <p>{servicio.codigoAuxiliar}</p>
+                    </div>
+                  )}
+                  <div>
+                    <Label className="text-muted-foreground">Precio Unitario</Label>
+                    <p>${parseFloat(servicio.precioUnitario).toFixed(2)}</p>
+                  </div>
+                  <div>
+                    <Label className="text-muted-foreground">IVA</Label>
+                    <p>
+                      {getCodigoPorcentajeLabel(servicio.codigoPorcentaje)}
+                      {servicio.codigoPorcentaje !== '0' && ` (${servicio.tarifa}%)`}
+                    </p>
+                  </div>
+                </div>
+              )}
+            </CardContent>
+          </Card>
+        ))}
+
+        {filteredServicios.length === 0 && !loading && (
+          <Card>
+            <CardContent className="text-center py-8">
+              <p className="text-gray-500">
+                {searchTerm ? 'No se encontraron servicios con ese criterio de búsqueda' : 'No hay servicios registrados'}
+              </p>
+              {!searchTerm && (
+                <Button
+                  className="mt-4"
+                  onClick={() => setIsCreateDialogOpen(true)}
+                >
+                  <Plus className="w-4 h-4 mr-2" />
+                  Crear Primer Servicio
+                </Button>
+              )}
+            </CardContent>
+          </Card>
+        )}
+      </div>
+    </div>
+  )
+}

+ 191 - 0
src/components/servicios/ServicioSelector.tsx

@@ -0,0 +1,191 @@
+"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 { useServicios } from '@/hooks/useServicios'
+import { ServicioForm } from './ServicioForm'
+import type { Servicio } from '@/hooks/useServicios'
+import { Plus, Search } from 'lucide-react'
+
+interface ServicioSelectorProps {
+  value?: string
+  onServicioSelect: (servicio: Servicio) => void
+  placeholder?: string
+}
+
+export function ServicioSelector({ 
+  value, 
+  onServicioSelect, 
+  placeholder = "Seleccionar servicio (opcional)..." 
+}: ServicioSelectorProps) {
+  const { servicios, createServicio, loading } = useServicios()
+  const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
+  const [searchTerm, setSearchTerm] = useState('')
+  const [formData, setFormData] = useState({
+    codigoPrincipal: '',
+    codigoAuxiliar: '',
+    descripcion: '',
+    precioUnitario: '',
+    codigoPorcentaje: '2',
+    tarifa: '15',
+    activo: true,
+  })
+
+  const resetForm = () => {
+    setFormData({
+      codigoPrincipal: '',
+      codigoAuxiliar: '',
+      descripcion: '',
+      precioUnitario: '',
+      codigoPorcentaje: '2',
+      tarifa: '15',
+      activo: true,
+    })
+  }
+
+  const handleCreateServicio = async () => {
+    try {
+      const dataToSubmit = {
+        ...formData,
+        codigoAuxiliar: formData.codigoAuxiliar || undefined
+      }
+      const newServicio = await createServicio(dataToSubmit)
+      
+      // Auto-seleccionar el servicio recién creado
+      onServicioSelect(newServicio)
+      setIsCreateDialogOpen(false)
+      resetForm()
+    } catch (error) {
+      console.error('Error creating servicio:', error)
+    }
+  }
+
+  const handleServicioChange = (servicioId: string) => {
+    const servicio = servicios.find(s => s.id === servicioId)
+    if (servicio) {
+      onServicioSelect(servicio)
+    }
+  }
+
+  const getCodigoPorcentajeLabel = (codigo: string) => {
+    const codigos: Record<string, string> = {
+      '0': 'No objeto IVA',
+      '2': 'IVA',
+    }
+    return codigos[codigo] || codigo
+  }
+
+  // Filtrar servicios activos
+  const serviciosActivos = servicios.filter(s => s.activo)
+
+  // Filtrar por búsqueda si hay término de búsqueda
+  const filteredServicios = searchTerm
+    ? serviciosActivos.filter(s =>
+        s.descripcion.toLowerCase().includes(searchTerm.toLowerCase()) ||
+        s.codigoPrincipal.toLowerCase().includes(searchTerm.toLowerCase()) ||
+        (s.codigoAuxiliar && s.codigoAuxiliar.toLowerCase().includes(searchTerm.toLowerCase()))
+      )
+    : serviciosActivos
+
+  return (
+    <div className="space-y-4">
+      {/* Header con título y botón */}
+      <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
+        <div className="space-y-1">
+          <Label className="text-base font-medium">Seleccionar Servicio</Label>
+          <p className="text-sm text-muted-foreground">
+            Elige un servicio existente o crea uno nuevo
+          </p>
+        </div>
+        <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
+          <DialogTrigger asChild>
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              onClick={() => { resetForm(); setIsCreateDialogOpen(true); }}
+              className="w-full sm:w-auto"
+            >
+              <Plus className="w-4 h-4 mr-2" />
+              Nuevo Servicio
+            </Button>
+          </DialogTrigger>
+          <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto sm:max-w-full sm:mx-4">
+            <DialogHeader>
+              <DialogTitle>Nuevo Servicio</DialogTitle>
+              <DialogDescription>
+                Ingresa los datos del servicio. Se agregará automáticamente a la factura.
+              </DialogDescription>
+            </DialogHeader>
+            <ServicioForm
+              formData={formData}
+              setFormData={setFormData}
+              onSave={handleCreateServicio}
+              onCancel={() => setIsCreateDialogOpen(false)}
+            />
+          </DialogContent>
+        </Dialog>
+      </div>
+
+      <Select
+        value={value}
+        onValueChange={handleServicioChange}
+        disabled={loading}
+      >
+        <SelectTrigger>
+          <SelectValue placeholder={placeholder}>
+            {value && (() => {
+              const servicio = servicios.find(s => s.id === value)
+              if (servicio) {
+                return (
+                  <div className="flex flex-col">
+                    <span>{servicio.codigoPrincipal} - {servicio.descripcion}</span>
+                    <span className="text-xs text-muted-foreground">
+                      ${parseFloat(servicio.precioUnitario).toFixed(2)} • {getCodigoPorcentajeLabel(servicio.codigoPorcentaje)}
+                      {servicio.codigoPorcentaje !== '0' && ` (${servicio.tarifa}%)`}
+                    </span>
+                  </div>
+                )
+              }
+              return null
+            })()}
+          </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 servicio..."
+                value={searchTerm}
+                onChange={(e) => setSearchTerm(e.target.value)}
+                className="pl-8"
+              />
+            </div>
+          </div>
+          {filteredServicios.length > 0 ? (
+            filteredServicios.map((servicio) => (
+              <SelectItem key={servicio.id} value={servicio.id}>
+                <div className="flex flex-col">
+                  <span>{servicio.codigoPrincipal} - {servicio.descripcion}</span>
+                  <span className="text-xs text-muted-foreground">
+                    ${parseFloat(servicio.precioUnitario).toFixed(2)} • {getCodigoPorcentajeLabel(servicio.codigoPorcentaje)}
+                    {servicio.codigoPorcentaje !== '0' && ` (${servicio.tarifa}%)`}
+                  </span>
+                </div>
+              </SelectItem>
+            ))
+          ) : (
+            <div className="p-4 text-center text-sm text-muted-foreground">
+              {searchTerm ? 'No se encontraron servicios' : 'No hay servicios registrados'}
+            </div>
+          )}
+        </SelectContent>
+      </Select>
+    </div>
+  )
+}

+ 143 - 0
src/hooks/useServicios.ts

@@ -0,0 +1,143 @@
+import { useState, useEffect, useCallback } from 'react'
+import { toast } from 'sonner'
+import type { Servicio, ServicioInput } from '@/types/servicio'
+
+export type { Servicio }
+
+export function useServicios() {
+  const [servicios, setServicios] = useState<Servicio[]>([])
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const fetchServicios = useCallback(async () => {
+    setLoading(true)
+    setError(null)
+    try {
+      const response = await fetch('/api/servicios')
+      if (!response.ok) {
+        throw new Error('Error al cargar servicios')
+      }
+      const data = await response.json()
+      setServicios(data)
+    } catch (err) {
+      const errorMessage = err instanceof Error ? err.message : 'Error desconocido'
+      setError(errorMessage)
+      toast.error(errorMessage)
+    } finally {
+      setLoading(false)
+    }
+  }, [])
+
+  useEffect(() => {
+    fetchServicios()
+  }, [fetchServicios])
+
+  const createServicio = async (servicioData: ServicioInput) => {
+    setLoading(true)
+    try {
+      const response = await fetch('/api/servicios', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+        },
+        body: JSON.stringify(servicioData),
+      })
+
+      if (!response.ok) {
+        const errorData = await response.json()
+        throw new Error(errorData.error || 'Error al crear servicio')
+      }
+
+      const newServicio = await response.json()
+      setServicios(prev => [newServicio, ...prev])
+      toast.success('Servicio creado exitosamente')
+      return newServicio
+    } catch (err) {
+      const errorMessage = err instanceof Error ? err.message : 'Error desconocido'
+      toast.error(errorMessage)
+      throw err
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const updateServicio = async (id: string, servicioData: ServicioInput) => {
+    setLoading(true)
+    try {
+      const response = await fetch(`/api/servicios/${id}`, {
+        method: 'PUT',
+        headers: {
+          'Content-Type': 'application/json',
+        },
+        body: JSON.stringify(servicioData),
+      })
+
+      if (!response.ok) {
+        const errorData = await response.json()
+        throw new Error(errorData.error || 'Error al actualizar servicio')
+      }
+
+      const updatedServicio = await response.json()
+      setServicios(prev => prev.map(s => s.id === id ? updatedServicio : s))
+      toast.success('Servicio actualizado exitosamente')
+      return updatedServicio
+    } catch (err) {
+      const errorMessage = err instanceof Error ? err.message : 'Error desconocido'
+      toast.error(errorMessage)
+      throw err
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const deleteServicio = async (id: string) => {
+    setLoading(true)
+    try {
+      const response = await fetch(`/api/servicios/${id}`, {
+        method: 'DELETE',
+      })
+
+      if (!response.ok) {
+        const errorData = await response.json()
+        throw new Error(errorData.error || 'Error al eliminar servicio')
+      }
+
+      setServicios(prev => prev.filter(s => s.id !== id))
+      toast.success('Servicio eliminado exitosamente')
+    } catch (err) {
+      const errorMessage = err instanceof Error ? err.message : 'Error desconocido'
+      toast.error(errorMessage)
+      throw err
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const getServicioById = async (id: string): Promise<Servicio | null> => {
+    try {
+      const response = await fetch(`/api/servicios/${id}`)
+      if (!response.ok) {
+        if (response.status === 404) {
+          return null
+        }
+        throw new Error('Error al obtener servicio')
+      }
+      return await response.json()
+    } catch (err) {
+      const errorMessage = err instanceof Error ? err.message : 'Error desconocido'
+      toast.error(errorMessage)
+      return null
+    }
+  }
+
+  return {
+    servicios,
+    loading,
+    error,
+    fetchServicios,
+    createServicio,
+    updateServicio,
+    deleteServicio,
+    getServicioById,
+  }
+}

+ 1 - 0
src/types/factura.ts

@@ -33,6 +33,7 @@ export interface InfoFactura {
 
 export interface DetalleItem {
   id: string
+  servicioId?: string // ID del servicio seleccionado (opcional)
   codigoPrincipal: string
   codigoAuxiliar: string
   descripcion: string

+ 22 - 0
src/types/servicio.ts

@@ -0,0 +1,22 @@
+export interface Servicio {
+  id: string
+  codigoPrincipal: string
+  codigoAuxiliar: string | null
+  descripcion: string
+  precioUnitario: string
+  codigoPorcentaje: string
+  tarifa: string
+  activo: boolean
+  createdAt: string
+  updatedAt: string
+}
+
+export interface ServicioInput {
+  codigoPrincipal: string
+  codigoAuxiliar?: string
+  descripcion: string
+  precioUnitario: string
+  codigoPorcentaje?: string
+  tarifa?: string
+  activo?: boolean
+}