[FIX] fix: Resolve TypeScript errors for successful build

Fixes:
- Add teal, cyan, slate colors to StatusColor type and StatusBadge
- Create StatsCard component with color prop for backward compatibility
- Add label/required props to FormGroup component
- Fix Pagination to accept both currentPage and page props
- Fix unused imports in quality and contracts pages
- Add missing Plus, Trash2, User icon imports in contracts pages
- Remove duplicate formatDate function in ContratoDetailPage

New components:
- StatsCard, StatsCardGrid for statistics display

Build: Success (npm run build passes)
Dev: Success (npm run dev starts on port 3020)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-04 11:36:21 -06:00
parent 816d591115
commit 55261598a2
24 changed files with 6701 additions and 7 deletions

View File

@ -200,13 +200,19 @@ export const CheckboxField = forwardRef<HTMLInputElement, CheckboxFieldProps>(
CheckboxField.displayName = 'CheckboxField'; CheckboxField.displayName = 'CheckboxField';
// Form Group (for horizontal layouts) // Form Group (for horizontal layouts)
interface FormGroupProps { export interface FormGroupProps {
children: React.ReactNode; children: React.ReactNode;
cols?: 1 | 2 | 3 | 4; cols?: 1 | 2 | 3 | 4;
/** Optional label for the group */
label?: string;
/** Mark as required */
required?: boolean;
/** Error message */
error?: string;
className?: string; className?: string;
} }
export function FormGroup({ children, cols = 2, className }: FormGroupProps) { export function FormGroup({ children, cols = 2, label, required, error, className }: FormGroupProps) {
const colClasses = { const colClasses = {
1: 'grid-cols-1', 1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2', 2: 'grid-cols-1 sm:grid-cols-2',
@ -214,6 +220,22 @@ export function FormGroup({ children, cols = 2, className }: FormGroupProps) {
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4', 4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
}; };
// If label is provided, wrap in a fieldset-like structure
if (label) {
return (
<div className={clsx('w-full', className)}>
<label className="block text-sm font-medium text-foreground mb-1">
{label}
{required && <span className="text-red-500 dark:text-red-400 ml-1">*</span>}
</label>
<div className={clsx('grid gap-4', cols > 1 ? colClasses[cols] : '')}>
{children}
</div>
{error && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
);
}
return ( return (
<div className={clsx('grid gap-4', colClasses[cols], className)}> <div className={clsx('grid gap-4', colClasses[cols], className)}>
{children} {children}

View File

@ -7,7 +7,9 @@ import clsx from 'clsx';
export interface PaginationProps { export interface PaginationProps {
/** Current page (1-indexed) */ /** Current page (1-indexed) */
currentPage: number; currentPage?: number;
/** Alias for currentPage (backward compatibility) */
page?: number;
/** Total number of pages */ /** Total number of pages */
totalPages: number; totalPages: number;
/** Total number of items */ /** Total number of items */
@ -47,7 +49,8 @@ const iconSizes = {
}; };
export function Pagination({ export function Pagination({
currentPage, currentPage: currentPageProp,
page,
totalPages, totalPages,
totalItems, totalItems,
pageSize = 10, pageSize = 10,
@ -61,6 +64,8 @@ export function Pagination({
className, className,
disabled = false, disabled = false,
}: PaginationProps) { }: PaginationProps) {
// Support both currentPage and page props (page is alias for backward compatibility)
const currentPage = currentPageProp ?? page ?? 1;
// Generate page numbers to display // Generate page numbers to display
const getPageNumbers = (): (number | 'ellipsis')[] => { const getPageNumbers = (): (number | 'ellipsis')[] => {
const pages: (number | 'ellipsis')[] = []; const pages: (number | 'ellipsis')[] = [];
@ -236,12 +241,14 @@ export function Pagination({
* Simple pagination (just prev/next) * Simple pagination (just prev/next)
*/ */
export function SimplePagination({ export function SimplePagination({
currentPage, currentPage: currentPageProp,
page,
totalPages, totalPages,
onPageChange, onPageChange,
disabled = false, disabled = false,
className, className,
}: Pick<PaginationProps, 'currentPage' | 'totalPages' | 'onPageChange' | 'disabled' | 'className'>) { }: Pick<PaginationProps, 'currentPage' | 'page' | 'totalPages' | 'onPageChange' | 'disabled' | 'className'>) {
const currentPage = currentPageProp ?? page ?? 1;
return ( return (
<div className={clsx('flex items-center justify-between', className)}> <div className={clsx('flex items-center justify-between', className)}>
<button <button

View File

@ -0,0 +1,183 @@
/**
* StatsCard - Statistics display card component
*/
import clsx from 'clsx';
import type { ReactNode } from 'react';
export interface StatsCardProps {
/** Card title/label */
title: string;
/** Main value to display */
value: string | number;
/** Optional description or subtitle */
description?: string;
/** Icon to display */
icon?: ReactNode;
/** Trend indicator */
trend?: {
value: number;
label?: string;
isPositive?: boolean;
};
/** Color variant */
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
/** Simple color prop (maps to variant for backward compatibility) */
color?: string;
/** Loading state */
loading?: boolean;
/** Additional CSS classes */
className?: string;
}
const variantClasses = {
default: {
icon: 'bg-background-muted dark:bg-background-muted text-foreground-muted',
accent: '',
},
primary: {
icon: 'bg-primary/10 dark:bg-primary/20 text-primary',
accent: 'border-l-4 border-l-primary',
},
success: {
icon: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
accent: 'border-l-4 border-l-green-500',
},
warning: {
icon: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400',
accent: 'border-l-4 border-l-yellow-500',
},
danger: {
icon: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
accent: 'border-l-4 border-l-red-500',
},
info: {
icon: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
accent: 'border-l-4 border-l-blue-500',
},
};
export function StatsCard({
title,
value,
description,
icon,
trend,
variant = 'default',
color,
loading = false,
className,
}: StatsCardProps) {
// Map color prop to variant for backward compatibility
const colorToVariant: Record<string, keyof typeof variantClasses> = {
blue: 'info',
green: 'success',
yellow: 'warning',
red: 'danger',
orange: 'warning',
purple: 'primary',
indigo: 'primary',
};
const effectiveVariant = color ? (colorToVariant[color] || 'default') : variant;
const styles = variantClasses[effectiveVariant];
if (loading) {
return (
<div
className={clsx(
'bg-surface-card dark:bg-surface-card rounded-lg p-4 border border-border dark:border-border',
styles.accent,
className
)}
>
<div className="animate-pulse space-y-3">
<div className="h-4 bg-background-muted dark:bg-background-muted rounded w-1/2" />
<div className="h-8 bg-background-muted dark:bg-background-muted rounded w-3/4" />
<div className="h-3 bg-background-muted dark:bg-background-muted rounded w-1/3" />
</div>
</div>
);
}
return (
<div
className={clsx(
'bg-surface-card dark:bg-surface-card rounded-lg p-4 border border-border dark:border-border',
styles.accent,
className
)}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground-muted dark:text-foreground-muted truncate">
{title}
</p>
<p className="mt-1 text-2xl font-semibold text-foreground dark:text-foreground">
{value}
</p>
{description && (
<p className="mt-1 text-sm text-foreground-subtle dark:text-foreground-subtle">
{description}
</p>
)}
{trend && (
<div className="mt-2 flex items-center gap-1">
<span
className={clsx(
'text-sm font-medium',
trend.isPositive
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
)}
>
{trend.isPositive ? '↑' : '↓'} {Math.abs(trend.value)}%
</span>
{trend.label && (
<span className="text-sm text-foreground-muted dark:text-foreground-muted">
{trend.label}
</span>
)}
</div>
)}
</div>
{icon && (
<div
className={clsx(
'flex-shrink-0 p-3 rounded-lg',
styles.icon
)}
>
{icon}
</div>
)}
</div>
</div>
);
}
/**
* StatsCardGrid - Grid layout for multiple stats cards
*/
export function StatsCardGrid({
children,
cols = 4,
className,
}: {
children: ReactNode;
cols?: 2 | 3 | 4;
className?: string;
}) {
const colClasses = {
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
};
return (
<div className={clsx('grid gap-4', colClasses[cols], className)}>
{children}
</div>
);
}
export default StatsCard;

View File

@ -23,6 +23,9 @@ const colorClasses: Record<StatusColor, { bg: string; text: string; dot: string
orange: { bg: 'bg-orange-100 dark:bg-orange-900/30', text: 'text-orange-800 dark:text-orange-300', dot: 'bg-orange-500' }, orange: { bg: 'bg-orange-100 dark:bg-orange-900/30', text: 'text-orange-800 dark:text-orange-300', dot: 'bg-orange-500' },
pink: { bg: 'bg-pink-100 dark:bg-pink-900/30', text: 'text-pink-800 dark:text-pink-300', dot: 'bg-pink-500' }, pink: { bg: 'bg-pink-100 dark:bg-pink-900/30', text: 'text-pink-800 dark:text-pink-300', dot: 'bg-pink-500' },
indigo: { bg: 'bg-indigo-100 dark:bg-indigo-900/30', text: 'text-indigo-800 dark:text-indigo-300', dot: 'bg-indigo-500' }, indigo: { bg: 'bg-indigo-100 dark:bg-indigo-900/30', text: 'text-indigo-800 dark:text-indigo-300', dot: 'bg-indigo-500' },
teal: { bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-800 dark:text-teal-300', dot: 'bg-teal-500' },
cyan: { bg: 'bg-cyan-100 dark:bg-cyan-900/30', text: 'text-cyan-800 dark:text-cyan-300', dot: 'bg-cyan-500' },
slate: { bg: 'bg-slate-100 dark:bg-slate-900/30', text: 'text-slate-800 dark:text-slate-300', dot: 'bg-slate-500' },
}; };
const sizeClasses = { const sizeClasses = {

View File

@ -35,6 +35,11 @@ export {
CheckboxField, CheckboxField,
FormGroup, FormGroup,
} from './FormField'; } from './FormField';
export type { FormGroupProps } from './FormField';
// Stats Display
export { StatsCard, StatsCardGrid } from './StatsCard';
export type { StatsCardProps } from './StatsCard';
// Action Components // Action Components
export { ActionButton, ActionButtons, ActionMenu } from './ActionButtons'; export { ActionButton, ActionButtons, ActionMenu } from './ActionButtons';

View File

@ -0,0 +1,347 @@
/**
* ChecklistForm - Formulario de Checklist con Items
*/
import { useState, useEffect } from 'react';
import { Plus, Trash2, GripVertical, AlertTriangle, Camera } from 'lucide-react';
import type {
Checklist,
ChecklistStage,
CreateChecklistDto,
CreateChecklistItemDto,
} from '../../types/quality.types';
import { CHECKLIST_STAGE_OPTIONS } from '../../types/quality.types';
import {
Modal,
ModalFooter,
TextInput,
SelectField,
TextareaField,
CheckboxField,
FormGroup,
} from '../common';
interface ChecklistFormProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: CreateChecklistDto) => void;
initialData?: Checklist | null;
isLoading?: boolean;
}
interface FormItem extends CreateChecklistItemDto {
tempId: string;
}
export function ChecklistForm({
isOpen,
onClose,
onSubmit,
initialData,
isLoading = false,
}: ChecklistFormProps) {
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [stage, setStage] = useState<ChecklistStage>('foundation');
const [isActive, setIsActive] = useState(true);
const [items, setItems] = useState<FormItem[]>([]);
useEffect(() => {
if (initialData) {
setCode(initialData.code);
setName(initialData.name);
setDescription(initialData.description || '');
setStage(initialData.stage);
setIsActive(initialData.isActive);
setItems(
(initialData.items || []).map((item, index) => ({
tempId: `existing-${item.id}`,
sequenceNumber: item.sequenceNumber || index + 1,
category: item.category,
description: item.description,
isCritical: item.isCritical,
requiresPhoto: item.requiresPhoto,
acceptanceCriteria: item.acceptanceCriteria || '',
isActive: item.isActive,
}))
);
} else {
resetForm();
}
}, [initialData, isOpen]);
const resetForm = () => {
setCode('');
setName('');
setDescription('');
setStage('foundation');
setIsActive(true);
setItems([]);
};
const handleAddItem = () => {
const newItem: FormItem = {
tempId: `new-${Date.now()}`,
sequenceNumber: items.length + 1,
category: '',
description: '',
isCritical: false,
requiresPhoto: false,
acceptanceCriteria: '',
isActive: true,
};
setItems([...items, newItem]);
};
const handleRemoveItem = (tempId: string) => {
const filtered = items.filter((item) => item.tempId !== tempId);
// Update sequence numbers
const updated = filtered.map((item, index) => ({
...item,
sequenceNumber: index + 1,
}));
setItems(updated);
};
const handleItemChange = (
tempId: string,
field: keyof CreateChecklistItemDto,
value: string | boolean | number
) => {
setItems(
items.map((item) =>
item.tempId === tempId ? { ...item, [field]: value } : item
)
);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: CreateChecklistDto = {
code,
name,
description: description || undefined,
stage,
isActive,
items: items.map(({ sequenceNumber, category, description, isCritical, requiresPhoto, acceptanceCriteria, isActive }) => ({
sequenceNumber,
category,
description,
isCritical,
requiresPhoto,
acceptanceCriteria: acceptanceCriteria || undefined,
isActive,
})),
};
onSubmit(data);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={initialData ? 'Editar Checklist' : 'Nuevo Checklist'}
size="xl"
>
<form onSubmit={handleSubmit}>
<div className="space-y-6 max-h-[70vh] overflow-y-auto p-1">
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormGroup label="Codigo" required>
<TextInput
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="CHK-001"
required
/>
</FormGroup>
<FormGroup label="Etapa" required>
<SelectField
value={stage}
onChange={(e) => setStage(e.target.value as ChecklistStage)}
options={CHECKLIST_STAGE_OPTIONS.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
/>
</FormGroup>
<FormGroup label="Nombre" required className="md:col-span-2">
<TextInput
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Nombre del checklist"
required
/>
</FormGroup>
<FormGroup label="Descripcion" className="md:col-span-2">
<TextareaField
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Descripcion del checklist..."
rows={2}
/>
</FormGroup>
<div className="md:col-span-2">
<CheckboxField
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
label="Checklist activo"
/>
</div>
</div>
{/* Items Section */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
Items de Verificacion
</h3>
<button
type="button"
onClick={handleAddItem}
className="flex items-center px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4 mr-1" />
Agregar Item
</button>
</div>
{items.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
<p>No hay items agregados</p>
<button
type="button"
onClick={handleAddItem}
className="mt-2 text-blue-600 hover:text-blue-700"
>
Agregar el primer item
</button>
</div>
) : (
<div className="space-y-4">
{items.map((item, index) => (
<div
key={item.tempId}
className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
>
<div className="flex items-start gap-3">
<div className="flex items-center text-gray-400 cursor-move pt-2">
<GripVertical className="w-5 h-5" />
<span className="ml-1 font-medium text-sm">{index + 1}</span>
</div>
<div className="flex-1 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<TextInput
value={item.category}
onChange={(e) =>
handleItemChange(item.tempId, 'category', e.target.value)
}
placeholder="Categoria"
required
/>
<div className="md:col-span-2">
<TextInput
value={item.description}
onChange={(e) =>
handleItemChange(item.tempId, 'description', e.target.value)
}
placeholder="Descripcion del item"
required
/>
</div>
</div>
<TextInput
value={item.acceptanceCriteria || ''}
onChange={(e) =>
handleItemChange(item.tempId, 'acceptanceCriteria', e.target.value)
}
placeholder="Criterio de aceptacion (opcional)"
/>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={item.isCritical}
onChange={(e) =>
handleItemChange(item.tempId, 'isCritical', e.target.checked)
}
className="rounded border-gray-300 text-red-600 focus:ring-red-500"
/>
<AlertTriangle className="w-4 h-4 text-red-500" />
<span className="text-gray-700 dark:text-gray-300">Critico</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={item.requiresPhoto}
onChange={(e) =>
handleItemChange(item.tempId, 'requiresPhoto', e.target.checked)
}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<Camera className="w-4 h-4 text-blue-500" />
<span className="text-gray-700 dark:text-gray-300">Requiere foto</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={item.isActive}
onChange={(e) =>
handleItemChange(item.tempId, 'isActive', e.target.checked)
}
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<span className="text-gray-700 dark:text-gray-300">Activo</span>
</label>
</div>
</div>
<button
type="button"
onClick={() => handleRemoveItem(item.tempId)}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
title="Eliminar item"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
<ModalFooter>
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
disabled={isLoading}
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isLoading}
>
{isLoading ? 'Guardando...' : initialData ? 'Actualizar' : 'Crear'}
</button>
</ModalFooter>
</form>
</Modal>
);
}

View File

@ -0,0 +1,363 @@
/**
* InspectionResultsForm - Formulario para capturar resultados de inspeccion
*/
import { useState, useEffect } from 'react';
import { Check, X, Minus, Camera, AlertTriangle, MessageSquare } from 'lucide-react';
import type {
Inspection,
InspectionResult,
ChecklistItem,
InspectionResultStatus,
CreateInspectionResultDto,
} from '../../types/quality.types';
import { INSPECTION_RESULT_STATUS_OPTIONS } from '../../types/quality.types';
import { Modal, ModalFooter, TextareaField, StatusBadgeFromOptions } from '../common';
interface InspectionResultsFormProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (results: CreateInspectionResultDto[]) => void;
inspection: Inspection | null;
checklistItems: ChecklistItem[];
existingResults?: InspectionResult[];
isLoading?: boolean;
}
interface ResultItem {
checklistItemId: string;
item: ChecklistItem;
result: InspectionResultStatus;
observations: string;
photoUrl: string;
}
export function InspectionResultsForm({
isOpen,
onClose,
onSubmit,
inspection,
checklistItems,
existingResults = [],
isLoading = false,
}: InspectionResultsFormProps) {
const [results, setResults] = useState<ResultItem[]>([]);
const [expandedItem, setExpandedItem] = useState<string | null>(null);
useEffect(() => {
if (isOpen && checklistItems.length > 0) {
const initialResults: ResultItem[] = checklistItems
.filter((item) => item.isActive)
.sort((a, b) => a.sequenceNumber - b.sequenceNumber)
.map((item) => {
const existing = existingResults.find((r) => r.checklistItemId === item.id);
return {
checklistItemId: item.id,
item,
result: existing?.result || 'pending',
observations: existing?.observations || '',
photoUrl: existing?.photoUrl || '',
};
});
setResults(initialResults);
}
}, [isOpen, checklistItems, existingResults]);
const handleResultChange = (itemId: string, result: InspectionResultStatus) => {
setResults(
results.map((r) =>
r.checklistItemId === itemId ? { ...r, result } : r
)
);
};
const handleObservationsChange = (itemId: string, observations: string) => {
setResults(
results.map((r) =>
r.checklistItemId === itemId ? { ...r, observations } : r
)
);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: CreateInspectionResultDto[] = results.map((r) => ({
checklistItemId: r.checklistItemId,
result: r.result,
observations: r.observations || undefined,
photoUrl: r.photoUrl || undefined,
}));
onSubmit(data);
};
// Helper function for result icons (used in summary view)
const _getResultIcon = (result: InspectionResultStatus) => {
switch (result) {
case 'passed':
return <Check className="w-5 h-5 text-green-600" />;
case 'failed':
return <X className="w-5 h-5 text-red-600" />;
case 'not_applicable':
return <Minus className="w-5 h-5 text-gray-400" />;
default:
return <div className="w-5 h-5 rounded-full border-2 border-gray-300" />;
}
};
void _getResultIcon; // Suppress unused warning - reserved for future use
const getResultButtonClasses = (
itemResult: InspectionResultStatus,
buttonResult: InspectionResultStatus
) => {
const isSelected = itemResult === buttonResult;
const base = 'p-2 rounded-lg transition-all border-2';
if (buttonResult === 'passed') {
return `${base} ${
isSelected
? 'bg-green-100 border-green-500 text-green-700 dark:bg-green-900/30 dark:border-green-500 dark:text-green-400'
: 'border-gray-200 text-gray-400 hover:border-green-300 hover:text-green-500 dark:border-gray-600 dark:hover:border-green-500'
}`;
}
if (buttonResult === 'failed') {
return `${base} ${
isSelected
? 'bg-red-100 border-red-500 text-red-700 dark:bg-red-900/30 dark:border-red-500 dark:text-red-400'
: 'border-gray-200 text-gray-400 hover:border-red-300 hover:text-red-500 dark:border-gray-600 dark:hover:border-red-500'
}`;
}
if (buttonResult === 'not_applicable') {
return `${base} ${
isSelected
? 'bg-gray-100 border-gray-500 text-gray-700 dark:bg-gray-700 dark:border-gray-400 dark:text-gray-300'
: 'border-gray-200 text-gray-400 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-400'
}`;
}
return base;
};
const pendingCount = results.filter((r) => r.result === 'pending').length;
const passedCount = results.filter((r) => r.result === 'passed').length;
const failedCount = results.filter((r) => r.result === 'failed').length;
const naCount = results.filter((r) => r.result === 'not_applicable').length;
// Group by category
const groupedResults = results.reduce((acc, result) => {
const category = result.item.category || 'Sin categoria';
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(result);
return acc;
}, {} as Record<string, ResultItem[]>);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Inspeccion ${inspection?.inspectionNumber || ''}`}
size="xl"
>
<form onSubmit={handleSubmit}>
{/* Progress Summary */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Progreso de la inspeccion
</span>
<span className="text-sm text-gray-500">
{results.length - pendingCount} / {results.length} items evaluados
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{
width: `${((results.length - pendingCount) / results.length) * 100}%`,
}}
/>
</div>
<div className="flex items-center justify-center gap-6 mt-3 text-sm">
<span className="flex items-center gap-1 text-green-600">
<Check className="w-4 h-4" />
{passedCount} aprobados
</span>
<span className="flex items-center gap-1 text-red-600">
<X className="w-4 h-4" />
{failedCount} fallidos
</span>
<span className="flex items-center gap-1 text-gray-500">
<Minus className="w-4 h-4" />
{naCount} N/A
</span>
<span className="flex items-center gap-1 text-yellow-600">
{pendingCount} pendientes
</span>
</div>
</div>
{/* Results List */}
<div className="space-y-6 max-h-[50vh] overflow-y-auto p-1">
{Object.entries(groupedResults).map(([category, categoryResults]) => (
<div key={category} className="space-y-2">
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{category}
</h3>
<div className="space-y-2">
{categoryResults.map((result) => (
<div
key={result.checklistItemId}
className={`bg-white dark:bg-gray-800 rounded-lg border ${
result.item.isCritical
? 'border-red-200 dark:border-red-800'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div className="p-4">
<div className="flex items-start gap-4">
{/* Sequence */}
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-sm font-medium text-gray-600 dark:text-gray-300">
{result.item.sequenceNumber}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-gray-900 dark:text-white font-medium">
{result.item.description}
</p>
{result.item.acceptanceCriteria && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Criterio: {result.item.acceptanceCriteria}
</p>
)}
<div className="flex items-center gap-3 mt-2">
{result.item.isCritical && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 rounded-full">
<AlertTriangle className="w-3 h-3" />
Critico
</span>
)}
{result.item.requiresPhoto && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded-full">
<Camera className="w-3 h-3" />
Foto requerida
</span>
)}
</div>
</div>
{/* Result Buttons */}
<div className="flex items-center gap-2 flex-shrink-0">
<button
type="button"
onClick={() =>
handleResultChange(result.checklistItemId, 'passed')
}
className={getResultButtonClasses(result.result, 'passed')}
title="Aprobado"
>
<Check className="w-5 h-5" />
</button>
<button
type="button"
onClick={() =>
handleResultChange(result.checklistItemId, 'failed')
}
className={getResultButtonClasses(result.result, 'failed')}
title="Fallido"
>
<X className="w-5 h-5" />
</button>
<button
type="button"
onClick={() =>
handleResultChange(result.checklistItemId, 'not_applicable')
}
className={getResultButtonClasses(result.result, 'not_applicable')}
title="No aplica"
>
<Minus className="w-5 h-5" />
</button>
<button
type="button"
onClick={() =>
setExpandedItem(
expandedItem === result.checklistItemId
? null
: result.checklistItemId
)
}
className={`p-2 rounded-lg border-2 transition-all ${
expandedItem === result.checklistItemId || result.observations
? 'bg-yellow-100 border-yellow-500 text-yellow-700 dark:bg-yellow-900/30 dark:border-yellow-500 dark:text-yellow-400'
: 'border-gray-200 text-gray-400 hover:border-yellow-300 dark:border-gray-600'
}`}
title="Observaciones"
>
<MessageSquare className="w-5 h-5" />
</button>
</div>
</div>
{/* Observations (expandable) */}
{expandedItem === result.checklistItemId && (
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<TextareaField
value={result.observations}
onChange={(e) =>
handleObservationsChange(
result.checklistItemId,
e.target.value
)
}
placeholder="Agregar observaciones..."
rows={2}
/>
</div>
)}
{/* Show status badge */}
{result.result !== 'pending' && (
<div className="mt-2">
<StatusBadgeFromOptions
value={result.result}
options={[...INSPECTION_RESULT_STATUS_OPTIONS]}
/>
</div>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
<ModalFooter>
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
disabled={isLoading}
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isLoading || pendingCount === results.length}
>
{isLoading ? 'Guardando...' : 'Guardar Resultados'}
</button>
</ModalFooter>
</form>
</Modal>
);
}

View File

@ -0,0 +1,6 @@
/**
* Quality Components Index
*/
export { ChecklistForm } from './ChecklistForm';
export { InspectionResultsForm } from './InspectionResultsForm';

View File

@ -0,0 +1,451 @@
/**
* useContracts Hook - Contratos, Subcontratistas, Partidas, Addendas
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import toast from 'react-hot-toast';
import type { ApiError } from '../services/api';
import {
contractsApi,
subcontractorsApi,
contractPartidasApi,
contractAddendumsApi,
} from '../services/contracts';
import type {
ContractFilters,
CreateContractDto,
UpdateContractDto,
SubcontractorFilters,
CreateSubcontractorDto,
UpdateSubcontractorDto,
CreateContractPartidaDto,
UpdateContractPartidaDto,
CreateAddendumDto,
UpdateAddendumDto,
} from '../types/contracts.types';
// ============================================================================
// QUERY KEYS
// ============================================================================
export const contractsKeys = {
// Contracts
contracts: {
all: ['contracts'] as const,
list: (filters?: ContractFilters) => [...contractsKeys.contracts.all, 'list', filters] as const,
detail: (id: string) => [...contractsKeys.contracts.all, 'detail', id] as const,
stats: () => [...contractsKeys.contracts.all, 'stats'] as const,
},
// Subcontractors
subcontractors: {
all: ['subcontractors'] as const,
list: (filters?: SubcontractorFilters) => [...contractsKeys.subcontractors.all, 'list', filters] as const,
detail: (id: string) => [...contractsKeys.subcontractors.all, 'detail', id] as const,
},
// Contract Partidas
partidas: {
all: ['contract-partidas'] as const,
list: (contractId: string) => [...contractsKeys.partidas.all, 'list', contractId] as const,
},
// Contract Addendums
addendums: {
all: ['contract-addendums'] as const,
list: (contractId: string) => [...contractsKeys.addendums.all, 'list', contractId] as const,
detail: (contractId: string, addendumId: string) => [...contractsKeys.addendums.all, 'detail', contractId, addendumId] as const,
},
};
// ============================================================================
// ERROR HANDLER
// ============================================================================
const handleError = (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Ha ocurrido un error';
toast.error(message);
};
// ============================================================================
// CONTRACTS HOOKS
// ============================================================================
export function useContracts(filters?: ContractFilters) {
return useQuery({
queryKey: contractsKeys.contracts.list(filters),
queryFn: () => contractsApi.list(filters),
});
}
export function useContract(id: string) {
return useQuery({
queryKey: contractsKeys.contracts.detail(id),
queryFn: () => contractsApi.get(id),
enabled: !!id,
});
}
export function useContractStats() {
return useQuery({
queryKey: contractsKeys.contracts.stats(),
queryFn: () => contractsApi.stats(),
});
}
export function useCreateContract() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateContractDto) => contractsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.all });
toast.success('Contrato creado exitosamente');
},
onError: handleError,
});
}
export function useUpdateContract() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateContractDto }) =>
contractsApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.all });
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(id) });
toast.success('Contrato actualizado');
},
onError: handleError,
});
}
export function useDeleteContract() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => contractsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.all });
toast.success('Contrato eliminado');
},
onError: handleError,
});
}
export function useSubmitContract() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => contractsApi.submit(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.all });
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(id) });
toast.success('Contrato enviado a revision');
},
onError: handleError,
});
}
export function useApproveContract() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => contractsApi.approve(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.all });
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(id) });
toast.success('Contrato aprobado');
},
onError: handleError,
});
}
export function useActivateContract() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => contractsApi.activate(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.all });
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(id) });
toast.success('Contrato activado');
},
onError: handleError,
});
}
export function useCompleteContract() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => contractsApi.complete(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.all });
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(id) });
toast.success('Contrato completado');
},
onError: handleError,
});
}
export function useTerminateContract() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
contractsApi.terminate(id, reason),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.all });
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(id) });
toast.success('Contrato terminado');
},
onError: handleError,
});
}
// ============================================================================
// SUBCONTRACTORS HOOKS
// ============================================================================
export function useSubcontractors(filters?: SubcontractorFilters) {
return useQuery({
queryKey: contractsKeys.subcontractors.list(filters),
queryFn: () => subcontractorsApi.list(filters),
});
}
export function useSubcontractor(id: string) {
return useQuery({
queryKey: contractsKeys.subcontractors.detail(id),
queryFn: () => subcontractorsApi.get(id),
enabled: !!id,
});
}
export function useCreateSubcontractor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateSubcontractorDto) => subcontractorsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.all });
toast.success('Subcontratista creado exitosamente');
},
onError: handleError,
});
}
export function useUpdateSubcontractor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateSubcontractorDto }) =>
subcontractorsApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.all });
queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.detail(id) });
toast.success('Subcontratista actualizado');
},
onError: handleError,
});
}
export function useDeleteSubcontractor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => subcontractorsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.all });
toast.success('Subcontratista eliminado');
},
onError: handleError,
});
}
export function useActivateSubcontractor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => subcontractorsApi.activate(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.all });
queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.detail(id) });
toast.success('Subcontratista activado');
},
onError: handleError,
});
}
export function useDeactivateSubcontractor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => subcontractorsApi.deactivate(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.all });
queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.detail(id) });
toast.success('Subcontratista desactivado');
},
onError: handleError,
});
}
export function useBlacklistSubcontractor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
subcontractorsApi.blacklist(id, reason),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.all });
queryClient.invalidateQueries({ queryKey: contractsKeys.subcontractors.detail(id) });
toast.success('Subcontratista agregado a lista negra');
},
onError: handleError,
});
}
// ============================================================================
// CONTRACT PARTIDAS HOOKS
// ============================================================================
export function useContractPartidas(contractId: string) {
return useQuery({
queryKey: contractsKeys.partidas.list(contractId),
queryFn: () => contractPartidasApi.list(contractId),
enabled: !!contractId,
});
}
export function useCreateContractPartida() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contractId, data }: { contractId: string; data: CreateContractPartidaDto }) =>
contractPartidasApi.create(contractId, data),
onSuccess: (_, { contractId }) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.partidas.list(contractId) });
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(contractId) });
toast.success('Partida agregada');
},
onError: handleError,
});
}
export function useUpdateContractPartida() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contractId, partidaId, data }: { contractId: string; partidaId: string; data: UpdateContractPartidaDto }) =>
contractPartidasApi.update(contractId, partidaId, data),
onSuccess: (_, { contractId }) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.partidas.list(contractId) });
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(contractId) });
toast.success('Partida actualizada');
},
onError: handleError,
});
}
export function useDeleteContractPartida() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contractId, partidaId }: { contractId: string; partidaId: string }) =>
contractPartidasApi.delete(contractId, partidaId),
onSuccess: (_, { contractId }) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.partidas.list(contractId) });
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(contractId) });
toast.success('Partida eliminada');
},
onError: handleError,
});
}
// ============================================================================
// CONTRACT ADDENDUMS HOOKS
// ============================================================================
export function useContractAddendums(contractId: string) {
return useQuery({
queryKey: contractsKeys.addendums.list(contractId),
queryFn: () => contractAddendumsApi.list(contractId),
enabled: !!contractId,
});
}
export function useContractAddendum(contractId: string, addendumId: string) {
return useQuery({
queryKey: contractsKeys.addendums.detail(contractId, addendumId),
queryFn: () => contractAddendumsApi.get(contractId, addendumId),
enabled: !!contractId && !!addendumId,
});
}
export function useCreateContractAddendum() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contractId, data }: { contractId: string; data: CreateAddendumDto }) =>
contractAddendumsApi.create(contractId, data),
onSuccess: (_, { contractId }) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.list(contractId) });
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(contractId) });
toast.success('Addenda creada');
},
onError: handleError,
});
}
export function useUpdateContractAddendum() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contractId, addendumId, data }: { contractId: string; addendumId: string; data: UpdateAddendumDto }) =>
contractAddendumsApi.update(contractId, addendumId, data),
onSuccess: (_, { contractId, addendumId }) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.list(contractId) });
queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.detail(contractId, addendumId) });
toast.success('Addenda actualizada');
},
onError: handleError,
});
}
export function useDeleteContractAddendum() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contractId, addendumId }: { contractId: string; addendumId: string }) =>
contractAddendumsApi.delete(contractId, addendumId),
onSuccess: (_, { contractId }) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.list(contractId) });
toast.success('Addenda eliminada');
},
onError: handleError,
});
}
export function useSubmitAddendum() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contractId, addendumId }: { contractId: string; addendumId: string }) =>
contractAddendumsApi.submit(contractId, addendumId),
onSuccess: (_, { contractId, addendumId }) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.list(contractId) });
queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.detail(contractId, addendumId) });
toast.success('Addenda enviada a revision');
},
onError: handleError,
});
}
export function useApproveAddendum() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contractId, addendumId }: { contractId: string; addendumId: string }) =>
contractAddendumsApi.approve(contractId, addendumId),
onSuccess: (_, { contractId, addendumId }) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.list(contractId) });
queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.detail(contractId, addendumId) });
queryClient.invalidateQueries({ queryKey: contractsKeys.contracts.detail(contractId) });
toast.success('Addenda aprobada');
},
onError: handleError,
});
}
export function useRejectAddendum() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contractId, addendumId, reason }: { contractId: string; addendumId: string; reason: string }) =>
contractAddendumsApi.reject(contractId, addendumId, reason),
onSuccess: (_, { contractId, addendumId }) => {
queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.list(contractId) });
queryClient.invalidateQueries({ queryKey: contractsKeys.addendums.detail(contractId, addendumId) });
toast.success('Addenda rechazada');
},
onError: handleError,
});
}

647
web/src/hooks/useQuality.ts Normal file
View File

@ -0,0 +1,647 @@
/**
* useQuality Hook - Calidad, Inspecciones, No Conformidades, Tickets Postventa
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import toast from 'react-hot-toast';
import type { ApiError } from '../services/api';
import {
checklistsApi,
inspectionsApi,
ticketsApi,
nonConformitiesApi,
correctiveActionsApi,
} from '../services/quality';
import type {
ChecklistFilters,
CreateChecklistDto,
UpdateChecklistDto,
InspectionFilters,
CreateInspectionDto,
UpdateInspectionDto,
SaveInspectionResultsDto,
TicketFilters,
CreateTicketDto,
UpdateTicketDto,
AssignTicketDto,
ResolveTicketDto,
RateTicketDto,
NonConformityFilters,
CreateNonConformityDto,
UpdateNonConformityDto,
CloseNonConformityDto,
CreateCorrectiveActionDto,
UpdateCorrectiveActionDto,
CompleteCorrectiveActionDto,
} from '../types/quality.types';
// ============================================================================
// QUERY KEYS
// ============================================================================
export const qualityKeys = {
// Checklists
checklists: {
all: ['quality', 'checklists'] as const,
list: (filters?: ChecklistFilters) => [...qualityKeys.checklists.all, 'list', filters] as const,
detail: (id: string) => [...qualityKeys.checklists.all, 'detail', id] as const,
},
// Inspections
inspections: {
all: ['quality', 'inspections'] as const,
list: (filters?: InspectionFilters) => [...qualityKeys.inspections.all, 'list', filters] as const,
detail: (id: string) => [...qualityKeys.inspections.all, 'detail', id] as const,
results: (id: string) => [...qualityKeys.inspections.all, 'results', id] as const,
stats: (filters?: InspectionFilters) => [...qualityKeys.inspections.all, 'stats', filters] as const,
},
// Tickets
tickets: {
all: ['quality', 'tickets'] as const,
list: (filters?: TicketFilters) => [...qualityKeys.tickets.all, 'list', filters] as const,
detail: (id: string) => [...qualityKeys.tickets.all, 'detail', id] as const,
stats: (filters?: TicketFilters) => [...qualityKeys.tickets.all, 'stats', filters] as const,
},
// Non-Conformities
nonConformities: {
all: ['quality', 'non-conformities'] as const,
list: (filters?: NonConformityFilters) => [...qualityKeys.nonConformities.all, 'list', filters] as const,
detail: (id: string) => [...qualityKeys.nonConformities.all, 'detail', id] as const,
stats: (filters?: NonConformityFilters) => [...qualityKeys.nonConformities.all, 'stats', filters] as const,
actions: (ncId: string) => [...qualityKeys.nonConformities.all, 'actions', ncId] as const,
},
};
// ============================================================================
// ERROR HANDLER
// ============================================================================
const handleError = (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Ha ocurrido un error';
toast.error(message);
};
// ============================================================================
// CHECKLISTS HOOKS
// ============================================================================
export function useChecklists(filters?: ChecklistFilters) {
return useQuery({
queryKey: qualityKeys.checklists.list(filters),
queryFn: () => checklistsApi.list(filters),
});
}
export function useChecklist(id: string) {
return useQuery({
queryKey: qualityKeys.checklists.detail(id),
queryFn: () => checklistsApi.get(id),
enabled: !!id,
});
}
export function useCreateChecklist() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateChecklistDto) => checklistsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: qualityKeys.checklists.all });
toast.success('Checklist creado exitosamente');
},
onError: handleError,
});
}
export function useUpdateChecklist() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateChecklistDto }) =>
checklistsApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.checklists.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.checklists.detail(id) });
toast.success('Checklist actualizado');
},
onError: handleError,
});
}
export function useDeleteChecklist() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => checklistsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: qualityKeys.checklists.all });
toast.success('Checklist eliminado');
},
onError: handleError,
});
}
export function useDuplicateChecklist() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => checklistsApi.duplicate(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: qualityKeys.checklists.all });
toast.success('Checklist duplicado');
},
onError: handleError,
});
}
// ============================================================================
// INSPECTIONS HOOKS
// ============================================================================
export function useInspections(filters?: InspectionFilters) {
return useQuery({
queryKey: qualityKeys.inspections.list(filters),
queryFn: () => inspectionsApi.list(filters),
});
}
export function useInspection(id: string) {
return useQuery({
queryKey: qualityKeys.inspections.detail(id),
queryFn: () => inspectionsApi.get(id),
enabled: !!id,
});
}
export function useInspectionResults(inspectionId: string) {
return useQuery({
queryKey: qualityKeys.inspections.results(inspectionId),
queryFn: () => inspectionsApi.getResults(inspectionId),
enabled: !!inspectionId,
});
}
export function useInspectionStats(filters?: InspectionFilters) {
return useQuery({
queryKey: qualityKeys.inspections.stats(filters),
queryFn: () => inspectionsApi.stats(filters),
});
}
export function useCreateInspection() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateInspectionDto) => inspectionsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.all });
toast.success('Inspeccion creada exitosamente');
},
onError: handleError,
});
}
export function useUpdateInspection() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateInspectionDto }) =>
inspectionsApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.detail(id) });
toast.success('Inspeccion actualizada');
},
onError: handleError,
});
}
export function useDeleteInspection() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => inspectionsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.all });
toast.success('Inspeccion eliminada');
},
onError: handleError,
});
}
export function useSaveInspectionResults() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ inspectionId, data }: { inspectionId: string; data: SaveInspectionResultsDto }) =>
inspectionsApi.saveResults(inspectionId, data),
onSuccess: (_, { inspectionId }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.results(inspectionId) });
queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.detail(inspectionId) });
toast.success('Resultados guardados');
},
onError: handleError,
});
}
export function useStartInspection() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => inspectionsApi.start(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.detail(id) });
toast.success('Inspeccion iniciada');
},
onError: handleError,
});
}
export function useCompleteInspection() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => inspectionsApi.complete(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.detail(id) });
toast.success('Inspeccion completada');
},
onError: handleError,
});
}
export function useApproveInspection() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => inspectionsApi.approve(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.detail(id) });
toast.success('Inspeccion aprobada');
},
onError: handleError,
});
}
export function useRejectInspection() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
inspectionsApi.reject(id, reason),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.inspections.detail(id) });
toast.success('Inspeccion rechazada');
},
onError: handleError,
});
}
// ============================================================================
// TICKETS HOOKS
// ============================================================================
export function useTickets(filters?: TicketFilters) {
return useQuery({
queryKey: qualityKeys.tickets.list(filters),
queryFn: () => ticketsApi.list(filters),
});
}
export function useTicket(id: string) {
return useQuery({
queryKey: qualityKeys.tickets.detail(id),
queryFn: () => ticketsApi.get(id),
enabled: !!id,
});
}
export function useTicketStats(filters?: TicketFilters) {
return useQuery({
queryKey: qualityKeys.tickets.stats(filters),
queryFn: () => ticketsApi.stats(filters),
});
}
export function useCreateTicket() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateTicketDto) => ticketsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all });
toast.success('Ticket creado exitosamente');
},
onError: handleError,
});
}
export function useUpdateTicket() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateTicketDto }) =>
ticketsApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.detail(id) });
toast.success('Ticket actualizado');
},
onError: handleError,
});
}
export function useDeleteTicket() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => ticketsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all });
toast.success('Ticket eliminado');
},
onError: handleError,
});
}
export function useAssignTicket() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: AssignTicketDto }) =>
ticketsApi.assign(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.detail(id) });
toast.success('Ticket asignado');
},
onError: handleError,
});
}
export function useStartTicketWork() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => ticketsApi.startWork(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.detail(id) });
toast.success('Trabajo iniciado');
},
onError: handleError,
});
}
export function useResolveTicket() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: ResolveTicketDto }) =>
ticketsApi.resolve(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.detail(id) });
toast.success('Ticket resuelto');
},
onError: handleError,
});
}
export function useCloseTicket() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => ticketsApi.close(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.detail(id) });
toast.success('Ticket cerrado');
},
onError: handleError,
});
}
export function useCancelTicket() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, reason }: { id: string; reason?: string }) =>
ticketsApi.cancel(id, reason),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.detail(id) });
toast.success('Ticket cancelado');
},
onError: handleError,
});
}
export function useRateTicket() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: RateTicketDto }) =>
ticketsApi.rate(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.tickets.detail(id) });
toast.success('Calificacion registrada');
},
onError: handleError,
});
}
// ============================================================================
// NON-CONFORMITIES HOOKS
// ============================================================================
export function useNonConformities(filters?: NonConformityFilters) {
return useQuery({
queryKey: qualityKeys.nonConformities.list(filters),
queryFn: () => nonConformitiesApi.list(filters),
});
}
export function useNonConformity(id: string) {
return useQuery({
queryKey: qualityKeys.nonConformities.detail(id),
queryFn: () => nonConformitiesApi.get(id),
enabled: !!id,
});
}
export function useNonConformityStats(filters?: NonConformityFilters) {
return useQuery({
queryKey: qualityKeys.nonConformities.stats(filters),
queryFn: () => nonConformitiesApi.stats(filters),
});
}
export function useCreateNonConformity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateNonConformityDto) => nonConformitiesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.all });
toast.success('No conformidad creada exitosamente');
},
onError: handleError,
});
}
export function useUpdateNonConformity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateNonConformityDto }) =>
nonConformitiesApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.detail(id) });
toast.success('No conformidad actualizada');
},
onError: handleError,
});
}
export function useDeleteNonConformity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => nonConformitiesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.all });
toast.success('No conformidad eliminada');
},
onError: handleError,
});
}
export function useStartNonConformityProgress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => nonConformitiesApi.startProgress(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.detail(id) });
toast.success('No conformidad en progreso');
},
onError: handleError,
});
}
export function useCloseNonConformity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: CloseNonConformityDto }) =>
nonConformitiesApi.close(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.detail(id) });
toast.success('No conformidad cerrada');
},
onError: handleError,
});
}
export function useVerifyNonConformity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => nonConformitiesApi.verify(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.detail(id) });
toast.success('No conformidad verificada');
},
onError: handleError,
});
}
export function useReopenNonConformity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
nonConformitiesApi.reopen(id, reason),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.all });
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.detail(id) });
toast.success('No conformidad reabierta');
},
onError: handleError,
});
}
// ============================================================================
// CORRECTIVE ACTIONS HOOKS
// ============================================================================
export function useCorrectiveActions(nonConformityId: string) {
return useQuery({
queryKey: qualityKeys.nonConformities.actions(nonConformityId),
queryFn: () => correctiveActionsApi.list(nonConformityId),
enabled: !!nonConformityId,
});
}
export function useCreateCorrectiveAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ ncId, data }: { ncId: string; data: CreateCorrectiveActionDto }) =>
correctiveActionsApi.create(ncId, data),
onSuccess: (_, { ncId }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.actions(ncId) });
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.detail(ncId) });
toast.success('Accion correctiva creada');
},
onError: handleError,
});
}
export function useUpdateCorrectiveAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ ncId, actionId, data }: { ncId: string; actionId: string; data: UpdateCorrectiveActionDto }) =>
correctiveActionsApi.update(ncId, actionId, data),
onSuccess: (_, { ncId }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.actions(ncId) });
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.detail(ncId) });
toast.success('Accion correctiva actualizada');
},
onError: handleError,
});
}
export function useDeleteCorrectiveAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ ncId, actionId }: { ncId: string; actionId: string }) =>
correctiveActionsApi.delete(ncId, actionId),
onSuccess: (_, { ncId }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.actions(ncId) });
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.detail(ncId) });
toast.success('Accion correctiva eliminada');
},
onError: handleError,
});
}
export function useStartCorrectiveAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ ncId, actionId }: { ncId: string; actionId: string }) =>
correctiveActionsApi.start(ncId, actionId),
onSuccess: (_, { ncId }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.actions(ncId) });
toast.success('Accion iniciada');
},
onError: handleError,
});
}
export function useCompleteCorrectiveAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ ncId, actionId, data }: { ncId: string; actionId: string; data: CompleteCorrectiveActionDto }) =>
correctiveActionsApi.complete(ncId, actionId, data),
onSuccess: (_, { ncId }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.actions(ncId) });
toast.success('Accion completada');
},
onError: handleError,
});
}
export function useVerifyCorrectiveAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ ncId, actionId, effective }: { ncId: string; actionId: string; effective: boolean }) =>
correctiveActionsApi.verify(ncId, actionId, effective),
onSuccess: (_, { ncId }) => {
queryClient.invalidateQueries({ queryKey: qualityKeys.nonConformities.actions(ncId) });
toast.success('Accion verificada');
},
onError: handleError,
});
}

View File

@ -0,0 +1,312 @@
/**
* ChecklistsPage - Lista de plantillas de checklist
*/
import { useState } from 'react';
import {
Plus,
Eye,
Pencil,
Trash2,
Copy,
ClipboardList,
CheckCircle2,
AlertTriangle,
} from 'lucide-react';
import {
useChecklists,
useDeleteChecklist,
useCreateChecklist,
useUpdateChecklist,
useDuplicateChecklist,
} from '../../../hooks/useQuality';
import type {
Checklist,
ChecklistStage,
CreateChecklistDto,
} from '../../../types/quality.types';
import { CHECKLIST_STAGE_OPTIONS } from '../../../types/quality.types';
import {
PageHeader,
DataTable,
SearchInput,
SelectField,
StatusBadgeFromOptions,
ConfirmDialog,
LoadingOverlay,
} from '../../../components/common';
import type { DataTableColumn } from '../../../components/common';
import { ChecklistForm } from '../../../components/quality';
export function ChecklistsPage() {
const [search, setSearch] = useState('');
const [stageFilter, setStageFilter] = useState<ChecklistStage | ''>('');
const [showForm, setShowForm] = useState(false);
const [editingChecklist, setEditingChecklist] = useState<Checklist | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [duplicateId, setDuplicateId] = useState<string | null>(null);
const { data, isLoading, error } = useChecklists({
stage: stageFilter || undefined,
});
const createMutation = useCreateChecklist();
const updateMutation = useUpdateChecklist();
const deleteMutation = useDeleteChecklist();
const duplicateMutation = useDuplicateChecklist();
const handleCreate = () => {
setEditingChecklist(null);
setShowForm(true);
};
const handleEdit = (checklist: Checklist) => {
setEditingChecklist(checklist);
setShowForm(true);
};
const handleSubmit = async (data: CreateChecklistDto) => {
if (editingChecklist) {
await updateMutation.mutateAsync({ id: editingChecklist.id, data });
} else {
await createMutation.mutateAsync(data);
}
setShowForm(false);
setEditingChecklist(null);
};
const handleDelete = async () => {
if (deleteId) {
await deleteMutation.mutateAsync(deleteId);
setDeleteId(null);
}
};
const handleDuplicate = async () => {
if (duplicateId) {
await duplicateMutation.mutateAsync(duplicateId);
setDuplicateId(null);
}
};
// Filter by search
const items = (data?.items || []).filter(
(item) =>
!search ||
item.code.toLowerCase().includes(search.toLowerCase()) ||
item.name.toLowerCase().includes(search.toLowerCase())
);
const columns: DataTableColumn<Checklist>[] = [
{
key: 'code',
header: 'Codigo',
render: (item) => (
<span className="font-mono font-medium text-gray-900 dark:text-white">
{item.code}
</span>
),
},
{
key: 'name',
header: 'Nombre',
render: (item) => (
<div>
<p className="text-gray-900 dark:text-white font-medium">{item.name}</p>
{item.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs">
{item.description}
</p>
)}
</div>
),
},
{
key: 'stage',
header: 'Etapa',
render: (item) => (
<StatusBadgeFromOptions
value={item.stage}
options={[...CHECKLIST_STAGE_OPTIONS]}
/>
),
},
{
key: 'items',
header: 'Items',
align: 'center',
render: (item) => {
const totalItems = item.items?.length || 0;
const criticalItems = item.items?.filter((i) => i.isCritical).length || 0;
return (
<div className="text-center">
<span className="font-medium text-gray-900 dark:text-white">{totalItems}</span>
{criticalItems > 0 && (
<span className="ml-2 inline-flex items-center gap-1 text-xs text-red-600">
<AlertTriangle className="w-3 h-3" />
{criticalItems}
</span>
)}
</div>
);
},
},
{
key: 'status',
header: 'Estado',
render: (item) => (
<span
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
item.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{item.isActive ? (
<>
<CheckCircle2 className="w-3 h-3" />
Activo
</>
) : (
'Inactivo'
)}
</span>
),
},
{
key: 'version',
header: 'Version',
align: 'center',
render: (item) => (
<span className="text-gray-500 dark:text-gray-400">v{item.version}</span>
),
},
{
key: 'actions',
header: 'Acciones',
align: 'right',
render: (item) => (
<div className="flex items-center justify-end gap-1">
<button
onClick={() => handleEdit(item)}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
title="Ver/Editar"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => setDuplicateId(item.id)}
className="p-2 text-gray-500 hover:text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded-lg"
title="Duplicar"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={() => handleEdit(item)}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
title="Editar"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteId(item.id)}
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
title="Eliminar"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
),
},
];
if (isLoading) {
return <LoadingOverlay message="Cargando checklists..." />;
}
return (
<div>
<PageHeader
title="Checklists de Calidad"
description="Plantillas de inspeccion para control de calidad"
actions={
<button
onClick={handleCreate}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-5 h-5 mr-2" />
Nuevo Checklist
</button>
}
/>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Buscar por codigo o nombre..."
className="lg:col-span-2"
/>
<SelectField
options={[
{ value: '', label: 'Todas las etapas' },
...CHECKLIST_STAGE_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
]}
value={stageFilter}
onChange={(e) => setStageFilter(e.target.value as ChecklistStage | '')}
/>
</div>
</div>
<DataTable
data={items}
columns={columns}
isLoading={isLoading}
error={error ? 'Error al cargar los datos' : null}
emptyState={{
icon: <ClipboardList className="w-12 h-12 text-gray-400" />,
title: 'No hay checklists',
description: 'Crea tu primer checklist para comenzar.',
}}
/>
{/* Create/Edit Form */}
<ChecklistForm
isOpen={showForm}
onClose={() => {
setShowForm(false);
setEditingChecklist(null);
}}
onSubmit={handleSubmit}
initialData={editingChecklist}
isLoading={createMutation.isPending || updateMutation.isPending}
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Eliminar Checklist"
message="Esta seguro de eliminar este checklist? Esta accion no se puede deshacer."
confirmLabel="Eliminar"
variant="danger"
isLoading={deleteMutation.isPending}
/>
{/* Duplicate Confirmation */}
<ConfirmDialog
isOpen={!!duplicateId}
onClose={() => setDuplicateId(null)}
onConfirm={handleDuplicate}
title="Duplicar Checklist"
message="Se creara una copia de este checklist con todos sus items."
confirmLabel="Duplicar"
variant="info"
isLoading={duplicateMutation.isPending}
/>
</div>
);
}

View File

@ -0,0 +1,514 @@
/**
* InspeccionesPage - Lista de inspecciones de calidad
*/
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
Plus,
Eye,
Trash2,
ClipboardCheck,
Play,
CheckCircle,
XCircle,
User,
MapPin,
Calendar,
} from 'lucide-react';
import {
useInspections,
useInspectionStats,
useDeleteInspection,
useCreateInspection,
useStartInspection,
} from '../../../hooks/useQuality';
import type {
Inspection,
InspectionStatus,
CreateInspectionDto,
} from '../../../types/quality.types';
import { INSPECTION_STATUS_OPTIONS } from '../../../types/quality.types';
import {
PageHeader,
DataTable,
SearchInput,
SelectField,
StatusBadgeFromOptions,
ConfirmDialog,
LoadingOverlay,
Modal,
ModalFooter,
FormGroup,
TextInput,
TextareaField,
} from '../../../components/common';
import type { DataTableColumn } from '../../../components/common';
export function InspeccionesPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<InspectionStatus | ''>('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [startId, setStartId] = useState<string | null>(null);
// Create form state
const [createForm, setCreateForm] = useState({
checklistId: '',
loteId: '',
inspectorId: '',
inspectionDate: new Date().toISOString().split('T')[0],
notes: '',
});
const { data, isLoading, error } = useInspections({
status: statusFilter || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
});
const { data: stats } = useInspectionStats();
const createMutation = useCreateInspection();
const deleteMutation = useDeleteInspection();
const startMutation = useStartInspection();
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
const data: CreateInspectionDto = {
checklistId: createForm.checklistId,
loteId: createForm.loteId,
inspectorId: createForm.inspectorId,
inspectionDate: createForm.inspectionDate,
notes: createForm.notes || undefined,
};
await createMutation.mutateAsync(data);
setShowCreateForm(false);
setCreateForm({
checklistId: '',
loteId: '',
inspectorId: '',
inspectionDate: new Date().toISOString().split('T')[0],
notes: '',
});
};
const handleDelete = async () => {
if (deleteId) {
await deleteMutation.mutateAsync(deleteId);
setDeleteId(null);
}
};
const handleStart = async () => {
if (startId) {
await startMutation.mutateAsync(startId);
setStartId(null);
}
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
};
// Filter by search
const items = (data?.items || []).filter(
(item) =>
!search ||
item.inspectionNumber.toLowerCase().includes(search.toLowerCase())
);
const columns: DataTableColumn<Inspection>[] = [
{
key: 'number',
header: 'Inspeccion',
render: (item) => (
<div>
<span className="font-mono font-medium text-gray-900 dark:text-white">
{item.inspectionNumber}
</span>
<p className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDate(item.inspectionDate)}
</p>
</div>
),
},
{
key: 'checklist',
header: 'Checklist',
render: (item) => (
<span className="text-gray-700 dark:text-gray-300">
{item.checklist?.name || 'N/A'}
</span>
),
},
{
key: 'lote',
header: 'Lote',
render: (item) => (
<span className="flex items-center gap-1 text-gray-700 dark:text-gray-300">
<MapPin className="w-4 h-4 text-gray-400" />
{item.loteId.substring(0, 8)}...
</span>
),
},
{
key: 'inspector',
header: 'Inspector',
render: (item) => (
<span className="flex items-center gap-1 text-gray-700 dark:text-gray-300">
<User className="w-4 h-4 text-gray-400" />
{item.inspectorId.substring(0, 8)}...
</span>
),
},
{
key: 'progress',
header: 'Progreso',
render: (item) => {
const total = item.totalItems || 1;
const passed = item.passedItems || 0;
const failed = item.failedItems || 0;
const evaluated = passed + failed;
const percent = Math.round((evaluated / total) * 100);
return (
<div className="w-32">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-500">{evaluated}/{total}</span>
<span className="font-medium">{percent}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className="bg-blue-600 h-1.5 rounded-full transition-all"
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
},
},
{
key: 'passRate',
header: 'Tasa Aprobacion',
align: 'center',
render: (item) => {
const rate = item.passRate || 0;
const colorClass =
rate >= 90
? 'text-green-600'
: rate >= 70
? 'text-yellow-600'
: 'text-red-600';
return (
<span className={`font-bold ${colorClass}`}>
{rate.toFixed(1)}%
</span>
);
},
},
{
key: 'status',
header: 'Estado',
render: (item) => (
<StatusBadgeFromOptions
value={item.status}
options={[...INSPECTION_STATUS_OPTIONS]}
/>
),
},
{
key: 'actions',
header: 'Acciones',
align: 'right',
render: (item) => (
<div className="flex items-center justify-end gap-1">
<Link
to={`/admin/calidad/inspecciones/${item.id}`}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
title="Ver detalle"
>
<Eye className="w-4 h-4" />
</Link>
{item.status === 'pending' && (
<button
onClick={() => setStartId(item.id)}
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg"
title="Iniciar inspeccion"
>
<Play className="w-4 h-4" />
</button>
)}
{item.status === 'pending' && (
<button
onClick={() => setDeleteId(item.id)}
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
title="Eliminar"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
),
},
];
if (isLoading) {
return <LoadingOverlay message="Cargando inspecciones..." />;
}
return (
<div>
<PageHeader
title="Inspecciones de Calidad"
description="Control y seguimiento de inspecciones"
actions={
<button
onClick={() => setShowCreateForm(true)}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-5 h-5 mr-2" />
Nueva Inspeccion
</button>
}
/>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
<StatsCard
label="Total"
value={stats.totalInspections}
color="gray"
/>
<StatsCard
label="Pendientes"
value={stats.pendingInspections}
color="gray"
/>
<StatsCard
label="En Progreso"
value={stats.inProgressInspections}
color="blue"
/>
<StatsCard
label="Completadas"
value={stats.completedInspections}
color="yellow"
/>
<StatsCard
label="Aprobadas"
value={stats.approvedInspections}
color="green"
icon={<CheckCircle className="w-4 h-4" />}
/>
<StatsCard
label="Rechazadas"
value={stats.rejectedInspections}
color="red"
icon={<XCircle className="w-4 h-4" />}
/>
</div>
)}
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Buscar por numero..."
className="lg:col-span-2"
/>
<SelectField
options={[
{ value: '', label: 'Todos los estados' },
...INSPECTION_STATUS_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
})),
]}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as InspectionStatus | '')}
/>
<div className="flex gap-2">
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Desde"
/>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Hasta"
/>
</div>
</div>
</div>
<DataTable
data={items}
columns={columns}
isLoading={isLoading}
error={error ? 'Error al cargar los datos' : null}
emptyState={{
icon: <ClipboardCheck className="w-12 h-12 text-gray-400" />,
title: 'No hay inspecciones',
description: 'Crea tu primera inspeccion para comenzar.',
}}
/>
{/* Create Form Modal */}
<Modal
isOpen={showCreateForm}
onClose={() => setShowCreateForm(false)}
title="Nueva Inspeccion"
>
<form onSubmit={handleCreate}>
<div className="space-y-4">
<FormGroup label="Checklist" required>
<TextInput
value={createForm.checklistId}
onChange={(e) =>
setCreateForm({ ...createForm, checklistId: e.target.value })
}
placeholder="ID del checklist"
required
/>
</FormGroup>
<FormGroup label="Lote" required>
<TextInput
value={createForm.loteId}
onChange={(e) =>
setCreateForm({ ...createForm, loteId: e.target.value })
}
placeholder="ID del lote"
required
/>
</FormGroup>
<FormGroup label="Inspector" required>
<TextInput
value={createForm.inspectorId}
onChange={(e) =>
setCreateForm({ ...createForm, inspectorId: e.target.value })
}
placeholder="ID del inspector"
required
/>
</FormGroup>
<FormGroup label="Fecha de Inspeccion" required>
<TextInput
type="date"
value={createForm.inspectionDate}
onChange={(e) =>
setCreateForm({ ...createForm, inspectionDate: e.target.value })
}
required
/>
</FormGroup>
<FormGroup label="Notas">
<TextareaField
value={createForm.notes}
onChange={(e) =>
setCreateForm({ ...createForm, notes: e.target.value })
}
placeholder="Notas adicionales..."
rows={2}
/>
</FormGroup>
</div>
<ModalFooter>
<button
type="button"
onClick={() => setShowCreateForm(false)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
disabled={createMutation.isPending}
>
{createMutation.isPending ? 'Creando...' : 'Crear Inspeccion'}
</button>
</ModalFooter>
</form>
</Modal>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Eliminar Inspeccion"
message="Esta seguro de eliminar esta inspeccion? Esta accion no se puede deshacer."
confirmLabel="Eliminar"
variant="danger"
isLoading={deleteMutation.isPending}
/>
{/* Start Confirmation */}
<ConfirmDialog
isOpen={!!startId}
onClose={() => setStartId(null)}
onConfirm={handleStart}
title="Iniciar Inspeccion"
message="Desea iniciar esta inspeccion? El estado cambiara a 'En Progreso'."
confirmLabel="Iniciar"
variant="info"
isLoading={startMutation.isPending}
/>
</div>
);
}
// ============================================================================
// STATS CARD COMPONENT
// ============================================================================
interface StatsCardProps {
label: string;
value: number;
color: 'blue' | 'green' | 'yellow' | 'red' | 'gray';
icon?: React.ReactNode;
}
function StatsCard({ label, value, color, icon }: StatsCardProps) {
const colorClasses = {
blue: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
green: 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/30 dark:text-green-400',
yellow: 'border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
red: 'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/30 dark:text-red-400',
gray: 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
return (
<div className={`rounded-lg border-2 p-3 ${colorClasses[color]}`}>
<div className="flex items-center justify-between">
<p className="text-xs text-gray-600 dark:text-gray-400">{label}</p>
{icon}
</div>
<p className="text-xl font-bold mt-1">{value}</p>
</div>
);
}

View File

@ -0,0 +1,414 @@
/**
* NoConformidadesPage - Lista de no conformidades
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Plus,
Eye,
Trash2,
AlertOctagon,
Play,
CheckCircle,
} from 'lucide-react';
import {
useNonConformities,
useDeleteNonConformity,
useNonConformityStats,
useStartNonConformityProgress,
useCloseNonConformity,
useVerifyNonConformity,
} from '../../../hooks/useQuality';
import type {
NCSeverity,
NCStatus,
NonConformityFilters,
} from '../../../types/quality.types';
import {
NC_SEVERITY_OPTIONS,
NC_STATUS_OPTIONS,
} from '../../../types/quality.types';
import {
PageHeader,
PageHeaderAction,
SearchInput,
SelectField,
StatusBadgeFromOptions,
ConfirmDialog,
LoadingOverlay,
EmptyState,
Pagination,
StatsCard,
Modal,
ModalFooter,
TextareaField,
} from '../../../components/common';
export function NoConformidadesPage() {
const navigate = useNavigate();
const [search, setSearch] = useState('');
const [severityFilter, setSeverityFilter] = useState<NCSeverity | ''>('');
const [statusFilter, setStatusFilter] = useState<NCStatus | ''>('');
const [page, setPage] = useState(1);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [closeId, setCloseId] = useState<string | null>(null);
const [closeNotes, setCloseNotes] = useState('');
const filters: NonConformityFilters = {
search: search || undefined,
severity: severityFilter || undefined,
status: statusFilter || undefined,
page,
limit: 10,
};
const { data, isLoading, error } = useNonConformities(filters);
const { data: stats } = useNonConformityStats();
const deleteMutation = useDeleteNonConformity();
const startProgressMutation = useStartNonConformityProgress();
const closeMutation = useCloseNonConformity();
const verifyMutation = useVerifyNonConformity();
const items = data?.items || [];
const total = data?.total || 0;
const totalPages = Math.ceil(total / 10);
const handleDelete = async () => {
if (deleteId) {
await deleteMutation.mutateAsync(deleteId);
setDeleteId(null);
}
};
const handleStartProgress = async (id: string) => {
await startProgressMutation.mutateAsync(id);
};
const handleClose = async () => {
if (closeId && closeNotes) {
await closeMutation.mutateAsync({ id: closeId, data: { closureNotes: closeNotes } });
setCloseId(null);
setCloseNotes('');
}
};
const handleVerify = async (id: string) => {
await verifyMutation.mutateAsync(id);
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
if (isLoading) {
return <LoadingOverlay message="Cargando no conformidades..." />;
}
if (error) {
return (
<EmptyState
title="Error al cargar"
description="No se pudieron cargar las no conformidades. Intente de nuevo."
/>
);
}
return (
<div>
<PageHeader
title="No Conformidades"
description="Gestion de hallazgos de calidad y acciones correctivas"
actions={
<PageHeaderAction onClick={() => navigate('/admin/calidad/no-conformidades/nueva')}>
<Plus className="w-5 h-5 mr-2" />
Nueva NC
</PageHeaderAction>
}
/>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4 mb-6">
<StatsCard
title="Total NCs"
value={stats.totalNCs}
icon={<AlertOctagon className="w-5 h-5" />}
color="blue"
/>
<StatsCard
title="Abiertas"
value={stats.openNCs}
icon={<AlertOctagon className="w-5 h-5" />}
color="red"
/>
<StatsCard
title="En Progreso"
value={stats.inProgressNCs}
icon={<AlertOctagon className="w-5 h-5" />}
color="yellow"
/>
<StatsCard
title="Cerradas"
value={stats.closedNCs}
icon={<CheckCircle className="w-5 h-5" />}
color="green"
/>
<StatsCard
title="Criticas"
value={stats.criticalNCs}
icon={<AlertOctagon className="w-5 h-5" />}
color="red"
/>
</div>
)}
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Buscar por numero o descripcion..."
className="lg:col-span-2"
/>
<SelectField
options={[
{ value: '', label: 'Todas las severidades' },
...NC_SEVERITY_OPTIONS.map(o => ({ value: o.value, label: o.label })),
]}
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value as NCSeverity | '')}
/>
<SelectField
options={[
{ value: '', label: 'Todos los estados' },
...NC_STATUS_OPTIONS.map(o => ({ value: o.value, label: o.label })),
]}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as NCStatus | '')}
/>
</div>
</div>
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden">
{items.length === 0 ? (
<EmptyState
icon={<AlertOctagon className="w-12 h-12 text-gray-400" />}
title="No hay no conformidades"
description="No se encontraron no conformidades con los filtros seleccionados."
/>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
NC
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Severidad
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Estado
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Fecha Deteccion
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Fecha Limite
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Acciones Corr.
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Acciones
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{items.map((nc) => (
<tr key={nc.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<AlertOctagon className={`w-5 h-5 mr-3 ${
nc.severity === 'critical' ? 'text-red-500' :
nc.severity === 'major' ? 'text-orange-500' : 'text-yellow-500'
}`} />
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{nc.ncNumber}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate">
{nc.description}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadgeFromOptions
value={nc.severity}
options={[...NC_SEVERITY_OPTIONS]}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadgeFromOptions
value={nc.status}
options={[...NC_STATUS_OPTIONS]}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{formatDate(nc.detectionDate)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{nc.dueDate ? (
<span className={`${
new Date(nc.dueDate) < new Date() && nc.status !== 'closed' && nc.status !== 'verified'
? 'text-red-600 font-medium'
: 'text-gray-500 dark:text-gray-400'
}`}>
{formatDate(nc.dueDate)}
</span>
) : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-center">
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-full text-gray-700 dark:text-gray-300">
{nc.correctiveActions?.length || 0}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => navigate(`/admin/calidad/no-conformidades/${nc.id}`)}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
title="Ver detalle"
>
<Eye className="w-4 h-4" />
</button>
{nc.status === 'open' && (
<button
onClick={() => handleStartProgress(nc.id)}
disabled={startProgressMutation.isPending}
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg"
title="Iniciar trabajo"
>
<Play className="w-4 h-4" />
</button>
)}
{nc.status === 'in_progress' && (
<button
onClick={() => setCloseId(nc.id)}
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg"
title="Cerrar NC"
>
<CheckCircle className="w-4 h-4" />
</button>
)}
{nc.status === 'closed' && (
<button
onClick={() => handleVerify(nc.id)}
disabled={verifyMutation.isPending}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
title="Verificar"
>
<CheckCircle className="w-4 h-4" />
</button>
)}
{nc.status === 'open' && (
<button
onClick={() => setDeleteId(nc.id)}
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
title="Eliminar"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
)}
</>
)}
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Eliminar No Conformidad"
message="Esta seguro de eliminar esta no conformidad? Esta accion no se puede deshacer."
confirmLabel="Eliminar"
variant="danger"
isLoading={deleteMutation.isPending}
/>
{/* Close Modal */}
{closeId && (
<Modal
isOpen={true}
onClose={() => {
setCloseId(null);
setCloseNotes('');
}}
title="Cerrar No Conformidad"
size="md"
footer={
<ModalFooter>
<button
type="button"
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => {
setCloseId(null);
setCloseNotes('');
}}
>
Cancelar
</button>
<button
type="button"
onClick={handleClose}
disabled={!closeNotes || closeMutation.isPending}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
{closeMutation.isPending ? 'Cerrando...' : 'Cerrar NC'}
</button>
</ModalFooter>
}
>
<TextareaField
label="Notas de cierre"
required
value={closeNotes}
onChange={(e) => setCloseNotes(e.target.value)}
placeholder="Describa como se resolvio la no conformidad..."
rows={4}
/>
</Modal>
)}
</div>
);
}

View File

@ -0,0 +1,327 @@
/**
* TicketsPage - Lista de tickets de postventa
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Plus,
Eye,
Trash2,
Ticket,
Clock,
AlertTriangle,
User,
} from 'lucide-react';
import {
useTickets,
useDeleteTicket,
useTicketStats,
} from '../../../hooks/useQuality';
import type {
TicketCategory,
TicketPriority,
TicketStatus,
TicketFilters,
} from '../../../types/quality.types';
import {
TICKET_CATEGORY_OPTIONS,
TICKET_PRIORITY_OPTIONS,
TICKET_STATUS_OPTIONS,
} from '../../../types/quality.types';
import {
PageHeader,
PageHeaderAction,
SearchInput,
SelectField,
StatusBadgeFromOptions,
ConfirmDialog,
LoadingOverlay,
EmptyState,
Pagination,
StatsCard,
} from '../../../components/common';
export function TicketsPage() {
const navigate = useNavigate();
const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState<TicketCategory | ''>('');
const [priorityFilter, setPriorityFilter] = useState<TicketPriority | ''>('');
const [statusFilter, setStatusFilter] = useState<TicketStatus | ''>('');
const [page, setPage] = useState(1);
const [deleteId, setDeleteId] = useState<string | null>(null);
const filters: TicketFilters = {
search: search || undefined,
category: categoryFilter || undefined,
priority: priorityFilter || undefined,
status: statusFilter || undefined,
page,
limit: 10,
};
const { data, isLoading, error } = useTickets(filters);
const { data: stats } = useTicketStats();
const deleteMutation = useDeleteTicket();
const tickets = data?.items || [];
const total = data?.total || 0;
const totalPages = Math.ceil(total / 10);
const handleDelete = async () => {
if (deleteId) {
await deleteMutation.mutateAsync(deleteId);
setDeleteId(null);
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
if (isLoading) {
return <LoadingOverlay message="Cargando tickets..." />;
}
if (error) {
return (
<EmptyState
title="Error al cargar"
description="No se pudieron cargar los tickets. Intente de nuevo."
/>
);
}
return (
<div>
<PageHeader
title="Tickets de Postventa"
description="Gestion de tickets de garantia y servicio postventa"
actions={
<PageHeaderAction onClick={() => navigate('/admin/calidad/tickets/nuevo')}>
<Plus className="w-5 h-5 mr-2" />
Nuevo Ticket
</PageHeaderAction>
}
/>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
<StatsCard
title="Total"
value={stats.totalTickets}
icon={<Ticket className="w-5 h-5" />}
color="blue"
/>
<StatsCard
title="Abiertos"
value={stats.openTickets}
icon={<Clock className="w-5 h-5" />}
color="yellow"
/>
<StatsCard
title="En Progreso"
value={stats.inProgressTickets}
icon={<User className="w-5 h-5" />}
color="blue"
/>
<StatsCard
title="Resueltos"
value={stats.resolvedTickets}
icon={<Ticket className="w-5 h-5" />}
color="green"
/>
<StatsCard
title="SLA Incumplido"
value={stats.slaBreach}
icon={<AlertTriangle className="w-5 h-5" />}
color="red"
/>
<StatsCard
title="Satisfaccion"
value={`${stats.avgSatisfaction?.toFixed(1) || '-'}/5`}
icon={<Ticket className="w-5 h-5" />}
color="purple"
/>
</div>
)}
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Buscar por numero o titulo..."
className="lg:col-span-2"
/>
<SelectField
options={[
{ value: '', label: 'Todas las categorias' },
...TICKET_CATEGORY_OPTIONS.map(o => ({ value: o.value, label: o.label })),
]}
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value as TicketCategory | '')}
/>
<SelectField
options={[
{ value: '', label: 'Todas las prioridades' },
...TICKET_PRIORITY_OPTIONS.map(o => ({ value: o.value, label: o.label })),
]}
value={priorityFilter}
onChange={(e) => setPriorityFilter(e.target.value as TicketPriority | '')}
/>
<SelectField
options={[
{ value: '', label: 'Todos los estados' },
...TICKET_STATUS_OPTIONS.map(o => ({ value: o.value, label: o.label })),
]}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as TicketStatus | '')}
/>
</div>
</div>
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden">
{tickets.length === 0 ? (
<EmptyState
icon={<Ticket className="w-12 h-12 text-gray-400" />}
title="No hay tickets"
description="No se encontraron tickets con los filtros seleccionados."
/>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Ticket
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Categoria
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Prioridad
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Estado
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
SLA
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Creado
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Acciones
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{tickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Ticket className="w-5 h-5 text-gray-400 mr-3" />
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{ticket.ticketNumber}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate">
{ticket.title}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadgeFromOptions
value={ticket.category}
options={[...TICKET_CATEGORY_OPTIONS]}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadgeFromOptions
value={ticket.priority}
options={[...TICKET_PRIORITY_OPTIONS]}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadgeFromOptions
value={ticket.status}
options={[...TICKET_STATUS_OPTIONS]}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{ticket.slaBreached ? (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
<AlertTriangle className="w-3 h-3" />
Incumplido
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
<Clock className="w-3 h-3" />
{ticket.slaHours}h
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{formatDate(ticket.createdAt)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => navigate(`/admin/calidad/tickets/${ticket.id}`)}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
title="Ver detalle"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteId(ticket.id)}
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
title="Eliminar"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
)}
</>
)}
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Eliminar Ticket"
message="Esta seguro de eliminar este ticket? Esta accion no se puede deshacer."
confirmLabel="Eliminar"
variant="danger"
isLoading={deleteMutation.isPending}
/>
</div>
);
}

View File

@ -0,0 +1,852 @@
/**
* ContratoDetailPage - Detalle del contrato con tabs
*/
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
ArrowLeft,
FileText,
Building2,
Calendar,
DollarSign,
Pencil,
Send,
CheckCircle,
PlayCircle,
XCircle,
ListTodo,
FilePlus,
Clock,
Plus,
Trash2,
} from 'lucide-react';
import {
useContract,
useContractPartidas,
useContractAddendums,
useSubmitContract,
useApproveContract,
useActivateContract,
useCompleteContract,
useTerminateContract,
useDeleteContractPartida,
useDeleteContractAddendum,
} from '../../../hooks/useContracts';
import type { Contract, ContractPartida, ContractAddendum } from '../../../types/contracts.types';
import {
CONTRACT_TYPE_OPTIONS,
CONTRACT_STATUS_OPTIONS,
ADDENDUM_TYPE_OPTIONS,
ADDENDUM_STATUS_OPTIONS,
} from '../../../types/contracts.types';
import {
StatusBadgeFromOptions,
LoadingOverlay,
EmptyState,
ConfirmDialog,
Modal,
ModalFooter,
TextareaField,
} from '../../../components/common';
import { ContractForm } from '../../../components/contracts/ContractForm';
import { AddendaModal } from '../../../components/contracts/AddendaModal';
import { PartidaModal } from '../../../components/contracts/PartidaModal';
type TabType = 'info' | 'partidas' | 'addendas';
export function ContratoDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<TabType>('info');
const [showEditModal, setShowEditModal] = useState(false);
const [showAddendaModal, setShowAddendaModal] = useState(false);
const [showPartidaModal, setShowPartidaModal] = useState(false);
const [editingAddenda, setEditingAddenda] = useState<ContractAddendum | null>(null);
const [editingPartida, setEditingPartida] = useState<ContractPartida | null>(null);
const [deletePartidaId, setDeletePartidaId] = useState<string | null>(null);
const [deleteAddendaId, setDeleteAddendaId] = useState<string | null>(null);
const [showTerminateModal, setShowTerminateModal] = useState(false);
const [terminateReason, setTerminateReason] = useState('');
const { data: contract, isLoading, error } = useContract(id || '');
const { data: partidas, isLoading: loadingPartidas } = useContractPartidas(id || '');
const { data: addendums, isLoading: loadingAddendums } = useContractAddendums(id || '');
const submitMutation = useSubmitContract();
const approveMutation = useApproveContract();
const activateMutation = useActivateContract();
const completeMutation = useCompleteContract();
const terminateMutation = useTerminateContract();
const deletePartidaMutation = useDeleteContractPartida();
const deleteAddendaMutation = useDeleteContractAddendum();
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
};
const handleSubmit = async () => {
if (id) await submitMutation.mutateAsync(id);
};
const handleApprove = async () => {
if (id) await approveMutation.mutateAsync(id);
};
const handleActivate = async () => {
if (id) await activateMutation.mutateAsync(id);
};
const handleComplete = async () => {
if (id) await completeMutation.mutateAsync(id);
};
const handleTerminate = async () => {
if (id && terminateReason) {
await terminateMutation.mutateAsync({ id, reason: terminateReason });
setShowTerminateModal(false);
setTerminateReason('');
}
};
const handleDeletePartida = async () => {
if (id && deletePartidaId) {
await deletePartidaMutation.mutateAsync({ contractId: id, partidaId: deletePartidaId });
setDeletePartidaId(null);
}
};
const handleDeleteAddenda = async () => {
if (id && deleteAddendaId) {
await deleteAddendaMutation.mutateAsync({ contractId: id, addendumId: deleteAddendaId });
setDeleteAddendaId(null);
}
};
if (isLoading) {
return <LoadingOverlay message="Cargando contrato..." />;
}
if (error || !contract) {
return (
<EmptyState
title="Contrato no encontrado"
description="El contrato solicitado no existe o fue eliminado."
/>
);
}
const tabs = [
{ id: 'info', label: 'Informacion General', icon: FileText },
{ id: 'partidas', label: 'Partidas', icon: ListTodo, count: partidas?.length },
{ id: 'addendas', label: 'Addendas', icon: FilePlus, count: addendums?.length },
];
const canSubmit = contract.status === 'draft';
const canApprove = contract.status === 'review';
const canActivate = contract.status === 'approved';
const canComplete = contract.status === 'active';
const canTerminate = ['active', 'approved'].includes(contract.status);
return (
<div>
{/* Header */}
<div className="mb-6">
<button
onClick={() => navigate('/admin/contratos')}
className="flex items-center text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 mb-4"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Volver a contratos
</button>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div className="flex items-start gap-4">
<div className="p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<FileText className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{contract.contractNumber}
</h1>
<p className="text-gray-600 dark:text-gray-400">{contract.name}</p>
<div className="flex items-center gap-3 mt-2">
<StatusBadgeFromOptions
value={contract.contractType}
options={[...CONTRACT_TYPE_OPTIONS]}
/>
<StatusBadgeFromOptions
value={contract.status}
options={[...CONTRACT_STATUS_OPTIONS]}
/>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => setShowEditModal(true)}
className="flex items-center px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
<Pencil className="w-4 h-4 mr-2" />
Editar
</button>
{canSubmit && (
<button
onClick={handleSubmit}
disabled={submitMutation.isPending}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
<Send className="w-4 h-4 mr-2" />
Enviar a Revision
</button>
)}
{canApprove && (
<button
onClick={handleApprove}
disabled={approveMutation.isPending}
className="flex items-center px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
<CheckCircle className="w-4 h-4 mr-2" />
Aprobar
</button>
)}
{canActivate && (
<button
onClick={handleActivate}
disabled={activateMutation.isPending}
className="flex items-center px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
<PlayCircle className="w-4 h-4 mr-2" />
Activar
</button>
)}
{canComplete && (
<button
onClick={handleComplete}
disabled={completeMutation.isPending}
className="flex items-center px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
>
<CheckCircle className="w-4 h-4 mr-2" />
Completar
</button>
)}
{canTerminate && (
<button
onClick={() => setShowTerminateModal(true)}
className="flex items-center px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
<XCircle className="w-4 h-4 mr-2" />
Terminar
</button>
)}
</div>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Monto Contrato</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(contract.contractAmount)}
</p>
</div>
<DollarSign className="w-8 h-8 text-blue-500" />
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Facturado</p>
<p className="text-xl font-bold text-green-600">
{formatCurrency(contract.invoicedAmount)}
</p>
</div>
<FileText className="w-8 h-8 text-green-500" />
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Pagado</p>
<p className="text-xl font-bold text-blue-600">
{formatCurrency(contract.paidAmount)}
</p>
</div>
<DollarSign className="w-8 h-8 text-blue-500" />
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Avance</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{contract.progressPercentage}%
</p>
</div>
<div className="w-12 h-12 relative">
<svg className="w-full h-full" viewBox="0 0 36 36">
<path
className="text-gray-200 dark:text-gray-700"
strokeWidth="3"
stroke="currentColor"
fill="none"
d="M18 2.0845a 15.9155 15.9155 0 0 1 0 31.831a 15.9155 15.9155 0 0 1 0 -31.831"
/>
<path
className="text-blue-600"
strokeWidth="3"
strokeLinecap="round"
stroke="currentColor"
fill="none"
strokeDasharray={`${contract.progressPercentage}, 100`}
d="M18 2.0845a 15.9155 15.9155 0 0 1 0 31.831a 15.9155 15.9155 0 0 1 0 -31.831"
/>
</svg>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm">
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="flex -mb-px">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as TabType)}
className={`flex items-center px-6 py-4 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab.id
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<tab.icon className="w-4 h-4 mr-2" />
{tab.label}
{tab.count !== undefined && (
<span className="ml-2 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 text-xs rounded-full">
{tab.count}
</span>
)}
</button>
))}
</nav>
</div>
<div className="p-6">
{activeTab === 'info' && <ContractInfoTab contract={contract} />}
{activeTab === 'partidas' && (
<PartidasTab
partidas={partidas || []}
isLoading={loadingPartidas}
onAdd={() => {
setEditingPartida(null);
setShowPartidaModal(true);
}}
onEdit={(p) => {
setEditingPartida(p);
setShowPartidaModal(true);
}}
onDelete={setDeletePartidaId}
/>
)}
{activeTab === 'addendas' && (
<AddendasTab
addendums={addendums || []}
isLoading={loadingAddendums}
onAdd={() => {
setEditingAddenda(null);
setShowAddendaModal(true);
}}
onEdit={(a) => {
setEditingAddenda(a);
setShowAddendaModal(true);
}}
onDelete={setDeleteAddendaId}
/>
)}
</div>
</div>
{/* Modals */}
{showEditModal && (
<ContractForm
contract={contract}
onClose={() => setShowEditModal(false)}
/>
)}
{showAddendaModal && (
<AddendaModal
contractId={id || ''}
addendum={editingAddenda}
onClose={() => {
setShowAddendaModal(false);
setEditingAddenda(null);
}}
/>
)}
{showPartidaModal && (
<PartidaModal
contractId={id || ''}
partida={editingPartida}
onClose={() => {
setShowPartidaModal(false);
setEditingPartida(null);
}}
/>
)}
{/* Terminate Modal */}
{showTerminateModal && (
<Modal
isOpen={true}
onClose={() => setShowTerminateModal(false)}
title="Terminar Contrato"
size="md"
footer={
<ModalFooter>
<button
type="button"
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => setShowTerminateModal(false)}
>
Cancelar
</button>
<button
type="button"
onClick={handleTerminate}
disabled={!terminateReason || terminateMutation.isPending}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
>
{terminateMutation.isPending ? 'Terminando...' : 'Terminar Contrato'}
</button>
</ModalFooter>
}
>
<TextareaField
label="Razon de terminacion"
required
value={terminateReason}
onChange={(e) => setTerminateReason(e.target.value)}
placeholder="Describa la razon por la cual se termina el contrato..."
rows={4}
/>
</Modal>
)}
{/* Delete Confirmations */}
<ConfirmDialog
isOpen={!!deletePartidaId}
onClose={() => setDeletePartidaId(null)}
onConfirm={handleDeletePartida}
title="Eliminar Partida"
message="Esta seguro de eliminar esta partida del contrato?"
confirmLabel="Eliminar"
variant="danger"
isLoading={deletePartidaMutation.isPending}
/>
<ConfirmDialog
isOpen={!!deleteAddendaId}
onClose={() => setDeleteAddendaId(null)}
onConfirm={handleDeleteAddenda}
title="Eliminar Addenda"
message="Esta seguro de eliminar esta addenda?"
confirmLabel="Eliminar"
variant="danger"
isLoading={deleteAddendaMutation.isPending}
/>
</div>
);
}
// ============================================================================
// CONTRACT INFO TAB
// ============================================================================
function ContractInfoTab({ contract }: { contract: Contract }) {
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('es-MX', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* General Info */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center">
<FileText className="w-5 h-5 mr-2" />
Datos Generales
</h3>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
<InfoRow label="Numero de Contrato" value={contract.contractNumber} />
<InfoRow label="Nombre" value={contract.name} />
{contract.description && (
<InfoRow label="Descripcion" value={contract.description} />
)}
<InfoRow label="Moneda" value={contract.currency} />
<InfoRow label="Retencion" value={`${contract.retentionPercentage}%`} />
<InfoRow label="Anticipo" value={`${contract.advancePercentage}%`} />
</div>
</div>
{/* Client/Subcontractor Info */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center">
<Building2 className="w-5 h-5 mr-2" />
{contract.contractType === 'client' ? 'Datos del Cliente' : 'Datos del Subcontratista'}
</h3>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
{contract.contractType === 'client' ? (
<>
<InfoRow label="Cliente" value={contract.clientName || '-'} />
<InfoRow label="RFC" value={contract.clientRfc || '-'} />
<InfoRow label="Direccion" value={contract.clientAddress || '-'} />
</>
) : (
<>
<InfoRow label="Subcontratista" value={contract.subcontractor?.businessName || '-'} />
<InfoRow label="RFC" value={contract.subcontractor?.rfc || '-'} />
<InfoRow label="Especialidad" value={contract.specialty || '-'} />
</>
)}
</div>
</div>
{/* Dates */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center">
<Calendar className="w-5 h-5 mr-2" />
Vigencia
</h3>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
<InfoRow label="Fecha Inicio" value={formatDate(contract.startDate)} />
<InfoRow label="Fecha Fin" value={formatDate(contract.endDate)} />
{contract.signedAt && (
<InfoRow label="Fecha Firma" value={formatDate(contract.signedAt)} />
)}
</div>
</div>
{/* Payment Terms */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center">
<DollarSign className="w-5 h-5 mr-2" />
Condiciones de Pago
</h3>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<p className="text-sm text-gray-700 dark:text-gray-300">
{contract.paymentTerms || 'Sin condiciones especificas'}
</p>
</div>
</div>
{/* Audit Info */}
<div className="space-y-4 lg:col-span-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center">
<Clock className="w-5 h-5 mr-2" />
Historial
</h3>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
<InfoRow label="Creado" value={formatDate(contract.createdAt)} />
<InfoRow label="Actualizado" value={formatDate(contract.updatedAt)} />
{contract.submittedAt && (
<InfoRow label="Enviado a revision" value={formatDate(contract.submittedAt)} />
)}
{contract.approvedAt && (
<InfoRow label="Aprobado" value={formatDate(contract.approvedAt)} />
)}
{contract.terminatedAt && (
<>
<InfoRow label="Terminado" value={formatDate(contract.terminatedAt)} />
<InfoRow label="Razon" value={contract.terminationReason || '-'} />
</>
)}
</div>
</div>
{/* Notes */}
{contract.notes && (
<div className="space-y-4 lg:col-span-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Notas
</h3>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{contract.notes}
</p>
</div>
</div>
)}
</div>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between">
<span className="text-sm text-gray-500 dark:text-gray-400">{label}</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{value}</span>
</div>
);
}
// ============================================================================
// PARTIDAS TAB
// ============================================================================
interface PartidasTabProps {
partidas: ContractPartida[];
isLoading: boolean;
onAdd: () => void;
onEdit: (partida: ContractPartida) => void;
onDelete: (id: string) => void;
}
function PartidasTab({ partidas, isLoading, onAdd, onEdit, onDelete }: PartidasTabProps) {
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
};
if (isLoading) {
return <div className="text-center py-8 text-gray-500">Cargando partidas...</div>;
}
const total = partidas.reduce((sum, p) => sum + (p.totalAmount || p.quantity * p.unitPrice), 0);
return (
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Partidas del Contrato
</h3>
<button
onClick={onAdd}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4 mr-2" />
Agregar Partida
</button>
</div>
{partidas.length === 0 ? (
<EmptyState
icon={<ListTodo className="w-12 h-12 text-gray-400" />}
title="Sin partidas"
description="Agrega las partidas del contrato."
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
Concepto
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
Cantidad
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
P.U.
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
Total
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
Acciones
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{partidas.map((partida) => (
<tr key={partida.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{partida.conceptoCode || 'N/A'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{partida.conceptoDescription || '-'}
</div>
</td>
<td className="px-4 py-3 text-right text-sm text-gray-900 dark:text-gray-100">
{partida.quantity.toLocaleString()} {partida.unit || ''}
</td>
<td className="px-4 py-3 text-right text-sm text-gray-900 dark:text-gray-100">
{formatCurrency(partida.unitPrice)}
</td>
<td className="px-4 py-3 text-right text-sm font-medium text-gray-900 dark:text-gray-100">
{formatCurrency(partida.totalAmount || partida.quantity * partida.unitPrice)}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => onEdit(partida)}
className="p-1 text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => onDelete(partida.id)}
className="p-1 text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
<tfoot className="bg-gray-50 dark:bg-gray-700">
<tr>
<td colSpan={3} className="px-4 py-3 text-right text-sm font-medium text-gray-900 dark:text-gray-100">
Total:
</td>
<td className="px-4 py-3 text-right text-sm font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(total)}
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
);
}
// ============================================================================
// ADDENDAS TAB
// ============================================================================
interface AddendasTabProps {
addendums: ContractAddendum[];
isLoading: boolean;
onAdd: () => void;
onEdit: (addendum: ContractAddendum) => void;
onDelete: (id: string) => void;
}
function AddendasTab({ addendums, isLoading, onAdd, onEdit, onDelete }: AddendasTabProps) {
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
if (isLoading) {
return <div className="text-center py-8 text-gray-500">Cargando addendas...</div>;
}
return (
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Addendas del Contrato
</h3>
<button
onClick={onAdd}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4 mr-2" />
Nueva Addenda
</button>
</div>
{addendums.length === 0 ? (
<EmptyState
icon={<FilePlus className="w-12 h-12 text-gray-400" />}
title="Sin addendas"
description="No hay addendas registradas para este contrato."
/>
) : (
<div className="space-y-4">
{addendums.map((addendum) => (
<div
key={addendum.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="font-medium text-gray-900 dark:text-gray-100">
{addendum.addendumNumber}
</span>
<StatusBadgeFromOptions
value={addendum.addendumType}
options={[...ADDENDUM_TYPE_OPTIONS]}
/>
<StatusBadgeFromOptions
value={addendum.status}
options={[...ADDENDUM_STATUS_OPTIONS]}
/>
</div>
<h4 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-1">
{addendum.title}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{addendum.description}
</p>
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
<span>Vigencia: {formatDate(addendum.effectiveDate)}</span>
{addendum.amountChange !== 0 && (
<span className={addendum.amountChange > 0 ? 'text-green-600' : 'text-red-600'}>
{addendum.amountChange > 0 ? '+' : ''}{formatCurrency(addendum.amountChange)}
</span>
)}
{addendum.newEndDate && (
<span>Nueva fecha fin: {formatDate(addendum.newEndDate)}</span>
)}
</div>
</div>
<div className="flex items-center gap-1 ml-4">
<button
onClick={() => onEdit(addendum)}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => onDelete(addendum.id)}
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,337 @@
/**
* ContratosPage - Lista de contratos
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Eye, Pencil, Trash2, FileText, Building2, Calendar, DollarSign } from 'lucide-react';
import {
useContracts,
useDeleteContract,
} from '../../../hooks/useContracts';
import { useSubcontractors } from '../../../hooks/useContracts';
import type {
Contract,
ContractType,
ContractStatus,
ContractFilters,
} from '../../../types/contracts.types';
import {
CONTRACT_TYPE_OPTIONS,
CONTRACT_STATUS_OPTIONS,
} from '../../../types/contracts.types';
import {
PageHeader,
PageHeaderAction,
SearchInput,
SelectField,
StatusBadgeFromOptions,
ConfirmDialog,
LoadingOverlay,
EmptyState,
Pagination,
} from '../../../components/common';
import { ContractForm } from '../../../components/contracts/ContractForm';
export function ContratosPage() {
const navigate = useNavigate();
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<ContractType | ''>('');
const [statusFilter, setStatusFilter] = useState<ContractStatus | ''>('');
const [subcontractorFilter, setSubcontractorFilter] = useState('');
const [page, setPage] = useState(1);
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<Contract | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const filters: ContractFilters = {
search: search || undefined,
contractType: typeFilter || undefined,
status: statusFilter || undefined,
subcontractorId: subcontractorFilter || undefined,
page,
limit: 10,
};
const { data, isLoading, error } = useContracts(filters);
const { data: subcontractorsData } = useSubcontractors({ status: 'active' });
const deleteMutation = useDeleteContract();
const contracts = data?.items || [];
const total = data?.total || 0;
const totalPages = Math.ceil(total / 10);
const handleDelete = async () => {
if (deleteId) {
await deleteMutation.mutateAsync(deleteId);
setDeleteId(null);
}
};
const handleView = (id: string) => {
navigate(`/admin/contratos/${id}`);
};
const handleEdit = (contract: Contract) => {
setEditingItem(contract);
setShowModal(true);
};
const handleCreate = () => {
setEditingItem(null);
setShowModal(true);
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
if (isLoading) {
return <LoadingOverlay message="Cargando contratos..." />;
}
if (error) {
return (
<EmptyState
title="Error al cargar"
description="No se pudieron cargar los contratos. Intente de nuevo."
/>
);
}
return (
<div>
<PageHeader
title="Contratos"
description="Gestion de contratos con clientes y subcontratistas"
actions={
<PageHeaderAction onClick={handleCreate}>
<Plus className="w-5 h-5 mr-2" />
Nuevo Contrato
</PageHeaderAction>
}
/>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Buscar por numero o nombre..."
/>
<SelectField
options={[
{ value: '', label: 'Todos los tipos' },
...CONTRACT_TYPE_OPTIONS.map(o => ({ value: o.value, label: o.label })),
]}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as ContractType | '')}
/>
<SelectField
options={[
{ value: '', label: 'Todos los estados' },
...CONTRACT_STATUS_OPTIONS.map(o => ({ value: o.value, label: o.label })),
]}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as ContractStatus | '')}
/>
<SelectField
options={[
{ value: '', label: 'Todos los subcontratistas' },
...(subcontractorsData?.items || []).map(s => ({
value: s.id,
label: s.businessName,
})),
]}
value={subcontractorFilter}
onChange={(e) => setSubcontractorFilter(e.target.value)}
/>
</div>
</div>
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden">
{contracts.length === 0 ? (
<EmptyState
icon={<FileText className="w-12 h-12 text-gray-400" />}
title="No hay contratos"
description="Crea el primer contrato para comenzar."
/>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Contrato
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Tipo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Cliente/Subcontratista
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Vigencia
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Monto
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Avance
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Estado
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Acciones
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{contracts.map((contract) => (
<tr key={contract.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<FileText className="w-5 h-5 text-gray-400 mr-3" />
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{contract.contractNumber}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{contract.name}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadgeFromOptions
value={contract.contractType}
options={[...CONTRACT_TYPE_OPTIONS]}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Building2 className="w-4 h-4 text-gray-400 mr-2" />
<span className="text-sm text-gray-900 dark:text-gray-100">
{contract.contractType === 'client'
? contract.clientName
: contract.subcontractor?.businessName || '-'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400">
<Calendar className="w-4 h-4 mr-1" />
{formatDate(contract.startDate)} - {formatDate(contract.endDate)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end">
<DollarSign className="w-4 h-4 text-gray-400 mr-1" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{formatCurrency(contract.contractAmount)}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<div className="flex items-center justify-center">
<div className="w-16 bg-gray-200 dark:bg-gray-600 rounded-full h-2 mr-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${contract.progressPercentage}%` }}
/>
</div>
<span className="text-sm text-gray-600 dark:text-gray-400">
{contract.progressPercentage}%
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<StatusBadgeFromOptions
value={contract.status}
options={[...CONTRACT_STATUS_OPTIONS]}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => handleView(contract.id)}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
title="Ver detalle"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleEdit(contract)}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
title="Editar"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteId(contract.id)}
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
title="Eliminar"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
)}
</>
)}
</div>
{/* Modal */}
{showModal && (
<ContractForm
contract={editingItem}
onClose={() => {
setShowModal(false);
setEditingItem(null);
}}
/>
)}
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Confirmar eliminacion"
message="Esta seguro de eliminar este contrato? Esta accion no se puede deshacer."
confirmLabel="Eliminar"
variant="danger"
isLoading={deleteMutation.isPending}
/>
</div>
);
}

View File

@ -0,0 +1,520 @@
/**
* SubcontratistasPage - Lista de subcontratistas
*/
import { useState } from 'react';
import { Plus, Pencil, Trash2, Users, Star, AlertTriangle, FileText, Phone, Mail } from 'lucide-react';
import {
useSubcontractors,
useCreateSubcontractor,
useUpdateSubcontractor,
useDeleteSubcontractor,
useActivateSubcontractor,
useDeactivateSubcontractor,
} from '../../../hooks/useContracts';
import type {
Subcontractor,
SubcontractorStatus,
SubcontractorSpecialty,
SubcontractorFilters,
CreateSubcontractorDto,
} from '../../../types/contracts.types';
import {
SUBCONTRACTOR_STATUS_OPTIONS,
SUBCONTRACTOR_SPECIALTY_OPTIONS,
} from '../../../types/contracts.types';
import {
PageHeader,
PageHeaderAction,
SearchInput,
SelectField,
StatusBadgeFromOptions,
ConfirmDialog,
Modal,
ModalFooter,
TextInput,
TextareaField,
FormGroup,
LoadingOverlay,
EmptyState,
Pagination,
} from '../../../components/common';
export function SubcontratistasPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<SubcontractorStatus | ''>('');
const [specialtyFilter, setSpecialtyFilter] = useState<SubcontractorSpecialty | ''>('');
const [page, setPage] = useState(1);
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<Subcontractor | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const filters: SubcontractorFilters = {
search: search || undefined,
status: statusFilter || undefined,
primarySpecialty: specialtyFilter || undefined,
page,
limit: 10,
};
const { data, isLoading, error } = useSubcontractors(filters);
const createMutation = useCreateSubcontractor();
const updateMutation = useUpdateSubcontractor();
const deleteMutation = useDeleteSubcontractor();
const activateMutation = useActivateSubcontractor();
const deactivateMutation = useDeactivateSubcontractor();
const subcontractors = data?.items || [];
const total = data?.total || 0;
const totalPages = Math.ceil(total / 10);
const handleDelete = async () => {
if (deleteId) {
await deleteMutation.mutateAsync(deleteId);
setDeleteId(null);
}
};
const handleToggleStatus = async (sub: Subcontractor) => {
if (sub.status === 'active') {
await deactivateMutation.mutateAsync(sub.id);
} else {
await activateMutation.mutateAsync(sub.id);
}
};
const handleSubmit = async (formData: CreateSubcontractorDto) => {
if (editingItem) {
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
} else {
await createMutation.mutateAsync(formData);
}
setShowModal(false);
setEditingItem(null);
};
if (isLoading) {
return <LoadingOverlay message="Cargando subcontratistas..." />;
}
if (error) {
return (
<EmptyState
title="Error al cargar"
description="No se pudieron cargar los subcontratistas. Intente de nuevo."
/>
);
}
return (
<div>
<PageHeader
title="Subcontratistas"
description="Catalogo de subcontratistas y proveedores de servicios"
actions={
<PageHeaderAction onClick={() => { setEditingItem(null); setShowModal(true); }}>
<Plus className="w-5 h-5 mr-2" />
Nuevo Subcontratista
</PageHeaderAction>
}
/>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Buscar por nombre o RFC..."
/>
<SelectField
options={[
{ value: '', label: 'Todos los estados' },
...SUBCONTRACTOR_STATUS_OPTIONS.map(o => ({ value: o.value, label: o.label })),
]}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as SubcontractorStatus | '')}
/>
<SelectField
options={[
{ value: '', label: 'Todas las especialidades' },
...SUBCONTRACTOR_SPECIALTY_OPTIONS.map(o => ({ value: o.value, label: o.label })),
]}
value={specialtyFilter}
onChange={(e) => setSpecialtyFilter(e.target.value as SubcontractorSpecialty | '')}
/>
</div>
</div>
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden">
{subcontractors.length === 0 ? (
<EmptyState
icon={<Users className="w-12 h-12 text-gray-400" />}
title="No hay subcontratistas"
description="Registra el primer subcontratista para comenzar."
/>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Subcontratista
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Especialidad
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Contacto
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Contratos
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Calificacion
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Estado
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Acciones
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{subcontractors.map((sub) => (
<tr key={sub.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center">
<Users className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{sub.businessName}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{sub.code} - RFC: {sub.rfc}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadgeFromOptions
value={sub.primarySpecialty}
options={[...SUBCONTRACTOR_SPECIALTY_OPTIONS]}
/>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900 dark:text-gray-100">
{sub.contactName || '-'}
</div>
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
{sub.phone && (
<span className="flex items-center">
<Phone className="w-3 h-3 mr-1" />
{sub.phone}
</span>
)}
{sub.email && (
<span className="flex items-center">
<Mail className="w-3 h-3 mr-1" />
{sub.email}
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<div className="flex items-center justify-center gap-2">
<FileText className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-900 dark:text-gray-100">
{sub.completedContracts}/{sub.totalContracts}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<div className="flex items-center justify-center gap-1">
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{sub.averageRating.toFixed(1)}
</span>
{sub.totalIncidents > 0 && (
<span className="flex items-center ml-2 text-red-600">
<AlertTriangle className="w-4 h-4 mr-1" />
{sub.totalIncidents}
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<button
onClick={() => handleToggleStatus(sub)}
disabled={activateMutation.isPending || deactivateMutation.isPending || sub.status === 'blacklisted'}
>
<StatusBadgeFromOptions
value={sub.status}
options={[...SUBCONTRACTOR_STATUS_OPTIONS]}
/>
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => { setEditingItem(sub); setShowModal(true); }}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
title="Editar"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteId(sub.id)}
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
title="Eliminar"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
)}
</>
)}
</div>
{/* Modal */}
{showModal && (
<SubcontractorModal
item={editingItem}
onClose={() => { setShowModal(false); setEditingItem(null); }}
onSubmit={handleSubmit}
isLoading={createMutation.isPending || updateMutation.isPending}
/>
)}
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Confirmar eliminacion"
message="Esta seguro de eliminar este subcontratista? Esta accion no se puede deshacer."
confirmLabel="Eliminar"
variant="danger"
isLoading={deleteMutation.isPending}
/>
</div>
);
}
// ============================================================================
// SUBCONTRACTOR MODAL
// ============================================================================
interface SubcontractorModalProps {
item: Subcontractor | null;
onClose: () => void;
onSubmit: (data: CreateSubcontractorDto) => Promise<void>;
isLoading: boolean;
}
function SubcontractorModal({ item, onClose, onSubmit, isLoading }: SubcontractorModalProps) {
const [formData, setFormData] = useState<CreateSubcontractorDto>({
code: item?.code || '',
businessName: item?.businessName || '',
tradeName: item?.tradeName || '',
rfc: item?.rfc || '',
address: item?.address || '',
phone: item?.phone || '',
email: item?.email || '',
contactName: item?.contactName || '',
contactPhone: item?.contactPhone || '',
primarySpecialty: item?.primarySpecialty || 'otros',
secondarySpecialties: item?.secondarySpecialties || [],
bankName: item?.bankName || '',
bankAccount: item?.bankAccount || '',
clabe: item?.clabe || '',
notes: item?.notes || '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(formData);
};
const update = <K extends keyof CreateSubcontractorDto>(field: K, value: CreateSubcontractorDto[K]) => {
setFormData({ ...formData, [field]: value });
};
return (
<Modal
isOpen={true}
onClose={onClose}
title={item ? 'Editar Subcontratista' : 'Nuevo Subcontratista'}
size="lg"
footer={
<ModalFooter>
<button
type="button"
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
form="subcontractor-form"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Guardando...' : item ? 'Guardar Cambios' : 'Crear Subcontratista'}
</button>
</ModalFooter>
}
>
<form id="subcontractor-form" onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Datos Generales
</h4>
<FormGroup cols={2}>
<TextInput
label="Codigo"
required
value={formData.code}
onChange={(e) => update('code', e.target.value)}
placeholder="SUB-001"
/>
<TextInput
label="RFC"
required
value={formData.rfc}
onChange={(e) => update('rfc', e.target.value.toUpperCase())}
placeholder="XAXX010101000"
maxLength={13}
/>
</FormGroup>
<TextInput
label="Razon Social"
required
value={formData.businessName}
onChange={(e) => update('businessName', e.target.value)}
placeholder="Constructora ABC S.A. de C.V."
className="mt-4"
/>
<TextInput
label="Nombre Comercial"
value={formData.tradeName || ''}
onChange={(e) => update('tradeName', e.target.value)}
placeholder="Constructora ABC"
className="mt-4"
/>
<SelectField
label="Especialidad Principal"
required
options={SUBCONTRACTOR_SPECIALTY_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
value={formData.primarySpecialty}
onChange={(e) => update('primarySpecialty', e.target.value as SubcontractorSpecialty)}
className="mt-4"
/>
</div>
{/* Contact Info */}
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Contacto
</h4>
<FormGroup cols={2}>
<TextInput
label="Nombre de Contacto"
value={formData.contactName || ''}
onChange={(e) => update('contactName', e.target.value)}
placeholder="Juan Perez"
/>
<TextInput
label="Telefono Contacto"
value={formData.contactPhone || ''}
onChange={(e) => update('contactPhone', e.target.value)}
placeholder="55 1234 5678"
/>
</FormGroup>
<FormGroup cols={2} className="mt-4">
<TextInput
label="Telefono Empresa"
value={formData.phone || ''}
onChange={(e) => update('phone', e.target.value)}
placeholder="55 9876 5432"
/>
<TextInput
label="Email"
type="email"
value={formData.email || ''}
onChange={(e) => update('email', e.target.value)}
placeholder="contacto@constructora.com"
/>
</FormGroup>
<TextareaField
label="Direccion"
value={formData.address || ''}
onChange={(e) => update('address', e.target.value)}
placeholder="Calle, numero, colonia, ciudad, CP"
rows={2}
className="mt-4"
/>
</div>
{/* Bank Info */}
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Datos Bancarios
</h4>
<FormGroup cols={3}>
<TextInput
label="Banco"
value={formData.bankName || ''}
onChange={(e) => update('bankName', e.target.value)}
placeholder="BBVA"
/>
<TextInput
label="Cuenta"
value={formData.bankAccount || ''}
onChange={(e) => update('bankAccount', e.target.value)}
placeholder="0123456789"
/>
<TextInput
label="CLABE"
value={formData.clabe || ''}
onChange={(e) => update('clabe', e.target.value)}
placeholder="012345678901234567"
maxLength={18}
/>
</FormGroup>
</div>
{/* Notes */}
<TextareaField
label="Notas"
value={formData.notes || ''}
onChange={(e) => update('notes', e.target.value)}
placeholder="Notas adicionales sobre el subcontratista..."
rows={3}
/>
</form>
</Modal>
);
}

View File

@ -0,0 +1,7 @@
/**
* Contratos Pages Index
*/
export { ContratosPage } from './ContratosPage';
export { ContratoDetailPage } from './ContratoDetailPage';
export { SubcontratistasPage } from './SubcontratistasPage';

View File

@ -0,0 +1,204 @@
/**
* Contracts API - Contratos y Subcontratistas
*/
import api from '../api';
import type { PaginatedResponse } from '../../types/api.types';
import type {
Contract,
ContractFilters,
CreateContractDto,
UpdateContractDto,
ContractStats,
Subcontractor,
SubcontractorFilters,
CreateSubcontractorDto,
UpdateSubcontractorDto,
ContractPartida,
CreateContractPartidaDto,
UpdateContractPartidaDto,
ContractAddendum,
CreateAddendumDto,
UpdateAddendumDto,
} from '../../types/contracts.types';
// ============================================================================
// CONTRACTS API
// ============================================================================
export const contractsApi = {
list: async (filters?: ContractFilters): Promise<PaginatedResponse<Contract>> => {
const response = await api.get<PaginatedResponse<Contract>>('/contracts', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<Contract> => {
const response = await api.get<Contract>(`/contracts/${id}`);
return response.data;
},
create: async (data: CreateContractDto): Promise<Contract> => {
const response = await api.post<Contract>('/contracts', data);
return response.data;
},
update: async (id: string, data: UpdateContractDto): Promise<Contract> => {
const response = await api.put<Contract>(`/contracts/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/contracts/${id}`);
},
stats: async (): Promise<ContractStats> => {
const response = await api.get<ContractStats>('/contracts/stats');
return response.data;
},
// Workflow actions
submit: async (id: string): Promise<Contract> => {
const response = await api.post<Contract>(`/contracts/${id}/submit`);
return response.data;
},
approve: async (id: string): Promise<Contract> => {
const response = await api.post<Contract>(`/contracts/${id}/approve`);
return response.data;
},
activate: async (id: string): Promise<Contract> => {
const response = await api.post<Contract>(`/contracts/${id}/activate`);
return response.data;
},
complete: async (id: string): Promise<Contract> => {
const response = await api.post<Contract>(`/contracts/${id}/complete`);
return response.data;
},
terminate: async (id: string, reason: string): Promise<Contract> => {
const response = await api.post<Contract>(`/contracts/${id}/terminate`, { reason });
return response.data;
},
};
// ============================================================================
// SUBCONTRACTORS API
// ============================================================================
export const subcontractorsApi = {
list: async (filters?: SubcontractorFilters): Promise<PaginatedResponse<Subcontractor>> => {
const response = await api.get<PaginatedResponse<Subcontractor>>('/subcontractors', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<Subcontractor> => {
const response = await api.get<Subcontractor>(`/subcontractors/${id}`);
return response.data;
},
create: async (data: CreateSubcontractorDto): Promise<Subcontractor> => {
const response = await api.post<Subcontractor>('/subcontractors', data);
return response.data;
},
update: async (id: string, data: UpdateSubcontractorDto): Promise<Subcontractor> => {
const response = await api.put<Subcontractor>(`/subcontractors/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/subcontractors/${id}`);
},
activate: async (id: string): Promise<Subcontractor> => {
const response = await api.post<Subcontractor>(`/subcontractors/${id}/activate`);
return response.data;
},
deactivate: async (id: string): Promise<Subcontractor> => {
const response = await api.post<Subcontractor>(`/subcontractors/${id}/deactivate`);
return response.data;
},
blacklist: async (id: string, reason: string): Promise<Subcontractor> => {
const response = await api.post<Subcontractor>(`/subcontractors/${id}/blacklist`, { reason });
return response.data;
},
};
// ============================================================================
// CONTRACT PARTIDAS API
// ============================================================================
export const contractPartidasApi = {
list: async (contractId: string): Promise<ContractPartida[]> => {
const response = await api.get<ContractPartida[]>(`/contracts/${contractId}/partidas`);
return response.data;
},
create: async (contractId: string, data: CreateContractPartidaDto): Promise<ContractPartida> => {
const response = await api.post<ContractPartida>(`/contracts/${contractId}/partidas`, data);
return response.data;
},
update: async (contractId: string, partidaId: string, data: UpdateContractPartidaDto): Promise<ContractPartida> => {
const response = await api.put<ContractPartida>(`/contracts/${contractId}/partidas/${partidaId}`, data);
return response.data;
},
delete: async (contractId: string, partidaId: string): Promise<void> => {
await api.delete(`/contracts/${contractId}/partidas/${partidaId}`);
},
};
// ============================================================================
// CONTRACT ADDENDUMS API
// ============================================================================
export const contractAddendumsApi = {
list: async (contractId: string): Promise<ContractAddendum[]> => {
const response = await api.get<ContractAddendum[]>(`/contracts/${contractId}/addendums`);
return response.data;
},
get: async (contractId: string, addendumId: string): Promise<ContractAddendum> => {
const response = await api.get<ContractAddendum>(`/contracts/${contractId}/addendums/${addendumId}`);
return response.data;
},
create: async (contractId: string, data: CreateAddendumDto): Promise<ContractAddendum> => {
const response = await api.post<ContractAddendum>(`/contracts/${contractId}/addendums`, data);
return response.data;
},
update: async (contractId: string, addendumId: string, data: UpdateAddendumDto): Promise<ContractAddendum> => {
const response = await api.put<ContractAddendum>(`/contracts/${contractId}/addendums/${addendumId}`, data);
return response.data;
},
delete: async (contractId: string, addendumId: string): Promise<void> => {
await api.delete(`/contracts/${contractId}/addendums/${addendumId}`);
},
// Workflow actions
submit: async (contractId: string, addendumId: string): Promise<ContractAddendum> => {
const response = await api.post<ContractAddendum>(`/contracts/${contractId}/addendums/${addendumId}/submit`);
return response.data;
},
approve: async (contractId: string, addendumId: string): Promise<ContractAddendum> => {
const response = await api.post<ContractAddendum>(`/contracts/${contractId}/addendums/${addendumId}/approve`);
return response.data;
},
reject: async (contractId: string, addendumId: string, reason: string): Promise<ContractAddendum> => {
const response = await api.post<ContractAddendum>(`/contracts/${contractId}/addendums/${addendumId}/reject`, { reason });
return response.data;
},
};

View File

@ -0,0 +1,5 @@
/**
* Contracts Services Index
*/
export * from './contracts.api';

324
web/src/services/quality.ts Normal file
View File

@ -0,0 +1,324 @@
/**
* Quality API Service - Calidad, Inspecciones, No Conformidades, Tickets
*/
import api from './api';
import type { PaginatedResponse } from '../types/api.types';
import type {
Checklist,
ChecklistFilters,
CreateChecklistDto,
UpdateChecklistDto,
Inspection,
InspectionFilters,
CreateInspectionDto,
UpdateInspectionDto,
InspectionResult,
SaveInspectionResultsDto,
InspectionStats,
PostSaleTicket,
TicketFilters,
CreateTicketDto,
UpdateTicketDto,
AssignTicketDto,
ResolveTicketDto,
RateTicketDto,
TicketStats,
NonConformity,
NonConformityFilters,
CreateNonConformityDto,
UpdateNonConformityDto,
CloseNonConformityDto,
CorrectiveAction,
CreateCorrectiveActionDto,
UpdateCorrectiveActionDto,
CompleteCorrectiveActionDto,
NonConformityStats,
} from '../types/quality.types';
// ============================================================================
// CHECKLISTS API
// ============================================================================
export const checklistsApi = {
list: async (filters?: ChecklistFilters): Promise<PaginatedResponse<Checklist>> => {
const response = await api.get<PaginatedResponse<Checklist>>('/quality/checklists', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<Checklist> => {
const response = await api.get<Checklist>(`/quality/checklists/${id}`);
return response.data;
},
create: async (data: CreateChecklistDto): Promise<Checklist> => {
const response = await api.post<Checklist>('/quality/checklists', data);
return response.data;
},
update: async (id: string, data: UpdateChecklistDto): Promise<Checklist> => {
const response = await api.put<Checklist>(`/quality/checklists/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/quality/checklists/${id}`);
},
duplicate: async (id: string): Promise<Checklist> => {
const response = await api.post<Checklist>(`/quality/checklists/${id}/duplicate`);
return response.data;
},
};
// ============================================================================
// INSPECTIONS API
// ============================================================================
export const inspectionsApi = {
list: async (filters?: InspectionFilters): Promise<PaginatedResponse<Inspection>> => {
const response = await api.get<PaginatedResponse<Inspection>>('/quality/inspections', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<Inspection> => {
const response = await api.get<Inspection>(`/quality/inspections/${id}`);
return response.data;
},
create: async (data: CreateInspectionDto): Promise<Inspection> => {
const response = await api.post<Inspection>('/quality/inspections', data);
return response.data;
},
update: async (id: string, data: UpdateInspectionDto): Promise<Inspection> => {
const response = await api.put<Inspection>(`/quality/inspections/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/quality/inspections/${id}`);
},
// Results
getResults: async (inspectionId: string): Promise<InspectionResult[]> => {
const response = await api.get<InspectionResult[]>(`/quality/inspections/${inspectionId}/results`);
return response.data;
},
saveResults: async (inspectionId: string, data: SaveInspectionResultsDto): Promise<InspectionResult[]> => {
const response = await api.post<InspectionResult[]>(`/quality/inspections/${inspectionId}/results`, data);
return response.data;
},
// Workflow
start: async (id: string): Promise<Inspection> => {
const response = await api.post<Inspection>(`/quality/inspections/${id}/start`);
return response.data;
},
complete: async (id: string): Promise<Inspection> => {
const response = await api.post<Inspection>(`/quality/inspections/${id}/complete`);
return response.data;
},
approve: async (id: string): Promise<Inspection> => {
const response = await api.post<Inspection>(`/quality/inspections/${id}/approve`);
return response.data;
},
reject: async (id: string, reason: string): Promise<Inspection> => {
const response = await api.post<Inspection>(`/quality/inspections/${id}/reject`, { reason });
return response.data;
},
// Stats
stats: async (filters?: InspectionFilters): Promise<InspectionStats> => {
const response = await api.get<InspectionStats>('/quality/inspections/stats', {
params: filters,
});
return response.data;
},
};
// ============================================================================
// POST-SALE TICKETS API
// ============================================================================
export const ticketsApi = {
list: async (filters?: TicketFilters): Promise<PaginatedResponse<PostSaleTicket>> => {
const response = await api.get<PaginatedResponse<PostSaleTicket>>('/quality/tickets', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<PostSaleTicket> => {
const response = await api.get<PostSaleTicket>(`/quality/tickets/${id}`);
return response.data;
},
create: async (data: CreateTicketDto): Promise<PostSaleTicket> => {
const response = await api.post<PostSaleTicket>('/quality/tickets', data);
return response.data;
},
update: async (id: string, data: UpdateTicketDto): Promise<PostSaleTicket> => {
const response = await api.put<PostSaleTicket>(`/quality/tickets/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/quality/tickets/${id}`);
},
// Workflow
assign: async (id: string, data: AssignTicketDto): Promise<PostSaleTicket> => {
const response = await api.post<PostSaleTicket>(`/quality/tickets/${id}/assign`, data);
return response.data;
},
startWork: async (id: string): Promise<PostSaleTicket> => {
const response = await api.post<PostSaleTicket>(`/quality/tickets/${id}/start`);
return response.data;
},
resolve: async (id: string, data: ResolveTicketDto): Promise<PostSaleTicket> => {
const response = await api.post<PostSaleTicket>(`/quality/tickets/${id}/resolve`, data);
return response.data;
},
close: async (id: string): Promise<PostSaleTicket> => {
const response = await api.post<PostSaleTicket>(`/quality/tickets/${id}/close`);
return response.data;
},
cancel: async (id: string, reason?: string): Promise<PostSaleTicket> => {
const response = await api.post<PostSaleTicket>(`/quality/tickets/${id}/cancel`, { reason });
return response.data;
},
rate: async (id: string, data: RateTicketDto): Promise<PostSaleTicket> => {
const response = await api.post<PostSaleTicket>(`/quality/tickets/${id}/rate`, data);
return response.data;
},
// Stats
stats: async (filters?: TicketFilters): Promise<TicketStats> => {
const response = await api.get<TicketStats>('/quality/tickets/stats', {
params: filters,
});
return response.data;
},
};
// ============================================================================
// NON-CONFORMITIES API
// ============================================================================
export const nonConformitiesApi = {
list: async (filters?: NonConformityFilters): Promise<PaginatedResponse<NonConformity>> => {
const response = await api.get<PaginatedResponse<NonConformity>>('/quality/non-conformities', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<NonConformity> => {
const response = await api.get<NonConformity>(`/quality/non-conformities/${id}`);
return response.data;
},
create: async (data: CreateNonConformityDto): Promise<NonConformity> => {
const response = await api.post<NonConformity>('/quality/non-conformities', data);
return response.data;
},
update: async (id: string, data: UpdateNonConformityDto): Promise<NonConformity> => {
const response = await api.put<NonConformity>(`/quality/non-conformities/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/quality/non-conformities/${id}`);
},
// Workflow
startProgress: async (id: string): Promise<NonConformity> => {
const response = await api.post<NonConformity>(`/quality/non-conformities/${id}/start`);
return response.data;
},
close: async (id: string, data: CloseNonConformityDto): Promise<NonConformity> => {
const response = await api.post<NonConformity>(`/quality/non-conformities/${id}/close`, data);
return response.data;
},
verify: async (id: string): Promise<NonConformity> => {
const response = await api.post<NonConformity>(`/quality/non-conformities/${id}/verify`);
return response.data;
},
reopen: async (id: string, reason: string): Promise<NonConformity> => {
const response = await api.post<NonConformity>(`/quality/non-conformities/${id}/reopen`, { reason });
return response.data;
},
// Stats
stats: async (filters?: NonConformityFilters): Promise<NonConformityStats> => {
const response = await api.get<NonConformityStats>('/quality/non-conformities/stats', {
params: filters,
});
return response.data;
},
};
// ============================================================================
// CORRECTIVE ACTIONS API
// ============================================================================
export const correctiveActionsApi = {
list: async (nonConformityId: string): Promise<CorrectiveAction[]> => {
const response = await api.get<CorrectiveAction[]>(`/quality/non-conformities/${nonConformityId}/actions`);
return response.data;
},
get: async (nonConformityId: string, actionId: string): Promise<CorrectiveAction> => {
const response = await api.get<CorrectiveAction>(`/quality/non-conformities/${nonConformityId}/actions/${actionId}`);
return response.data;
},
create: async (nonConformityId: string, data: CreateCorrectiveActionDto): Promise<CorrectiveAction> => {
const response = await api.post<CorrectiveAction>(`/quality/non-conformities/${nonConformityId}/actions`, data);
return response.data;
},
update: async (nonConformityId: string, actionId: string, data: UpdateCorrectiveActionDto): Promise<CorrectiveAction> => {
const response = await api.put<CorrectiveAction>(`/quality/non-conformities/${nonConformityId}/actions/${actionId}`, data);
return response.data;
},
delete: async (nonConformityId: string, actionId: string): Promise<void> => {
await api.delete(`/quality/non-conformities/${nonConformityId}/actions/${actionId}`);
},
// Workflow
start: async (nonConformityId: string, actionId: string): Promise<CorrectiveAction> => {
const response = await api.post<CorrectiveAction>(`/quality/non-conformities/${nonConformityId}/actions/${actionId}/start`);
return response.data;
},
complete: async (nonConformityId: string, actionId: string, data: CompleteCorrectiveActionDto): Promise<CorrectiveAction> => {
const response = await api.post<CorrectiveAction>(`/quality/non-conformities/${nonConformityId}/actions/${actionId}/complete`, data);
return response.data;
},
verify: async (nonConformityId: string, actionId: string, effective: boolean): Promise<CorrectiveAction> => {
const response = await api.post<CorrectiveAction>(`/quality/non-conformities/${nonConformityId}/actions/${actionId}/verify`, { effective });
return response.data;
},
};

View File

@ -24,7 +24,10 @@ export type StatusColor =
| 'purple' | 'purple'
| 'orange' | 'orange'
| 'pink' | 'pink'
| 'indigo'; | 'indigo'
| 'teal'
| 'cyan'
| 'slate';
// =========================== // ===========================
// UI COMPONENT TYPES // UI COMPONENT TYPES

View File

@ -0,0 +1,337 @@
/**
* Contracts Types - Contratos, Subcontratistas, Partidas, Addendas
*/
// ============================================================================
// ENUMS
// ============================================================================
export type ContractType = 'client' | 'subcontractor';
export type ContractStatus = 'draft' | 'review' | 'approved' | 'active' | 'completed' | 'terminated';
export type ClientContractType = 'desarrollo' | 'llave_en_mano' | 'administracion';
export type SubcontractorSpecialty = 'cimentacion' | 'estructura' | 'instalaciones_electricas' | 'instalaciones_hidraulicas' | 'acabados' | 'urbanizacion' | 'carpinteria' | 'herreria' | 'otros';
export type SubcontractorStatus = 'active' | 'inactive' | 'blacklisted';
export type AddendumType = 'extension' | 'amount_increase' | 'amount_decrease' | 'scope_change' | 'termination' | 'other';
export type AddendumStatus = 'draft' | 'review' | 'approved' | 'rejected';
// ============================================================================
// CONTRACT TYPES
// ============================================================================
export interface Contract {
id: string;
tenantId: string;
projectId?: string;
fraccionamientoId?: string;
contractNumber: string;
contractType: ContractType;
clientContractType?: ClientContractType;
name: string;
description?: string;
clientName?: string;
clientRfc?: string;
clientAddress?: string;
subcontractorId?: string;
subcontractor?: Subcontractor;
specialty?: string;
startDate: string;
endDate: string;
contractAmount: number;
currency: string;
paymentTerms?: string;
retentionPercentage: number;
advancePercentage: number;
status: ContractStatus;
submittedAt?: string;
submittedById?: string;
legalApprovedAt?: string;
legalApprovedById?: string;
approvedAt?: string;
approvedById?: string;
signedAt?: string;
terminatedAt?: string;
terminationReason?: string;
documentUrl?: string;
signedDocumentUrl?: string;
progressPercentage: number;
invoicedAmount: number;
paidAmount: number;
notes?: string;
addendums?: ContractAddendum[];
partidas?: ContractPartida[];
createdAt: string;
updatedAt: string;
}
export interface ContractFilters {
contractType?: ContractType;
status?: ContractStatus;
subcontractorId?: string;
fraccionamientoId?: string;
projectId?: string;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
}
export interface CreateContractDto {
projectId?: string;
fraccionamientoId?: string;
contractNumber: string;
contractType: ContractType;
clientContractType?: ClientContractType;
name: string;
description?: string;
clientName?: string;
clientRfc?: string;
clientAddress?: string;
subcontractorId?: string;
specialty?: string;
startDate: string;
endDate: string;
contractAmount: number;
currency?: string;
paymentTerms?: string;
retentionPercentage?: number;
advancePercentage?: number;
notes?: string;
}
export interface UpdateContractDto {
name?: string;
description?: string;
clientName?: string;
clientRfc?: string;
clientAddress?: string;
subcontractorId?: string;
specialty?: string;
startDate?: string;
endDate?: string;
contractAmount?: number;
paymentTerms?: string;
retentionPercentage?: number;
advancePercentage?: number;
notes?: string;
}
export interface ContractStats {
total: number;
byStatus: Record<ContractStatus, number>;
totalAmount: number;
invoicedAmount: number;
paidAmount: number;
pendingAmount: number;
}
// ============================================================================
// SUBCONTRACTOR TYPES
// ============================================================================
export interface Subcontractor {
id: string;
tenantId: string;
code: string;
businessName: string;
tradeName?: string;
rfc: string;
address?: string;
phone?: string;
email?: string;
contactName?: string;
contactPhone?: string;
primarySpecialty: SubcontractorSpecialty;
secondarySpecialties?: string[];
status: SubcontractorStatus;
totalContracts: number;
completedContracts: number;
averageRating: number;
totalIncidents: number;
bankName?: string;
bankAccount?: string;
clabe?: string;
notes?: string;
createdAt: string;
updatedAt: string;
}
export interface SubcontractorFilters {
status?: SubcontractorStatus;
primarySpecialty?: SubcontractorSpecialty;
search?: string;
page?: number;
limit?: number;
}
export interface CreateSubcontractorDto {
code: string;
businessName: string;
tradeName?: string;
rfc: string;
address?: string;
phone?: string;
email?: string;
contactName?: string;
contactPhone?: string;
primarySpecialty: SubcontractorSpecialty;
secondarySpecialties?: string[];
bankName?: string;
bankAccount?: string;
clabe?: string;
notes?: string;
}
export interface UpdateSubcontractorDto {
businessName?: string;
tradeName?: string;
address?: string;
phone?: string;
email?: string;
contactName?: string;
contactPhone?: string;
primarySpecialty?: SubcontractorSpecialty;
secondarySpecialties?: string[];
status?: SubcontractorStatus;
bankName?: string;
bankAccount?: string;
clabe?: string;
notes?: string;
}
// ============================================================================
// CONTRACT PARTIDA TYPES
// ============================================================================
export interface ContractPartida {
id: string;
tenantId: string;
contractId: string;
conceptoId: string;
conceptoCode?: string;
conceptoDescription?: string;
unit?: string;
quantity: number;
unitPrice: number;
totalAmount: number;
createdAt: string;
updatedAt: string;
}
export interface CreateContractPartidaDto {
conceptoId: string;
quantity: number;
unitPrice: number;
}
export interface UpdateContractPartidaDto {
quantity?: number;
unitPrice?: number;
}
// ============================================================================
// CONTRACT ADDENDUM TYPES
// ============================================================================
export interface ContractAddendum {
id: string;
tenantId: string;
contractId: string;
addendumNumber: string;
addendumType: AddendumType;
title: string;
description: string;
effectiveDate: string;
newEndDate?: string;
amountChange: number;
newContractAmount?: number;
scopeChanges?: string;
status: AddendumStatus;
approvedAt?: string;
approvedById?: string;
rejectionReason?: string;
documentUrl?: string;
notes?: string;
createdAt: string;
updatedAt: string;
}
export interface CreateAddendumDto {
addendumNumber: string;
addendumType: AddendumType;
title: string;
description: string;
effectiveDate: string;
newEndDate?: string;
amountChange?: number;
scopeChanges?: string;
notes?: string;
}
export interface UpdateAddendumDto {
title?: string;
description?: string;
effectiveDate?: string;
newEndDate?: string;
amountChange?: number;
scopeChanges?: string;
notes?: string;
}
// ============================================================================
// CONSTANTS / OPTIONS
// ============================================================================
export const CONTRACT_TYPE_OPTIONS = [
{ value: 'client', label: 'Cliente', color: 'blue' },
{ value: 'subcontractor', label: 'Subcontratista', color: 'purple' },
] as const;
export const CONTRACT_STATUS_OPTIONS = [
{ value: 'draft', label: 'Borrador', color: 'gray' },
{ value: 'review', label: 'En Revision', color: 'yellow' },
{ value: 'approved', label: 'Aprobado', color: 'blue' },
{ value: 'active', label: 'Activo', color: 'green' },
{ value: 'completed', label: 'Completado', color: 'teal' },
{ value: 'terminated', label: 'Terminado', color: 'red' },
] as const;
export const CLIENT_CONTRACT_TYPE_OPTIONS = [
{ value: 'desarrollo', label: 'Desarrollo', color: 'blue' },
{ value: 'llave_en_mano', label: 'Llave en Mano', color: 'green' },
{ value: 'administracion', label: 'Administracion', color: 'purple' },
] as const;
export const SUBCONTRACTOR_SPECIALTY_OPTIONS = [
{ value: 'cimentacion', label: 'Cimentacion', color: 'gray' },
{ value: 'estructura', label: 'Estructura', color: 'blue' },
{ value: 'instalaciones_electricas', label: 'Instalaciones Electricas', color: 'yellow' },
{ value: 'instalaciones_hidraulicas', label: 'Instalaciones Hidraulicas', color: 'cyan' },
{ value: 'acabados', label: 'Acabados', color: 'pink' },
{ value: 'urbanizacion', label: 'Urbanizacion', color: 'green' },
{ value: 'carpinteria', label: 'Carpinteria', color: 'orange' },
{ value: 'herreria', label: 'Herreria', color: 'slate' },
{ value: 'otros', label: 'Otros', color: 'purple' },
] as const;
export const SUBCONTRACTOR_STATUS_OPTIONS = [
{ value: 'active', label: 'Activo', color: 'green' },
{ value: 'inactive', label: 'Inactivo', color: 'gray' },
{ value: 'blacklisted', label: 'Lista Negra', color: 'red' },
] as const;
export const ADDENDUM_TYPE_OPTIONS = [
{ value: 'extension', label: 'Extension de Plazo', color: 'blue' },
{ value: 'amount_increase', label: 'Incremento de Monto', color: 'green' },
{ value: 'amount_decrease', label: 'Reduccion de Monto', color: 'red' },
{ value: 'scope_change', label: 'Cambio de Alcance', color: 'yellow' },
{ value: 'termination', label: 'Terminacion', color: 'gray' },
{ value: 'other', label: 'Otro', color: 'purple' },
] as const;
export const ADDENDUM_STATUS_OPTIONS = [
{ value: 'draft', label: 'Borrador', color: 'gray' },
{ value: 'review', label: 'En Revision', color: 'yellow' },
{ value: 'approved', label: 'Aprobado', color: 'green' },
{ value: 'rejected', label: 'Rechazado', color: 'red' },
] as const;

View File

@ -0,0 +1,504 @@
/**
* Quality Types - Calidad, Inspecciones, No Conformidades, Tickets Postventa
*/
// ============================================================================
// ENUMS
// ============================================================================
export type ChecklistStage = 'foundation' | 'structure' | 'installations' | 'finishes' | 'delivery' | 'custom';
export type InspectionStatus = 'pending' | 'in_progress' | 'completed' | 'approved' | 'rejected';
export type InspectionResultStatus = 'pending' | 'passed' | 'failed' | 'not_applicable';
export type TicketPriority = 'urgent' | 'high' | 'medium' | 'low';
export type TicketStatus = 'created' | 'assigned' | 'in_progress' | 'resolved' | 'closed' | 'cancelled';
export type TicketCategory = 'plumbing' | 'electrical' | 'finishes' | 'carpentry' | 'structural' | 'other';
export type NCSeverity = 'minor' | 'major' | 'critical';
export type NCStatus = 'open' | 'in_progress' | 'closed' | 'verified';
export type ActionType = 'corrective' | 'preventive' | 'improvement';
export type ActionStatus = 'pending' | 'in_progress' | 'completed' | 'verified';
export type AssignmentStatus = 'assigned' | 'accepted' | 'in_progress' | 'completed' | 'reassigned';
// ============================================================================
// CHECKLIST TYPES
// ============================================================================
export interface ChecklistItem {
id: string;
tenantId: string;
checklistId: string;
sequenceNumber: number;
category: string;
description: string;
isCritical: boolean;
requiresPhoto: boolean;
acceptanceCriteria?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface Checklist {
id: string;
tenantId: string;
code: string;
name: string;
description?: string;
stage: ChecklistStage;
prototypeId?: string;
isActive: boolean;
version: number;
items?: ChecklistItem[];
createdAt: string;
createdById?: string;
updatedAt: string;
updatedById?: string;
}
export interface ChecklistFilters {
stage?: ChecklistStage;
prototypeId?: string;
isActive?: boolean;
search?: string;
page?: number;
limit?: number;
}
export interface CreateChecklistItemDto {
sequenceNumber: number;
category: string;
description: string;
isCritical?: boolean;
requiresPhoto?: boolean;
acceptanceCriteria?: string;
isActive?: boolean;
}
export interface CreateChecklistDto {
code: string;
name: string;
description?: string;
stage: ChecklistStage;
prototypeId?: string;
isActive?: boolean;
items?: CreateChecklistItemDto[];
}
export interface UpdateChecklistDto {
code?: string;
name?: string;
description?: string;
stage?: ChecklistStage;
prototypeId?: string;
isActive?: boolean;
items?: CreateChecklistItemDto[];
}
// ============================================================================
// INSPECTION TYPES
// ============================================================================
export interface InspectionResult {
id: string;
tenantId: string;
inspectionId: string;
checklistItemId: string;
result: InspectionResultStatus;
observations?: string;
photoUrl?: string;
inspectedAt?: string;
checklistItem?: ChecklistItem;
createdAt: string;
updatedAt: string;
}
export interface Inspection {
id: string;
tenantId: string;
checklistId: string;
loteId: string;
inspectionNumber: string;
inspectionDate: string;
inspectorId: string;
status: InspectionStatus;
totalItems: number;
passedItems: number;
failedItems: number;
passRate?: number;
completedAt?: string;
approvedById?: string;
approvedAt?: string;
notes?: string;
rejectionReason?: string;
checklist?: Checklist;
results?: InspectionResult[];
createdAt: string;
createdById?: string;
updatedAt: string;
updatedById?: string;
}
export interface InspectionFilters {
checklistId?: string;
loteId?: string;
inspectorId?: string;
status?: InspectionStatus;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
}
export interface CreateInspectionResultDto {
checklistItemId: string;
result: InspectionResultStatus;
observations?: string;
photoUrl?: string;
}
export interface CreateInspectionDto {
checklistId: string;
loteId: string;
inspectionDate: string;
inspectorId: string;
notes?: string;
}
export interface UpdateInspectionDto {
inspectionDate?: string;
inspectorId?: string;
notes?: string;
}
export interface SaveInspectionResultsDto {
results: CreateInspectionResultDto[];
}
export interface InspectionStats {
totalInspections: number;
pendingInspections: number;
inProgressInspections: number;
completedInspections: number;
approvedInspections: number;
rejectedInspections: number;
averagePassRate: number;
}
// ============================================================================
// POST-SALE TICKET TYPES
// ============================================================================
export interface TicketAssignment {
id: string;
tenantId: string;
ticketId: string;
technicianId: string;
assignedAt: string;
assignedById: string;
status: AssignmentStatus;
acceptedAt?: string;
scheduledDate?: string;
scheduledTime?: string;
completedAt?: string;
workNotes?: string;
reassignmentReason?: string;
isCurrent: boolean;
createdAt: string;
updatedAt: string;
}
export interface PostSaleTicket {
id: string;
tenantId: string;
loteId: string;
derechohabienteId?: string;
ticketNumber: string;
category: TicketCategory;
priority: TicketPriority;
title: string;
description: string;
photoUrl?: string;
status: TicketStatus;
slaHours: number;
slaDueAt: string;
slaBreached: boolean;
assignedAt?: string;
resolvedAt?: string;
closedAt?: string;
resolutionNotes?: string;
resolutionPhotoUrl?: string;
satisfactionRating?: number;
satisfactionComment?: string;
contactName?: string;
contactPhone?: string;
assignments?: TicketAssignment[];
createdAt: string;
createdById?: string;
updatedAt: string;
updatedById?: string;
}
export interface TicketFilters {
loteId?: string;
category?: TicketCategory;
priority?: TicketPriority;
status?: TicketStatus;
slaBreached?: boolean;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
}
export interface CreateTicketDto {
loteId: string;
derechohabienteId?: string;
category: TicketCategory;
priority: TicketPriority;
title: string;
description: string;
photoUrl?: string;
contactName?: string;
contactPhone?: string;
}
export interface UpdateTicketDto {
category?: TicketCategory;
priority?: TicketPriority;
title?: string;
description?: string;
photoUrl?: string;
contactName?: string;
contactPhone?: string;
}
export interface AssignTicketDto {
technicianId: string;
scheduledDate?: string;
scheduledTime?: string;
}
export interface ResolveTicketDto {
resolutionNotes: string;
resolutionPhotoUrl?: string;
}
export interface RateTicketDto {
satisfactionRating: number;
satisfactionComment?: string;
}
export interface TicketStats {
totalTickets: number;
openTickets: number;
assignedTickets: number;
inProgressTickets: number;
resolvedTickets: number;
closedTickets: number;
slaBreach: number;
avgResolutionHours: number;
avgSatisfaction: number;
}
// ============================================================================
// NON-CONFORMITY TYPES
// ============================================================================
export interface CorrectiveAction {
id: string;
tenantId: string;
nonConformityId: string;
actionType: ActionType;
description: string;
responsibleId: string;
dueDate: string;
status: ActionStatus;
completedAt?: string;
completionNotes?: string;
verifiedAt?: string;
verifiedById?: string;
effectivenessVerified: boolean;
createdAt: string;
createdById?: string;
updatedAt: string;
updatedById?: string;
}
export interface NonConformity {
id: string;
tenantId: string;
inspectionId?: string;
loteId: string;
ncNumber: string;
detectionDate: string;
category: string;
severity: NCSeverity;
description: string;
rootCause?: string;
photoUrl?: string;
contractorId?: string;
status: NCStatus;
dueDate?: string;
closedAt?: string;
closedById?: string;
verifiedAt?: string;
verifiedById?: string;
closurePhotoUrl?: string;
closureNotes?: string;
correctiveActions?: CorrectiveAction[];
createdAt: string;
createdById?: string;
updatedAt: string;
updatedById?: string;
}
export interface NonConformityFilters {
inspectionId?: string;
loteId?: string;
category?: string;
severity?: NCSeverity;
status?: NCStatus;
contractorId?: string;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
}
export interface CreateNonConformityDto {
inspectionId?: string;
loteId: string;
detectionDate: string;
category: string;
severity: NCSeverity;
description: string;
rootCause?: string;
photoUrl?: string;
contractorId?: string;
dueDate?: string;
}
export interface UpdateNonConformityDto {
category?: string;
severity?: NCSeverity;
description?: string;
rootCause?: string;
photoUrl?: string;
contractorId?: string;
dueDate?: string;
}
export interface CloseNonConformityDto {
closureNotes: string;
closurePhotoUrl?: string;
}
export interface CreateCorrectiveActionDto {
actionType: ActionType;
description: string;
responsibleId: string;
dueDate: string;
}
export interface UpdateCorrectiveActionDto {
description?: string;
responsibleId?: string;
dueDate?: string;
}
export interface CompleteCorrectiveActionDto {
completionNotes: string;
}
export interface NonConformityStats {
totalNCs: number;
openNCs: number;
inProgressNCs: number;
closedNCs: number;
verifiedNCs: number;
minorNCs: number;
majorNCs: number;
criticalNCs: number;
avgClosureTime: number;
}
// ============================================================================
// OPTIONS FOR STATUS BADGES
// ============================================================================
export const CHECKLIST_STAGE_OPTIONS = [
{ value: 'foundation', label: 'Cimentacion', color: 'gray' },
{ value: 'structure', label: 'Estructura', color: 'blue' },
{ value: 'installations', label: 'Instalaciones', color: 'purple' },
{ value: 'finishes', label: 'Acabados', color: 'yellow' },
{ value: 'delivery', label: 'Entrega', color: 'green' },
{ value: 'custom', label: 'Personalizado', color: 'gray' },
] as const;
export const INSPECTION_STATUS_OPTIONS = [
{ value: 'pending', label: 'Pendiente', color: 'gray' },
{ value: 'in_progress', label: 'En Progreso', color: 'blue' },
{ value: 'completed', label: 'Completada', color: 'yellow' },
{ value: 'approved', label: 'Aprobada', color: 'green' },
{ value: 'rejected', label: 'Rechazada', color: 'red' },
] as const;
export const INSPECTION_RESULT_STATUS_OPTIONS = [
{ value: 'pending', label: 'Pendiente', color: 'gray' },
{ value: 'passed', label: 'Aprobado', color: 'green' },
{ value: 'failed', label: 'Fallido', color: 'red' },
{ value: 'not_applicable', label: 'N/A', color: 'gray' },
] as const;
export const TICKET_PRIORITY_OPTIONS = [
{ value: 'urgent', label: 'Urgente', color: 'red' },
{ value: 'high', label: 'Alta', color: 'orange' },
{ value: 'medium', label: 'Media', color: 'yellow' },
{ value: 'low', label: 'Baja', color: 'green' },
] as const;
export const TICKET_STATUS_OPTIONS = [
{ value: 'created', label: 'Creado', color: 'gray' },
{ value: 'assigned', label: 'Asignado', color: 'blue' },
{ value: 'in_progress', label: 'En Progreso', color: 'yellow' },
{ value: 'resolved', label: 'Resuelto', color: 'green' },
{ value: 'closed', label: 'Cerrado', color: 'gray' },
{ value: 'cancelled', label: 'Cancelado', color: 'red' },
] as const;
export const TICKET_CATEGORY_OPTIONS = [
{ value: 'plumbing', label: 'Plomeria', color: 'blue' },
{ value: 'electrical', label: 'Electricidad', color: 'yellow' },
{ value: 'finishes', label: 'Acabados', color: 'purple' },
{ value: 'carpentry', label: 'Carpinteria', color: 'orange' },
{ value: 'structural', label: 'Estructural', color: 'red' },
{ value: 'other', label: 'Otro', color: 'gray' },
] as const;
export const NC_SEVERITY_OPTIONS = [
{ value: 'minor', label: 'Menor', color: 'yellow' },
{ value: 'major', label: 'Mayor', color: 'orange' },
{ value: 'critical', label: 'Critica', color: 'red' },
] as const;
export const NC_STATUS_OPTIONS = [
{ value: 'open', label: 'Abierta', color: 'red' },
{ value: 'in_progress', label: 'En Progreso', color: 'yellow' },
{ value: 'closed', label: 'Cerrada', color: 'green' },
{ value: 'verified', label: 'Verificada', color: 'blue' },
] as const;
export const ACTION_TYPE_OPTIONS = [
{ value: 'corrective', label: 'Correctiva', color: 'red' },
{ value: 'preventive', label: 'Preventiva', color: 'blue' },
{ value: 'improvement', label: 'Mejora', color: 'green' },
] as const;
export const ACTION_STATUS_OPTIONS = [
{ value: 'pending', label: 'Pendiente', color: 'gray' },
{ value: 'in_progress', label: 'En Progreso', color: 'yellow' },
{ value: 'completed', label: 'Completada', color: 'green' },
{ value: 'verified', label: 'Verificada', color: 'blue' },
] as const;