update for marcos

This commit is contained in:
Levi Planelles 2026-01-09 18:39:19 +01:00
parent 17eba11f0b
commit 09bdf14c0a
9 changed files with 481 additions and 121 deletions

228
AGENTS.md Normal file
View File

@ -0,0 +1,228 @@
# AGENTS.md
This file contains guidelines and commands for agentic coding agents working in this repository.
## Build/Lint/Test Commands
```bash
# Development
npm run dev # Start development server (Next.js)
# Build & Production
npm run build # Build for production
npm run start # Start production server
# Code Quality
npm run lint # Run ESLint
```
**Note**: This project does not have test commands configured. If tests are added, update the scripts in package.json.
## Project Architecture
This is a **Next.js 16** application with **TypeScript** that serves as a dashboard for Dolibarr project management. The app uses:
- **UI Framework**: shadcn/ui components with Radix UI primitives
- **Styling**: Tailwind CSS with custom design system
- **State Management**: React hooks and context
- **API Integration**: Custom Dolibarr client
- **Icons**: Lucide React
## Code Style Guidelines
### Imports & Dependencies
```typescript
// 1. React imports first
import React from "react";
import { forwardRef } from "react";
// 2. Third-party libraries (alphabetical)
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "@radix-ui/react-slot";
// 3. Internal imports (use @/ aliases)
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Project } from "@/types/project";
```
### Component Structure
```typescript
"use client"; // Add for client components
// Imports
import { ComponentProps } from "react";
// Types/Interfaces
interface ComponentProps {
// props here
}
// Helper functions (if any)
function helper() {
// implementation
}
// Main component
export default function Component({ prop }: ComponentProps) {
// implementation
}
```
### TypeScript Guidelines
- **Always use types** for props, function parameters, and return values
- **Prefer interfaces** for object shapes, types for unions/primitives
- **Use generic types** when appropriate: `React.FC<Props>`, `VariantProps<T>`
- **Strict mode enabled** - no implicit `any`
### Naming Conventions
- **Components**: PascalCase (`ProjectCard`, `DashboardHeader`)
- **Functions**: camelCase (`getInitials`, `mapDolibarrProject`)
- **Constants**: UPPER_SNAKE_CASE (`STATUS_CONFIG`, `API_BASE_URL`)
- **Files**: kebab-case (`project-card.tsx`, `dolibarr-client.ts`)
- **Types**: PascalCase with descriptive suffixes (`ProjectStatus`, `DolibarrProject`)
### shadcn/ui Component Patterns
```typescript
// Use cva for variant styling
const buttonVariants = cva(
"base-classes",
{
variants: {
variant: {
default: "variant-classes",
// other variants
},
},
defaultVariants: {
variant: "default",
},
}
);
// Forward ref for composable components
const Component = forwardRef<HTMLDivElement, ComponentProps>(
({ className, ...props }, ref) => {
return (
<div
className={cn(variantClasses, className)}
ref={ref}
{...props}
/>
);
}
);
Component.displayName = "Component";
```
### Styling Guidelines
- **Use Tailwind classes** for all styling
- **Utility-first approach** - avoid custom CSS when possible
- **Responsive design**: `sm:`, `md:`, `lg:`, `xl:` prefixes
- **State styling**: `hover:`, `focus:`, `disabled:` prefixes
- **Use cn() utility** for conditional class merging
- **Design tokens**: Use CSS custom properties from `globals.css`
### Error Handling
```typescript
// API calls - throw errors, handle at call site
export async function apiCall() {
const res = await fetch(url);
if (!res.ok) {
console.error("API Error:", res.status, await res.text());
throw new Error("API request failed");
}
return res.json();
}
// Components - handle errors gracefully
try {
const data = await apiCall();
// render data
} catch (error) {
console.error("Failed to load data:", error);
// render error state or fallback
}
```
### File Organization
```
src/
├── app/ # Next.js app router
├── components/ # React components
│ ├── ui/ # shadcn/ui components
│ └── dashboard/ # Feature components
├── hooks/ # Custom React hooks
├── lib/ # Utilities, API clients
├── types/ # TypeScript type definitions
└── public/ # Static assets
```
### Path Aliases
Use these configured path aliases:
- `@/components` → `./components`
- `@/lib` → `./lib`
- `@/hooks` → `./hooks`
- `@/utils` → `./lib/utils`
- `@/ui` → `./components/ui`
### API Integration
- **Environment variables**: Use `NEXT_PUBLIC_*` for client-side access
- **Dolibarr client**: Use `dolibarrFetch()` from `@/lib/dolibarrClient`
- **Data transformation**: Map API responses to UI types using helper functions
- **Error boundaries**: Implement error handling for API failures
### Performance Guidelines
- **Dynamic imports**: Use `next/dynamic` for heavy components
- **Image optimization**: Use Next.js Image component
- **Bundle analysis**: Check bundle size with `npm run build`
- **Memoization**: Use `React.memo()` for expensive components
### Accessibility
- **Semantic HTML**: Use appropriate elements (`<button>`, `<nav>`, etc.)
- **ARIA attributes**: Add when needed for screen readers
- **Keyboard navigation**: Ensure all interactive elements are keyboard accessible
- **Focus management**: Handle focus in modals and dropdowns
## Development Workflow
1. **Start development**: `npm run dev`
2. **Run linter**: `npm run lint` (fix any errors before committing)
3. **Build test**: `npm run build` (ensure production build works)
4. **Type checking**: TypeScript is strict - fix all type errors
## Key Dependencies
- **Next.js**: 16.0.7 (App Router)
- **React**: 19.2.0
- **TypeScript**: 5.x
- **Tailwind CSS**: 3.4.18 + tailwindcss-animate
- **Radix UI**: Headless components for accessibility
- **Lucide React**: Icon library
- **shadcn/ui**: Component library built on Radix UI
## Environment Variables
Required environment variables (create `.env.local`):
- `NEXT_PUBLIC_API_URL` - Dolibarr API base URL
- `NEXT_PUBLIC_DOLIBARR_API_KEY` - Dolibarr API key
## Testing
No test framework is currently configured. Recommended setup:
- Add Jest/Vitest for unit tests
- Add React Testing Library for component tests
- Add Playwright/Cypress for E2E tests
- Update package.json with test scripts

View File

@ -26,7 +26,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<SidebarProvider>
<AppSidebar />

View File

@ -1,16 +1,62 @@
"use client";
import { Calendar, DollarSign, Users, TrendingUp } from 'lucide-react';
import { Calendar, DollarSign, TrendingUp, FileText } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Project, STATUS_CONFIG } from '@/types/project';
import { Project, ProjectStatus } from '@/types/project';
interface ProjectCardProps {
project: Project;
}
// Función para generar iniciales
function getInitials(name: string): string {
return name
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
// Componente para el badge de estado con estilos directos
function StatusBadge({ status }: { status: ProjectStatus }) {
if (status === '0') {
return (
<Badge variant="outline" className="mt-3 border bg-gray-100 text-gray-800 border-gray-200">
Borrador
</Badge>
);
}
if (status === '1') {
return (
<Badge variant="outline" className="mt-3 border bg-blue-100 text-blue-800 border-blue-200">
Abierto
</Badge>
);
}
if (status === '2') {
return (
<Badge variant="outline" className="mt-3 border bg-red-100 text-red-800 border-red-200">
Cerrado
</Badge>
);
}
// Fallback
return (
<Badge variant="outline" className="mt-3 border bg-gray-100 text-gray-800 border-gray-200">
Desconocido
</Badge>
);
}
export default function ProjectCard({ project }: ProjectCardProps) {
const initials = getInitials(project.name);
return (
<Card className="border-gray-200 hover:shadow-lg transition-all duration-300 hover:-translate-y-1">
<CardHeader>
@ -18,20 +64,29 @@ export default function ProjectCard({ project }: ProjectCardProps) {
<div className="flex items-center gap-3">
<Avatar className="w-12 h-12">
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white font-semibold">
{project.avatar}
{initials}
</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-lg text-gray-900">{project.name}</CardTitle>
<CardDescription className="text-sm">{project.client}</CardDescription>
<CardDescription className="text-sm">{project.ref}</CardDescription>
</div>
</div>
</div>
<Badge variant="outline" className={`mt-3 ${STATUS_CONFIG[project.status].color} border`}>
{STATUS_CONFIG[project.status].label}
</Badge>
<StatusBadge status={project.status} />
</CardHeader>
<CardContent>
{/* Description */}
{project.description && (
<div className="mb-4">
<div className="flex items-center gap-1 text-gray-500 mb-1">
<FileText className="w-3 h-3" />
<span className="text-xs">Descripción</span>
</div>
<p className="text-sm text-gray-700 line-clamp-2">{project.description}</p>
</div>
)}
{/* Progress Bar */}
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
@ -53,28 +108,44 @@ export default function ProjectCard({ project }: ProjectCardProps) {
<DollarSign className="w-3 h-3" />
<span className="text-xs">Presupuesto</span>
</div>
<p className="text-sm font-semibold text-gray-900">€{(project.budget / 1000).toFixed(0)}k</p>
<p className="text-sm font-semibold text-gray-900">
€{project.budget.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</p>
</div>
<div>
<div className="flex items-center gap-1 text-gray-500 mb-1">
<TrendingUp className="w-3 h-3" />
<span className="text-xs">Gastado</span>
<span className="text-xs">Estimado</span>
</div>
<p className="text-sm font-semibold text-gray-900">€{(project.spent / 1000).toFixed(0)}k</p>
<p className="text-sm font-semibold text-gray-900">
€{project.spent.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</p>
</div>
</div>
{/* Footer Info */}
<div className="pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-1 text-gray-500">
<Users className="w-4 h-4" />
<span className="text-xs">{project.team} miembros</span>
<Calendar className="w-4 h-4" />
<span className="text-xs">
{new Date(project.startDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })}
</span>
</div>
<div className="flex items-center gap-1 text-gray-500">
<Calendar className="w-4 h-4" />
<span className="text-xs">{new Date(project.endDate).toLocaleDateString('es-ES')}</span>
<span className="text-xs">
{new Date(project.endDate).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })}
</span>
</div>
</div>
{/* Cliente */}
{project.client !== 'Sin cliente' && (
<div className="mt-3 pt-3 border-t border-gray-100">
<span className="text-xs text-gray-500">Cliente: </span>
<span className="text-xs font-medium text-gray-700">{project.client}</span>
</div>
)}
</CardContent>
</Card>
);

View File

@ -1,105 +1,69 @@
"use client";
import { useState } from 'react';
import { useState, useEffect } from 'react';
import DashboardHeader from './dashboard-header';
import StatsOverview from './stats-overview';
import ProjectGrid from './project-grid';
import { getProjects } from '@/lib/projectsService'; // Importar desde el service
import { Project } from '@/types/project';
// Tipos
type ProjectStatus = 'en_progreso' | 'planificacion' | 'completado' | 'revision';
// Datos mock (luego esto vendrá de Dolibarr)
const mockProjects: Project[] = [
{
id: 1,
name: "Rediseño Web Corporativa",
client: "Tech Solutions SA",
status: "en_progreso",
progress: 65,
budget: 45000,
spent: 29250,
startDate: "2024-10-15",
endDate: "2024-12-30",
team: 4,
avatar: "TS"
},
{
id: 2,
name: "App Móvil E-commerce",
client: "Fashion Store",
status: "en_progreso",
progress: 40,
budget: 80000,
spent: 32000,
startDate: "2024-11-01",
endDate: "2025-02-28",
team: 6,
avatar: "FS"
},
{
id: 3,
name: "Sistema CRM Interno",
client: "Industrial Corp",
status: "planificacion",
progress: 10,
budget: 120000,
spent: 12000,
startDate: "2024-12-01",
endDate: "2025-05-30",
team: 8,
avatar: "IC"
},
{
id: 4,
name: "Dashboard Analytics",
client: "Data Insights",
status: "completado",
progress: 100,
budget: 35000,
spent: 33500,
startDate: "2024-08-01",
endDate: "2024-11-15",
team: 3,
avatar: "DI"
},
{
id: 5,
name: "Migración Cloud AWS",
client: "StartUp Innovate",
status: "en_progreso",
progress: 75,
budget: 95000,
spent: 71250,
startDate: "2024-09-15",
endDate: "2024-12-20",
team: 5,
avatar: "SI"
},
{
id: 6,
name: "Portal de Clientes",
client: "Services Group",
status: "revision",
progress: 90,
budget: 52000,
spent: 48880,
startDate: "2024-10-01",
endDate: "2024-12-10",
team: 4,
avatar: "SG"
}
];
export default function ProjectDashboard() {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [searchTerm, setSearchTerm] = useState('');
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const filteredProjects = mockProjects.filter(project =>
useEffect(() => {
async function loadProjects() {
try {
setLoading(true);
const data = await getProjects();
setProjects(data);
setError(null);
} catch (err) {
console.error('Error loading projects:', err);
setError('Error al cargar los proyectos');
} finally {
setLoading(false);
}
}
loadProjects();
}, []);
const filteredProjects = projects.filter(project =>
project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.client.toLowerCase().includes(searchTerm.toLowerCase())
project.client.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.ref.toLowerCase().includes(searchTerm.toLowerCase())
);
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"></div>
<p className="mt-4 text-gray-600">Cargando proyectos...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center">
<div className="text-center">
<p className="text-red-600">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Reintentar
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
<DashboardHeader
@ -110,11 +74,9 @@ export default function ProjectDashboard() {
/>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<StatsOverview projects={mockProjects} />
<StatsOverview projects={projects} />
<ProjectGrid projects={filteredProjects} viewMode={viewMode} />
</main>
</div>
);
}
}

View File

@ -11,7 +11,7 @@ interface StatsOverviewProps {
export default function StatsOverview({ projects }: StatsOverviewProps) {
const stats = {
total: projects.length,
activos: projects.filter(p => p.progress < 100).length,
activos: projects.filter(p => p.progress < 100 && p.status !== "2").length,
totalBudget: projects.reduce((acc, p) => acc + p.budget, 0),
avgProgress: Math.round(projects.reduce((acc, p) => acc + p.progress, 0) / projects.length)
};

View File

@ -1,3 +1,4 @@
'use client'
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

View File

@ -1,9 +1,5 @@
export async function dolibarrFetch(endpoint: string, options: RequestInit = {}) {
const apiKey = process.env.NEXT_PUBLIC_DOLIBARR_API_KEY
console.log(apiKey)
const url = `${process.env.NEXT_PUBLIC_API_URL}/${endpoint}?DOLAPIKEY=${process.env.NEXT_PUBLIC_DOLIBARR_API_KEY}`;
// ${process.env.DOLIBARR_API_KEY}
console.log(url)
const res = await fetch(url);

View File

@ -1,5 +1,37 @@
// lib/projectsService.ts
import { dolibarrFetch } from "./dolibarrClient";
import { DolibarrProject, Project, mapDolibarrProject } from "@/types/project";
export async function getProjects() {
// Obtener proyectos crudos de Dolibarr
export async function getDolibarrProjects(): Promise<DolibarrProject[]> {
return dolibarrFetch("projects");
}
// Obtener proyectos mapeados al formato de la UI
export async function getProjects(): Promise<Project[]> {
try {
const dolibarrProjects = await getDolibarrProjects();
// Mapear los proyectos de Dolibarr a nuestro formato
return dolibarrProjects.map(mapDolibarrProject);
} catch (error) {
console.error('Error fetching projects from Dolibarr:', error);
throw error;
}
}
// Obtener un proyecto específico por ID
export async function getProjectById(id: number): Promise<Project | undefined> {
try {
const projects = await getProjects();
return projects.find(p => p.id === id);
} catch (error) {
console.error('Error fetching project by id:', error);
throw error;
}
}
// Si necesitas obtener un proyecto específico directamente de Dolibarr
export async function getDolibarrProjectById(id: number): Promise<DolibarrProject> {
return dolibarrFetch(`projects/${id}`);
}

View File

@ -1,7 +1,33 @@
export type ProjectStatus = 'en_progreso' | 'planificacion' | 'completado' | 'revision';
// types/project.ts
export type ProjectStatus = '0' | '1' | '2'; // 0: borrador, 1: validado/abierto, 2: cerrado
// Interface para los datos crudos que vienen de Dolibarr
export interface DolibarrProject {
id: string | number;
ref: string;
title: string;
description: string;
date_start: number; // timestamp
date_end: number; // timestamp
status: string;
statut: string;
budget_amount: string;
opp_amount: string; // opportunity amount (presupuesto estimado)
opp_percent: string; // porcentaje de progreso
user_author_id: string | number;
thirdparty_name?: string;
socid?: string | number;
public: string | number;
usage_opportunity: string | number;
usage_task: string | number;
date_c: number; // fecha creación
date_m: number; // fecha modificación
}
// Interface para usar en la UI (formato normalizado)
export interface Project {
id: number;
ref: string;
name: string;
client: string;
status: ProjectStatus;
@ -10,13 +36,57 @@ export interface Project {
spent: number;
startDate: string;
endDate: string;
team: number;
avatar: string;
description: string;
createdAt: string;
updatedAt: string;
}
export const STATUS_CONFIG: Record<ProjectStatus, { label: string; color: string }> = {
en_progreso: { label: "En Progreso", color: "bg-blue-100 text-blue-800 border-blue-200" },
planificacion: { label: "Planificación", color: "bg-purple-100 text-purple-800 border-purple-200" },
completado: { label: "Completado", color: "bg-green-100 text-green-800 border-green-200" },
revision: { label: "En Revisión", color: "bg-amber-100 text-amber-800 border-amber-200" }
};
// Configuración de estados - AHORA CON FUNCIÓN QUE RETORNA LAS CLASES COMPLETAS
export const STATUS_CONFIG: Record<ProjectStatus, { label: string; getColorClasses: () => string }> = {
'0': {
label: "Borrador",
getColorClasses: () => "bg-gray-100 text-gray-800 border-gray-200"
},
'1': {
label: "Abierto",
getColorClasses: () => "bg-blue-100 text-blue-800 border-blue-200"
},
'2': {
label: "Cerrado",
getColorClasses: () => "bg-green-100 text-green-800 border-green-200"
}
};
// Función helper para obtener las clases de color según el estado
export function getStatusColorClasses(status: ProjectStatus): string {
const statusMap: Record<ProjectStatus, string> = {
'0': 'bg-gray-100 text-gray-800 border-gray-200',
'1': 'bg-blue-100 text-blue-800 border-blue-200',
'2': 'bg-green-100 text-green-800 border-green-200'
};
return statusMap[status] || 'bg-gray-100 text-gray-800 border-gray-200';
}
// Función helper para convertir datos de Dolibarr a nuestro formato
export function mapDolibarrProject(dolibarr: DolibarrProject): Project {
const budget = parseFloat(dolibarr.budget_amount || '0');
const oppAmount = parseFloat(dolibarr.opp_amount || '0');
const progress = parseFloat(dolibarr.opp_percent || '0');
return {
id: typeof dolibarr.id === 'string' ? parseInt(dolibarr.id) : dolibarr.id,
ref: dolibarr.ref,
name: dolibarr.title,
client: dolibarr.thirdparty_name || 'Sin cliente',
status: (dolibarr.status || dolibarr.statut) as ProjectStatus,
progress: progress,
budget: oppAmount || budget,
spent: (oppAmount || budget) * (progress / 100), // Calcular gastado según progreso
startDate: new Date(dolibarr.date_start * 1000).toISOString().split('T')[0],
endDate: new Date(dolibarr.date_end * 1000).toISOString().split('T')[0],
description: dolibarr.description || '',
createdAt: new Date(dolibarr.date_c * 1000).toISOString().split('T')[0],
updatedAt: new Date(dolibarr.date_m * 1000).toISOString().split('T')[0],
};
}