feat: Initial commit - Mexus App

Sistema de Gestión de Obras de Construcción completo con:
- Dashboard con KPIs y gráficos
- Módulo de obras con fases y tareas
- Control financiero (gastos, presupuestos)
- Gestión de recursos (personal, subcontratistas)
- Inventario de materiales con alertas de stock
- Reportes con exportación CSV
- Autenticación con roles (NextAuth.js v5)
- API REST completa
- Documentación de API y base de datos
- Configuración Docker para despliegue

Stack: Next.js 14+, TypeScript, Tailwind CSS, Prisma, PostgreSQL

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mexus
2026-01-19 01:10:55 +00:00
commit 86bfbd2039
82 changed files with 18845 additions and 0 deletions

11
src/app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,11 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
<div className="w-full max-w-md p-4">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { loginSchema, type LoginInput } from "@/lib/validations";
import { Building2, Loader2 } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginInput) => {
setIsLoading(true);
setError(null);
try {
const result = await signIn("credentials", {
email: data.email,
password: data.password,
redirect: false,
});
if (result?.error) {
setError("Credenciales invalidas. Verifica tu email y contrasena.");
} else {
router.push("/dashboard");
router.refresh();
}
} catch {
setError("Ocurrio un error. Intenta de nuevo.");
} finally {
setIsLoading(false);
}
};
return (
<Card>
<CardHeader className="space-y-1">
<div className="flex items-center justify-center mb-4">
<div className="p-3 bg-primary rounded-full">
<Building2 className="h-8 w-8 text-primary-foreground" />
</div>
</div>
<CardTitle className="text-2xl text-center">Iniciar Sesion</CardTitle>
<CardDescription className="text-center">
Sistema de Gestion de Obras de Construccion
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Correo electronico</Label>
<Input
id="email"
type="email"
placeholder="tu@email.com"
{...register("email")}
/>
{errors.email && (
<p className="text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Contrasena</Label>
<Input id="password" type="password" {...register("password")} />
{errors.password && (
<p className="text-sm text-red-600">{errors.password.message}</p>
)}
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Iniciar Sesion
</Button>
<p className="text-sm text-center text-muted-foreground">
No tienes cuenta?{" "}
<Link href="/registro" className="text-primary hover:underline">
Registrate aqui
</Link>
</p>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -0,0 +1,163 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { registerSchema, type RegisterInput } from "@/lib/validations";
import { Building2, Loader2 } from "lucide-react";
export default function RegistroPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterInput>({
resolver: zodResolver(registerSchema),
});
const onSubmit = async (data: RegisterInput) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const result = await response.json();
if (!response.ok) {
setError(result.error || "Error al registrar usuario");
} else {
router.push("/login?registered=true");
}
} catch {
setError("Ocurrio un error. Intenta de nuevo.");
} finally {
setIsLoading(false);
}
};
return (
<Card>
<CardHeader className="space-y-1">
<div className="flex items-center justify-center mb-4">
<div className="p-3 bg-primary rounded-full">
<Building2 className="h-8 w-8 text-primary-foreground" />
</div>
</div>
<CardTitle className="text-2xl text-center">Crear Cuenta</CardTitle>
<CardDescription className="text-center">
Registra tu empresa para comenzar
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="nombre">Nombre</Label>
<Input id="nombre" placeholder="Juan" {...register("nombre")} />
{errors.nombre && (
<p className="text-sm text-red-600">{errors.nombre.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="apellido">Apellido</Label>
<Input
id="apellido"
placeholder="Perez"
{...register("apellido")}
/>
{errors.apellido && (
<p className="text-sm text-red-600">
{errors.apellido.message}
</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="empresaNombre">Nombre de la Empresa</Label>
<Input
id="empresaNombre"
placeholder="Constructora ABC"
{...register("empresaNombre")}
/>
{errors.empresaNombre && (
<p className="text-sm text-red-600">
{errors.empresaNombre.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Correo electronico</Label>
<Input
id="email"
type="email"
placeholder="tu@email.com"
{...register("email")}
/>
{errors.email && (
<p className="text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Contrasena</Label>
<Input id="password" type="password" {...register("password")} />
{errors.password && (
<p className="text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirmar Contrasena</Label>
<Input
id="confirmPassword"
type="password"
{...register("confirmPassword")}
/>
{errors.confirmPassword && (
<p className="text-sm text-red-600">
{errors.confirmPassword.message}
</p>
)}
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Crear Cuenta
</Button>
<p className="text-sm text-center text-muted-foreground">
Ya tienes cuenta?{" "}
<Link href="/login" className="text-primary hover:underline">
Inicia sesion
</Link>
</p>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -0,0 +1,297 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
Building2,
DollarSign,
TrendingUp,
CheckCircle2,
Clock,
AlertTriangle,
} from "lucide-react";
import {
formatCurrency,
formatPercentage,
formatDateShort,
} from "@/lib/utils";
import {
ESTADO_OBRA_LABELS,
ESTADO_OBRA_COLORS,
CATEGORIA_GASTO_LABELS,
type EstadoObra,
type CategoriaGasto,
} from "@/types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
Legend,
} from "recharts";
import Link from "next/link";
interface DashboardData {
stats: {
obrasActivas: number;
presupuestoTotal: number;
gastoTotal: number;
avancePromedio: number;
obrasCompletadas: number;
gastoPendiente: number;
};
obrasRecientes: {
id: string;
nombre: string;
estado: EstadoObra;
porcentajeAvance: number;
presupuestoTotal: number;
gastoTotal: number;
fechaInicio: Date | null;
fechaFinPrevista: Date | null;
}[];
gastosPorMes: {
mes: string;
gastos: number;
presupuesto: number;
}[];
gastosPorCategoria: {
categoria: string;
total: number;
porcentaje: number;
}[];
}
const COLORS = [
"#0088FE",
"#00C49F",
"#FFBB28",
"#FF8042",
"#8884d8",
"#82ca9d",
"#ffc658",
"#ff7300",
];
export function DashboardClient({ data }: { data: DashboardData }) {
const { stats, obrasRecientes, gastosPorMes, gastosPorCategoria } = data;
return (
<div className="space-y-6">
<div>
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
<p className="text-muted-foreground">
Resumen general de tus obras y finanzas
</p>
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Obras Activas</CardTitle>
<Building2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.obrasActivas}</div>
<p className="text-xs text-muted-foreground">
{stats.obrasCompletadas} completadas
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Presupuesto Total
</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(stats.presupuestoTotal)}
</div>
<p className="text-xs text-muted-foreground">
Gastado: {formatCurrency(stats.gastoTotal)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Avance Promedio
</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatPercentage(stats.avancePromedio)}
</div>
<Progress value={stats.avancePromedio} className="mt-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Gastos Pendientes
</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(stats.gastoPendiente)}
</div>
<p className="text-xs text-muted-foreground">Por aprobar</p>
</CardContent>
</Card>
</div>
{/* Charts */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Gastos Mensuales</CardTitle>
</CardHeader>
<CardContent>
{gastosPorMes.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={gastosPorMes}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="mes" />
<YAxis
tickFormatter={(value) =>
`$${(value / 1000).toFixed(0)}k`
}
/>
<Tooltip
formatter={(value: number) => formatCurrency(value)}
/>
<Bar dataKey="gastos" fill="#0088FE" name="Gastos" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
No hay datos de gastos
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Distribucion por Categoria</CardTitle>
</CardHeader>
<CardContent>
{gastosPorCategoria.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={gastosPorCategoria}
cx="50%"
cy="50%"
labelLine={false}
label={({ categoria, porcentaje }) =>
`${CATEGORIA_GASTO_LABELS[categoria as CategoriaGasto] || categoria} ${porcentaje.toFixed(0)}%`
}
outerRadius={80}
fill="#8884d8"
dataKey="total"
>
{gastosPorCategoria.map((_, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value: number) => formatCurrency(value)}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
No hay datos de categorias
</div>
)}
</CardContent>
</Card>
</div>
{/* Recent Projects */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Obras Recientes</CardTitle>
<Link
href="/obras"
className="text-sm text-primary hover:underline"
>
Ver todas
</Link>
</CardHeader>
<CardContent>
{obrasRecientes.length > 0 ? (
<div className="space-y-4">
{obrasRecientes.map((obra) => (
<div
key={obra.id}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="space-y-1">
<Link
href={`/obras/${obra.id}`}
className="font-medium hover:underline"
>
{obra.nombre}
</Link>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge className={ESTADO_OBRA_COLORS[obra.estado]}>
{ESTADO_OBRA_LABELS[obra.estado]}
</Badge>
{obra.fechaFinPrevista && (
<span>
Fin previsto:{" "}
{formatDateShort(obra.fechaFinPrevista)}
</span>
)}
</div>
</div>
<div className="text-right">
<div className="text-sm font-medium">
{formatPercentage(obra.porcentajeAvance)}
</div>
<Progress
value={obra.porcentajeAvance}
className="mt-1 w-24"
/>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertTriangle className="mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-muted-foreground">No tienes obras registradas</p>
<Link
href="/obras/nueva"
className="mt-2 text-sm text-primary hover:underline"
>
Crear primera obra
</Link>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { DashboardClient } from "./dashboard-client";
async function getDashboardData(empresaId: string) {
const [obras, gastos, obrasResumen] = await Promise.all([
prisma.obra.findMany({
where: { empresaId },
select: {
id: true,
nombre: true,
estado: true,
porcentajeAvance: true,
presupuestoTotal: true,
gastoTotal: true,
fechaInicio: true,
fechaFinPrevista: true,
},
orderBy: { updatedAt: "desc" },
take: 5,
}),
prisma.gasto.findMany({
where: { obra: { empresaId } },
select: {
monto: true,
categoria: true,
fecha: true,
estado: true,
},
}),
prisma.obra.aggregate({
where: { empresaId },
_count: { _all: true },
_sum: {
presupuestoTotal: true,
gastoTotal: true,
},
_avg: {
porcentajeAvance: true,
},
}),
]);
const obrasActivas = await prisma.obra.count({
where: {
empresaId,
estado: { in: ["EN_PROGRESO", "PLANIFICACION"] },
},
});
const obrasCompletadas = await prisma.obra.count({
where: { empresaId, estado: "COMPLETADA" },
});
const gastosPendientes = await prisma.gasto.aggregate({
where: {
obra: { empresaId },
estado: "PENDIENTE",
},
_sum: { monto: true },
});
// Gastos por mes (ultimos 6 meses)
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
const gastosMensuales = await prisma.gasto.groupBy({
by: ["fecha"],
where: {
obra: { empresaId },
fecha: { gte: sixMonthsAgo },
},
_sum: { monto: true },
});
// Agrupar por mes
const gastosPorMes = gastosMensuales.reduce(
(acc, g) => {
const mes = new Date(g.fecha).toLocaleDateString("es-MX", {
month: "short",
year: "2-digit",
});
acc[mes] = (acc[mes] || 0) + (g._sum.monto || 0);
return acc;
},
{} as Record<string, number>
);
// Gastos por categoria
const gastosPorCategoria = gastos.reduce(
(acc, g) => {
acc[g.categoria] = (acc[g.categoria] || 0) + g.monto;
return acc;
},
{} as Record<string, number>
);
const totalGastos = Object.values(gastosPorCategoria).reduce(
(a, b) => a + b,
0
);
return {
stats: {
obrasActivas,
presupuestoTotal: obrasResumen._sum.presupuestoTotal || 0,
gastoTotal: obrasResumen._sum.gastoTotal || 0,
avancePromedio: obrasResumen._avg.porcentajeAvance || 0,
obrasCompletadas,
gastoPendiente: gastosPendientes._sum.monto || 0,
},
obrasRecientes: obras,
gastosPorMes: Object.entries(gastosPorMes).map(([mes, gastos]) => ({
mes,
gastos,
presupuesto: 0,
})),
gastosPorCategoria: Object.entries(gastosPorCategoria).map(
([categoria, total]) => ({
categoria,
total,
porcentaje: totalGastos > 0 ? (total / totalGastos) * 100 : 0,
})
),
};
}
export default async function DashboardPage() {
const session = await auth();
if (!session?.user?.empresaId) {
return <div>Error: No se encontro la empresa</div>;
}
const dashboardData = await getDashboardData(session.user.empresaId);
return <DashboardClient data={dashboardData} />;
}

View File

@@ -0,0 +1,500 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Plus,
DollarSign,
Clock,
CheckCircle,
MoreVertical,
Check,
X,
Loader2,
} from "lucide-react";
import { formatCurrency, formatDateShort } from "@/lib/utils";
import {
ESTADO_GASTO_LABELS,
ESTADO_GASTO_COLORS,
CATEGORIA_GASTO_LABELS,
type EstadoGasto,
type CategoriaGasto,
type Role,
} from "@/types";
import { toast } from "@/hooks/use-toast";
interface Gasto {
id: string;
concepto: string;
descripcion: string | null;
monto: number;
fecha: Date;
categoria: CategoriaGasto;
estado: EstadoGasto;
notas: string | null;
obra: { id: string; nombre: string };
creadoPor: { nombre: string; apellido: string };
aprobadoPor: { nombre: string; apellido: string } | null;
}
interface FinanzasData {
gastos: Gasto[];
obras: { id: string; nombre: string; presupuestoTotal: number; gastoTotal: number }[];
resumen: {
totalGastos: number;
gastosPendientes: number;
gastosAprobados: number;
gastosPagados: number;
countPendientes: number;
};
}
export function FinanzasClient({
data,
userRole,
}: {
data: FinanzasData;
userRole: Role;
}) {
const router = useRouter();
const [isCreating, setIsCreating] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [filterObra, setFilterObra] = useState<string>("all");
const [filterEstado, setFilterEstado] = useState<string>("all");
const [formData, setFormData] = useState({
concepto: "",
descripcion: "",
monto: "",
fecha: new Date().toISOString().split("T")[0],
categoria: "MATERIALES" as CategoriaGasto,
obraId: "",
notas: "",
});
const canApprove = ["ADMIN", "GERENTE", "CONTADOR"].includes(userRole);
const filteredGastos = data.gastos.filter((gasto) => {
if (filterObra !== "all" && gasto.obra.id !== filterObra) return false;
if (filterEstado !== "all" && gasto.estado !== filterEstado) return false;
return true;
});
const handleCreateGasto = async () => {
if (!formData.obraId || !formData.concepto || !formData.monto) {
toast({
title: "Error",
description: "Completa los campos requeridos",
variant: "destructive",
});
return;
}
setIsCreating(true);
try {
const response = await fetch("/api/gastos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...formData,
monto: parseFloat(formData.monto),
}),
});
if (!response.ok) throw new Error("Error al crear");
toast({ title: "Gasto registrado exitosamente" });
setIsDialogOpen(false);
setFormData({
concepto: "",
descripcion: "",
monto: "",
fecha: new Date().toISOString().split("T")[0],
categoria: "MATERIALES",
obraId: "",
notas: "",
});
router.refresh();
} catch {
toast({
title: "Error",
description: "No se pudo registrar el gasto",
variant: "destructive",
});
} finally {
setIsCreating(false);
}
};
const handleApprove = async (id: string, estado: "APROBADO" | "RECHAZADO") => {
try {
const response = await fetch(`/api/gastos/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ estado }),
});
if (!response.ok) throw new Error("Error al actualizar");
toast({
title: estado === "APROBADO" ? "Gasto aprobado" : "Gasto rechazado",
});
router.refresh();
} catch {
toast({
title: "Error",
description: "No se pudo actualizar el gasto",
variant: "destructive",
});
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Finanzas</h2>
<p className="text-muted-foreground">
Control de gastos y presupuestos
</p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Registrar Gasto
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Nuevo Gasto</DialogTitle>
<DialogDescription>
Registra un nuevo gasto para una obra
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label>Obra *</Label>
<Select
value={formData.obraId}
onValueChange={(value) =>
setFormData({ ...formData, obraId: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Seleccionar obra" />
</SelectTrigger>
<SelectContent>
{data.obras.map((obra) => (
<SelectItem key={obra.id} value={obra.id}>
{obra.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Concepto *</Label>
<Input
value={formData.concepto}
onChange={(e) =>
setFormData({ ...formData, concepto: e.target.value })
}
placeholder="Ej: Compra de cemento"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Monto *</Label>
<Input
type="number"
value={formData.monto}
onChange={(e) =>
setFormData({ ...formData, monto: e.target.value })
}
placeholder="0.00"
/>
</div>
<div className="space-y-2">
<Label>Fecha</Label>
<Input
type="date"
value={formData.fecha}
onChange={(e) =>
setFormData({ ...formData, fecha: e.target.value })
}
/>
</div>
</div>
<div className="space-y-2">
<Label>Categoria</Label>
<Select
value={formData.categoria}
onValueChange={(value) =>
setFormData({
...formData,
categoria: value as CategoriaGasto,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(CATEGORIA_GASTO_LABELS).map(
([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Notas</Label>
<Textarea
value={formData.notas}
onChange={(e) =>
setFormData({ ...formData, notas: e.target.value })
}
placeholder="Notas adicionales..."
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
disabled={isCreating}
>
Cancelar
</Button>
<Button onClick={handleCreateGasto} disabled={isCreating}>
{isCreating && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Registrar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Gastos</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(data.resumen.totalGastos)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pendientes</CardTitle>
<Clock className="h-4 w-4 text-yellow-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(data.resumen.gastosPendientes)}
</div>
<p className="text-xs text-muted-foreground">
{data.resumen.countPendientes} por aprobar
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Aprobados</CardTitle>
<CheckCircle className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(data.resumen.gastosAprobados)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pagados</CardTitle>
<CheckCircle className="h-4 w-4 text-blue-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(data.resumen.gastosPagados)}
</div>
</CardContent>
</Card>
</div>
{/* Filters and Table */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Registro de Gastos</CardTitle>
<div className="flex gap-2">
<Select value={filterObra} onValueChange={setFilterObra}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filtrar por obra" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas las obras</SelectItem>
{data.obras.map((obra) => (
<SelectItem key={obra.id} value={obra.id}>
{obra.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterEstado} onValueChange={setFilterEstado}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Estado" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
{Object.entries(ESTADO_GASTO_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Fecha</TableHead>
<TableHead>Concepto</TableHead>
<TableHead>Obra</TableHead>
<TableHead>Categoria</TableHead>
<TableHead>Monto</TableHead>
<TableHead>Estado</TableHead>
<TableHead className="w-[100px]">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredGastos.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
No se encontraron gastos
</TableCell>
</TableRow>
) : (
filteredGastos.map((gasto) => (
<TableRow key={gasto.id}>
<TableCell>{formatDateShort(gasto.fecha)}</TableCell>
<TableCell>
<div>
<p className="font-medium">{gasto.concepto}</p>
<p className="text-xs text-muted-foreground">
{gasto.creadoPor.nombre} {gasto.creadoPor.apellido}
</p>
</div>
</TableCell>
<TableCell>{gasto.obra.nombre}</TableCell>
<TableCell>
{CATEGORIA_GASTO_LABELS[gasto.categoria]}
</TableCell>
<TableCell className="font-medium">
{formatCurrency(gasto.monto)}
</TableCell>
<TableCell>
<Badge className={ESTADO_GASTO_COLORS[gasto.estado]}>
{ESTADO_GASTO_LABELS[gasto.estado]}
</Badge>
</TableCell>
<TableCell>
{canApprove && gasto.estado === "PENDIENTE" ? (
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleApprove(gasto.id, "APROBADO")}
>
<Check className="h-4 w-4 text-green-600" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleApprove(gasto.id, "RECHAZADO")}
>
<X className="h-4 w-4 text-red-600" />
</Button>
</div>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Ver detalles</DropdownMenuItem>
<DropdownMenuItem>Editar</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { FinanzasClient } from "./finanzas-client";
async function getFinanzasData(empresaId: string) {
const [gastos, obras, totales] = await Promise.all([
prisma.gasto.findMany({
where: { obra: { empresaId } },
include: {
obra: { select: { id: true, nombre: true } },
creadoPor: { select: { nombre: true, apellido: true } },
aprobadoPor: { select: { nombre: true, apellido: true } },
},
orderBy: { fecha: "desc" },
}),
prisma.obra.findMany({
where: { empresaId },
select: {
id: true,
nombre: true,
presupuestoTotal: true,
gastoTotal: true,
},
}),
prisma.gasto.groupBy({
by: ["estado"],
where: { obra: { empresaId } },
_sum: { monto: true },
_count: true,
}),
]);
const resumen = {
totalGastos: gastos.reduce((sum, g) => sum + g.monto, 0),
gastosPendientes:
totales.find((t) => t.estado === "PENDIENTE")?._sum.monto || 0,
gastosAprobados:
totales.find((t) => t.estado === "APROBADO")?._sum.monto || 0,
gastosPagados: totales.find((t) => t.estado === "PAGADO")?._sum.monto || 0,
countPendientes:
totales.find((t) => t.estado === "PENDIENTE")?._count || 0,
};
return { gastos, obras, resumen };
}
export default async function FinanzasPage() {
const session = await auth();
if (!session?.user?.empresaId) {
return <div>Error: No se encontro la empresa</div>;
}
const data = await getFinanzasData(session.user.empresaId);
return <FinanzasClient data={data} userRole={session.user.role} />;
}

View File

@@ -0,0 +1,18 @@
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-auto bg-slate-50 p-6">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { ObraForm } from "@/components/forms/obra-form";
import Link from "next/link";
import { ChevronLeft } from "lucide-react";
async function getObraWithFormData(id: string, empresaId: string) {
const [obra, clientes, supervisores] = await Promise.all([
prisma.obra.findFirst({
where: { id, empresaId },
select: {
id: true,
nombre: true,
descripcion: true,
direccion: true,
estado: true,
fechaInicio: true,
fechaFinPrevista: true,
clienteId: true,
supervisorId: true,
},
}),
prisma.cliente.findMany({
where: { empresaId },
select: { id: true, nombre: true },
orderBy: { nombre: "asc" },
}),
prisma.user.findMany({
where: {
empresaId,
role: { in: ["ADMIN", "GERENTE", "SUPERVISOR"] },
activo: true,
},
select: { id: true, nombre: true, apellido: true },
orderBy: { nombre: "asc" },
}),
]);
return { obra, clientes, supervisores };
}
export default async function EditarObraPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await auth();
const { id } = await params;
if (!session?.user?.empresaId) {
return <div>Error: No se encontro la empresa</div>;
}
const { obra, clientes, supervisores } = await getObraWithFormData(
id,
session.user.empresaId
);
if (!obra) {
notFound();
}
return (
<div className="space-y-6">
<div>
<Link
href={`/obras/${id}`}
className="flex items-center text-sm text-muted-foreground hover:text-foreground mb-2"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Volver a detalles
</Link>
<h2 className="text-3xl font-bold tracking-tight">Editar Obra</h2>
<p className="text-muted-foreground">{obra.nombre}</p>
</div>
<ObraForm obra={obra} clientes={clientes} supervisores={supervisores} />
</div>
);
}

View File

@@ -0,0 +1,490 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
ChevronLeft,
Edit,
MapPin,
Calendar,
User,
Building,
DollarSign,
Clock,
CheckCircle2,
AlertCircle,
} from "lucide-react";
import {
formatCurrency,
formatPercentage,
formatDate,
formatDateShort,
} from "@/lib/utils";
import {
ESTADO_OBRA_LABELS,
ESTADO_OBRA_COLORS,
ESTADO_TAREA_LABELS,
ESTADO_GASTO_LABELS,
ESTADO_GASTO_COLORS,
CATEGORIA_GASTO_LABELS,
type EstadoObra,
type EstadoTarea,
type EstadoGasto,
type CategoriaGasto,
} from "@/types";
interface ObraDetailProps {
obra: {
id: string;
nombre: string;
descripcion: string | null;
direccion: string;
estado: EstadoObra;
porcentajeAvance: number;
presupuestoTotal: number;
gastoTotal: number;
fechaInicio: Date | null;
fechaFinPrevista: Date | null;
fechaFinReal: Date | null;
cliente: { id: string; nombre: string; email: string | null } | null;
supervisor: {
id: string;
nombre: string;
apellido: string;
email: string | null;
} | null;
fases: {
id: string;
nombre: string;
descripcion: string | null;
orden: number;
porcentajeAvance: number;
tareas: {
id: string;
nombre: string;
estado: EstadoTarea;
prioridad: number;
asignado: { nombre: string; apellido: string } | null;
}[];
}[];
presupuestos: {
id: string;
nombre: string;
total: number;
aprobado: boolean;
partidas: {
id: string;
codigo: string;
descripcion: string;
total: number;
}[];
}[];
gastos: {
id: string;
concepto: string;
monto: number;
fecha: Date;
categoria: CategoriaGasto;
estado: EstadoGasto;
creadoPor: { nombre: string; apellido: string };
}[];
registrosAvance: {
id: string;
descripcion: string;
porcentaje: number;
fotos: string[];
createdAt: Date;
registradoPor: { nombre: string; apellido: string };
}[];
};
}
export function ObraDetailClient({ obra }: ObraDetailProps) {
const variacion = obra.presupuestoTotal - obra.gastoTotal;
const variacionPorcentaje =
obra.presupuestoTotal > 0
? ((obra.gastoTotal / obra.presupuestoTotal) * 100).toFixed(1)
: 0;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<Link
href="/obras"
className="flex items-center text-sm text-muted-foreground hover:text-foreground mb-2"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Volver a obras
</Link>
<h2 className="text-3xl font-bold tracking-tight">{obra.nombre}</h2>
<div className="flex items-center gap-4 mt-2">
<Badge className={ESTADO_OBRA_COLORS[obra.estado]}>
{ESTADO_OBRA_LABELS[obra.estado]}
</Badge>
<span className="flex items-center text-sm text-muted-foreground">
<MapPin className="h-4 w-4 mr-1" />
{obra.direccion}
</span>
</div>
</div>
<Link href={`/obras/${obra.id}/editar`}>
<Button>
<Edit className="h-4 w-4 mr-2" />
Editar
</Button>
</Link>
</div>
{/* Progress and Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Avance</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatPercentage(obra.porcentajeAvance)}
</div>
<Progress value={obra.porcentajeAvance} className="mt-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Presupuesto</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(obra.presupuestoTotal)}
</div>
<p className="text-xs text-muted-foreground">Total aprobado</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Gastado</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(obra.gastoTotal)}
</div>
<p className="text-xs text-muted-foreground">
{variacionPorcentaje}% del presupuesto
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Variacion</CardTitle>
</CardHeader>
<CardContent>
<div
className={`text-2xl font-bold ${variacion >= 0 ? "text-green-600" : "text-red-600"}`}
>
{variacion >= 0 ? "+" : ""}
{formatCurrency(variacion)}
</div>
<p className="text-xs text-muted-foreground">
{variacion >= 0 ? "Bajo presupuesto" : "Sobre presupuesto"}
</p>
</CardContent>
</Card>
</div>
{/* Tabs */}
<Tabs defaultValue="general" className="space-y-4">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="cronograma">Cronograma</TabsTrigger>
<TabsTrigger value="presupuesto">Presupuesto</TabsTrigger>
<TabsTrigger value="gastos">Gastos</TabsTrigger>
<TabsTrigger value="avances">Avances</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Informacion del Proyecto</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{obra.descripcion && (
<div>
<p className="text-sm font-medium text-muted-foreground">
Descripcion
</p>
<p>{obra.descripcion}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Calendar className="h-4 w-4" />
Fecha Inicio
</p>
<p>
{obra.fechaInicio
? formatDate(obra.fechaInicio)
: "No definida"}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Calendar className="h-4 w-4" />
Fecha Fin Prevista
</p>
<p>
{obra.fechaFinPrevista
? formatDate(obra.fechaFinPrevista)
: "No definida"}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Equipo</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Building className="h-4 w-4" />
Cliente
</p>
<p>{obra.cliente?.nombre || "Sin cliente asignado"}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<User className="h-4 w-4" />
Supervisor
</p>
<p>
{obra.supervisor
? `${obra.supervisor.nombre} ${obra.supervisor.apellido}`
: "Sin supervisor asignado"}
</p>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="cronograma" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Fases y Tareas</h3>
<Button variant="outline" size="sm">
Agregar Fase
</Button>
</div>
{obra.fases.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay fases definidas para esta obra
</CardContent>
</Card>
) : (
<div className="space-y-4">
{obra.fases.map((fase) => (
<Card key={fase.id}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{fase.nombre}</CardTitle>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{formatPercentage(fase.porcentajeAvance)}
</span>
<Progress
value={fase.porcentajeAvance}
className="w-24"
/>
</div>
</div>
{fase.descripcion && (
<CardDescription>{fase.descripcion}</CardDescription>
)}
</CardHeader>
<CardContent>
{fase.tareas.length === 0 ? (
<p className="text-sm text-muted-foreground">
Sin tareas asignadas
</p>
) : (
<div className="space-y-2">
{fase.tareas.map((tarea) => (
<div
key={tarea.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-2">
{tarea.estado === "COMPLETADA" ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : tarea.estado === "BLOQUEADA" ? (
<AlertCircle className="h-4 w-4 text-red-600" />
) : (
<Clock className="h-4 w-4 text-muted-foreground" />
)}
<span>{tarea.nombre}</span>
</div>
<div className="flex items-center gap-2">
{tarea.asignado && (
<span className="text-sm text-muted-foreground">
{tarea.asignado.nombre}
</span>
)}
<Badge variant="outline">
{ESTADO_TAREA_LABELS[tarea.estado]}
</Badge>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
<TabsContent value="presupuesto" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Presupuestos</h3>
<Button variant="outline" size="sm">
Nuevo Presupuesto
</Button>
</div>
{obra.presupuestos.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay presupuestos definidos
</CardContent>
</Card>
) : (
<div className="space-y-4">
{obra.presupuestos.map((presupuesto) => (
<Card key={presupuesto.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">
{presupuesto.nombre}
</CardTitle>
<CardDescription>
{presupuesto.partidas.length} partidas
</CardDescription>
</div>
<div className="text-right">
<p className="text-xl font-bold">
{formatCurrency(presupuesto.total)}
</p>
<Badge
variant={presupuesto.aprobado ? "default" : "outline"}
>
{presupuesto.aprobado ? "Aprobado" : "Pendiente"}
</Badge>
</div>
</div>
</CardHeader>
</Card>
))}
</div>
)}
</TabsContent>
<TabsContent value="gastos" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Gastos Recientes</h3>
<Link href={`/finanzas?obra=${obra.id}`}>
<Button variant="outline" size="sm">
Ver todos los gastos
</Button>
</Link>
</div>
{obra.gastos.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay gastos registrados
</CardContent>
</Card>
) : (
<div className="space-y-2">
{obra.gastos.map((gasto) => (
<Card key={gasto.id}>
<CardContent className="flex items-center justify-between py-4">
<div>
<p className="font-medium">{gasto.concepto}</p>
<p className="text-sm text-muted-foreground">
{CATEGORIA_GASTO_LABELS[gasto.categoria]} -{" "}
{formatDateShort(gasto.fecha)}
</p>
</div>
<div className="text-right">
<p className="font-bold">{formatCurrency(gasto.monto)}</p>
<Badge className={ESTADO_GASTO_COLORS[gasto.estado]}>
{ESTADO_GASTO_LABELS[gasto.estado]}
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
<TabsContent value="avances" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Registros de Avance</h3>
<Button variant="outline" size="sm">
Registrar Avance
</Button>
</div>
{obra.registrosAvance.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay registros de avance
</CardContent>
</Card>
) : (
<div className="space-y-4">
{obra.registrosAvance.map((registro) => (
<Card key={registro.id}>
<CardContent className="py-4">
<div className="flex items-start justify-between">
<div>
<p className="font-medium">{registro.descripcion}</p>
<p className="text-sm text-muted-foreground">
{registro.registradoPor.nombre}{" "}
{registro.registradoPor.apellido} -{" "}
{formatDate(registro.createdAt)}
</p>
</div>
<Badge variant="outline">
{formatPercentage(registro.porcentaje)}
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { ObraDetailClient } from "./obra-detail-client";
async function getObra(id: string, empresaId: string) {
return await prisma.obra.findFirst({
where: { id, empresaId },
include: {
cliente: true,
supervisor: {
select: { id: true, nombre: true, apellido: true, email: true },
},
fases: {
include: {
tareas: {
include: {
asignado: { select: { id: true, nombre: true, apellido: true } },
},
orderBy: { prioridad: "desc" },
},
},
orderBy: { orden: "asc" },
},
presupuestos: {
include: { partidas: true },
orderBy: { createdAt: "desc" },
},
gastos: {
orderBy: { fecha: "desc" },
take: 10,
include: {
creadoPor: { select: { nombre: true, apellido: true } },
},
},
registrosAvance: {
orderBy: { createdAt: "desc" },
take: 5,
include: {
registradoPor: { select: { nombre: true, apellido: true } },
},
},
},
});
}
export default async function ObraDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await auth();
const { id } = await params;
if (!session?.user?.empresaId) {
return <div>Error: No se encontro la empresa</div>;
}
const obra = await getObra(id, session.user.empresaId);
if (!obra) {
notFound();
}
return <ObraDetailClient obra={obra} />;
}

View File

@@ -0,0 +1,56 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { ObraForm } from "@/components/forms/obra-form";
import Link from "next/link";
import { ChevronLeft } from "lucide-react";
async function getFormData(empresaId: string) {
const [clientes, supervisores] = await Promise.all([
prisma.cliente.findMany({
where: { empresaId },
select: { id: true, nombre: true },
orderBy: { nombre: "asc" },
}),
prisma.user.findMany({
where: {
empresaId,
role: { in: ["ADMIN", "GERENTE", "SUPERVISOR"] },
activo: true,
},
select: { id: true, nombre: true, apellido: true },
orderBy: { nombre: "asc" },
}),
]);
return { clientes, supervisores };
}
export default async function NuevaObraPage() {
const session = await auth();
if (!session?.user?.empresaId) {
return <div>Error: No se encontro la empresa</div>;
}
const { clientes, supervisores } = await getFormData(session.user.empresaId);
return (
<div className="space-y-6">
<div>
<Link
href="/obras"
className="flex items-center text-sm text-muted-foreground hover:text-foreground mb-2"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Volver a obras
</Link>
<h2 className="text-3xl font-bold tracking-tight">Nueva Obra</h2>
<p className="text-muted-foreground">
Crea un nuevo proyecto de construccion
</p>
</div>
<ObraForm clientes={clientes} supervisores={supervisores} />
</div>
);
}

View File

@@ -0,0 +1,271 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import {
Plus,
Search,
MoreVertical,
Eye,
Edit,
Trash2,
MapPin,
Calendar,
Users,
} from "lucide-react";
import {
formatCurrency,
formatPercentage,
formatDateShort,
} from "@/lib/utils";
import {
ESTADO_OBRA_LABELS,
ESTADO_OBRA_COLORS,
type EstadoObra,
} from "@/types";
import { toast } from "@/hooks/use-toast";
interface Obra {
id: string;
nombre: string;
descripcion: string | null;
direccion: string;
estado: EstadoObra;
porcentajeAvance: number;
presupuestoTotal: number;
gastoTotal: number;
fechaInicio: Date | null;
fechaFinPrevista: Date | null;
cliente: { id: string; nombre: string } | null;
supervisor: { id: string; nombre: string; apellido: string } | null;
_count: { fases: number; gastos: number };
}
export function ObrasClient({ obras }: { obras: Obra[] }) {
const router = useRouter();
const [search, setSearch] = useState("");
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const filteredObras = obras.filter(
(obra) =>
obra.nombre.toLowerCase().includes(search.toLowerCase()) ||
obra.direccion.toLowerCase().includes(search.toLowerCase())
);
const handleDelete = async () => {
if (!deleteId) return;
setIsDeleting(true);
try {
const response = await fetch(`/api/obras/${deleteId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Error al eliminar");
}
toast({
title: "Obra eliminada",
description: "La obra ha sido eliminada exitosamente",
});
router.refresh();
} catch {
toast({
title: "Error",
description: "No se pudo eliminar la obra",
variant: "destructive",
});
} finally {
setIsDeleting(false);
setDeleteId(null);
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Obras</h2>
<p className="text-muted-foreground">
Gestiona tus proyectos de construccion
</p>
</div>
<Link href="/obras/nueva">
<Button>
<Plus className="mr-2 h-4 w-4" />
Nueva Obra
</Button>
</Link>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Buscar obras..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
{filteredObras.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground mb-4">
{obras.length === 0
? "No tienes obras registradas"
: "No se encontraron resultados"}
</p>
{obras.length === 0 && (
<Link href="/obras/nueva">
<Button>
<Plus className="mr-2 h-4 w-4" />
Crear primera obra
</Button>
</Link>
)}
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredObras.map((obra) => (
<Card key={obra.id} className="overflow-hidden">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="space-y-1">
<CardTitle className="line-clamp-1">{obra.nombre}</CardTitle>
<CardDescription className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
<span className="line-clamp-1">{obra.direccion}</span>
</CardDescription>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/obras/${obra.id}`}>
<Eye className="mr-2 h-4 w-4" />
Ver detalles
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/obras/${obra.id}/editar`}>
<Edit className="mr-2 h-4 w-4" />
Editar
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() => setDeleteId(obra.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Badge className={ESTADO_OBRA_COLORS[obra.estado]}>
{ESTADO_OBRA_LABELS[obra.estado]}
</Badge>
<span className="text-sm font-medium">
{formatPercentage(obra.porcentajeAvance)}
</span>
</div>
<Progress value={obra.porcentajeAvance} />
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<p className="text-muted-foreground">Presupuesto</p>
<p className="font-medium">
{formatCurrency(obra.presupuestoTotal)}
</p>
</div>
<div>
<p className="text-muted-foreground">Gastado</p>
<p className="font-medium">
{formatCurrency(obra.gastoTotal)}
</p>
</div>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
{obra.fechaInicio && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDateShort(obra.fechaInicio)}
</div>
)}
{obra.supervisor && (
<div className="flex items-center gap-1">
<Users className="h-3 w-3" />
{obra.supervisor.nombre}
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Eliminar obra</AlertDialogTitle>
<AlertDialogDescription>
Esta accion no se puede deshacer. Se eliminaran todos los datos
relacionados con esta obra (fases, gastos, presupuestos, etc.).
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? "Eliminando..." : "Eliminar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { ObrasClient } from "./obras-client";
async function getObras(empresaId: string) {
return await prisma.obra.findMany({
where: { empresaId },
include: {
cliente: { select: { id: true, nombre: true } },
supervisor: { select: { id: true, nombre: true, apellido: true } },
_count: {
select: { fases: true, gastos: true },
},
},
orderBy: { createdAt: "desc" },
});
}
export default async function ObrasPage() {
const session = await auth();
if (!session?.user?.empresaId) {
return <div>Error: No se encontro la empresa</div>;
}
const obras = await getObras(session.user.empresaId);
return <ObrasClient obras={obras} />;
}

View File

@@ -0,0 +1,635 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Plus,
Package,
AlertTriangle,
Loader2,
ChevronLeft,
MoreVertical,
Edit,
Trash2,
ArrowUpCircle,
ArrowDownCircle,
} from "lucide-react";
import { formatCurrency } from "@/lib/utils";
import { UNIDAD_MEDIDA_LABELS, type UnidadMedida } from "@/types";
import { toast } from "@/hooks/use-toast";
interface Material {
id: string;
codigo: string;
nombre: string;
descripcion: string | null;
unidad: UnidadMedida;
precioUnitario: number;
stockMinimo: number;
stockActual: number;
ubicacion: string | null;
activo: boolean;
movimientos: {
id: string;
tipo: string;
cantidad: number;
motivo: string | null;
createdAt: Date;
obra: { nombre: string } | null;
}[];
}
interface MaterialesData {
materiales: Material[];
obras: { id: string; nombre: string }[];
alertas: Material[];
}
export function MaterialesClient({ data }: { data: MaterialesData }) {
const router = useRouter();
const [isCreating, setIsCreating] = useState(false);
const [isMoving, setIsMoving] = useState(false);
const [isMaterialDialogOpen, setIsMaterialDialogOpen] = useState(false);
const [isMovimientoDialogOpen, setIsMovimientoDialogOpen] = useState(false);
const [selectedMaterial, setSelectedMaterial] = useState<Material | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [materialForm, setMaterialForm] = useState({
codigo: "",
nombre: "",
descripcion: "",
unidad: "UNIDAD" as UnidadMedida,
precioUnitario: "",
stockMinimo: "0",
ubicacion: "",
});
const [movimientoForm, setMovimientoForm] = useState({
tipo: "ENTRADA" as "ENTRADA" | "SALIDA" | "AJUSTE",
cantidad: "",
motivo: "",
obraId: "",
});
const resetMaterialForm = () => {
setMaterialForm({
codigo: "",
nombre: "",
descripcion: "",
unidad: "UNIDAD",
precioUnitario: "",
stockMinimo: "0",
ubicacion: "",
});
};
const handleCreateMaterial = async () => {
if (!materialForm.codigo || !materialForm.nombre || !materialForm.precioUnitario) {
toast({
title: "Error",
description: "Completa los campos requeridos",
variant: "destructive",
});
return;
}
setIsCreating(true);
try {
const response = await fetch("/api/materiales", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...materialForm,
precioUnitario: parseFloat(materialForm.precioUnitario),
stockMinimo: parseFloat(materialForm.stockMinimo),
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Error al crear");
}
toast({ title: "Material creado exitosamente" });
setIsMaterialDialogOpen(false);
resetMaterialForm();
router.refresh();
} catch (error: unknown) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "No se pudo crear el material",
variant: "destructive",
});
} finally {
setIsCreating(false);
}
};
const handleMovimiento = async () => {
if (!selectedMaterial || !movimientoForm.cantidad) {
toast({
title: "Error",
description: "Ingresa la cantidad",
variant: "destructive",
});
return;
}
setIsMoving(true);
try {
const response = await fetch("/api/materiales/movimiento", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
materialId: selectedMaterial.id,
tipo: movimientoForm.tipo,
cantidad: parseFloat(movimientoForm.cantidad),
motivo: movimientoForm.motivo,
obraId: movimientoForm.obraId || null,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Error al registrar movimiento");
}
toast({ title: "Movimiento registrado exitosamente" });
setIsMovimientoDialogOpen(false);
setSelectedMaterial(null);
setMovimientoForm({ tipo: "ENTRADA", cantidad: "", motivo: "", obraId: "" });
router.refresh();
} catch (error: unknown) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "No se pudo registrar el movimiento",
variant: "destructive",
});
} finally {
setIsMoving(false);
}
};
const handleDelete = async () => {
if (!deleteId) return;
setIsDeleting(true);
try {
const response = await fetch(`/api/materiales/${deleteId}`, {
method: "DELETE",
});
if (!response.ok) throw new Error("Error al eliminar");
toast({ title: "Material eliminado exitosamente" });
router.refresh();
} catch {
toast({
title: "Error",
description: "No se pudo eliminar el material",
variant: "destructive",
});
} finally {
setIsDeleting(false);
setDeleteId(null);
}
};
const openMovimientoDialog = (material: Material, tipo: "ENTRADA" | "SALIDA") => {
setSelectedMaterial(material);
setMovimientoForm({ tipo, cantidad: "", motivo: "", obraId: "" });
setIsMovimientoDialogOpen(true);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Link
href="/recursos"
className="flex items-center text-sm text-muted-foreground hover:text-foreground mb-2"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Volver a recursos
</Link>
<h2 className="text-3xl font-bold tracking-tight">Inventario de Materiales</h2>
<p className="text-muted-foreground">
Gestiona el stock y movimientos de materiales
</p>
</div>
<Dialog open={isMaterialDialogOpen} onOpenChange={setIsMaterialDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Nuevo Material
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Nuevo Material</DialogTitle>
<DialogDescription>
Agrega un nuevo material al inventario
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Codigo *</Label>
<Input
value={materialForm.codigo}
onChange={(e) =>
setMaterialForm({ ...materialForm, codigo: e.target.value })
}
placeholder="MAT-001"
/>
</div>
<div className="space-y-2">
<Label>Unidad</Label>
<Select
value={materialForm.unidad}
onValueChange={(value) =>
setMaterialForm({ ...materialForm, unidad: value as UnidadMedida })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(UNIDAD_MEDIDA_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Nombre *</Label>
<Input
value={materialForm.nombre}
onChange={(e) =>
setMaterialForm({ ...materialForm, nombre: e.target.value })
}
placeholder="Cemento Portland"
/>
</div>
<div className="space-y-2">
<Label>Descripcion</Label>
<Textarea
value={materialForm.descripcion}
onChange={(e) =>
setMaterialForm({ ...materialForm, descripcion: e.target.value })
}
placeholder="Descripcion del material..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Precio Unitario *</Label>
<Input
type="number"
value={materialForm.precioUnitario}
onChange={(e) =>
setMaterialForm({ ...materialForm, precioUnitario: e.target.value })
}
placeholder="0.00"
/>
</div>
<div className="space-y-2">
<Label>Stock Minimo</Label>
<Input
type="number"
value={materialForm.stockMinimo}
onChange={(e) =>
setMaterialForm({ ...materialForm, stockMinimo: e.target.value })
}
/>
</div>
</div>
<div className="space-y-2">
<Label>Ubicacion</Label>
<Input
value={materialForm.ubicacion}
onChange={(e) =>
setMaterialForm({ ...materialForm, ubicacion: e.target.value })
}
placeholder="Bodega A - Estante 3"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsMaterialDialogOpen(false)}
disabled={isCreating}
>
Cancelar
</Button>
<Button onClick={handleCreateMaterial} disabled={isCreating}>
{isCreating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Crear Material
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Alerts */}
{data.alertas.length > 0 && (
<Card className="border-yellow-200 bg-yellow-50">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-yellow-800">
<AlertTriangle className="h-5 w-5" />
Alertas de Stock Bajo
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{data.alertas.map((m) => (
<Badge key={m.id} variant="outline" className="bg-white">
{m.nombre}: {m.stockActual} / {m.stockMinimo} {UNIDAD_MEDIDA_LABELS[m.unidad]}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Materiales</CardTitle>
<Package className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.materiales.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Con Stock Bajo</CardTitle>
<AlertTriangle className="h-4 w-4 text-yellow-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">{data.alertas.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Valor Total Inventario</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(
data.materiales.reduce((sum, m) => sum + m.stockActual * m.precioUnitario, 0)
)}
</div>
</CardContent>
</Card>
</div>
{/* Table */}
<Card>
<CardHeader>
<CardTitle>Catalogo de Materiales</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Codigo</TableHead>
<TableHead>Nombre</TableHead>
<TableHead>Unidad</TableHead>
<TableHead>Precio Unit.</TableHead>
<TableHead>Stock Actual</TableHead>
<TableHead>Stock Min.</TableHead>
<TableHead>Estado</TableHead>
<TableHead>Ubicacion</TableHead>
<TableHead className="w-[120px]">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.materiales.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="h-24 text-center">
No hay materiales registrados
</TableCell>
</TableRow>
) : (
data.materiales.map((material) => (
<TableRow key={material.id}>
<TableCell className="font-mono">{material.codigo}</TableCell>
<TableCell className="font-medium">{material.nombre}</TableCell>
<TableCell>{UNIDAD_MEDIDA_LABELS[material.unidad]}</TableCell>
<TableCell>{formatCurrency(material.precioUnitario)}</TableCell>
<TableCell className="font-medium">{material.stockActual}</TableCell>
<TableCell>{material.stockMinimo}</TableCell>
<TableCell>
{material.stockActual <= material.stockMinimo ? (
<Badge variant="destructive">Bajo</Badge>
) : (
<Badge variant="outline" className="bg-green-50 text-green-700">OK</Badge>
)}
</TableCell>
<TableCell>{material.ubicacion || "-"}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
title="Entrada"
onClick={() => openMovimientoDialog(material, "ENTRADA")}
>
<ArrowDownCircle className="h-4 w-4 text-green-600" />
</Button>
<Button
variant="ghost"
size="icon"
title="Salida"
onClick={() => openMovimientoDialog(material, "SALIDA")}
>
<ArrowUpCircle className="h-4 w-4 text-red-600" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Edit className="mr-2 h-4 w-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() => setDeleteId(material.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Movimiento Dialog */}
<Dialog open={isMovimientoDialogOpen} onOpenChange={setIsMovimientoDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{movimientoForm.tipo === "ENTRADA" ? "Entrada de Material" : "Salida de Material"}
</DialogTitle>
<DialogDescription>
{selectedMaterial?.nombre} - Stock actual: {selectedMaterial?.stockActual}{" "}
{selectedMaterial && UNIDAD_MEDIDA_LABELS[selectedMaterial.unidad]}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label>Cantidad *</Label>
<Input
type="number"
value={movimientoForm.cantidad}
onChange={(e) =>
setMovimientoForm({ ...movimientoForm, cantidad: e.target.value })
}
placeholder="0"
/>
</div>
{movimientoForm.tipo === "SALIDA" && (
<div className="space-y-2">
<Label>Obra destino</Label>
<Select
value={movimientoForm.obraId}
onValueChange={(value) =>
setMovimientoForm({ ...movimientoForm, obraId: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Seleccionar obra (opcional)" />
</SelectTrigger>
<SelectContent>
{data.obras.map((obra) => (
<SelectItem key={obra.id} value={obra.id}>
{obra.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label>Motivo / Notas</Label>
<Textarea
value={movimientoForm.motivo}
onChange={(e) =>
setMovimientoForm({ ...movimientoForm, motivo: e.target.value })
}
placeholder="Descripcion del movimiento..."
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsMovimientoDialogOpen(false)}
disabled={isMoving}
>
Cancelar
</Button>
<Button onClick={handleMovimiento} disabled={isMoving}>
{isMoving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Registrar {movimientoForm.tipo === "ENTRADA" ? "Entrada" : "Salida"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Eliminar material</AlertDialogTitle>
<AlertDialogDescription>
Esta accion no se puede deshacer. Se eliminara el material y todo su historial de movimientos.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? "Eliminando..." : "Eliminar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { MaterialesClient } from "./materiales-client";
async function getMateriales(empresaId: string) {
const materiales = await prisma.material.findMany({
where: { empresaId },
include: {
movimientos: {
orderBy: { createdAt: "desc" },
take: 5,
include: {
obra: { select: { nombre: true } },
},
},
},
orderBy: { nombre: "asc" },
});
const obras = await prisma.obra.findMany({
where: { empresaId },
select: { id: true, nombre: true },
});
const alertas = materiales.filter(
(m) => m.activo && m.stockActual <= m.stockMinimo
);
return { materiales, obras, alertas };
}
export default async function MaterialesPage() {
const session = await auth();
if (!session?.user?.empresaId) {
return <div>Error: No se encontro la empresa</div>;
}
const data = await getMateriales(session.user.empresaId);
return <MaterialesClient data={data} />;
}

View File

@@ -0,0 +1,39 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { RecursosClient } from "./recursos-client";
async function getRecursosData(empresaId: string) {
const [materiales, empleados, subcontratistas] = await Promise.all([
prisma.material.findMany({
where: { empresaId },
orderBy: { nombre: "asc" },
}),
prisma.empleado.findMany({
where: { empresaId, activo: true },
orderBy: { nombre: "asc" },
}),
prisma.subcontratista.findMany({
where: { empresaId, activo: true },
orderBy: { nombre: "asc" },
}),
]);
// Get materials with low stock
const alertas = materiales.filter(
(m) => m.stockActual <= m.stockMinimo && m.activo
);
return { materiales, empleados, subcontratistas, alertas };
}
export default async function RecursosPage() {
const session = await auth();
if (!session?.user?.empresaId) {
return <div>Error: No se encontro la empresa</div>;
}
const data = await getRecursosData(session.user.empresaId);
return <RecursosClient data={data} />;
}

View File

@@ -0,0 +1,482 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Plus,
Package,
Users,
Briefcase,
AlertTriangle,
Loader2,
} from "lucide-react";
import { formatCurrency } from "@/lib/utils";
import { UNIDAD_MEDIDA_LABELS, type UnidadMedida } from "@/types";
import { toast } from "@/hooks/use-toast";
interface Material {
id: string;
codigo: string;
nombre: string;
descripcion: string | null;
unidad: UnidadMedida;
precioUnitario: number;
stockMinimo: number;
stockActual: number;
ubicacion: string | null;
activo: boolean;
}
interface Empleado {
id: string;
nombre: string;
apellido: string;
documento: string | null;
telefono: string | null;
puesto: string;
salarioBase: number | null;
}
interface Subcontratista {
id: string;
nombre: string;
especialidad: string;
telefono: string | null;
email: string | null;
}
interface RecursosData {
materiales: Material[];
empleados: Empleado[];
subcontratistas: Subcontratista[];
alertas: Material[];
}
export function RecursosClient({ data }: { data: RecursosData }) {
const router = useRouter();
const [isCreating, setIsCreating] = useState(false);
const [isMaterialDialogOpen, setIsMaterialDialogOpen] = useState(false);
const [materialForm, setMaterialForm] = useState({
codigo: "",
nombre: "",
descripcion: "",
unidad: "UNIDAD" as UnidadMedida,
precioUnitario: "",
stockMinimo: "0",
ubicacion: "",
});
const handleCreateMaterial = async () => {
if (!materialForm.codigo || !materialForm.nombre || !materialForm.precioUnitario) {
toast({
title: "Error",
description: "Completa los campos requeridos",
variant: "destructive",
});
return;
}
setIsCreating(true);
try {
const response = await fetch("/api/materiales", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...materialForm,
precioUnitario: parseFloat(materialForm.precioUnitario),
stockMinimo: parseFloat(materialForm.stockMinimo),
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Error al crear");
}
toast({ title: "Material creado exitosamente" });
setIsMaterialDialogOpen(false);
setMaterialForm({
codigo: "",
nombre: "",
descripcion: "",
unidad: "UNIDAD",
precioUnitario: "",
stockMinimo: "0",
ubicacion: "",
});
router.refresh();
} catch (error: unknown) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "No se pudo crear el material",
variant: "destructive",
});
} finally {
setIsCreating(false);
}
};
return (
<div className="space-y-6">
<div>
<h2 className="text-3xl font-bold tracking-tight">Recursos</h2>
<p className="text-muted-foreground">
Gestiona materiales, personal y subcontratistas
</p>
</div>
{/* Alerts */}
{data.alertas.length > 0 && (
<Card className="border-yellow-200 bg-yellow-50">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-yellow-800">
<AlertTriangle className="h-5 w-5" />
Alertas de Inventario
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-yellow-700">
{data.alertas.length} materiales con stock bajo o agotado
</p>
<div className="mt-2 flex flex-wrap gap-2">
{data.alertas.slice(0, 5).map((m) => (
<Badge key={m.id} variant="outline" className="bg-white">
{m.nombre}: {m.stockActual} {UNIDAD_MEDIDA_LABELS[m.unidad]}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Materiales</CardTitle>
<Package className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.materiales.length}</div>
<p className="text-xs text-muted-foreground">
{data.alertas.length} con stock bajo
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Personal</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.empleados.length}</div>
<p className="text-xs text-muted-foreground">Empleados activos</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Subcontratistas</CardTitle>
<Briefcase className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.subcontratistas.length}</div>
<p className="text-xs text-muted-foreground">Proveedores activos</p>
</CardContent>
</Card>
</div>
{/* Tabs */}
<Tabs defaultValue="materiales" className="space-y-4">
<TabsList>
<TabsTrigger value="materiales">Materiales</TabsTrigger>
<TabsTrigger value="personal">Personal</TabsTrigger>
<TabsTrigger value="subcontratistas">Subcontratistas</TabsTrigger>
</TabsList>
<TabsContent value="materiales" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Catalogo de Materiales</h3>
<Dialog open={isMaterialDialogOpen} onOpenChange={setIsMaterialDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Nuevo Material
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Nuevo Material</DialogTitle>
<DialogDescription>
Agrega un nuevo material al catalogo
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Codigo *</Label>
<Input
value={materialForm.codigo}
onChange={(e) =>
setMaterialForm({ ...materialForm, codigo: e.target.value })
}
placeholder="MAT-001"
/>
</div>
<div className="space-y-2">
<Label>Unidad</Label>
<Select
value={materialForm.unidad}
onValueChange={(value) =>
setMaterialForm({ ...materialForm, unidad: value as UnidadMedida })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(UNIDAD_MEDIDA_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Nombre *</Label>
<Input
value={materialForm.nombre}
onChange={(e) =>
setMaterialForm({ ...materialForm, nombre: e.target.value })
}
placeholder="Cemento Portland"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Precio Unitario *</Label>
<Input
type="number"
value={materialForm.precioUnitario}
onChange={(e) =>
setMaterialForm({ ...materialForm, precioUnitario: e.target.value })
}
placeholder="0.00"
/>
</div>
<div className="space-y-2">
<Label>Stock Minimo</Label>
<Input
type="number"
value={materialForm.stockMinimo}
onChange={(e) =>
setMaterialForm({ ...materialForm, stockMinimo: e.target.value })
}
/>
</div>
</div>
<div className="space-y-2">
<Label>Ubicacion</Label>
<Input
value={materialForm.ubicacion}
onChange={(e) =>
setMaterialForm({ ...materialForm, ubicacion: e.target.value })
}
placeholder="Bodega A - Estante 3"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsMaterialDialogOpen(false)}
disabled={isCreating}
>
Cancelar
</Button>
<Button onClick={handleCreateMaterial} disabled={isCreating}>
{isCreating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Crear Material
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Codigo</TableHead>
<TableHead>Nombre</TableHead>
<TableHead>Unidad</TableHead>
<TableHead>Precio</TableHead>
<TableHead>Stock</TableHead>
<TableHead>Estado</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.materiales.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
No hay materiales registrados
</TableCell>
</TableRow>
) : (
data.materiales.map((material) => (
<TableRow key={material.id}>
<TableCell className="font-mono">{material.codigo}</TableCell>
<TableCell>{material.nombre}</TableCell>
<TableCell>{UNIDAD_MEDIDA_LABELS[material.unidad]}</TableCell>
<TableCell>{formatCurrency(material.precioUnitario)}</TableCell>
<TableCell>{material.stockActual}</TableCell>
<TableCell>
{material.stockActual <= material.stockMinimo ? (
<Badge variant="destructive">Bajo</Badge>
) : (
<Badge variant="outline">OK</Badge>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="personal" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Personal</h3>
<Button>
<Plus className="mr-2 h-4 w-4" />
Nuevo Empleado
</Button>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nombre</TableHead>
<TableHead>Puesto</TableHead>
<TableHead>Telefono</TableHead>
<TableHead>Salario Base</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.empleados.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
No hay empleados registrados
</TableCell>
</TableRow>
) : (
data.empleados.map((empleado) => (
<TableRow key={empleado.id}>
<TableCell>
{empleado.nombre} {empleado.apellido}
</TableCell>
<TableCell>{empleado.puesto}</TableCell>
<TableCell>{empleado.telefono || "-"}</TableCell>
<TableCell>
{empleado.salarioBase ? formatCurrency(empleado.salarioBase) : "-"}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="subcontratistas" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Subcontratistas</h3>
<Button>
<Plus className="mr-2 h-4 w-4" />
Nuevo Subcontratista
</Button>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nombre</TableHead>
<TableHead>Especialidad</TableHead>
<TableHead>Telefono</TableHead>
<TableHead>Email</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.subcontratistas.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
No hay subcontratistas registrados
</TableCell>
</TableRow>
) : (
data.subcontratistas.map((sub) => (
<TableRow key={sub.id}>
<TableCell>{sub.nombre}</TableCell>
<TableCell>{sub.especialidad}</TableCell>
<TableCell>{sub.telefono || "-"}</TableCell>
<TableCell>{sub.email || "-"}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { ReportesClient } from "./reportes-client";
async function getReportesData(empresaId: string) {
const [obras, gastosPorCategoria, gastosPorMes, presupuestoVsReal] =
await Promise.all([
prisma.obra.findMany({
where: { empresaId },
select: {
id: true,
nombre: true,
estado: true,
presupuestoTotal: true,
gastoTotal: true,
porcentajeAvance: true,
fechaInicio: true,
fechaFinPrevista: true,
},
}),
prisma.gasto.groupBy({
by: ["categoria"],
where: { obra: { empresaId } },
_sum: { monto: true },
}),
prisma.gasto.findMany({
where: { obra: { empresaId } },
select: {
monto: true,
fecha: true,
categoria: true,
},
}),
prisma.obra.findMany({
where: {
empresaId,
presupuestoTotal: { gt: 0 },
},
select: {
nombre: true,
presupuestoTotal: true,
gastoTotal: true,
},
}),
]);
// Process gastos por mes
const gastosMensuales = gastosPorMes.reduce(
(acc, g) => {
const mes = new Date(g.fecha).toLocaleDateString("es-MX", {
month: "short",
year: "2-digit",
});
acc[mes] = (acc[mes] || 0) + g.monto;
return acc;
},
{} as Record<string, number>
);
return {
obras,
gastosPorCategoria: gastosPorCategoria.map((g) => ({
categoria: g.categoria,
total: g._sum.monto || 0,
})),
gastosMensuales: Object.entries(gastosMensuales).map(([mes, total]) => ({
mes,
total,
})),
presupuestoVsReal,
};
}
export default async function ReportesPage() {
const session = await auth();
if (!session?.user?.empresaId) {
return <div>Error: No se encontro la empresa</div>;
}
const data = await getReportesData(session.user.empresaId);
return <ReportesClient data={data} />;
}

View File

@@ -0,0 +1,366 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Download, FileSpreadsheet, FileText } from "lucide-react";
import { formatCurrency, formatPercentage } from "@/lib/utils";
import {
CATEGORIA_GASTO_LABELS,
ESTADO_OBRA_LABELS,
type CategoriaGasto,
type EstadoObra,
} from "@/types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
Legend,
LineChart,
Line,
} from "recharts";
import { useState } from "react";
interface ReportesData {
obras: {
id: string;
nombre: string;
estado: EstadoObra;
presupuestoTotal: number;
gastoTotal: number;
porcentajeAvance: number;
fechaInicio: Date | null;
fechaFinPrevista: Date | null;
}[];
gastosPorCategoria: {
categoria: CategoriaGasto;
total: number;
}[];
gastosMensuales: {
mes: string;
total: number;
}[];
presupuestoVsReal: {
nombre: string;
presupuestoTotal: number;
gastoTotal: number;
}[];
}
const COLORS = [
"#0088FE",
"#00C49F",
"#FFBB28",
"#FF8042",
"#8884d8",
"#82ca9d",
"#ffc658",
"#ff7300",
];
export function ReportesClient({ data }: { data: ReportesData }) {
const [selectedObra, setSelectedObra] = useState<string>("all");
const totalPresupuesto = data.obras.reduce(
(sum, o) => sum + o.presupuestoTotal,
0
);
const totalGastado = data.obras.reduce((sum, o) => sum + o.gastoTotal, 0);
const variacion = totalPresupuesto - totalGastado;
const exportToCSV = () => {
const headers = [
"Obra",
"Estado",
"Presupuesto",
"Gastado",
"Variacion",
"Avance",
];
const rows = data.obras.map((o) => [
o.nombre,
ESTADO_OBRA_LABELS[o.estado],
o.presupuestoTotal,
o.gastoTotal,
o.presupuestoTotal - o.gastoTotal,
`${o.porcentajeAvance}%`,
]);
const csvContent = [headers.join(","), ...rows.map((r) => r.join(","))].join(
"\n"
);
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `reporte-obras-${new Date().toISOString().split("T")[0]}.csv`;
link.click();
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Reportes</h2>
<p className="text-muted-foreground">
Analisis y exportacion de datos
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={exportToCSV}>
<FileSpreadsheet className="mr-2 h-4 w-4" />
Exportar CSV
</Button>
<Button variant="outline">
<FileText className="mr-2 h-4 w-4" />
Exportar PDF
</Button>
</div>
</div>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Presupuesto Total
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(totalPresupuesto)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Gastado</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(totalGastado)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Variacion</CardTitle>
</CardHeader>
<CardContent>
<div
className={`text-2xl font-bold ${variacion >= 0 ? "text-green-600" : "text-red-600"}`}
>
{variacion >= 0 ? "+" : ""}
{formatCurrency(variacion)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">% Ejecutado</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{totalPresupuesto > 0
? formatPercentage((totalGastado / totalPresupuesto) * 100)
: "0%"}
</div>
</CardContent>
</Card>
</div>
{/* Charts */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Presupuesto vs Real por Obra</CardTitle>
<CardDescription>
Comparativo de presupuesto y gastos reales
</CardDescription>
</CardHeader>
<CardContent>
{data.presupuestoVsReal.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.presupuestoVsReal}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="nombre"
tick={{ fontSize: 12 }}
interval={0}
angle={-45}
textAnchor="end"
height={80}
/>
<YAxis
tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`}
/>
<Tooltip
formatter={(value: number) => formatCurrency(value)}
/>
<Legend />
<Bar
dataKey="presupuestoTotal"
fill="#0088FE"
name="Presupuesto"
/>
<Bar dataKey="gastoTotal" fill="#00C49F" name="Gastado" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
No hay datos disponibles
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Gastos por Categoria</CardTitle>
<CardDescription>Distribucion de gastos</CardDescription>
</CardHeader>
<CardContent>
{data.gastosPorCategoria.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data.gastosPorCategoria.map((g) => ({
...g,
name: CATEGORIA_GASTO_LABELS[g.categoria],
}))}
cx="50%"
cy="50%"
labelLine={false}
outerRadius={80}
fill="#8884d8"
dataKey="total"
>
{data.gastosPorCategoria.map((_, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value: number) => formatCurrency(value)}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
No hay datos disponibles
</div>
)}
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Tendencia de Gastos Mensuales</CardTitle>
<CardDescription>Evolucion de gastos en el tiempo</CardDescription>
</CardHeader>
<CardContent>
{data.gastosMensuales.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data.gastosMensuales}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="mes" />
<YAxis
tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`}
/>
<Tooltip
formatter={(value: number) => formatCurrency(value)}
/>
<Line
type="monotone"
dataKey="total"
stroke="#0088FE"
strokeWidth={2}
name="Gastos"
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
No hay datos disponibles
</div>
)}
</CardContent>
</Card>
</div>
{/* Detailed Table */}
<Card>
<CardHeader>
<CardTitle>Resumen por Obra</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-2">Obra</th>
<th className="text-left p-2">Estado</th>
<th className="text-right p-2">Presupuesto</th>
<th className="text-right p-2">Gastado</th>
<th className="text-right p-2">Variacion</th>
<th className="text-right p-2">Avance</th>
</tr>
</thead>
<tbody>
{data.obras.map((obra) => {
const obraVariacion = obra.presupuestoTotal - obra.gastoTotal;
return (
<tr key={obra.id} className="border-b">
<td className="p-2 font-medium">{obra.nombre}</td>
<td className="p-2">{ESTADO_OBRA_LABELS[obra.estado]}</td>
<td className="p-2 text-right">
{formatCurrency(obra.presupuestoTotal)}
</td>
<td className="p-2 text-right">
{formatCurrency(obra.gastoTotal)}
</td>
<td
className={`p-2 text-right ${obraVariacion >= 0 ? "text-green-600" : "text-red-600"}`}
>
{obraVariacion >= 0 ? "+" : ""}
{formatCurrency(obraVariacion)}
</td>
<td className="p-2 text-right">
{formatPercentage(obra.porcentajeAvance)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { registerSchema } from "@/lib/validations";
export async function POST(request: Request) {
try {
const body = await request.json();
const validatedData = registerSchema.parse(body);
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email: validatedData.email },
});
if (existingUser) {
return NextResponse.json(
{ error: "El correo electronico ya esta registrado" },
{ status: 400 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(validatedData.password, 12);
// Create empresa and user in a transaction
const result = await prisma.$transaction(async (tx) => {
// Create the company
const empresa = await tx.empresa.create({
data: {
nombre: validatedData.empresaNombre,
},
});
// Create the admin user
const user = await tx.user.create({
data: {
email: validatedData.email,
password: hashedPassword,
nombre: validatedData.nombre,
apellido: validatedData.apellido,
role: "ADMIN",
empresaId: empresa.id,
},
});
return { empresa, user };
});
return NextResponse.json(
{
message: "Usuario creado exitosamente",
userId: result.user.id,
},
{ status: 201 }
);
} catch (error) {
console.error("Registration error:", error);
return NextResponse.json(
{ error: "Error al crear el usuario" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,166 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const gasto = await prisma.gasto.findFirst({
where: {
id,
obra: { empresaId: session.user.empresaId },
},
include: {
obra: { select: { id: true, nombre: true } },
partida: true,
creadoPor: { select: { id: true, nombre: true, apellido: true } },
aprobadoPor: { select: { id: true, nombre: true, apellido: true } },
},
});
if (!gasto) {
return NextResponse.json(
{ error: "Gasto no encontrado" },
{ status: 404 }
);
}
return NextResponse.json(gasto);
} catch (error) {
console.error("Error fetching gasto:", error);
return NextResponse.json(
{ error: "Error al obtener el gasto" },
{ status: 500 }
);
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId || !session.user.id) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
// Verify the gasto belongs to the user's empresa
const existingGasto = await prisma.gasto.findFirst({
where: {
id,
obra: { empresaId: session.user.empresaId },
},
});
if (!existingGasto) {
return NextResponse.json(
{ error: "Gasto no encontrado" },
{ status: 404 }
);
}
// Handle approval/rejection
if (body.estado && ["APROBADO", "RECHAZADO"].includes(body.estado)) {
// Check if user has permission to approve
if (!["ADMIN", "GERENTE", "CONTADOR"].includes(session.user.role)) {
return NextResponse.json(
{ error: "No tienes permisos para aprobar gastos" },
{ status: 403 }
);
}
const gasto = await prisma.gasto.update({
where: { id },
data: {
estado: body.estado,
aprobadoPorId: session.user.id,
fechaAprobacion: new Date(),
},
});
return NextResponse.json(gasto);
}
// Regular update
const gasto = await prisma.gasto.update({
where: { id },
data: {
concepto: body.concepto,
descripcion: body.descripcion,
monto: body.monto,
fecha: body.fecha ? new Date(body.fecha) : undefined,
categoria: body.categoria,
notas: body.notas,
},
});
return NextResponse.json(gasto);
} catch (error) {
console.error("Error updating gasto:", error);
return NextResponse.json(
{ error: "Error al actualizar el gasto" },
{ status: 500 }
);
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const existingGasto = await prisma.gasto.findFirst({
where: {
id,
obra: { empresaId: session.user.empresaId },
},
});
if (!existingGasto) {
return NextResponse.json(
{ error: "Gasto no encontrado" },
{ status: 404 }
);
}
// Update obra gastoTotal before deleting
await prisma.obra.update({
where: { id: existingGasto.obraId },
data: {
gastoTotal: { decrement: existingGasto.monto },
},
});
await prisma.gasto.delete({
where: { id },
});
return NextResponse.json({ message: "Gasto eliminado exitosamente" });
} catch (error) {
console.error("Error deleting gasto:", error);
return NextResponse.json(
{ error: "Error al eliminar el gasto" },
{ status: 500 }
);
}
}

102
src/app/api/gastos/route.ts Normal file
View File

@@ -0,0 +1,102 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { gastoSchema } from "@/lib/validations";
export async function GET(request: Request) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const obraId = searchParams.get("obraId");
const estado = searchParams.get("estado");
const categoria = searchParams.get("categoria");
const where: Record<string, unknown> = {
obra: { empresaId: session.user.empresaId },
};
if (obraId) where.obraId = obraId;
if (estado) where.estado = estado;
if (categoria) where.categoria = categoria;
const gastos = await prisma.gasto.findMany({
where,
include: {
obra: { select: { id: true, nombre: true } },
partida: { select: { id: true, codigo: true, descripcion: true } },
creadoPor: { select: { id: true, nombre: true, apellido: true } },
aprobadoPor: { select: { id: true, nombre: true, apellido: true } },
},
orderBy: { fecha: "desc" },
});
return NextResponse.json(gastos);
} catch (error) {
console.error("Error fetching gastos:", error);
return NextResponse.json(
{ error: "Error al obtener los gastos" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.empresaId || !session.user.id) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const body = await request.json();
const validatedData = gastoSchema.parse(body);
// Verify the obra belongs to the user's empresa
const obra = await prisma.obra.findFirst({
where: {
id: validatedData.obraId,
empresaId: session.user.empresaId,
},
});
if (!obra) {
return NextResponse.json(
{ error: "Obra no encontrada" },
{ status: 404 }
);
}
const gasto = await prisma.gasto.create({
data: {
concepto: validatedData.concepto,
descripcion: validatedData.descripcion,
monto: validatedData.monto,
fecha: new Date(validatedData.fecha),
categoria: validatedData.categoria,
notas: validatedData.notas,
obraId: validatedData.obraId,
partidaId: validatedData.partidaId || null,
creadoPorId: session.user.id,
},
});
// Update obra gastoTotal
await prisma.obra.update({
where: { id: validatedData.obraId },
data: {
gastoTotal: { increment: validatedData.monto },
},
});
return NextResponse.json(gasto, { status: 201 });
} catch (error) {
console.error("Error creating gasto:", error);
return NextResponse.json(
{ error: "Error al crear el gasto" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,140 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { materialSchema } from "@/lib/validations";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const material = await prisma.material.findFirst({
where: {
id,
empresaId: session.user.empresaId,
},
include: {
movimientos: {
orderBy: { createdAt: "desc" },
take: 20,
include: {
obra: { select: { nombre: true } },
},
},
},
});
if (!material) {
return NextResponse.json(
{ error: "Material no encontrado" },
{ status: 404 }
);
}
return NextResponse.json(material);
} catch (error) {
console.error("Error fetching material:", error);
return NextResponse.json(
{ error: "Error al obtener el material" },
{ status: 500 }
);
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const validatedData = materialSchema.parse(body);
const existingMaterial = await prisma.material.findFirst({
where: {
id,
empresaId: session.user.empresaId,
},
});
if (!existingMaterial) {
return NextResponse.json(
{ error: "Material no encontrado" },
{ status: 404 }
);
}
const material = await prisma.material.update({
where: { id },
data: {
codigo: validatedData.codigo,
nombre: validatedData.nombre,
descripcion: validatedData.descripcion,
unidad: validatedData.unidad,
precioUnitario: validatedData.precioUnitario,
stockMinimo: validatedData.stockMinimo,
ubicacion: validatedData.ubicacion,
},
});
return NextResponse.json(material);
} catch (error) {
console.error("Error updating material:", error);
return NextResponse.json(
{ error: "Error al actualizar el material" },
{ status: 500 }
);
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const existingMaterial = await prisma.material.findFirst({
where: {
id,
empresaId: session.user.empresaId,
},
});
if (!existingMaterial) {
return NextResponse.json(
{ error: "Material no encontrado" },
{ status: 404 }
);
}
await prisma.material.delete({
where: { id },
});
return NextResponse.json({ message: "Material eliminado exitosamente" });
} catch (error) {
console.error("Error deleting material:", error);
return NextResponse.json(
{ error: "Error al eliminar el material" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const movimientoSchema = z.object({
materialId: z.string(),
tipo: z.enum(["ENTRADA", "SALIDA", "AJUSTE"]),
cantidad: z.number().positive("La cantidad debe ser mayor a 0"),
motivo: z.string().optional(),
obraId: z.string().nullable().optional(),
});
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const body = await request.json();
const validatedData = movimientoSchema.parse(body);
// Verify material belongs to user's empresa
const material = await prisma.material.findFirst({
where: {
id: validatedData.materialId,
empresaId: session.user.empresaId,
},
});
if (!material) {
return NextResponse.json(
{ error: "Material no encontrado" },
{ status: 404 }
);
}
// Calculate new stock
let newStock = material.stockActual;
if (validatedData.tipo === "ENTRADA") {
newStock += validatedData.cantidad;
} else if (validatedData.tipo === "SALIDA") {
newStock -= validatedData.cantidad;
if (newStock < 0) {
return NextResponse.json(
{ error: "Stock insuficiente para esta salida" },
{ status: 400 }
);
}
} else {
// AJUSTE - set directly
newStock = validatedData.cantidad;
}
// Create movement and update stock in transaction
const result = await prisma.$transaction(async (tx) => {
const movimiento = await tx.movimientoInventario.create({
data: {
tipo: validatedData.tipo,
cantidad: validatedData.cantidad,
motivo: validatedData.motivo,
materialId: validatedData.materialId,
obraId: validatedData.obraId || null,
},
});
await tx.material.update({
where: { id: validatedData.materialId },
data: { stockActual: newStock },
});
return movimiento;
});
return NextResponse.json(result, { status: 201 });
} catch (error) {
console.error("Error creating movimiento:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0].message },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Error al registrar el movimiento" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { materialSchema } from "@/lib/validations";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const materiales = await prisma.material.findMany({
where: { empresaId: session.user.empresaId },
orderBy: { nombre: "asc" },
});
return NextResponse.json(materiales);
} catch (error) {
console.error("Error fetching materiales:", error);
return NextResponse.json(
{ error: "Error al obtener los materiales" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const body = await request.json();
const validatedData = materialSchema.parse(body);
// Check if material code already exists
const existing = await prisma.material.findFirst({
where: {
codigo: validatedData.codigo,
empresaId: session.user.empresaId,
},
});
if (existing) {
return NextResponse.json(
{ error: "Ya existe un material con ese codigo" },
{ status: 400 }
);
}
const material = await prisma.material.create({
data: {
codigo: validatedData.codigo,
nombre: validatedData.nombre,
descripcion: validatedData.descripcion,
unidad: validatedData.unidad,
precioUnitario: validatedData.precioUnitario,
stockMinimo: validatedData.stockMinimo,
ubicacion: validatedData.ubicacion,
empresaId: session.user.empresaId,
},
});
return NextResponse.json(material, { status: 201 });
} catch (error) {
console.error("Error creating material:", error);
return NextResponse.json(
{ error: "Error al crear el material" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,173 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { obraSchema } from "@/lib/validations";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const obra = await prisma.obra.findFirst({
where: {
id,
empresaId: session.user.empresaId,
},
include: {
cliente: true,
supervisor: {
select: { id: true, nombre: true, apellido: true, email: true },
},
fases: {
include: {
tareas: {
include: {
asignado: {
select: { id: true, nombre: true, apellido: true },
},
},
},
},
orderBy: { orden: "asc" },
},
presupuestos: {
include: {
partidas: true,
},
},
gastos: {
orderBy: { fecha: "desc" },
take: 10,
},
registrosAvance: {
orderBy: { createdAt: "desc" },
take: 5,
include: {
registradoPor: {
select: { nombre: true, apellido: true },
},
},
},
},
});
if (!obra) {
return NextResponse.json(
{ error: "Obra no encontrada" },
{ status: 404 }
);
}
return NextResponse.json(obra);
} catch (error) {
console.error("Error fetching obra:", error);
return NextResponse.json(
{ error: "Error al obtener la obra" },
{ status: 500 }
);
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const validatedData = obraSchema.parse(body);
// Verify the obra belongs to the user's empresa
const existingObra = await prisma.obra.findFirst({
where: {
id,
empresaId: session.user.empresaId,
},
});
if (!existingObra) {
return NextResponse.json(
{ error: "Obra no encontrada" },
{ status: 404 }
);
}
const obra = await prisma.obra.update({
where: { id },
data: {
nombre: validatedData.nombre,
descripcion: validatedData.descripcion,
direccion: validatedData.direccion,
fechaInicio: validatedData.fechaInicio
? new Date(validatedData.fechaInicio)
: null,
fechaFinPrevista: validatedData.fechaFinPrevista
? new Date(validatedData.fechaFinPrevista)
: null,
clienteId: validatedData.clienteId || null,
supervisorId: validatedData.supervisorId || null,
},
});
return NextResponse.json(obra);
} catch (error) {
console.error("Error updating obra:", error);
return NextResponse.json(
{ error: "Error al actualizar la obra" },
{ status: 500 }
);
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const { id } = await params;
// Verify the obra belongs to the user's empresa
const existingObra = await prisma.obra.findFirst({
where: {
id,
empresaId: session.user.empresaId,
},
});
if (!existingObra) {
return NextResponse.json(
{ error: "Obra no encontrada" },
{ status: 404 }
);
}
await prisma.obra.delete({
where: { id },
});
return NextResponse.json({ message: "Obra eliminada exitosamente" });
} catch (error) {
console.error("Error deleting obra:", error);
return NextResponse.json(
{ error: "Error al eliminar la obra" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { obraSchema } from "@/lib/validations";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const obras = await prisma.obra.findMany({
where: { empresaId: session.user.empresaId },
include: {
cliente: { select: { id: true, nombre: true } },
supervisor: { select: { id: true, nombre: true, apellido: true } },
_count: {
select: {
fases: true,
gastos: true,
presupuestos: true,
},
},
},
orderBy: { createdAt: "desc" },
});
return NextResponse.json(obras);
} catch (error) {
console.error("Error fetching obras:", error);
return NextResponse.json(
{ error: "Error al obtener las obras" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.empresaId) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
const body = await request.json();
const validatedData = obraSchema.parse(body);
const obra = await prisma.obra.create({
data: {
nombre: validatedData.nombre,
descripcion: validatedData.descripcion,
direccion: validatedData.direccion,
fechaInicio: validatedData.fechaInicio
? new Date(validatedData.fechaInicio)
: null,
fechaFinPrevista: validatedData.fechaFinPrevista
? new Date(validatedData.fechaFinPrevista)
: null,
clienteId: validatedData.clienteId || null,
supervisorId: validatedData.supervisorId || null,
empresaId: session.user.empresaId,
},
});
return NextResponse.json(obra, { status: 201 });
} catch (error) {
console.error("Error creating obra:", error);
return NextResponse.json(
{ error: "Error al crear la obra" },
{ status: 500 }
);
}
}

69
src/app/globals.css Normal file
View File

@@ -0,0 +1,69 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

29
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/toaster";
import { AuthProvider } from "@/components/providers/auth-provider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Sistema de Gestion de Obras",
description: "Aplicacion para la gestion integral de obras de construccion",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="es" suppressHydrationWarning>
<body className={inter.className}>
<AuthProvider>
{children}
<Toaster />
</AuthProvider>
</body>
</html>
);
}

12
src/app/page.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
export default async function Home() {
const session = await auth();
if (session) {
redirect("/dashboard");
} else {
redirect("/login");
}
}

View File

@@ -0,0 +1,279 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { obraSchema, type ObraInput } from "@/lib/validations";
import { toast } from "@/hooks/use-toast";
import { Loader2 } from "lucide-react";
import { EstadoObra } from "@prisma/client";
import { ESTADO_OBRA_LABELS } from "@/types";
interface ObraFormProps {
obra?: {
id: string;
nombre: string;
descripcion: string | null;
direccion: string;
estado: EstadoObra;
fechaInicio: Date | null;
fechaFinPrevista: Date | null;
clienteId: string | null;
supervisorId: string | null;
};
clientes?: { id: string; nombre: string }[];
supervisores?: { id: string; nombre: string; apellido: string }[];
}
export function ObraForm({
obra,
clientes = [],
supervisores = [],
}: ObraFormProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const isEditing = !!obra;
const {
register,
handleSubmit,
formState: { errors },
setValue,
watch,
} = useForm<ObraInput>({
resolver: zodResolver(obraSchema),
defaultValues: {
nombre: obra?.nombre || "",
descripcion: obra?.descripcion || "",
direccion: obra?.direccion || "",
fechaInicio: obra?.fechaInicio
? new Date(obra.fechaInicio).toISOString().split("T")[0]
: "",
fechaFinPrevista: obra?.fechaFinPrevista
? new Date(obra.fechaFinPrevista).toISOString().split("T")[0]
: "",
clienteId: obra?.clienteId || "",
supervisorId: obra?.supervisorId || "",
},
});
const onSubmit = async (data: ObraInput) => {
setIsLoading(true);
try {
const url = isEditing ? `/api/obras/${obra.id}` : "/api/obras";
const method = isEditing ? "PUT" : "POST";
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error("Error al guardar");
}
const result = await response.json();
toast({
title: isEditing ? "Obra actualizada" : "Obra creada",
description: isEditing
? "Los cambios han sido guardados"
: "La obra ha sido creada exitosamente",
});
router.push(`/obras/${result.id}`);
router.refresh();
} catch {
toast({
title: "Error",
description: "No se pudo guardar la obra",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Informacion General</CardTitle>
<CardDescription>
Datos basicos del proyecto de construccion
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="nombre">Nombre de la Obra *</Label>
<Input
id="nombre"
placeholder="Ej: Torre Residencial Norte"
{...register("nombre")}
/>
{errors.nombre && (
<p className="text-sm text-red-600">{errors.nombre.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="descripcion">Descripcion</Label>
<Textarea
id="descripcion"
placeholder="Descripcion del proyecto..."
{...register("descripcion")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="direccion">Direccion *</Label>
<Input
id="direccion"
placeholder="Ej: Av. Principal #123, Ciudad"
{...register("direccion")}
/>
{errors.direccion && (
<p className="text-sm text-red-600">{errors.direccion.message}</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Fechas</CardTitle>
<CardDescription>Cronograma del proyecto</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="fechaInicio">Fecha de Inicio</Label>
<Input id="fechaInicio" type="date" {...register("fechaInicio")} />
</div>
<div className="space-y-2">
<Label htmlFor="fechaFinPrevista">Fecha Fin Prevista</Label>
<Input
id="fechaFinPrevista"
type="date"
{...register("fechaFinPrevista")}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Asignaciones</CardTitle>
<CardDescription>Cliente y supervisor del proyecto</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Cliente</Label>
<Select
value={watch("clienteId") || "none"}
onValueChange={(value) =>
setValue("clienteId", value === "none" ? "" : value)
}
>
<SelectTrigger>
<SelectValue placeholder="Seleccionar cliente" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Sin cliente asignado</SelectItem>
{clientes.map((cliente) => (
<SelectItem key={cliente.id} value={cliente.id}>
{cliente.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Supervisor</Label>
<Select
value={watch("supervisorId") || "none"}
onValueChange={(value) =>
setValue("supervisorId", value === "none" ? "" : value)
}
>
<SelectTrigger>
<SelectValue placeholder="Seleccionar supervisor" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Sin supervisor asignado</SelectItem>
{supervisores.map((sup) => (
<SelectItem key={sup.id} value={sup.id}>
{sup.nombre} {sup.apellido}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{isEditing && (
<Card>
<CardHeader>
<CardTitle>Estado</CardTitle>
</CardHeader>
<CardContent>
<Select
defaultValue={obra.estado}
onValueChange={(value) => {
// This would need additional handling for estado changes
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(ESTADO_OBRA_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
)}
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isLoading}
>
Cancelar
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEditing ? "Guardar Cambios" : "Crear Obra"}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { useSession, signOut } from "next-auth/react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Bell, LogOut, User, Settings } from "lucide-react";
import { getInitials } from "@/lib/utils";
import { ROLES_LABELS } from "@/types";
export function Header() {
const { data: session } = useSession();
const handleSignOut = () => {
signOut({ callbackUrl: "/login" });
};
return (
<header className="flex h-16 items-center justify-between border-b bg-white px-6">
<div>
<h1 className="text-xl font-semibold text-slate-900">
Sistema de Gestion de Obras
</h1>
</div>
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
3
</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-blue-600 text-white">
{session?.user
? getInitials(session.user.nombre, session.user.apellido)
: "U"}
</AvatarFallback>
</Avatar>
<div className="hidden text-left md:block">
<p className="text-sm font-medium">
{session?.user?.nombre} {session?.user?.apellido}
</p>
<p className="text-xs text-muted-foreground">
{session?.user?.role
? ROLES_LABELS[session.user.role]
: "Usuario"}
</p>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Mi Cuenta</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<User className="mr-2 h-4 w-4" />
Perfil
</DropdownMenuItem>
<DropdownMenuItem>
<Settings className="mr-2 h-4 w-4" />
Configuracion
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleSignOut}
className="text-red-600 focus:text-red-600"
>
<LogOut className="mr-2 h-4 w-4" />
Cerrar Sesion
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import {
Building2,
LayoutDashboard,
HardHat,
DollarSign,
Users,
FileBarChart,
Package,
Settings,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useState } from "react";
const navigation = [
{
name: "Dashboard",
href: "/dashboard",
icon: LayoutDashboard,
},
{
name: "Obras",
href: "/obras",
icon: HardHat,
},
{
name: "Finanzas",
href: "/finanzas",
icon: DollarSign,
},
{
name: "Recursos",
href: "/recursos",
icon: Users,
},
{
name: "Inventario",
href: "/recursos/materiales",
icon: Package,
},
{
name: "Reportes",
href: "/reportes",
icon: FileBarChart,
},
];
export function Sidebar() {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
return (
<aside
className={cn(
"relative flex flex-col bg-slate-900 text-white transition-all duration-300",
collapsed ? "w-16" : "w-64"
)}
>
<div className="flex h-16 items-center justify-center border-b border-slate-700 px-4">
{!collapsed && (
<Link href="/dashboard" className="flex items-center gap-2">
<Building2 className="h-8 w-8 text-blue-400" />
<span className="text-lg font-bold">ConstruApp</span>
</Link>
)}
{collapsed && (
<Link href="/dashboard">
<Building2 className="h-8 w-8 text-blue-400" />
</Link>
)}
</div>
<nav className="flex-1 space-y-1 px-2 py-4">
{navigation.map((item) => {
const isActive =
pathname === item.href || pathname.startsWith(item.href + "/");
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-blue-600 text-white"
: "text-slate-300 hover:bg-slate-800 hover:text-white"
)}
>
<item.icon className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>{item.name}</span>}
</Link>
);
})}
</nav>
<div className="border-t border-slate-700 p-2">
<Link
href="/configuracion"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-slate-300 transition-colors hover:bg-slate-800 hover:text-white"
)}
>
<Settings className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>Configuracion</span>}
</Link>
</div>
<Button
variant="ghost"
size="icon"
className="absolute -right-3 top-20 h-6 w-6 rounded-full border border-slate-600 bg-slate-800 text-white hover:bg-slate-700"
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</Button>
</aside>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
import { SessionProvider } from "next-auth/react";
import { ReactNode } from "react";
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
return <SessionProvider>{children}</SessionProvider>;
}

View File

@@ -0,0 +1,180 @@
"use client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
} from "lucide-react";
import { useState, useMemo } from "react";
export interface Column<T> {
key: keyof T | string;
header: string;
render?: (item: T) => React.ReactNode;
sortable?: boolean;
}
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
searchPlaceholder?: string;
searchKey?: keyof T;
pageSize?: number;
actions?: (item: T) => React.ReactNode;
}
export function DataTable<T extends { id: string }>({
data,
columns,
searchPlaceholder = "Buscar...",
searchKey,
pageSize = 10,
actions,
}: DataTableProps<T>) {
const [search, setSearch] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const filteredData = useMemo(() => {
if (!search || !searchKey) return data;
return data.filter((item) => {
const value = item[searchKey];
if (typeof value === "string") {
return value.toLowerCase().includes(search.toLowerCase());
}
return false;
});
}, [data, search, searchKey]);
const totalPages = Math.ceil(filteredData.length / pageSize);
const startIndex = (currentPage - 1) * pageSize;
const paginatedData = filteredData.slice(startIndex, startIndex + pageSize);
const getValue = (item: T, key: string): unknown => {
const keys = key.split(".");
let value: unknown = item;
for (const k of keys) {
if (value && typeof value === "object" && k in value) {
value = (value as Record<string, unknown>)[k];
} else {
return undefined;
}
}
return value;
};
return (
<div className="space-y-4">
{searchKey && (
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={searchPlaceholder}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setCurrentPage(1);
}}
className="pl-10"
/>
</div>
)}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead key={String(column.key)}>{column.header}</TableHead>
))}
{actions && <TableHead className="w-[100px]">Acciones</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{paginatedData.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length + (actions ? 1 : 0)}
className="h-24 text-center"
>
No se encontraron resultados.
</TableCell>
</TableRow>
) : (
paginatedData.map((item) => (
<TableRow key={item.id}>
{columns.map((column) => (
<TableCell key={String(column.key)}>
{column.render
? column.render(item)
: String(getValue(item, String(column.key)) ?? "")}
</TableCell>
))}
{actions && <TableCell>{actions(item)}</TableCell>}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Mostrando {startIndex + 1} -{" "}
{Math.min(startIndex + pageSize, filteredData.length)} de{" "}
{filteredData.length} registros
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm">
Pagina {currentPage} de {totalPages}
</span>
<Button
variant="outline"
size="icon"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,140 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,49 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,35 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,55 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,78 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,121 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,199 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,25 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,38 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value?: number;
max?: number;
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, max = 100, ...props }, ref) => {
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
return (
<div
ref={ref}
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={max}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<div
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - percentage}%)` }}
/>
</div>
);
}
);
Progress.displayName = "Progress";
export { Progress };

View File

@@ -0,0 +1,159 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };

116
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -0,0 +1,54 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
export { Textarea };

128
src/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,128 @@
"use client";
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@@ -0,0 +1,35 @@
"use client";
import { useToast } from "@/hooks/use-toast";
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

187
src/hooks/use-toast.ts Normal file
View File

@@ -0,0 +1,187 @@
"use client";
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

111
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,111 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import type { Role } from "@prisma/client";
declare module "next-auth" {
interface User {
id: string;
email: string;
nombre: string;
apellido: string;
role: Role;
empresaId: string;
}
interface Session {
user: {
id: string;
email: string;
nombre: string;
apellido: string;
role: Role;
empresaId: string;
};
}
}
declare module "@auth/core/jwt" {
interface JWT {
id: string;
role: Role;
empresaId: string;
nombre: string;
apellido: string;
}
}
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
include: { empresa: true },
});
if (!user || !user.activo) {
return null;
}
const passwordMatch = await bcrypt.compare(
credentials.password as string,
user.password
);
if (!passwordMatch) {
return null;
}
return {
id: user.id,
email: user.email,
nombre: user.nombre,
apellido: user.apellido,
role: user.role,
empresaId: user.empresaId,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
token.empresaId = user.empresaId;
token.nombre = user.nombre;
token.apellido = user.apellido;
}
return token;
},
async session({ session, token }) {
if (token) {
session.user.id = token.id as string;
session.user.role = token.role;
session.user.empresaId = token.empresaId;
session.user.nombre = token.nombre;
session.user.apellido = token.apellido;
}
return session;
},
},
pages: {
signIn: "/login",
error: "/login",
},
session: {
strategy: "jwt",
maxAge: 24 * 60 * 60, // 24 hours
},
});

11
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
export default prisma;

49
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,49 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat("es-MX", {
style: "currency",
currency: "MXN",
}).format(amount);
}
export function formatDate(date: Date | string): string {
return new Intl.DateTimeFormat("es-MX", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
}
export function formatDateShort(date: Date | string): string {
return new Intl.DateTimeFormat("es-MX", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date(date));
}
export function formatPercentage(value: number): string {
return `${value.toFixed(1)}%`;
}
export function calculatePercentage(value: number, total: number): number {
if (total === 0) return 0;
return (value / total) * 100;
}
export function getInitials(nombre: string, apellido?: string): string {
const first = nombre?.charAt(0).toUpperCase() || "";
const last = apellido?.charAt(0).toUpperCase() || "";
return `${first}${last}`;
}
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return `${text.substring(0, maxLength)}...`;
}

207
src/lib/validations.ts Normal file
View File

@@ -0,0 +1,207 @@
import { z } from "zod";
// Auth validations
export const loginSchema = z.object({
email: z.string().email("Email invalido"),
password: z.string().min(6, "La contrasena debe tener al menos 6 caracteres"),
});
export const registerSchema = z
.object({
email: z.string().email("Email invalido"),
password: z
.string()
.min(6, "La contrasena debe tener al menos 6 caracteres"),
confirmPassword: z.string(),
nombre: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
apellido: z.string().min(2, "El apellido debe tener al menos 2 caracteres"),
empresaNombre: z
.string()
.min(2, "El nombre de empresa debe tener al menos 2 caracteres"),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Las contrasenas no coinciden",
path: ["confirmPassword"],
});
// Obra validations
export const obraSchema = z.object({
nombre: z
.string()
.min(3, "El nombre debe tener al menos 3 caracteres")
.max(100, "El nombre no puede exceder 100 caracteres"),
descripcion: z.string().optional(),
direccion: z
.string()
.min(5, "La direccion debe tener al menos 5 caracteres"),
fechaInicio: z.string().optional(),
fechaFinPrevista: z.string().optional(),
clienteId: z.string().optional(),
supervisorId: z.string().optional(),
});
// Gasto validations
export const gastoSchema = z.object({
concepto: z
.string()
.min(3, "El concepto debe tener al menos 3 caracteres")
.max(200, "El concepto no puede exceder 200 caracteres"),
descripcion: z.string().optional(),
monto: z.number().positive("El monto debe ser mayor a 0"),
fecha: z.string(),
categoria: z.enum([
"MATERIALES",
"MANO_DE_OBRA",
"EQUIPOS",
"SUBCONTRATISTAS",
"PERMISOS",
"TRANSPORTE",
"SERVICIOS",
"OTROS",
]),
obraId: z.string(),
partidaId: z.string().optional(),
notas: z.string().optional(),
});
// Presupuesto validations
export const presupuestoSchema = z.object({
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
descripcion: z.string().optional(),
obraId: z.string(),
});
export const partidaPresupuestoSchema = z.object({
codigo: z.string().min(1, "El codigo es requerido"),
descripcion: z.string().min(3, "La descripcion es requerida"),
unidad: z.enum([
"UNIDAD",
"METRO",
"METRO_CUADRADO",
"METRO_CUBICO",
"KILOGRAMO",
"TONELADA",
"LITRO",
"BOLSA",
"PIEZA",
"ROLLO",
"CAJA",
]),
cantidad: z.number().positive("La cantidad debe ser mayor a 0"),
precioUnitario: z.number().positive("El precio debe ser mayor a 0"),
categoria: z.enum([
"MATERIALES",
"MANO_DE_OBRA",
"EQUIPOS",
"SUBCONTRATISTAS",
"PERMISOS",
"TRANSPORTE",
"SERVICIOS",
"OTROS",
]),
presupuestoId: z.string(),
});
// Material validations
export const materialSchema = z.object({
codigo: z.string().min(1, "El codigo es requerido"),
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
descripcion: z.string().optional(),
unidad: z.enum([
"UNIDAD",
"METRO",
"METRO_CUADRADO",
"METRO_CUBICO",
"KILOGRAMO",
"TONELADA",
"LITRO",
"BOLSA",
"PIEZA",
"ROLLO",
"CAJA",
]),
precioUnitario: z.number().positive("El precio debe ser mayor a 0"),
stockMinimo: z.number().min(0, "El stock minimo no puede ser negativo"),
ubicacion: z.string().optional(),
});
// Empleado validations
export const empleadoSchema = z.object({
nombre: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
apellido: z.string().min(2, "El apellido debe tener al menos 2 caracteres"),
documento: z.string().optional(),
telefono: z.string().optional(),
email: z.string().email("Email invalido").optional().or(z.literal("")),
puesto: z.string().min(2, "El puesto es requerido"),
salarioBase: z.number().optional(),
fechaIngreso: z.string(),
});
// Subcontratista validations
export const subcontratistaSchema = z.object({
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
rfc: z.string().optional(),
especialidad: z.string().min(3, "La especialidad es requerida"),
telefono: z.string().optional(),
email: z.string().email("Email invalido").optional().or(z.literal("")),
direccion: z.string().optional(),
});
// Factura validations
export const facturaSchema = z.object({
numero: z.string().min(1, "El numero de factura es requerido"),
tipo: z.enum(["EMITIDA", "RECIBIDA"]),
concepto: z.string().min(3, "El concepto es requerido"),
monto: z.number().positive("El monto debe ser mayor a 0"),
iva: z.number().min(0, "El IVA no puede ser negativo"),
fechaEmision: z.string(),
fechaVencimiento: z.string().optional(),
obraId: z.string(),
proveedorNombre: z.string().optional(),
proveedorRfc: z.string().optional(),
clienteNombre: z.string().optional(),
clienteRfc: z.string().optional(),
});
// Cliente validations
export const clienteSchema = z.object({
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
rfc: z.string().optional(),
direccion: z.string().optional(),
telefono: z.string().optional(),
email: z.string().email("Email invalido").optional().or(z.literal("")),
});
// Fase validations
export const faseSchema = z.object({
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
descripcion: z.string().optional(),
fechaInicio: z.string().optional(),
fechaFin: z.string().optional(),
obraId: z.string(),
});
// Tarea validations
export const tareaSchema = z.object({
nombre: z.string().min(3, "El nombre debe tener al menos 3 caracteres"),
descripcion: z.string().optional(),
prioridad: z.number().min(1).max(5),
fechaInicio: z.string().optional(),
fechaFin: z.string().optional(),
faseId: z.string(),
asignadoId: z.string().optional(),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterInput = z.infer<typeof registerSchema>;
export type ObraInput = z.infer<typeof obraSchema>;
export type GastoInput = z.infer<typeof gastoSchema>;
export type PresupuestoInput = z.infer<typeof presupuestoSchema>;
export type PartidaPresupuestoInput = z.infer<typeof partidaPresupuestoSchema>;
export type MaterialInput = z.infer<typeof materialSchema>;
export type EmpleadoInput = z.infer<typeof empleadoSchema>;
export type SubcontratistaInput = z.infer<typeof subcontratistaSchema>;
export type FacturaInput = z.infer<typeof facturaSchema>;
export type ClienteInput = z.infer<typeof clienteSchema>;
export type FaseInput = z.infer<typeof faseSchema>;
export type TareaInput = z.infer<typeof tareaSchema>;

36
src/middleware.ts Normal file
View File

@@ -0,0 +1,36 @@
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const { nextUrl } = req;
const isLoggedIn = !!req.auth;
const isAuthPage =
nextUrl.pathname.startsWith("/login") ||
nextUrl.pathname.startsWith("/registro");
const isPublicPage = nextUrl.pathname === "/";
const isApiRoute = nextUrl.pathname.startsWith("/api");
// Allow API routes to pass through
if (isApiRoute) {
return NextResponse.next();
}
// Redirect logged-in users away from auth pages
if (isAuthPage && isLoggedIn) {
return NextResponse.redirect(new URL("/dashboard", nextUrl));
}
// Redirect non-logged-in users to login page
if (!isAuthPage && !isPublicPage && !isLoggedIn) {
return NextResponse.redirect(new URL("/login", nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.png$).*)"],
};

151
src/types/index.ts Normal file
View File

@@ -0,0 +1,151 @@
import {
Role,
EstadoObra,
EstadoTarea,
EstadoGasto,
CategoriaGasto,
TipoFactura,
EstadoFactura,
TipoMovimiento,
UnidadMedida,
} from "@prisma/client";
export type {
Role,
EstadoObra,
EstadoTarea,
EstadoGasto,
CategoriaGasto,
TipoFactura,
EstadoFactura,
TipoMovimiento,
UnidadMedida,
};
export interface DashboardStats {
obrasActivas: number;
presupuestoTotal: number;
gastoTotal: number;
avancePromedio: number;
obrasCompletadas: number;
gastoPendiente: number;
}
export interface ObraResumen {
id: string;
nombre: string;
estado: EstadoObra;
porcentajeAvance: number;
presupuestoTotal: number;
gastoTotal: number;
fechaInicio: Date | null;
fechaFinPrevista: Date | null;
}
export interface GastoMensual {
mes: string;
gastos: number;
presupuesto: number;
}
export interface GastoPorCategoria {
categoria: CategoriaGasto;
total: number;
porcentaje: number;
}
export interface AlertaInventario {
materialId: string;
nombre: string;
stockActual: number;
stockMinimo: number;
}
export const ROLES_LABELS: Record<Role, string> = {
ADMIN: "Administrador",
GERENTE: "Gerente",
SUPERVISOR: "Supervisor",
CONTADOR: "Contador",
EMPLEADO: "Empleado",
};
export const ESTADO_OBRA_LABELS: Record<EstadoObra, string> = {
PLANIFICACION: "Planificacion",
EN_PROGRESO: "En Progreso",
PAUSADA: "Pausada",
COMPLETADA: "Completada",
CANCELADA: "Cancelada",
};
export const ESTADO_OBRA_COLORS: Record<EstadoObra, string> = {
PLANIFICACION: "bg-blue-100 text-blue-800",
EN_PROGRESO: "bg-green-100 text-green-800",
PAUSADA: "bg-yellow-100 text-yellow-800",
COMPLETADA: "bg-gray-100 text-gray-800",
CANCELADA: "bg-red-100 text-red-800",
};
export const ESTADO_TAREA_LABELS: Record<EstadoTarea, string> = {
PENDIENTE: "Pendiente",
EN_PROGRESO: "En Progreso",
COMPLETADA: "Completada",
BLOQUEADA: "Bloqueada",
};
export const ESTADO_TAREA_COLORS: Record<EstadoTarea, string> = {
PENDIENTE: "bg-gray-100 text-gray-800",
EN_PROGRESO: "bg-blue-100 text-blue-800",
COMPLETADA: "bg-green-100 text-green-800",
BLOQUEADA: "bg-red-100 text-red-800",
};
export const ESTADO_GASTO_LABELS: Record<EstadoGasto, string> = {
PENDIENTE: "Pendiente",
APROBADO: "Aprobado",
RECHAZADO: "Rechazado",
PAGADO: "Pagado",
};
export const ESTADO_GASTO_COLORS: Record<EstadoGasto, string> = {
PENDIENTE: "bg-yellow-100 text-yellow-800",
APROBADO: "bg-green-100 text-green-800",
RECHAZADO: "bg-red-100 text-red-800",
PAGADO: "bg-blue-100 text-blue-800",
};
export const CATEGORIA_GASTO_LABELS: Record<CategoriaGasto, string> = {
MATERIALES: "Materiales",
MANO_DE_OBRA: "Mano de Obra",
EQUIPOS: "Equipos",
SUBCONTRATISTAS: "Subcontratistas",
PERMISOS: "Permisos",
TRANSPORTE: "Transporte",
SERVICIOS: "Servicios",
OTROS: "Otros",
};
export const UNIDAD_MEDIDA_LABELS: Record<UnidadMedida, string> = {
UNIDAD: "Unidad",
METRO: "Metro",
METRO_CUADRADO: "m2",
METRO_CUBICO: "m3",
KILOGRAMO: "Kg",
TONELADA: "Ton",
LITRO: "Lt",
BOLSA: "Bolsa",
PIEZA: "Pieza",
ROLLO: "Rollo",
CAJA: "Caja",
};
export const ESTADO_FACTURA_LABELS: Record<EstadoFactura, string> = {
PENDIENTE: "Pendiente",
PAGADA: "Pagada",
VENCIDA: "Vencida",
CANCELADA: "Cancelada",
};
export const TIPO_FACTURA_LABELS: Record<TipoFactura, string> = {
EMITIDA: "Emitida",
RECIBIDA: "Recibida",
};