prueba tecnica

This commit is contained in:
Alejandro
2025-06-15 18:29:25 +02:00
parent 9758ee0bc6
commit d97e55a83f
127 changed files with 6488 additions and 1 deletions

View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:5009/api

View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=https://quediaempiezo.asarmientotest.es/api

24
front/contracts-frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/proximaenergia.com.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prueba técnica proxima contracts</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3786
front/contracts-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
{
"name": "contracts-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"bootstrap": "^5.3.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.57.0",
"react-router-dom": "^7.6.2"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/node": "^24.0.1",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

View File

@@ -0,0 +1,59 @@
import type {
FieldError,
FieldValues,
Path,
RegisterOptions,
UseFormRegister,
} from 'react-hook-form';
type FormFieldProps<T extends FieldValues> = {
label: string;
name: Path<T>;
register: UseFormRegister<T>;
error?: FieldError;
rules?: RegisterOptions<T>;
type?: string;
className?: string;
};
export default function FormField<T extends FieldValues>({
label,
name,
register,
error,
rules,
type = 'text',
className = '',
}: FormFieldProps<T>) {
const buildValidationRules = (): RegisterOptions<T, Path<T>> => {
const base: RegisterOptions<T, Path<T>> = { required: 'Obligatorio' };
if (type === 'number') {
(base as any).valueAsNumber = true;
}
if (rules) Object.assign(base, rules);
return base;
};
const validationRules = buildValidationRules();
return (
<div className={`col-12 col-md-6 col-lg-4 ${className}`}>
<label htmlFor={name} className="form-label">
{label}
</label>
<input
id={name}
type={type}
className={`form-control ${error ? 'is-invalid' : ''}`}
{...register(name, validationRules)}
/>
{error && (
<small className="text-danger">{error.message ?? 'Obligatorio'}</small>
)}
</div>
);
}

7
front/contracts-frontend/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,26 @@
import { get, post, put } from '../../../utils/http/Http';
import type { ContractSummary } from '../types/ContractSummary';
import type { ContractDetail } from '../types/ContractDetail';
import type { UpdateContractDto } from '../types/UpdateContractDto';
import type { CreateContractDto } from '../types/CreateContractDto';
export const getAllContracts = () =>
get<ContractSummary[]>('/Contracts/GetAll');
export const getContractById = (id: number) =>
get<ContractDetail>('/Contracts/GetById', { params: { id } });
export const updateContract = (dto: UpdateContractDto) =>
put<void, UpdateContractDto>('/Contracts/UpdateContract', dto);
export const createContract = (dto: CreateContractDto) =>
post<{ newContractId: number }, CreateContractDto>(
'/Contracts/CreateContract',
dto
);

View File

@@ -0,0 +1,100 @@
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import type { CreateContractDto } from '@/features/contracts/types/CreateContractDto';
import FormField from '@/components/forms/FormField';
import { useRates } from '@/features/rates/hooks/useRates';
import { useCreateContract } from '@/features/contracts/hooks/useCreateContracts';
import { validateNifNie } from '@/utils/validations/ValidateNifNie';
import { maxLengthNonEmpty } from '@/utils/validations/MaxLengthNonEmpty';
export default function ContractCreateForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateContractDto>();
const navigate = useNavigate();
const { rates, loading: loadingRates, error: rateError } = useRates();
const { create, loading: creating, error: submitError } = useCreateContract();
return (
<form onSubmit={handleSubmit(create)} className="row g-3">
<FormField<CreateContractDto>
label="Nombre"
name="contractorName"
register={register}
error={errors.contractorName}
rules={{validate: maxLengthNonEmpty(50)}}
/>
<FormField<CreateContractDto>
label="Apellidos"
name="contractorSurname"
register={register}
error={errors.contractorSurname}
rules={{validate: maxLengthNonEmpty(100)}}
/>
<FormField<CreateContractDto>
label="DNI / NIE"
name="contractorIdNumber"
register={register}
error={errors.contractorIdNumber}
rules={{
validate: {
nifNie : validateNifNie,
maxLengthNonEmpty: maxLengthNonEmpty(9)
}
}}
/>
<div className="col-12 col-md-6 col-lg-4">
<label htmlFor="rateId" className="form-label">
Tarifa
</label>
<select
id="rateId"
className="form-select"
{...register('rateId', { required: true, valueAsNumber: true })}
disabled={loadingRates || !!rateError}
>
<option value="">
{loadingRates ? 'Cargando…' : '— Selecciona —'}
</option>
{rates.map((rate) => (
<option key={rate.id} value={rate.id}>
{rate.name} ({rate.price.toFixed(2)} /kWh)
</option>
))}
</select>
{errors.rateId && <small className="text-danger">Obligatorio</small>}
{rateError && <small className="text-danger">{rateError}</small>}
</div>
{submitError && (
<div className="col-12">
<small className="text-danger">{submitError}</small>
</div>
)}
<div className="col-12 mt-2">
<button type="submit" className="btn btn-primary me-2" disabled={creating}>
{creating ? 'Guardando…' : 'Guardar'}
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => navigate(-1)}
disabled={creating}
>
Cancelar
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,28 @@
import type { ContractDetail } from '@/features/contracts/types/ContractDetail';
export default function ContractDetailComponent({
contract,
onEdit,
}: {
contract: ContractDetail;
onEdit: () => void;
}) {
return (
<div className="card shadow-sm">
<div className="card-body">
<h4 className="card-title mb-3">Contrato #{contract.id}</h4>
<p><strong>Nif/Nie:</strong> {contract.contractorIdNumber}</p>
<p><strong>Nombre:</strong> {contract.contractorName}</p>
<p><strong>Apellidos:</strong> {contract.contractorSurname}</p>
<p><strong>Inicio:</strong> {new Date(contract.contractInitDate).toLocaleDateString()}</p>
<p><strong>Tarifa:</strong> {contract.rateId}, {contract.rateName} </p>
<button onClick={onEdit} className="btn btn-primary mt-3">
Editar
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import type { ContractDetail } from '@/features/contracts/types/ContractDetail';
import type { UpdateContractDto } from '@/features/contracts/types/UpdateContractDto';
import { updateContract } from '@/features/contracts/api/ContractsApi';
import { getProblemMessage } from '@/utils/http/ErrorHelper';
import FormField from '@/components/forms/FormField';
import { maxLengthNonEmpty } from '@/utils/validations/MaxLengthNonEmpty';
import { maxDate, minDate } from '@/utils/validations/dateValidators';
interface Props {
contract: ContractDetail;
onSaved: (updated: ContractDetail) => void;
onCancel: () => void;
}
interface FormData {
contractorName: string;
contractorSurname: string;
contractInitDate: string;
rateId: number;
}
export default function ContractUpdateComponent({
contract,
onSaved,
onCancel,
}: Props) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<FormData>();
useEffect(() => {
reset({
contractorName: contract.contractorName,
contractorSurname: contract.contractorSurname,
contractInitDate: contract.contractInitDate.slice(0, 10),
rateId: contract.rateId,
});
}, [contract, reset]);
const onSubmit = async (data: FormData) => {
const dto: UpdateContractDto = { contractId: contract.id, ...data };
try {
await updateContract(dto);
onSaved({ ...contract, ...data });
} catch (err) {
alert(getProblemMessage(err, 'Error al actualizar.'));
}
};
const todayIso = new Date().toISOString().slice(0, 10);
return (
<form onSubmit={handleSubmit(onSubmit)} className="card shadow-sm p-4">
<h4 className="mb-4">Editar contrato #{contract.id}</h4>
<div className="row gy-3">
<FormField<FormData>
label="Nombre"
name="contractorName"
register={register}
error={errors.contractorName}
rules={{
validate: maxLengthNonEmpty(50),
}}
className="col-12"
/>
<FormField<FormData>
label="Apellidos"
name="contractorSurname"
register={register}
error={errors.contractorSurname}
rules={{
validate: maxLengthNonEmpty(100),
}}
className="col-12"
/>
<FormField<FormData>
label="Inicio"
name="contractInitDate"
type="date"
register={register}
error={errors.contractInitDate}
rules={{
required: 'Obligatorio',
validate: {
minDate: minDate("01/01/1999"),
maxDate: maxDate(todayIso)
}
}}
className="col-12 col-md-6"
/>
<FormField<FormData>
label="Rate ID"
name="rateId"
type="number"
register={register}
error={errors.rateId}
rules={{
required: 'Obligatorio',
valueAsNumber: true,
min: { value: 1, message: 'Debe ser ≥ 1' },
}}
className="col-12 col-md-6"
/>
</div>
<div className="d-flex gap-2 mt-4">
<button
type="submit"
disabled={isSubmitting}
className="btn btn-success"
>
{isSubmitting ? 'Guardando…' : 'Guardar'}
</button>
<button type="button" onClick={onCancel} className="btn btn-secondary">
Cancelar
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,42 @@
import { Link } from 'react-router-dom';
import { useContractsAll } from '../hooks/useContractsAll';
export default function ContractsTableComponent() {
const { contracts, loading, error } = useContractsAll();
if (loading) return <p>Cargando</p>;
if (error) return <p className="text-danger">{error}</p>;
return (
<table className="table table-hover table-contracts">
<thead>
<tr>
<th>ID</th>
<th>Nombre</th>
<th>Apellidos</th>
<th>Inicio</th>
<th>Tarifa</th>
<th className="text-center">Acciones</th>
</tr>
</thead>
<tbody>
{contracts.map(c => (
<tr key={c.id}>
<td>{c.id}</td>
<td>{c.contractorName}</td>
<td>{c.contractorSurname}</td>
<td>{new Date(c.contractInitDate).toLocaleDateString()}</td>
<td>{c.rateName}</td>
<td className="text-center">
<Link to={`/contracts/${c.id}`} className="btn btn-sm btn-primary">
Detalle
</Link>
</td>
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -0,0 +1,28 @@
import { useState, useEffect, useCallback } from 'react';
import { getContractById } from '../api/ContractsApi';
import type { ContractDetail } from '../types/ContractDetail';
export function useContractDetail(contractId: number) {
const [contract, setContract] = useState<ContractDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchContract = useCallback(async () => {
setLoading(true);
try {
const data = await getContractById(contractId);
setContract(data);
setError(null);
} catch {
setError('Contrato no encontrado');
} finally {
setLoading(false);
}
}, [contractId]);
useEffect(() => {
fetchContract();
}, [fetchContract]);
return { contract, loading, error, refresh: fetchContract };
}

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
import { getAllContracts } from '@/features/contracts/api/ContractsApi';
import type { ContractSummary } from '@/features/contracts/types/ContractSummary';
import { getProblemMessage } from '@/utils/http/ErrorHelper';
import { HttpError } from '@/utils/http/HttpError';
export function useContractsAll() {
const [contracts, setContractsAll] = useState<ContractSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
setContractsAll(await getAllContracts());
} catch (err) {
setError(
err instanceof HttpError && err.status === 404
? 'No se encontraron contratos.'
: getProblemMessage(err, 'Error al cargar los contratos.')
);
} finally {
setLoading(false);
}
})();
}, []);
return { contracts, loading, error };
}

View File

@@ -0,0 +1,26 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { createContract as apiCreate } from '../api/ContractsApi';
import type { CreateContractDto } from '../types/CreateContractDto';
import { getProblemMessage } from '@/utils/http/ErrorHelper';
export function useCreateContract() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const create = async (dto: CreateContractDto) => {
setLoading(true);
setError(null);
try {
const { newContractId } = await apiCreate(dto);
navigate(`/contracts/${newContractId}`);
} catch (err) {
setError(getProblemMessage(err, 'No se pudo crear el contrato'));
} finally {
setLoading(false);
}
};
return { create, loading, error };
}

View File

@@ -0,0 +1,31 @@
import ContractCreateForm from '@/features/contracts/components/ContractCreateForm';
import { useNavigate } from 'react-router-dom';
export default function ContractCreate() {
const navigate = useNavigate();
return (
<div className="container-fluid py-4 px-4 px-lg-5">
<div className="card shadow-sm">
<div className="card-body">
{/* Cabecera */}
<div className="d-flex align-items-center mb-4">
<button
type="button"
onClick={() => navigate(-1)}
className="btn btn-outline-primary btn-sm me-3"
>
&larr; Atrás
</button>
<h2 className="h4 m-0">Crear nuevo contrato</h2>
</div>
{/* Formulario */}
<ContractCreateForm />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
// src/features/contracts/components/ContractDetail.tsx
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useContractDetail } from '@/features/contracts/hooks/useContractDetail';
import ContractDetailComponent from '@/features/contracts/components/ContractDetailComponent';
import ContractUpdateComponent from '@/features/contracts/components/ContractUpdateComponent';
export default function ContractDetail() {
const { id } = useParams<{ id: string }>();
const numericId = Number(id);
const navigate = useNavigate();
const { contract, loading, error, refresh } = useContractDetail(numericId);
const [editing, setEditing] = useState(false);
if (loading) return <p>Cargando</p>;
if (error || !contract) return <p className="text-red-600">{error}</p>;
return (
<div className="mx-auto max-w-xl space-y-6 p-4">
{editing ? (
<div className="container my-4">
<div className="card shadow-sm">
<div className="card-body">
<div className="d-flex align-items-center my-4">
<h2 className="h4 m-0">Modificar contrato</h2>
</div>
<ContractUpdateComponent
contract={contract}
onSaved={async () => {
setEditing(false);
await refresh();
}}
onCancel={() => setEditing(false)}
/>
</div>
</div>
</div>
) : (
<div className="container my-4">
<div className="card shadow-sm">
<div className="card-body">
<div className="d-flex align-items-center my-4">
<button
onClick={() => navigate(`/`)}
className="btn btn-outline-primary btn-sm me-3"
>
&larr; Atrás
</button>
<h2 className="h4 m-0">Detalle de contrato</h2>
</div>
<ContractDetailComponent
contract={contract}
onEdit={() => setEditing(true)}
/>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import ContractsTableComponent from '@/features/contracts/components/ContractsTableComponent';
import { Link } from 'react-router-dom';
export default function ContractsList() {
return (
<div className="container my-4">
<div className="card shadow-sm">
<div className="card-body">
<h2 className="h4 my-4">Listado de contratos</h2>
<Link to="/contracts/new" className="btn btn-primary mb-3">
+ Nuevo contrato
</Link>
<ContractsTableComponent />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
import type { ContractSummary } from "./ContractSummary";
export interface ContractDetail extends ContractSummary {
contractorIdNumber: string;
rateId: number;
ratePrice: number;
}

View File

@@ -0,0 +1,7 @@
export interface ContractSummary {
id: number;
contractorName: string;
contractorSurname: string;
contractInitDate: string;
rateName: string;
}

View File

@@ -0,0 +1,6 @@
export interface CreateContractDto {
contractorIdNumber: string;
contractorName: string;
contractorSurname: string;
rateId: number;
}

View File

@@ -0,0 +1,8 @@
export interface UpdateContractDto {
contractId: number;
rateId: number;
contractorIdNumber?: string;
contractorName?: string;
contractorSurname?: string;
contractInitDate?: string;
}

View File

@@ -0,0 +1,4 @@
import {get} from '@/utils/http/Http'
import type { RateDetails } from '../types/RateDetails'
export const getAllRates = () => get<RateDetails[]>('/Rates/GetAllRates');

View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from 'react';
import { getAllRates } from '../api/RatesApi';
import type { RateDetails } from '../types/RateDetails';
import { getProblemMessage } from '@/utils/http/ErrorHelper';
export function useRates() {
const [rates, setRates] = useState<RateDetails[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
setRates(await getAllRates());
} catch (err) {
setError(getProblemMessage(err, 'No se pudieron cargar las tarifas'));
} finally {
setLoading(false);
}
})();
}, []);
return { rates, loading, error };
}

View File

@@ -0,0 +1,5 @@
export interface RateDetails {
id: number;
name: string;
price: number;
}

View File

@@ -0,0 +1,14 @@
import 'bootstrap/dist/css/bootstrap.min.css';
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@/styles/brand-proxima.css';
import { BrowserRouter } from 'react-router-dom'
import AppRoutes from './routes/Index.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</StrictMode>,
)

View File

@@ -0,0 +1,14 @@
import { Routes, Route } from 'react-router-dom';
import ContractsList from '@/features/contracts/pages/ContractsList';
import ContractDetail from '@/features/contracts/pages/ContractDetail';
import ContractCreate from '@/features/contracts/pages/ContractCreate';
export default function AppRoutes() {
return (
<Routes>
<Route path="/" element={<ContractsList />} />
<Route path="/contracts/:id" element={<ContractDetail />} />
<Route path="/contracts/new" element={<ContractCreate />} />
</Routes>
);
}

View File

@@ -0,0 +1,58 @@
:root {
--brand-bg: #051622;
--brand-bg-alt: #0f2432;
--brand-bg-hover: #2d6588;
--brand-border: #113345;
--brand-text: #e5f2f7;
--brand-heading: #ffffff;
--brand-primary: #00d68f;
--brand-primary-hover: #00b67a;
}
:root {
--bs-body-bg: var(--brand-bg);
--bs-body-color: var(--brand-text);
--bs-border-color: var(--brand-border);
--bs-heading-color: var(--brand-heading);
--bs-primary: var(--brand-primary);
--bs-primary-rgb: 0,214,143;
--bs-link-color: var(--brand-primary);
--bs-link-hover-color: var(--brand-primary-hover);
}
.table-contracts {
--bs-table-color: var(--brand-text);
--bs-table-bg: transparent;
--bs-table-border-color: var(--brand-border);
}
.table-contracts > thead > tr {
background-color: #0b1e2c;
color: var(--brand-heading);
}
.table-contracts > tbody > tr:nth-of-type(odd) { background-color: var(--brand-bg-alt); }
.table-contracts > tbody > tr:nth-of-type(even) { background-color: var(--brand-bg); }
.table-contracts > tbody > tr:hover {
background-color: var(--brand-bg-hover);
outline: 1px solid var(--brand-primary-hover);
outline-offset: -1px;
}
.btn-primary {
background-color: var(--brand-primary);
border-color: var(--brand-primary);
color:#051622
}
.btn-primary:hover,
.btn-primary:focus {
background-color: var(--brand-primary-hover);
border-color: var(--brand-primary-hover);
}

View File

@@ -0,0 +1,17 @@
import { HttpError } from '@/utils/http/HttpError';
export function getProblemMessage(err: unknown, fallback = 'Error inesperado') {
console.log("Hemos entrado aqui")
if (err instanceof HttpError) {
console.log(
err
)
console.log(
err.body
)
return err.body?.detail
?? err.body?.title
?? err.message;
}
return fallback;
}

View File

@@ -0,0 +1,87 @@
import { HttpError } from "./HttpError";
import type { ProblemDetails } from './ProblemDetails'
type Params = Record<string, string | number | boolean | undefined>;
function buildQuery(params?: Params): string {
if (!params) return '';
const usp = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) usp.append(key, String(value));
}
const qs = usp.toString();
return qs ? `?${qs}` : '';
}
interface RequestOptions<T> {
params?: Params;
body?: T;
headers?: HeadersInit;
timeout?: number;
}
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';
async function request<R, B = unknown>(
method: 'GET' | 'POST' | 'PUT',
url: string,
{ params, body, headers, timeout = 15_000 }: RequestOptions<B> = {}
): Promise<R> {
const controller = new AbortController();
const id =
timeout >= 0 ? setTimeout(() => controller.abort(), timeout) : null;
try {
const q = buildQuery(params);
const res = await fetch(`${BASE_URL}${url}${q}`, {
method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...headers,
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
credentials: 'include',
});
const contentType = res.headers.get('Content-Type') ?? '';
const isJson = /application\/(json|problem\+json)/i.test(contentType);
const raw = await res.text();
if (!res.ok) {
const problem: ProblemDetails | null =
isJson && raw ? JSON.parse(raw) : null;
throw new HttpError(res.status, res.statusText, problem);
}
const data: R =
isJson && raw ? (JSON.parse(raw) as R) : (undefined as unknown as R);
return data;
} finally {
if (id) clearTimeout(id);
}
}
export const get = <R>(url: string, options?: RequestOptions<never>) =>
request<R>('GET', url, options);
export const post = <R, B = unknown>(
url: string,
body: B,
options?: Omit<RequestOptions<B>, 'body'>
) => request<R, B>('POST', url, { ...options, body });
export const put = <R, B = unknown>(
url: string,
body: B,
options?: Omit<RequestOptions<B>, 'body'>
) => request<R, B>('PUT', url, { ...options, body });

View File

@@ -0,0 +1,19 @@
import type { ProblemDetails } from './ProblemDetails';
export class HttpError extends Error {
public readonly status: number;
public readonly statusText: string;
public readonly body: ProblemDetails | null;
constructor(
status: number,
statusText: string,
body: ProblemDetails | null
) {
super(`${status} ${statusText}`);
this.name = 'HttpError';
this.status = status;
this.statusText = statusText;
this.body = body;
}
}

View File

@@ -0,0 +1,7 @@
export interface ProblemDetails {
type?: string;
title?: string;
status?: number;
detail?: string;
instance?: string;
}

View File

@@ -0,0 +1,11 @@
export const maxLengthNonEmpty =
(max: number) =>
(value: unknown): true | string => {
const str = (value ?? '').toString().trim();
if (!str) return 'Campo obligatorio';
if (str.length > max)
return `Debe tener como máximo ${max} caracteres`;
return true;
};

View File

@@ -0,0 +1,27 @@
export const validateNifNie = (value: unknown): true | string => {
if (typeof value !== 'string') return 'Formato no válido';
const sanitized = value.toUpperCase().replace(/[\s-]/g, '');
const nifReg = /^[0-9]{8}[A-Z]$/;
const nieReg = /^[XYZ][0-9]{7}[A-Z]$/;
const letters = 'TRWAGMYFPDXBNJZSQVHLCKE';
let number: number;
let control: string;
if (nifReg.test(sanitized)) {
number = parseInt(sanitized.slice(0, 8), 10);
control = sanitized[8];
} else if (nieReg.test(sanitized)) {
const prefix = { X: '0', Y: '1', Z: '2' }[sanitized[0] as 'X' | 'Y' | 'Z'];
number = parseInt(prefix + sanitized.slice(1, 8), 10);
control = sanitized[8];
} else {
return 'Formato de NIF/NIE inválido';
}
const expected = letters[number % 23];
return expected === control
? true
: 'Dígito de control incorrecto';
};

View File

@@ -0,0 +1,49 @@
const parseToDate = (raw: string): Date | null => {
const trimmed = raw.trim();
let day: number, month: number, year: number;
const slashMatch = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(trimmed);
if (slashMatch) {
[, day, month, year] = slashMatch.map(Number);
} else {
const dashMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
if (!dashMatch) return null;
[, year, month, day] = dashMatch.map(Number);
}
const date = new Date(year, month - 1, day);
return date.getFullYear() === year &&
date.getMonth() === month - 1 &&
date.getDate() === day
? date
: null;
};
export const minDate =
(min: string) =>
(value: unknown): true | string => {
const reference = parseToDate(min);
const dateVal = typeof value === 'string' ? parseToDate(value) : null;
if (!reference) throw new Error('minDate: formato de fecha incorrecto');
if (!dateVal) return 'Fecha inválida';
return dateVal >= reference
? true
: `Debe ser igual o posterior a ${min}`;
};
export const maxDate =
(max: string) =>
(value: unknown): true | string => {
const reference = parseToDate(max);
const dateVal = typeof value === 'string' ? parseToDate(value) : null;
if (!reference) throw new Error('maxDate: formato de fecha incorrecto');
if (!dateVal) return 'Fecha inválida';
return dateVal <= reference
? true
: `Debe ser igual o anterior a ${max}`;
};

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["node"],
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [react(),],
resolve: {
alias: {
'@': resolve(root, 'src'),
},
},
});