[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:
parent
816d591115
commit
55261598a2
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
183
web/src/components/common/StatsCard.tsx
Normal file
183
web/src/components/common/StatsCard.tsx
Normal 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;
|
||||||
@ -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 = {
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
347
web/src/components/quality/ChecklistForm.tsx
Normal file
347
web/src/components/quality/ChecklistForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
363
web/src/components/quality/InspectionResultsForm.tsx
Normal file
363
web/src/components/quality/InspectionResultsForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
web/src/components/quality/index.ts
Normal file
6
web/src/components/quality/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Quality Components Index
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { ChecklistForm } from './ChecklistForm';
|
||||||
|
export { InspectionResultsForm } from './InspectionResultsForm';
|
||||||
451
web/src/hooks/useContracts.ts
Normal file
451
web/src/hooks/useContracts.ts
Normal 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
647
web/src/hooks/useQuality.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
312
web/src/pages/admin/calidad/ChecklistsPage.tsx
Normal file
312
web/src/pages/admin/calidad/ChecklistsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
514
web/src/pages/admin/calidad/InspeccionesPage.tsx
Normal file
514
web/src/pages/admin/calidad/InspeccionesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
414
web/src/pages/admin/calidad/NoConformidadesPage.tsx
Normal file
414
web/src/pages/admin/calidad/NoConformidadesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
327
web/src/pages/admin/calidad/TicketsPage.tsx
Normal file
327
web/src/pages/admin/calidad/TicketsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
852
web/src/pages/admin/contratos/ContratoDetailPage.tsx
Normal file
852
web/src/pages/admin/contratos/ContratoDetailPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
337
web/src/pages/admin/contratos/ContratosPage.tsx
Normal file
337
web/src/pages/admin/contratos/ContratosPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
520
web/src/pages/admin/contratos/SubcontratistasPage.tsx
Normal file
520
web/src/pages/admin/contratos/SubcontratistasPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
web/src/pages/admin/contratos/index.ts
Normal file
7
web/src/pages/admin/contratos/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Contratos Pages Index
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { ContratosPage } from './ContratosPage';
|
||||||
|
export { ContratoDetailPage } from './ContratoDetailPage';
|
||||||
|
export { SubcontratistasPage } from './SubcontratistasPage';
|
||||||
204
web/src/services/contracts/contracts.api.ts
Normal file
204
web/src/services/contracts/contracts.api.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
5
web/src/services/contracts/index.ts
Normal file
5
web/src/services/contracts/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Contracts Services Index
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './contracts.api';
|
||||||
324
web/src/services/quality.ts
Normal file
324
web/src/services/quality.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -24,7 +24,10 @@ export type StatusColor =
|
|||||||
| 'purple'
|
| 'purple'
|
||||||
| 'orange'
|
| 'orange'
|
||||||
| 'pink'
|
| 'pink'
|
||||||
| 'indigo';
|
| 'indigo'
|
||||||
|
| 'teal'
|
||||||
|
| 'cyan'
|
||||||
|
| 'slate';
|
||||||
|
|
||||||
// ===========================
|
// ===========================
|
||||||
// UI COMPONENT TYPES
|
// UI COMPONENT TYPES
|
||||||
|
|||||||
337
web/src/types/contracts.types.ts
Normal file
337
web/src/types/contracts.types.ts
Normal 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;
|
||||||
504
web/src/types/quality.types.ts
Normal file
504
web/src/types/quality.types.ts
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user