Initial commit: proyecto ContabilidadSaPolar completo
This commit is contained in:
@@ -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?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Gestión Contable</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2152
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.18.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"recharts": "^3.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,58 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import Layout from './components/Layout';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Properties from './pages/Properties';
|
||||
import PropertyGroups from './pages/PropertyGroups';
|
||||
import Tenants from './pages/Tenants';
|
||||
import Contracts from './pages/Contracts';
|
||||
import IncomeReceipts from './pages/IncomeReceipts';
|
||||
import ExpenseTemplates from './pages/ExpenseTemplates';
|
||||
import ExpenseReceipts from './pages/ExpenseReceipts';
|
||||
import Incidents from './pages/Incidents';
|
||||
import Maintenance from './pages/Maintenance';
|
||||
import Documents from './pages/Documents';
|
||||
import BankAccounts from './pages/BankAccounts';
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
if (loading) return null;
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="properties" element={<Properties />} />
|
||||
<Route path="property-groups" element={<PropertyGroups />} />
|
||||
<Route path="tenants" element={<Tenants />} />
|
||||
<Route path="contracts" element={<Contracts />} />
|
||||
<Route path="income-receipts" element={<IncomeReceipts />} />
|
||||
<Route path="expense-templates" element={<ExpenseTemplates />} />
|
||||
<Route path="expense-receipts" element={<ExpenseReceipts />} />
|
||||
<Route path="incidents" element={<Incidents />} />
|
||||
<Route path="maintenance" element={<Maintenance />} />
|
||||
<Route path="documents" element={<Documents />} />
|
||||
<Route path="bank-accounts" element={<BankAccounts />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<AppRoutes />
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import client from './client';
|
||||
import type { ApiResponse, LoginRequest, TokenResponse } from '../types/api';
|
||||
|
||||
export const authApi = {
|
||||
login: (data: LoginRequest) =>
|
||||
client.post<ApiResponse<TokenResponse>>('/auth/login', data),
|
||||
|
||||
refresh: (refreshToken: string) =>
|
||||
client.post<ApiResponse<TokenResponse>>('/auth/refresh', { refreshToken }),
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: API_BASE,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default client;
|
||||
@@ -0,0 +1,201 @@
|
||||
import client from './client';
|
||||
import type { ApiResponse, PagedResponse, Property, PropertyGroup, Tenant, TenantBankData, Contract, IncomeReceipt, ExpenseTemplate, ExpenseReceipt, Incident, Document, DocumentType, DocumentEntity, User, ScheduledMaintenance, ExpenseCategory, BankAccount, ExpenseRepercussion } from '../types/api';
|
||||
|
||||
interface PageParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
sort?: string;
|
||||
dir?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const propertyApi = {
|
||||
getAll: (params?: PageParams) => client.get<ApiResponse<PagedResponse<Property>>>('/properties', { params }).then(r => r.data),
|
||||
getById: (id: number) => client.get<ApiResponse<Property>>(`/properties/${id}`).then(r => r.data),
|
||||
create: (data: Partial<Property>) => client.post<ApiResponse<Property>>('/properties', data).then(r => r.data),
|
||||
update: (id: number, data: Partial<Property>) => client.put<ApiResponse<Property>>(`/properties/${id}`, data).then(r => r.data),
|
||||
remove: (id: number) => client.delete<ApiResponse<void>>(`/properties/${id}`).then(r => r.data),
|
||||
changeStatus: (id: number, statusId: number, notes?: string) =>
|
||||
client.patch<ApiResponse<Property>>(`/properties/${id}/status`, { statusId, notes }).then(r => r.data),
|
||||
getTypes: () => client.get<ApiResponse<{ id: number; name: string }[]>>('/properties/types').then(r => r.data),
|
||||
getStatuses: () => client.get<ApiResponse<{ id: number; name: string }[]>>('/properties/statuses').then(r => r.data),
|
||||
};
|
||||
|
||||
export const tenantApi = {
|
||||
getAll: (params?: PageParams) => client.get<ApiResponse<PagedResponse<Tenant>>>('/tenants', { params }).then(r => r.data),
|
||||
getById: (id: number) => client.get<ApiResponse<Tenant>>(`/tenants/${id}`).then(r => r.data),
|
||||
create: (data: Partial<Tenant>) => client.post<ApiResponse<Tenant>>('/tenants', data).then(r => r.data),
|
||||
update: (id: number, data: Partial<Tenant>) => client.put<ApiResponse<Tenant>>(`/tenants/${id}`, data).then(r => r.data),
|
||||
remove: (id: number) => client.delete<ApiResponse<void>>(`/tenants/${id}`).then(r => r.data),
|
||||
};
|
||||
|
||||
export const tenantBankDataApi = {
|
||||
getAll: (tenantId: number) =>
|
||||
client.get<ApiResponse<TenantBankData[]>>(`/tenants/${tenantId}/bank-data`).then(r => r.data),
|
||||
getAllBanks: () =>
|
||||
client.get<ApiResponse<string[]>>(`/tenants/0/bank-data/banks`).then(r => r.data),
|
||||
create: (tenantId: number, data: Partial<TenantBankData>) =>
|
||||
client.post<ApiResponse<TenantBankData>>(`/tenants/${tenantId}/bank-data`, data).then(r => r.data),
|
||||
update: (tenantId: number, id: number, data: Partial<TenantBankData>) =>
|
||||
client.put<ApiResponse<TenantBankData>>(`/tenants/${tenantId}/bank-data/${id}`, data).then(r => r.data),
|
||||
setPrincipal: (tenantId: number, id: number) =>
|
||||
client.patch<ApiResponse<TenantBankData>>(`/tenants/${tenantId}/bank-data/${id}/principal`).then(r => r.data),
|
||||
remove: (tenantId: number, id: number) =>
|
||||
client.delete<ApiResponse<void>>(`/tenants/${tenantId}/bank-data/${id}`).then(r => r.data),
|
||||
};
|
||||
|
||||
export const contractApi = {
|
||||
getAll: (params?: PageParams) => client.get<ApiResponse<PagedResponse<Contract>>>('/contracts', { params }).then(r => r.data),
|
||||
getById: (id: number) => client.get<ApiResponse<Contract>>(`/contracts/${id}`).then(r => r.data),
|
||||
create: (data: Partial<Contract>) => client.post<ApiResponse<Contract>>('/contracts', data).then(r => r.data),
|
||||
update: (id: number, data: Partial<Contract>) => client.put<ApiResponse<Contract>>(`/contracts/${id}`, data).then(r => r.data),
|
||||
remove: (id: number) => client.delete<ApiResponse<void>>(`/contracts/${id}`).then(r => r.data),
|
||||
terminate: (id: number, cause?: string) =>
|
||||
client.post<ApiResponse<Contract>>(`/contracts/${id}/terminate`, { cause }).then(r => r.data),
|
||||
};
|
||||
|
||||
export const incomeReceiptApi = {
|
||||
getAll: (params?: PageParams) => client.get<ApiResponse<PagedResponse<IncomeReceipt>>>('/income-receipts', { params }).then(r => r.data),
|
||||
getById: (id: number) => client.get<ApiResponse<IncomeReceipt>>(`/income-receipts/${id}`).then(r => r.data),
|
||||
create: (data: Partial<IncomeReceipt>) => client.post<ApiResponse<IncomeReceipt>>('/income-receipts', data).then(r => r.data),
|
||||
update: (id: number, data: Partial<IncomeReceipt>) => client.put<ApiResponse<IncomeReceipt>>(`/income-receipts/${id}`, data).then(r => r.data),
|
||||
remove: (id: number) => client.delete<ApiResponse<void>>(`/income-receipts/${id}`).then(r => r.data),
|
||||
registerPayment: (id: number, data: { paymentDate: string; paymentMethod: string }) =>
|
||||
client.patch<ApiResponse<IncomeReceipt>>(`/income-receipts/${id}/pay`, data).then(r => r.data),
|
||||
getPending: () => client.get<ApiResponse<IncomeReceipt[]>>('/income-receipts/pending').then(r => r.data),
|
||||
getPendingCount: () => client.get<ApiResponse<number>>('/income-receipts/pending/count').then(r => r.data),
|
||||
};
|
||||
|
||||
export const expenseTemplateApi = {
|
||||
getAll: (params?: PageParams) => client.get<ApiResponse<PagedResponse<ExpenseTemplate>>>('/expense-templates', { params }).then(r => r.data),
|
||||
getById: (id: number) => client.get<ApiResponse<ExpenseTemplate>>(`/expense-templates/${id}`).then(r => r.data),
|
||||
getActive: () => client.get<ApiResponse<ExpenseTemplate[]>>('/expense-templates/active').then(r => r.data),
|
||||
create: (data: Partial<ExpenseTemplate>) => client.post<ApiResponse<ExpenseTemplate>>('/expense-templates', data).then(r => r.data),
|
||||
update: (id: number, data: Partial<ExpenseTemplate>) => client.put<ApiResponse<ExpenseTemplate>>(`/expense-templates/${id}`, data).then(r => r.data),
|
||||
toggleActive: (id: number) => client.patch<ApiResponse<ExpenseTemplate>>(`/expense-templates/${id}/toggle-active`).then(r => r.data),
|
||||
remove: (id: number) => client.delete<ApiResponse<void>>(`/expense-templates/${id}`).then(r => r.data),
|
||||
};
|
||||
|
||||
export const expenseReceiptApi = {
|
||||
getAll: (params?: PageParams) => client.get<ApiResponse<PagedResponse<ExpenseReceipt>>>('/expense-receipts', { params }).then(r => r.data),
|
||||
getById: (id: number) => client.get<ApiResponse<ExpenseReceipt>>(`/expense-receipts/${id}`).then(r => r.data),
|
||||
create: (data: Partial<ExpenseReceipt>) => client.post<ApiResponse<ExpenseReceipt>>('/expense-receipts', data).then(r => r.data),
|
||||
update: (id: number, data: Partial<ExpenseReceipt>) => client.put<ApiResponse<ExpenseReceipt>>(`/expense-receipts/${id}`, data).then(r => r.data),
|
||||
remove: (id: number) => client.delete<ApiResponse<void>>(`/expense-receipts/${id}`).then(r => r.data),
|
||||
registerPayment: (id: number, data: { paymentDate: string; paymentMethod: string }) =>
|
||||
client.patch<ApiResponse<ExpenseReceipt>>(`/expense-receipts/${id}/pay`, data).then(r => r.data),
|
||||
updateAmount: (id: number, data: { amount: number }) =>
|
||||
client.patch<ApiResponse<ExpenseReceipt>>(`/expense-receipts/${id}/amount`, data).then(r => r.data),
|
||||
getPending: () => client.get<ApiResponse<ExpenseReceipt[]>>('/expense-receipts/pending').then(r => r.data),
|
||||
getPendingCount: () => client.get<ApiResponse<{ total: number; variable: number }>>('/expense-receipts/pending/count').then(r => r.data),
|
||||
getPendingVariable: () => client.get<ApiResponse<ExpenseReceipt[]>>('/expense-receipts/pending/variable').then(r => r.data),
|
||||
};
|
||||
|
||||
export const bankAccountApi = {
|
||||
getAll: (params?: { all?: boolean }) =>
|
||||
client.get<ApiResponse<BankAccount[]>>('/bank-accounts', { params }).then(r => r.data),
|
||||
getById: (id: number) =>
|
||||
client.get<ApiResponse<BankAccount>>(`/bank-accounts/${id}`).then(r => r.data),
|
||||
create: (data: Partial<BankAccount>) =>
|
||||
client.post<ApiResponse<BankAccount>>('/bank-accounts', data).then(r => r.data),
|
||||
update: (id: number, data: Partial<BankAccount>) =>
|
||||
client.put<ApiResponse<BankAccount>>(`/bank-accounts/${id}`, data).then(r => r.data),
|
||||
remove: (id: number) =>
|
||||
client.delete<ApiResponse<void>>(`/bank-accounts/${id}`).then(r => r.data),
|
||||
};
|
||||
|
||||
export const groupApi = {
|
||||
getAll: (params?: PageParams) => client.get<ApiResponse<PagedResponse<PropertyGroup>>>('/property-groups', { params }).then(r => r.data),
|
||||
getById: (id: number) => client.get<ApiResponse<PropertyGroup>>(`/property-groups/${id}`).then(r => r.data),
|
||||
create: (data: Partial<PropertyGroup>) => client.post<ApiResponse<PropertyGroup>>('/property-groups', data).then(r => r.data),
|
||||
update: (id: number, data: Partial<PropertyGroup>) => client.put<ApiResponse<PropertyGroup>>(`/property-groups/${id}`, data).then(r => r.data),
|
||||
remove: (id: number) => client.delete<ApiResponse<void>>(`/property-groups/${id}`).then(r => r.data),
|
||||
};
|
||||
|
||||
export const incidentApi = {
|
||||
getAll: (params?: PageParams) => client.get<ApiResponse<PagedResponse<Incident>>>('/incidents', { params }).then(r => r.data),
|
||||
getById: (id: number) => client.get<ApiResponse<Incident>>(`/incidents/${id}`).then(r => r.data),
|
||||
create: (data: Partial<Incident>) => client.post<ApiResponse<Incident>>('/incidents', data).then(r => r.data),
|
||||
update: (id: number, data: Partial<Incident>) => client.put<ApiResponse<Incident>>(`/incidents/${id}`, data).then(r => r.data),
|
||||
remove: (id: number) => client.delete<ApiResponse<void>>(`/incidents/${id}`).then(r => r.data),
|
||||
updateStatus: (id: number, statusId: number, resolutionNotes?: string) =>
|
||||
client.patch<ApiResponse<Incident>>(`/incidents/${id}/status`, { statusId, resolutionNotes }).then(r => r.data),
|
||||
assign: (id: number, assignedTo: number) =>
|
||||
client.patch<ApiResponse<Incident>>(`/incidents/${id}/assign`, { assignedTo }).then(r => r.data),
|
||||
schedule: (id: number, scheduledDate: string) =>
|
||||
client.patch<ApiResponse<Incident>>(`/incidents/${id}/schedule`, { scheduledDate }).then(r => r.data),
|
||||
};
|
||||
|
||||
export const documentApi = {
|
||||
getForEntity: (entityType: string, entityId: number) =>
|
||||
client.get<ApiResponse<Document[]>>(`/documents/entity/${entityType}/${entityId}`).then(r => r.data),
|
||||
|
||||
search: (params?: { entityType?: string; entityId?: number; documentTypeId?: number; originalName?: string }) =>
|
||||
client.get<ApiResponse<Document[]>>('/documents/search', { params }).then(r => r.data),
|
||||
|
||||
upload: (formData: FormData) =>
|
||||
client.post<ApiResponse<Document>>('/documents/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}).then(r => r.data),
|
||||
|
||||
download: (id: number) =>
|
||||
client.get(`/documents/${id}/download`, { responseType: 'blob' }).then(r => r.data),
|
||||
|
||||
remove: (id: number) =>
|
||||
client.delete<ApiResponse<void>>(`/documents/${id}`).then(r => r.data),
|
||||
|
||||
getEntities: (id: number) =>
|
||||
client.get<ApiResponse<DocumentEntity[]>>(`/documents/${id}/entities`).then(r => r.data),
|
||||
|
||||
addEntity: (id: number, entityType: string, entityId: number) =>
|
||||
client.post<ApiResponse<Document>>(`/documents/${id}/entities`, null, {
|
||||
params: { entityType, entityId },
|
||||
}).then(r => r.data),
|
||||
|
||||
removeEntity: (id: number, entityType: string, entityId: number) =>
|
||||
client.delete<ApiResponse<void>>(`/documents/${id}/entities/${entityType}/${entityId}`).then(r => r.data),
|
||||
|
||||
getTypes: () => client.get<ApiResponse<DocumentType[]>>('/documents/types').then(r => r.data),
|
||||
|
||||
getTypesForEntity: (entityType: string) =>
|
||||
client.get<ApiResponse<{ documentTypeId: number; documentTypeName: string; entityType: string; canUpload: boolean; mustHave: boolean; description: string }[]>>(`/documents/types/entity/${entityType}`).then(r => r.data),
|
||||
};
|
||||
|
||||
export const maintenanceApi = {
|
||||
getAll: (params?: { propertyId?: number }) =>
|
||||
client.get<ApiResponse<ScheduledMaintenance[]>>('/maintenance', { params }).then(r => r.data),
|
||||
getById: (id: number) =>
|
||||
client.get<ApiResponse<ScheduledMaintenance>>(`/maintenance/${id}`).then(r => r.data),
|
||||
getPending: () =>
|
||||
client.get<ApiResponse<ScheduledMaintenance[]>>('/maintenance/pending').then(r => r.data),
|
||||
getUpcoming: (from: string, to: string) =>
|
||||
client.get<ApiResponse<ScheduledMaintenance[]>>('/maintenance/upcoming', { params: { from, to } }).then(r => r.data),
|
||||
getExpenseCategories: () =>
|
||||
client.get<ApiResponse<ExpenseCategory[]>>('/maintenance/expense-categories').then(r => r.data),
|
||||
create: (data: Partial<ScheduledMaintenance>) =>
|
||||
client.post<ApiResponse<ScheduledMaintenance>>('/maintenance', data).then(r => r.data),
|
||||
update: (id: number, data: Partial<ScheduledMaintenance>) =>
|
||||
client.put<ApiResponse<ScheduledMaintenance>>(`/maintenance/${id}`, data).then(r => r.data),
|
||||
markCompleted: (id: number) =>
|
||||
client.patch<ApiResponse<ScheduledMaintenance>>(`/maintenance/${id}/complete`).then(r => r.data),
|
||||
reopen: (id: number) =>
|
||||
client.patch<ApiResponse<ScheduledMaintenance>>(`/maintenance/${id}/reopen`).then(r => r.data),
|
||||
remove: (id: number) =>
|
||||
client.delete<ApiResponse<void>>(`/maintenance/${id}`).then(r => r.data),
|
||||
};
|
||||
|
||||
export const userApi = {
|
||||
getAll: (params?: PageParams) => client.get<ApiResponse<PagedResponse<User>>>('/users', { params }).then(r => r.data),
|
||||
getById: (id: number) => client.get<ApiResponse<User>>(`/users/${id}`).then(r => r.data),
|
||||
};
|
||||
|
||||
export const expenseRepercussionApi = {
|
||||
getByContract: (contractId: number) =>
|
||||
client.get<ApiResponse<ExpenseRepercussion[]>>('/expense-repercussions', { params: { contractId } }).then(r => r.data),
|
||||
getById: (id: number) => client.get<ApiResponse<ExpenseRepercussion>>(`/expense-repercussions/${id}`).then(r => r.data),
|
||||
create: (contractId: number, data: Partial<ExpenseRepercussion>) =>
|
||||
client.post<ApiResponse<ExpenseRepercussion>>('/expense-repercussions', data, { params: { contractId } }).then(r => r.data),
|
||||
update: (id: number, data: Partial<ExpenseRepercussion>) =>
|
||||
client.put<ApiResponse<ExpenseRepercussion>>(`/expense-repercussions/${id}`, data).then(r => r.data),
|
||||
remove: (id: number) => client.delete<ApiResponse<void>>(`/expense-repercussions/${id}`).then(r => r.data),
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,343 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { tenantBankDataApi } from '../api/resources';
|
||||
import type { TenantBankData } from '../types/api';
|
||||
import { useToast } from './Toast';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
|
||||
interface BankDataManagerProps {
|
||||
tenantId: number;
|
||||
tenantFullName?: string;
|
||||
tenantBusinessName?: string;
|
||||
tenantTypeId?: number;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
alias: string;
|
||||
iban: string;
|
||||
bic: string;
|
||||
bankName: string;
|
||||
accountHolder: string;
|
||||
notes: string;
|
||||
isPrincipal: boolean;
|
||||
}
|
||||
|
||||
const emptyForm = (): FormData => ({
|
||||
alias: '',
|
||||
iban: '',
|
||||
bic: '',
|
||||
bankName: '',
|
||||
accountHolder: '',
|
||||
notes: '',
|
||||
isPrincipal: false,
|
||||
});
|
||||
|
||||
function formatIban(iban: string): string {
|
||||
return iban.replace(/\s/g, '').replace(/(.{4})/g, '$1 ').trim();
|
||||
}
|
||||
|
||||
export default function BankDataManager({ tenantId, tenantFullName, tenantBusinessName, tenantTypeId }: BankDataManagerProps) {
|
||||
const [items, setItems] = useState<TenantBankData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<FormData>(emptyForm());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [bankNames, setBankNames] = useState<string[]>([]);
|
||||
const { toast } = useToast();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await tenantBankDataApi.getAll(tenantId);
|
||||
if (res.success) setItems(res.data);
|
||||
} catch {
|
||||
toast('Error al cargar datos bancarios', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tenantId, toast]);
|
||||
|
||||
const loadBankNames = useCallback(async () => {
|
||||
try {
|
||||
const res = await tenantBankDataApi.getAllBanks();
|
||||
if (res.success && res.data) {
|
||||
setBankNames(res.data);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - autocomplete is not critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
loadBankNames();
|
||||
}, [load, loadBankNames]);
|
||||
|
||||
const startCreate = () => {
|
||||
// Persona Jurídica (typeId 2) usa businessName, Persona Física usa fullName
|
||||
const defaultAccountHolder = tenantTypeId === 2 ? (tenantBusinessName || '') : (tenantFullName || '');
|
||||
setForm({ ...emptyForm(), accountHolder: defaultAccountHolder });
|
||||
setEditingId(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const startEdit = (item: TenantBankData) => {
|
||||
setForm({
|
||||
alias: item.alias ?? '',
|
||||
iban: item.iban,
|
||||
bic: item.bic ?? '',
|
||||
bankName: item.bankName ?? '',
|
||||
accountHolder: item.accountHolder ?? '',
|
||||
notes: item.notes ?? '',
|
||||
isPrincipal: item.isPrincipal,
|
||||
});
|
||||
setEditingId(item.id);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setForm(emptyForm());
|
||||
};
|
||||
|
||||
const validateIban = (iban: string): string | null => {
|
||||
const v = iban.replace(/\s/g, '').toUpperCase();
|
||||
if (!v) return 'El IBAN es obligatorio';
|
||||
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(v)) {
|
||||
return 'Formato de IBAN no válido (ej: ES91 2100 0418 4502 0005 1332)';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const ibanError = validateIban(form.iban);
|
||||
if (ibanError) {
|
||||
toast(ibanError, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
alias: form.alias || undefined,
|
||||
iban: form.iban.replace(/\s/g, '').toUpperCase(),
|
||||
bic: form.bic || undefined,
|
||||
bankName: form.bankName || undefined,
|
||||
accountHolder: form.accountHolder || undefined,
|
||||
notes: form.notes || undefined,
|
||||
isPrincipal: form.isPrincipal,
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await tenantBankDataApi.update(tenantId, editingId, payload);
|
||||
toast('Dato bancario actualizado', 'success');
|
||||
} else {
|
||||
await tenantBankDataApi.create(tenantId, payload);
|
||||
toast('Dato bancario creado', 'success');
|
||||
}
|
||||
cancel();
|
||||
load();
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { message?: string } } };
|
||||
toast(e.response?.data?.message ?? 'Error al guardar', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPrincipal = async (id: number) => {
|
||||
try {
|
||||
await tenantBankDataApi.setPrincipal(tenantId, id);
|
||||
toast('Marcado como principal', 'success');
|
||||
load();
|
||||
} catch {
|
||||
toast('Error al marcar como principal', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
try {
|
||||
await tenantBankDataApi.remove(tenantId, deleteId);
|
||||
toast('Dato bancario eliminado', 'success');
|
||||
setDeleteId(null);
|
||||
load();
|
||||
} catch {
|
||||
toast('Error al eliminar', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Cargando datos bancarios...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bank-data-manager">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||
<h3 style={{ margin: 0 }}>Datos Bancarios</h3>
|
||||
{!showForm && (
|
||||
<button className="btn btn-primary" onClick={startCreate}>
|
||||
+ Añadir datos bancarios
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="form">
|
||||
<div className="form-section-title">{editingId ? 'Editar' : 'Nuevos'} datos bancarios</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Alias (opcional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.alias}
|
||||
onChange={(e) => setForm({ ...form, alias: e.target.value })}
|
||||
placeholder="Ej: Cuenta principal, Cuenta nómina..."
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">IBAN *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.iban}
|
||||
onChange={(e) => setForm({ ...form, iban: e.target.value })}
|
||||
placeholder="ES91 2100 0418 4502 0005 1332"
|
||||
required
|
||||
maxLength={40}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">BIC/SWIFT (opcional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.bic}
|
||||
onChange={(e) => setForm({ ...form, bic: e.target.value.toUpperCase() })}
|
||||
placeholder="Ej: CAIXESBBXXX"
|
||||
maxLength={11}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Banco (opcional)</label>
|
||||
<input
|
||||
type="text"
|
||||
list="bank-names-list"
|
||||
value={form.bankName}
|
||||
onChange={(e) => setForm({ ...form, bankName: e.target.value })}
|
||||
placeholder="Nombre del banco"
|
||||
maxLength={200}
|
||||
/>
|
||||
<datalist id="bank-names-list">
|
||||
{bankNames.map((name) => (
|
||||
<option key={name} value={name} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Titular (opcional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.accountHolder}
|
||||
onChange={(e) => setForm({ ...form, accountHolder: e.target.value })}
|
||||
placeholder="Titular de la cuenta"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isPrincipal}
|
||||
onChange={(e) => setForm({ ...form, isPrincipal: e.target.checked })}
|
||||
/>
|
||||
<span>Marcar como principal</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group full-width">
|
||||
<label className="form-label">Notas (opcional)</label>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
placeholder="Notas adicionales..."
|
||||
style={{ minHeight: '80px', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-buttons">
|
||||
<button type="button" className="btn btn-secondary" onClick={cancel} disabled={saving}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Guardando...' : (editingId ? 'Guardar' : 'Crear')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">🏦</div>
|
||||
<p>No hay datos bancarios registrados</p>
|
||||
<small>Añada una cuenta para empezar</small>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Alias</th>
|
||||
<th>IBAN</th>
|
||||
<th>Banco</th>
|
||||
<th>Titular</th>
|
||||
<th>Principal</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.alias || <span className="text-muted">-</span>}</td>
|
||||
<td className="font-mono">{formatIban(item.iban)}</td>
|
||||
<td className="text-secondary">{item.bankName || <span className="text-muted">-</span>}</td>
|
||||
<td className="text-secondary">{item.accountHolder || <span className="text-muted">-</span>}</td>
|
||||
<td>
|
||||
{item.isPrincipal ? (
|
||||
<span className="badge badge-success">Principal</span>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleSetPrincipal(item.id)}
|
||||
title="Marcar como principal"
|
||||
>
|
||||
Marcar principal
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="actions">
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => startEdit(item)}>
|
||||
Editar
|
||||
</button>
|
||||
<button className="btn btn-sm btn-danger ml-2" onClick={() => setDeleteId(item.id)}>
|
||||
Eliminar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteId !== null}
|
||||
title="Eliminar datos bancarios"
|
||||
message="¿Está seguro de que desea eliminar estos datos bancarios? Esta acción no se puede deshacer."
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteId(null)}
|
||||
confirmLabel="Eliminar"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Modal from './Modal';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'danger' | 'warning' | 'primary' | 'success';
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open, title, message, confirmLabel = 'Confirmar', cancelLabel = 'Cancelar',
|
||||
variant = 'danger', onConfirm, onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Modal open={open} title={title} onClose={onCancel} size="narrow">
|
||||
<p className="confirm-message">{message}</p>
|
||||
<div className="confirm-actions">
|
||||
<button className="btn btn-secondary" onClick={onCancel}>{cancelLabel}</button>
|
||||
<button className={`btn btn-${variant}`} onClick={onConfirm}>{confirmLabel}</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { documentApi } from '../api/resources';
|
||||
import type { Document, DocumentTypeForEntity } from '../types/api';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import { useEntityNavigation } from '../hooks/useEntityNavigation';
|
||||
|
||||
interface DocumentUploaderProps {
|
||||
entityType: string;
|
||||
entityId: number;
|
||||
onDocumentUploaded?: () => void;
|
||||
}
|
||||
|
||||
export default function DocumentUploader({ entityType, entityId, onDocumentUploaded }: DocumentUploaderProps) {
|
||||
const { navigateToEntity } = useEntityNavigation();
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [allowedTypes, setAllowedTypes] = useState<DocumentTypeForEntity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedTypeId, setSelectedTypeId] = useState<string>('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [description, setDescription] = useState<string>('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
try {
|
||||
const response = await documentApi.getForEntity(entityType, entityId);
|
||||
if (response.success && response.data) {
|
||||
setDocuments(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading documents:', err);
|
||||
}
|
||||
}, [entityType, entityId]);
|
||||
|
||||
const loadAllowedTypes = useCallback(async () => {
|
||||
try {
|
||||
const response = await documentApi.getTypesForEntity(entityType);
|
||||
if (response.success && response.data) {
|
||||
setAllowedTypes(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading allowed types:', err);
|
||||
try {
|
||||
const allTypes = await documentApi.getTypes();
|
||||
if (allTypes.success && allTypes.data) {
|
||||
setAllowedTypes(allTypes.data.map(t => ({
|
||||
documentTypeId: t.id,
|
||||
documentTypeName: t.name,
|
||||
entityType,
|
||||
canUpload: true,
|
||||
mustHave: false,
|
||||
})));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading all types:', e);
|
||||
}
|
||||
}
|
||||
}, [entityType]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadDocuments(), loadAllowedTypes()])
|
||||
.finally(() => setLoading(false));
|
||||
}, [loadDocuments, loadAllowedTypes]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearFile = () => {
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedTypeId) {
|
||||
setError('Seleccione un tipo de documento');
|
||||
return;
|
||||
}
|
||||
if (!selectedFile) {
|
||||
setError('Seleccione un archivo');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('entityType', entityType);
|
||||
formData.append('entityId', entityId.toString());
|
||||
formData.append('documentTypeId', selectedTypeId);
|
||||
if (description.trim()) {
|
||||
formData.append('description', description.trim());
|
||||
}
|
||||
|
||||
try {
|
||||
await documentApi.upload(formData);
|
||||
await loadDocuments();
|
||||
onDocumentUploaded?.();
|
||||
setShowForm(false);
|
||||
setSelectedTypeId('');
|
||||
setSelectedFile(null);
|
||||
setDescription('');
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Error al subir el documento';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (doc: Document) => {
|
||||
try {
|
||||
const blob = await documentApi.download(doc.id);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = doc.originalName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('Error downloading document:', err);
|
||||
setError('Error al descargar el documento');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
try {
|
||||
await documentApi.remove(deleteId);
|
||||
await loadDocuments();
|
||||
} catch (err) {
|
||||
console.error('Error deleting document:', err);
|
||||
setError('Error al eliminar el documento');
|
||||
} finally {
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const mandatoryTypes = allowedTypes.filter(t => t.mustHave);
|
||||
const uploadedMandatoryIds = documents.map(d => d.documentType.id);
|
||||
const missingMandatory = mandatoryTypes.filter(t => !uploadedMandatoryIds.includes(t.documentTypeId));
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Cargando documentos...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="document-uploader">
|
||||
<div className="document-uploader-header">
|
||||
<h4>Documentos Adjuntos</h4>
|
||||
{missingMandatory.length > 0 && (
|
||||
<span className="badge badge-warning">
|
||||
{missingMandatory.length} obligatorio(s) pendiente(s)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error" onClick={() => setError(null)}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Botón para mostrar formulario */}
|
||||
{!showForm && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowForm(true)}
|
||||
style={{ marginBottom: '16px' }}
|
||||
>
|
||||
+ Agregar documento
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Formulario de subida */}
|
||||
{showForm && (
|
||||
<div className="document-upload-form">
|
||||
<div className="document-upload-form-row">
|
||||
<div className="document-upload-form-field">
|
||||
<label className="form-label">Tipo de documento</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={selectedTypeId}
|
||||
onChange={(e) => setSelectedTypeId(e.target.value)}
|
||||
>
|
||||
<option value="">Seleccionar tipo...</option>
|
||||
{allowedTypes.filter(t => t.canUpload).map(type => (
|
||||
<option key={type.documentTypeId} value={type.documentTypeId}>
|
||||
{type.documentTypeName}
|
||||
{type.mustHave && ' (obligatorio)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="document-upload-form-field">
|
||||
<label className="form-label">Descripción (opcional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Agregue una descripción del documento"
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="document-upload-form-field">
|
||||
<label className="form-label">Archivo</label>
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
className={`file-drop-zone ${isDragOver ? 'drag-over' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.xls,.xlsx,.ppt,.pptx"
|
||||
/>
|
||||
{selectedFile ? (
|
||||
<div className="file-selected">
|
||||
<span className="file-selected-icon">📄</span>
|
||||
<span className="file-selected-name" title={selectedFile.name}>{selectedFile.name}</span>
|
||||
<span className="file-selected-size">{formatFileSize(selectedFile.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="file-selected-clear"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleClearFile(); }}
|
||||
title="Quitar archivo"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="file-drop-zone-icon">📁</span>
|
||||
<span className="file-drop-zone-text">
|
||||
Arrastra aquí tu archivo o <strong>haz clic para seleccionar</strong>
|
||||
</span>
|
||||
<span className="file-drop-zone-hint">PDF, DOC, DOCX, JPG, PNG, XLS, XLSX (máx. 20 MB)</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="document-upload-form-actions">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setSelectedTypeId('');
|
||||
setSelectedFile(null);
|
||||
setDescription('');
|
||||
setError(null);
|
||||
}}
|
||||
disabled={uploading}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || !selectedTypeId || !selectedFile}
|
||||
>
|
||||
{uploading ? 'Subiendo...' : 'Subir documento'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lista de documentos */}
|
||||
{documents.length === 0 && !showForm ? (
|
||||
<div className="empty-state">
|
||||
<p>No hay documentos adjuntos</p>
|
||||
<small>Haga clic en "Agregar documento" para subir un archivo</small>
|
||||
</div>
|
||||
) : documents.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tipo</th>
|
||||
<th>Nombre</th>
|
||||
<th>Descripción</th>
|
||||
<th>Tamaño</th>
|
||||
<th>Fecha</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{documents.map(doc => {
|
||||
const isMandatory = mandatoryTypes.some(t => t.documentTypeId === doc.documentType.id);
|
||||
return (
|
||||
<tr key={doc.id}>
|
||||
<td>
|
||||
{doc.documentType.name}
|
||||
{isMandatory && <span className="badge badge-warning ml-2">Obligatorio</span>}
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="#"
|
||||
className="entity-link-inline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigateToEntity('DOCUMENT', doc.id);
|
||||
}}
|
||||
title="Ver detalle del documento"
|
||||
>
|
||||
{doc.originalName}
|
||||
</a>
|
||||
</td>
|
||||
<td className="text-muted">{doc.description || '-'}</td>
|
||||
<td>{formatFileSize(doc.fileSize)}</td>
|
||||
<td>{formatDate(doc.uploadedAt)}</td>
|
||||
<td className="actions">
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => navigateToEntity('DOCUMENT', doc.id)}
|
||||
title="Ver detalle del documento"
|
||||
>
|
||||
Ver
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary ml-2"
|
||||
onClick={() => handleDownload(doc)}
|
||||
>
|
||||
Descargar
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger ml-2"
|
||||
onClick={() => setDeleteId(doc.id)}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteId !== null}
|
||||
title="Eliminar documento"
|
||||
message="¿Está seguro de que desea eliminar este documento? Esta acción no se puede deshacer."
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteId(null)}
|
||||
confirmLabel="Eliminar"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEntityNavigation, type EntityType } from '../hooks/useEntityNavigation';
|
||||
|
||||
interface EntityLinkProps {
|
||||
entityType: EntityType;
|
||||
entityId: number;
|
||||
children: ReactNode;
|
||||
variant?: 'badge' | 'inline';
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function EntityLink({ entityType, entityId, children, variant = 'badge', title }: EntityLinkProps) {
|
||||
const { navigateToEntity } = useEntityNavigation();
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigateToEntity(entityType, entityId);
|
||||
};
|
||||
|
||||
if (variant === 'inline') {
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
onClick={handleClick}
|
||||
className="entity-link entity-link-inline"
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="badge badge-info entity-link"
|
||||
onClick={handleClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
navigateToEntity(entityType, entityId);
|
||||
}
|
||||
}}
|
||||
title={title ?? 'Ir al detalle'}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface NavSection {
|
||||
section: string;
|
||||
items: { path: string; icon: string; label: string }[];
|
||||
}
|
||||
|
||||
const navSections: NavSection[] = [
|
||||
{
|
||||
section: 'General',
|
||||
items: [
|
||||
{ path: '/dashboard', icon: '▦', label: 'Dashboard' },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'Activos',
|
||||
items: [
|
||||
{ path: '/property-groups', icon: '🏢', label: 'Conjuntos' },
|
||||
{ path: '/properties', icon: '🏠', label: 'Propiedades' },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'Personas',
|
||||
items: [
|
||||
{ path: '/tenants', icon: '🤝', label: 'Inquilinos' },
|
||||
{ path: '/contracts', icon: '📄', label: 'Contratos' },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'Contabilidad',
|
||||
items: [
|
||||
{ path: '/income-receipts', icon: '💰', label: 'Recibos Ingresos' },
|
||||
{ path: '/expense-templates', icon: '📋', label: 'Plantillas Gastos' },
|
||||
{ path: '/expense-receipts', icon: '📉', label: 'Recibos Gastos' },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'Operaciones',
|
||||
items: [
|
||||
{ path: '/incidents', icon: '⚠️', label: 'Incidencias' },
|
||||
{ path: '/maintenance', icon: '🔧', label: 'Mantenimiento' },
|
||||
{ path: '/documents', icon: '📎', label: 'Documentos' },
|
||||
{ path: '/bank-accounts', icon: '🏦', label: 'Cuentas' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const { username, role, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2>Sa Polar</h2>
|
||||
<div className="sidebar-subtitle">Gestión de Alquileres</div>
|
||||
</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navSections.map((section) => (
|
||||
<div key={section.section} className="sidebar-section">
|
||||
<div className="sidebar-section-title">{section.section}</div>
|
||||
{section.items.map((item) => (
|
||||
<NavLink key={item.path} to={item.path}>
|
||||
<span className="nav-icon">{item.icon}</span>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
<div className="user-info">
|
||||
<span className="user-name">{username}</span>
|
||||
<span className="user-role">{role}</span>
|
||||
</div>
|
||||
<button className="logout-btn" onClick={handleLogout}>
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
size?: 'default' | 'wide' | 'narrow';
|
||||
}
|
||||
|
||||
export default function Modal({ open, title, onClose, children, size = 'default' }: ModalProps) {
|
||||
if (!open) return null;
|
||||
const sizeClass = size === 'wide' ? ' wide' : size === 'narrow' ? ' narrow' : '';
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className={`modal-content${sizeClass}`} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{title}</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
totalElements: number;
|
||||
size: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onSizeChange?: (size: number) => void;
|
||||
}
|
||||
|
||||
export default function Pagination({ page, totalPages, totalElements, size, onPageChange, onSizeChange }: PaginationProps) {
|
||||
if (totalPages <= 1 && totalElements <= size) return null;
|
||||
|
||||
const startItem = page * size + 1;
|
||||
const endItem = Math.min((page + 1) * size, totalElements);
|
||||
|
||||
const pages: (number | string)[] = [];
|
||||
const maxVisible = 5;
|
||||
if (totalPages <= maxVisible + 2) {
|
||||
for (let i = 0; i < totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(0);
|
||||
let start = Math.max(1, page - 1);
|
||||
let end = Math.min(totalPages - 2, page + 1);
|
||||
if (page <= 2) { start = 1; end = Math.min(maxVisible - 1, totalPages - 2); }
|
||||
if (page >= totalPages - 3) { start = Math.max(1, totalPages - maxVisible); end = totalPages - 2; }
|
||||
if (start > 1) pages.push('...');
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
if (end < totalPages - 2) pages.push('...');
|
||||
pages.push(totalPages - 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pagination">
|
||||
<div className="pagination-info">
|
||||
{startItem}-{endItem} de {totalElements}
|
||||
</div>
|
||||
<div className="pagination-controls">
|
||||
<button className="btn btn-sm btn-secondary" disabled={page === 0} onClick={() => onPageChange(0)}>«</button>
|
||||
<button className="btn btn-sm btn-secondary" disabled={page === 0} onClick={() => onPageChange(page - 1)}>‹</button>
|
||||
{pages.map((p, i) =>
|
||||
typeof p === 'string' ? (
|
||||
<span key={`ellipsis-${i}`} className="pagination-ellipsis">...</span>
|
||||
) : (
|
||||
<button key={p} className={`btn btn-sm ${p === page ? 'btn-primary' : 'btn-secondary'}`} onClick={() => onPageChange(p)}>
|
||||
{p + 1}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button className="btn btn-sm btn-secondary" disabled={page >= totalPages - 1} onClick={() => onPageChange(page + 1)}>›</button>
|
||||
<button className="btn btn-sm btn-secondary" disabled={page >= totalPages - 1} onClick={() => onPageChange(totalPages - 1)}>»</button>
|
||||
</div>
|
||||
{onSizeChange && (
|
||||
<div className="pagination-size">
|
||||
<select value={size} onChange={(e) => onSizeChange(Number(e.target.value))}>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { SortConfig } from '../hooks/useSort';
|
||||
|
||||
interface SortableHeaderProps {
|
||||
label: string;
|
||||
sortKey: string;
|
||||
sort: SortConfig;
|
||||
onToggle: (key: string) => void;
|
||||
}
|
||||
|
||||
export default function SortableHeader({ label, sortKey, sort, onToggle }: SortableHeaderProps) {
|
||||
const active = sort.key === sortKey;
|
||||
const dir = active ? sort.direction : null;
|
||||
|
||||
return (
|
||||
<th className="sortable-th" onClick={() => onToggle(sortKey)}>
|
||||
<span className="sortable-label">
|
||||
{label}
|
||||
<span className={`sort-arrow ${dir && dir === 'asc' ? 'sort-arrow-active' : ''}`}>▲</span>
|
||||
<span className={`sort-arrow ${dir && dir === 'desc' ? 'sort-arrow-active' : ''}`}>▼</span>
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
exiting?: boolean;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue>({ toast: () => {} });
|
||||
|
||||
export const useToast = () => useContext(ToastContext);
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.map((t) => t.id === id ? { ...t, exiting: true } : t));
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 200);
|
||||
}, []);
|
||||
|
||||
const toast = useCallback((message: string, type: ToastType = 'info') => {
|
||||
const id = nextId++;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => removeToast(id), 4000);
|
||||
}, [removeToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast }}>
|
||||
{children}
|
||||
<div className="toast-container">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className={`toast toast-${t.type}${t.exiting ? ' toast-exit' : ''}`}>
|
||||
<span className="toast-icon">
|
||||
{t.type === 'success' ? '✓' : t.type === 'error' ? '✕' : t.type === 'warning' ? '!' : 'i'}
|
||||
</span>
|
||||
<span className="toast-message">{t.message}</span>
|
||||
<button className="toast-close" onClick={() => removeToast(t.id)}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
import { authApi } from '../api/auth';
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
userId: number | null;
|
||||
username: string | null;
|
||||
role: string | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [userId, setUserId] = useState<number | null>(null);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const savedUsername = localStorage.getItem('username');
|
||||
const savedRole = localStorage.getItem('role');
|
||||
const savedUserId = localStorage.getItem('user_id');
|
||||
if (token && savedUsername) {
|
||||
setUsername(savedUsername);
|
||||
setRole(savedRole);
|
||||
setUserId(savedUserId ? Number(savedUserId) : null);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const response = await authApi.login({ username, password });
|
||||
const data = response.data.data;
|
||||
localStorage.setItem('access_token', data.accessToken);
|
||||
localStorage.setItem('refresh_token', data.refreshToken);
|
||||
localStorage.setItem('username', data.username);
|
||||
localStorage.setItem('role', data.role);
|
||||
localStorage.setItem('user_id', String(data.userId));
|
||||
setUserId(data.userId);
|
||||
setUsername(data.username);
|
||||
setRole(data.role);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.clear();
|
||||
setUserId(null);
|
||||
setUsername(null);
|
||||
setRole(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated: !!username, loading, userId, username, role, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) throw new Error('useAuth must be used within AuthProvider');
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export type EntityType = 'PROPERTY' | 'TENANT' | 'CONTRACT' | 'INCOME' | 'EXPENSE' | 'INCIDENT' | 'PROPERTY_GROUP' | 'DOCUMENT' | 'BANK_ACCOUNT';
|
||||
|
||||
const ROUTES: Record<EntityType, string> = {
|
||||
PROPERTY: '/properties',
|
||||
PROPERTY_GROUP: '/property-groups',
|
||||
TENANT: '/tenants',
|
||||
CONTRACT: '/contracts',
|
||||
INCOME: '/incomes',
|
||||
EXPENSE: '/expenses',
|
||||
INCIDENT: '/incidents',
|
||||
DOCUMENT: '/documents',
|
||||
BANK_ACCOUNT: '/bank-accounts',
|
||||
};
|
||||
|
||||
export function useEntityNavigation() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navigateToEntity = (entityType: EntityType, entityId: number) => {
|
||||
const route = ROUTES[entityType];
|
||||
if (route) {
|
||||
navigate(route, { state: { openDetailId: entityId } });
|
||||
}
|
||||
};
|
||||
|
||||
return { navigateToEntity };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
export type SortDirection = 'asc' | 'desc' | null;
|
||||
|
||||
export interface SortConfig {
|
||||
key: string;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
function getValue(obj: unknown, path: string): unknown {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== 'object') return '';
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current ?? '';
|
||||
}
|
||||
|
||||
function compare(a: unknown, b: unknown): number {
|
||||
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
||||
const sa = String(a ?? '');
|
||||
const sb = String(b ?? '');
|
||||
return sa.localeCompare(sb, 'es', { numeric: true });
|
||||
}
|
||||
|
||||
export function useSort<T>(items: T[], defaultKey?: string) {
|
||||
const [sort, setSort] = useState<SortConfig>({ key: defaultKey ?? '', direction: null });
|
||||
|
||||
const toggleSort = (key: string) => {
|
||||
setSort((prev) => {
|
||||
if (prev.key !== key) return { key, direction: 'asc' };
|
||||
if (prev.direction === 'asc') return { key, direction: 'desc' };
|
||||
return { key: '', direction: null };
|
||||
});
|
||||
};
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sort.direction || !sort.key) return items;
|
||||
return [...items].sort((a, b) => {
|
||||
const aVal = getValue(a, sort.key);
|
||||
const bVal = getValue(b, sort.key);
|
||||
const cmp = compare(aVal, bVal);
|
||||
return sort.direction === 'desc' ? -cmp : cmp;
|
||||
});
|
||||
}, [items, sort]);
|
||||
|
||||
return { sorted, sort, toggleSort };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { bankAccountApi } from '../api/resources';
|
||||
import type { BankAccount } from '../types/api';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import { useToast } from '../components/Toast';
|
||||
import SortableHeader from '../components/SortableHeader';
|
||||
import { useSort } from '../hooks/useSort';
|
||||
|
||||
type ViewMode = 'list' | 'detail' | 'edit' | 'create';
|
||||
|
||||
interface FormData {
|
||||
name: string;
|
||||
holder: string;
|
||||
iban: string;
|
||||
bankName: string;
|
||||
swiftBic: string;
|
||||
currency: string;
|
||||
isDefault: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const emptyForm = (): FormData => ({
|
||||
name: '', holder: '', iban: '', bankName: '', swiftBic: '',
|
||||
currency: 'EUR', isDefault: false, description: '',
|
||||
});
|
||||
|
||||
function formatIban(iban: string): string {
|
||||
const cleaned = iban.replace(/\s+/g, '').toUpperCase();
|
||||
return cleaned.replace(/(.{4})/g, '$1 ').trim();
|
||||
}
|
||||
|
||||
export default function BankAccounts() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [items, setItems] = useState<BankAccount[]>([]);
|
||||
const [selected, setSelected] = useState<BankAccount | null>(null);
|
||||
const [form, setForm] = useState<FormData>(emptyForm());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<BankAccount | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { sorted, sort, toggleSort } = useSort(items, 'name');
|
||||
|
||||
// -- Bank name autocomplete options --
|
||||
const bankNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
items.forEach((item) => {
|
||||
if (item.bankName) names.add(item.bankName);
|
||||
});
|
||||
return [...names].sort();
|
||||
}, [items]);
|
||||
|
||||
const loadList = () => {
|
||||
setLoading(true);
|
||||
bankAccountApi.getAll({ all: true })
|
||||
.then((resp) => setItems(resp.data))
|
||||
.catch(() => toast('Error al cargar cuentas bancarias', 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { loadList(); }, []);
|
||||
|
||||
const goToDetail = (item: BankAccount) => {
|
||||
setSelected(item);
|
||||
setViewMode('detail');
|
||||
};
|
||||
|
||||
const goToCreate = () => {
|
||||
setSelected(null);
|
||||
setForm(emptyForm());
|
||||
setViewMode('create');
|
||||
};
|
||||
|
||||
const goToEdit = (item: BankAccount) => {
|
||||
setSelected(item);
|
||||
setForm({
|
||||
name: item.name,
|
||||
holder: item.holder,
|
||||
iban: item.iban,
|
||||
bankName: item.bankName ?? '',
|
||||
swiftBic: item.swiftBic ?? '',
|
||||
currency: item.currency,
|
||||
isDefault: item.isDefault,
|
||||
description: item.description ?? '',
|
||||
});
|
||||
setViewMode('edit');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim() || !form.holder.trim() || !form.iban.trim()) {
|
||||
toast('Completa los campos obligatorios', 'warning');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name,
|
||||
holder: form.holder,
|
||||
iban: form.iban.replace(/\s+/g, '').toUpperCase(),
|
||||
bankName: form.bankName || null,
|
||||
swiftBic: form.swiftBic || null,
|
||||
currency: form.currency,
|
||||
isDefault: form.isDefault,
|
||||
description: form.description || null,
|
||||
};
|
||||
|
||||
if (viewMode === 'edit' && selected) {
|
||||
await bankAccountApi.update(selected.id, payload as Partial<BankAccount>);
|
||||
toast('Cuenta actualizada', 'success');
|
||||
} else {
|
||||
await bankAccountApi.create(payload as Partial<BankAccount>);
|
||||
toast('Cuenta creada', 'success');
|
||||
}
|
||||
setViewMode('list');
|
||||
loadList();
|
||||
} catch {
|
||||
toast('Error al guardar la cuenta', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await bankAccountApi.remove(deleteTarget.id);
|
||||
toast('Cuenta desactivada', 'success');
|
||||
setDeleteTarget(null);
|
||||
loadList();
|
||||
if (viewMode === 'detail') setViewMode('list');
|
||||
} catch {
|
||||
toast('Error al eliminar la cuenta', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (viewMode === 'list') {
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Cuentas Bancarias</h1>
|
||||
<p className="text-muted">Gestiona las cuentas bancarias de la empresa</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={goToCreate}>+ Nueva Cuenta</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{loading ? (
|
||||
<div className="loading"><div className="spinner" /> Cargando...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No hay cuentas bancarias</h3>
|
||||
<p>Añade una cuenta bancaria para asociarla a gastos e ingresos.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader label="Nombre" sortKey="name" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Banco" sortKey="bankName" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="IBAN" sortKey="iban" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Titular" sortKey="holder" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Divisa" sortKey="currency" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Estado" sortKey="active" sort={sort} onToggle={toggleSort} />
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((acc) => (
|
||||
<tr key={acc.id}>
|
||||
<td className="font-semibold">
|
||||
{acc.name}
|
||||
{acc.isDefault && <span className="badge badge-primary" style={{ marginLeft: 8 }}>Ppal</span>}
|
||||
</td>
|
||||
<td className="text-secondary">{acc.bankName ?? '-'}</td>
|
||||
<td className="font-mono" style={{ fontSize: '0.85em' }}>{formatIban(acc.iban)}</td>
|
||||
<td className="text-secondary">{acc.holder}</td>
|
||||
<td>{acc.currency}</td>
|
||||
<td>{acc.active ? <span className="badge badge-success"><span className="badge-dot badge-dot-success" /> Activa</span> : <span className="badge badge-danger"><span className="badge-dot badge-dot-danger" /> Inactiva</span>}</td>
|
||||
<td>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => goToDetail(acc)}>Ver</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => goToEdit(acc)}>Editar</button>
|
||||
{acc.active && (
|
||||
<button className="btn btn-sm btn-danger" onClick={() => setDeleteTarget(acc)}>Desactivar</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Desactivar Cuenta"
|
||||
message={`¿Estás seguro de desactivar "${deleteTarget?.name}"? Los gastos e ingresos asociados no se verán afectados.`}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'detail' && selected) {
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>{selected.name}</h1>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-secondary" onClick={() => setViewMode('list')}>Volver</button>
|
||||
<button className="btn btn-primary" onClick={() => goToEdit(selected)}>Editar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="detail-grid">
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Nombre</span>
|
||||
<span className="detail-value">{selected.name}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Banco</span>
|
||||
<span className="detail-value">{selected.bankName ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">IBAN</span>
|
||||
<span className="detail-value font-mono">{formatIban(selected.iban)}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">SWIFT/BIC</span>
|
||||
<span className="detail-value font-mono">{selected.swiftBic ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Titular</span>
|
||||
<span className="detail-value">{selected.holder}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Divisa</span>
|
||||
<span className="detail-value">{selected.currency}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Por Defecto</span>
|
||||
<span className="detail-value">{selected.isDefault ? 'Sí' : 'No'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Estado</span>
|
||||
<span className="detail-value">{selected.active ? <span className="badge badge-success"><span className="badge-dot badge-dot-success" /> Activa</span> : <span className="badge badge-danger"><span className="badge-dot badge-dot-danger" /> Inactiva</span>}</span>
|
||||
</div>
|
||||
{selected.description && (
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Descripción</span>
|
||||
<span className="detail-value">{selected.description}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Create / Edit form
|
||||
const isEdit = viewMode === 'edit';
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>{isEdit ? `Editar: ${selected?.name}` : 'Nueva Cuenta Bancaria'}</h1>
|
||||
</div>
|
||||
<div className="form-sticky-bar">
|
||||
<button className="btn btn-secondary" onClick={() => setViewMode(isEdit && selected ? 'detail' : 'list')}>← Cancelar</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving} form="bank-account-form">
|
||||
{saving ? 'Guardando...' : (isEdit ? 'Actualizar' : 'Crear')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<form id="bank-account-form" onSubmit={handleSubmit} className="form">
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nombre *</label>
|
||||
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required placeholder="Ej: BBVA Empresas" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Titular *</label>
|
||||
<input type="text" value={form.holder} onChange={(e) => setForm({ ...form, holder: e.target.value })} required placeholder="Nombre del titular" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">IBAN *</label>
|
||||
<input type="text" value={form.iban} onChange={(e) => setForm({ ...form, iban: e.target.value })} required placeholder="ES00 0000 0000 00 0000000000" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Banco</label>
|
||||
<input type="text" list="bank-names-list" value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} placeholder="Ej: Banco Santander" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">SWIFT/BIC</label>
|
||||
<input type="text" value={form.swiftBic} onChange={(e) => setForm({ ...form, swiftBic: e.target.value })} placeholder="Ej: BSCHESMM" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Divisa</label>
|
||||
<select value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })}>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="GBP">GBP</option>
|
||||
<option value="CHF">CHF</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label"> </label>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={form.isDefault} onChange={(e) => setForm({ ...form, isDefault: e.target.checked })} />
|
||||
Cuenta por defecto
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group full-width">
|
||||
<label className="form-label">Descripción</label>
|
||||
<textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Notas adicionales..." style={{ minHeight: '80px' }} />
|
||||
</div>
|
||||
|
||||
<datalist id="bank-names-list">{bankNames.map(name => <option key={name} value={name} />)}</datalist>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,553 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, Legend,
|
||||
PieChart, Pie, Cell, ResponsiveContainer,
|
||||
CartesianGrid, AreaChart, Area,
|
||||
} from 'recharts';
|
||||
import client from '../api/client';
|
||||
|
||||
// ─── Colores para gráficos ───
|
||||
const COLORS = ['#2563eb', '#16a34a', '#d97706', '#dc2626', '#0891b2', '#8b5cf6', '#ec4899', '#f97316'];
|
||||
|
||||
// ─── Formateo de números ───
|
||||
const fmt = (n: number) => n.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const fmtInt = (n: number) => n.toLocaleString('es-ES');
|
||||
|
||||
// ─── Tipos ───
|
||||
interface Summary {
|
||||
totalProperties: number; rentedProperties: number; freeProperties: number;
|
||||
totalTenants: number; activeContracts: number;
|
||||
pendingIncomes: number; overdueIncomes: number;
|
||||
pendingExpenses: number; overdueExpenses: number;
|
||||
incomeThisYear: number; expenseThisYear: number; balanceThisYear: number;
|
||||
incomeThisMonth: number; expenseThisMonth: number; balanceThisMonth: number;
|
||||
openIncidents: number; pendingMaintenance: number;
|
||||
pendingIncomeList: PendingItem[];
|
||||
pendingExpenseList: PendingItem[];
|
||||
expiringContracts: ExpiringContract[];
|
||||
incomeByCategory: Record<string, number>;
|
||||
expenseByCategory: Record<string, number>;
|
||||
}
|
||||
|
||||
interface PendingItem {
|
||||
id: number;
|
||||
propertyName?: string;
|
||||
tenantName?: string;
|
||||
supplierName?: string;
|
||||
categoryName?: string;
|
||||
description?: string;
|
||||
amount: number;
|
||||
periodLabel?: string;
|
||||
dueDate?: string;
|
||||
status: string;
|
||||
daysOverdue: number;
|
||||
}
|
||||
|
||||
interface ExpiringContract {
|
||||
id: number; propertyName: string; contractNumber?: string;
|
||||
endDate: string; daysLeft: number;
|
||||
}
|
||||
|
||||
type MonthData = Record<string, { income: number; expense: number }>;
|
||||
|
||||
// ─── Componente principal ───
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [summary, setSummary] = useState<Partial<Summary>>({});
|
||||
const [monthlyData, setMonthlyData] = useState<MonthData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
client.get('/dashboard/summary'),
|
||||
client.get('/dashboard/income-expense'),
|
||||
])
|
||||
.then(([sRes, mRes]) => {
|
||||
setSummary(sRes.data.data ?? {});
|
||||
setMonthlyData(mRes.data.data ?? {});
|
||||
})
|
||||
.catch(() => setError('Error al cargar el dashboard'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="spinner" />
|
||||
Cargando dashboard...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="error-banner">{error}</div>;
|
||||
}
|
||||
|
||||
const s = summary as Summary;
|
||||
const totalPending = (s.pendingIncomes ?? 0) + (s.pendingExpenses ?? 0);
|
||||
const totalOverdue = (s.overdueIncomes ?? 0) + (s.overdueExpenses ?? 0);
|
||||
|
||||
// Preparar datos para el gráfico de barras mensual
|
||||
const barData = monthlyData
|
||||
? Object.entries(monthlyData).map(([month, vals]) => {
|
||||
const balance = vals.income - vals.expense;
|
||||
return {
|
||||
month: month.substring(0, 3),
|
||||
Ingresos: vals.income,
|
||||
Gastos: vals.expense,
|
||||
Balance: balance,
|
||||
// Para el gráfico de área: separar balance positivo y negativo
|
||||
// para poder colorear cada uno correctamente
|
||||
BalancePositivo: balance >= 0 ? balance : 0,
|
||||
BalanceNegativo: balance < 0 ? balance : 0,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
// Preparar datos para el gráfico circular de gastos por categoría
|
||||
const expensePieData = s.expenseByCategory
|
||||
? Object.entries(s.expenseByCategory).map(([name, value]) => ({ name, value }))
|
||||
: [];
|
||||
|
||||
const incomePieData = s.incomeByCategory
|
||||
? Object.entries(s.incomeByCategory).map(([name, value]) => ({ name, value }))
|
||||
: [];
|
||||
|
||||
// Mes actual para títulos
|
||||
const monthNames = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||||
const currentMonth = monthNames[new Date().getMonth()];
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
<p className="page-subtitle">Resumen general de {new Date().getFullYear()}</p>
|
||||
</div>
|
||||
|
||||
{/* ─── FILA 1: KPIs principales ─── */}
|
||||
<section className="dashboard-section">
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
icon="🏠"
|
||||
label="Propiedades"
|
||||
value={fmtInt(s.totalProperties ?? 0)}
|
||||
sub={`${fmtInt(s.rentedProperties ?? 0)} alquiladas · ${fmtInt(s.freeProperties ?? 0)} libres`}
|
||||
color="blue"
|
||||
onClick={() => navigate('/properties')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon="🤝"
|
||||
label="Inquilinos"
|
||||
value={fmtInt(s.totalTenants ?? 0)}
|
||||
color="green"
|
||||
onClick={() => navigate('/tenants')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon="📄"
|
||||
label="Contratos Activos"
|
||||
value={fmtInt(s.activeContracts ?? 0)}
|
||||
color="cyan"
|
||||
onClick={() => navigate('/contracts')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon="⚠️"
|
||||
label="Incidencias Abiertas"
|
||||
value={fmtInt(s.openIncidents ?? 0)}
|
||||
color="red"
|
||||
onClick={() => navigate('/incidents')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon="🔧"
|
||||
label="Mantenimiento Pendiente"
|
||||
value={fmtInt(s.pendingMaintenance ?? 0)}
|
||||
color="yellow"
|
||||
onClick={() => navigate('/maintenance')}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── FILA 2: Finanzas del mes + balance anual ─── */}
|
||||
<section className="dashboard-section">
|
||||
<h2 className="section-title">Resumen Financiero de {currentMonth}</h2>
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
icon="💰"
|
||||
label="Ingresos del Mes"
|
||||
value={`${fmt(s.incomeThisMonth ?? 0)} €`}
|
||||
color="green"
|
||||
tooltip="Total cobrado este mes (recibos marcados como PAGADO)"
|
||||
/>
|
||||
<KpiCard
|
||||
icon="📉"
|
||||
label="Gastos del Mes"
|
||||
value={`${fmt(s.expenseThisMonth ?? 0)} €`}
|
||||
color="red"
|
||||
tooltip="Total pagado este mes (recibos marcados como PAGADO)"
|
||||
/>
|
||||
<KpiCard
|
||||
icon="⚖️"
|
||||
label="Balance del Mes"
|
||||
value={`${fmt(s.balanceThisMonth ?? 0)} €`}
|
||||
color={((s.balanceThisMonth ?? 0) >= 0) ? 'green' : 'red'}
|
||||
tooltip="Ingresos − Gastos del mes actual"
|
||||
/>
|
||||
<KpiCard
|
||||
icon="📊"
|
||||
label="Balance Anual"
|
||||
value={`${fmt(s.balanceThisYear ?? 0)} €`}
|
||||
color={((s.balanceThisYear ?? 0) >= 0) ? 'green' : 'red'}
|
||||
tooltip="Ingresos − Gastos del año completo"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={totalPending > 0 ? '⏳' : '✅'}
|
||||
label="Pendientes Total"
|
||||
value={fmtInt(totalPending)}
|
||||
sub={`${fmtInt(totalOverdue)} vencidos`}
|
||||
color={totalOverdue > 0 ? 'red' : totalPending > 0 ? 'yellow' : 'green'}
|
||||
tooltip="Recibos de ingresos y gastos aún no pagados"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── FILA 3: Pendientes de Cobro y Pago ─── */}
|
||||
<div className="dashboard-row-2">
|
||||
{/* Pendientes de cobro */}
|
||||
<section className="dashboard-section">
|
||||
<div className="section-title-row">
|
||||
<h2 className="section-title">Pendientes de Cobro</h2>
|
||||
{s.pendingIncomes > 0 && (
|
||||
<button className="btn btn-sm" onClick={() => navigate('/income-receipts', { state: { statusId: 1 } })}>
|
||||
Ver todos ({s.pendingIncomes})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{s.pendingIncomeList && s.pendingIncomeList.length > 0 ? (
|
||||
<table className="table dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Propiedad</th>
|
||||
<th>Inquilino</th>
|
||||
<th>Periodo</th>
|
||||
<th>Importe</th>
|
||||
<th>Vence</th>
|
||||
<th>Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{s.pendingIncomeList.map((item) => (
|
||||
<tr key={item.id} className="clickable-row" onClick={() => navigate('/income-receipts', { state: { openDetailId: item.id } })}>
|
||||
<td>{item.propertyName}</td>
|
||||
<td>{item.tenantName ?? '-'}</td>
|
||||
<td>{item.periodLabel ?? '-'}</td>
|
||||
<td className="text-right">{fmt(item.amount)} €</td>
|
||||
<td>
|
||||
{item.dueDate ? (
|
||||
<span className={item.daysOverdue > 0 ? 'text-danger' : ''}>
|
||||
{new Date(item.dueDate).toLocaleDateString('es-ES')}
|
||||
{item.daysOverdue > 0 && <span className="badge badge-danger ml-1">+{item.daysOverdue}d</span>}
|
||||
</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge badge-${item.status === 'VENCIDO' ? 'danger' : 'warning'}`}>
|
||||
{item.status === 'VENCIDO' ? 'Vencido' : 'Pendiente'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="empty-state">✅ No hay cobros pendientes</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Pendientes de pago */}
|
||||
<section className="dashboard-section">
|
||||
<div className="section-title-row">
|
||||
<h2 className="section-title">Pendientes de Pago</h2>
|
||||
{s.pendingExpenses > 0 && (
|
||||
<button className="btn btn-sm" onClick={() => navigate('/expense-receipts', { state: { statusId: 1 } })}>
|
||||
Ver todos ({s.pendingExpenses})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{s.pendingExpenseList && s.pendingExpenseList.length > 0 ? (
|
||||
<table className="table dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Proveedor</th>
|
||||
<th>Categoría</th>
|
||||
<th>Concepto</th>
|
||||
<th>Importe</th>
|
||||
<th>Vence</th>
|
||||
<th>Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{s.pendingExpenseList.map((item) => (
|
||||
<tr key={item.id} className="clickable-row" onClick={() => navigate('/expense-receipts', { state: { openDetailId: item.id } })}>
|
||||
<td>{item.supplierName ?? '-'}</td>
|
||||
<td>{item.categoryName ?? '-'}</td>
|
||||
<td title={item.description}>{item.description?.substring(0, 30)}{item.description && item.description.length > 30 ? '…' : ''}</td>
|
||||
<td className="text-right">{fmt(item.amount)} €</td>
|
||||
<td>
|
||||
{item.dueDate ? (
|
||||
<span className={item.daysOverdue > 0 ? 'text-danger' : ''}>
|
||||
{new Date(item.dueDate).toLocaleDateString('es-ES')}
|
||||
{item.daysOverdue > 0 && <span className="badge badge-danger ml-1">+{item.daysOverdue}d</span>}
|
||||
</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge badge-${item.status === 'VENCIDO' ? 'danger' : 'warning'}`}>
|
||||
{item.status === 'VENCIDO' ? 'Vencido' : 'Pendiente'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="empty-state">✅ No hay gastos pendientes</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ─── FILA 4: Gráficos de barras y balance lado a lado ─── */}
|
||||
<div className="dashboard-row-2">
|
||||
{/* Gráfico de barras: Ingresos vs Gastos mensuales */}
|
||||
<section className="dashboard-section chart-section">
|
||||
<h2 className="section-title">
|
||||
Ingresos vs Gastos Mensuales
|
||||
<span className="help-icon" title="Comparativa mensual de ingresos cobrados y gastos pagados durante el año">i</span>
|
||||
</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={barData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
|
||||
<YAxis tickFormatter={(v: number) => `${(v / 1000).toFixed(0)}k`} tick={{ fontSize: 12 }} />
|
||||
<Tooltip formatter={(value: any) => `${fmt(Number(value))} €`} />
|
||||
<Legend />
|
||||
<Bar dataKey="Ingresos" fill="#16a34a" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="Gastos" fill="#dc2626" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</section>
|
||||
|
||||
{/* Gráfico de área: Beneficios / Pérdidas */}
|
||||
<section className="dashboard-section chart-section">
|
||||
<h2 className="section-title">
|
||||
Beneficios / Pérdidas
|
||||
<span className="help-icon" title="Balance mensual (ingresos - gastos). Verde si es positivo, rojo si es negativo.">i</span>
|
||||
</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={barData}>
|
||||
<defs>
|
||||
<linearGradient id="balancePositiveGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#16a34a" stopOpacity={0.7} />
|
||||
<stop offset="100%" stopColor="#16a34a" stopOpacity={0.3} />
|
||||
</linearGradient>
|
||||
<linearGradient id="balanceNegativeGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#dc2626" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="#dc2626" stopOpacity={0.7} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
|
||||
<YAxis tickFormatter={(v: number) => `${(v / 1000).toFixed(0)}k`} tick={{ fontSize: 12 }} />
|
||||
<Tooltip formatter={(value: any, name: any) =>
|
||||
name === 'Balance' ? [`${fmt(Number(value))} €`, 'Balance'] : null
|
||||
} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="BalancePositivo"
|
||||
stroke="#16a34a"
|
||||
fill="url(#balancePositiveGradient)"
|
||||
fillOpacity={1}
|
||||
strokeWidth={0}
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="BalanceNegativo"
|
||||
stroke="#dc2626"
|
||||
fill="url(#balanceNegativeGradient)"
|
||||
fillOpacity={1}
|
||||
strokeWidth={0}
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="Balance"
|
||||
stroke="#2563eb"
|
||||
fill="none"
|
||||
fillOpacity={0}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ─── FILA 5: Gráficos circulares de categorías ─── */}
|
||||
<div className="dashboard-row-2">
|
||||
{/* Gráfico circular: Gastos por categoría */}
|
||||
<section className="dashboard-section chart-section">
|
||||
<h2 className="section-title">
|
||||
Gastos por Categoría
|
||||
<span className="help-icon" title="Distribución del total de gastos pagados agrupados por categoría en el año actual">i</span>
|
||||
</h2>
|
||||
{expensePieData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={expensePieData}
|
||||
cx="50%" cy="50%"
|
||||
innerRadius={50} outerRadius={100}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
|
||||
>
|
||||
{expensePieData.map((_, idx) => (
|
||||
<Cell key={idx} fill={COLORS[idx % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value: any) => `${fmt(Number(value))} €`} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="empty-state">Sin datos de gastos este año</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Gráfico circular: Ingresos por categoría */}
|
||||
<section className="dashboard-section chart-section">
|
||||
<h2 className="section-title">
|
||||
Ingresos por Categoría
|
||||
<span className="help-icon" title="Distribución del total de ingresos cobrados agrupados por categoría en el año actual">i</span>
|
||||
</h2>
|
||||
{incomePieData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={incomePieData}
|
||||
cx="50%" cy="50%"
|
||||
innerRadius={50} outerRadius={100}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
|
||||
>
|
||||
{incomePieData.map((_, idx) => (
|
||||
<Cell key={idx} fill={COLORS[idx % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value: any) => `${fmt(Number(value))} €`} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="empty-state">Sin datos de ingresos este año</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ─── FILA 6: Contratos próximos a vencer ─── */}
|
||||
{s.expiringContracts && s.expiringContracts.length > 0 && (
|
||||
<section className="dashboard-section">
|
||||
<div className="section-title-row">
|
||||
<h2 className="section-title">📅 Contratos Próximos a Vencer</h2>
|
||||
<button className="btn btn-sm" onClick={() => navigate('/contracts')}>
|
||||
Ver contratos
|
||||
</button>
|
||||
</div>
|
||||
<table className="table dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Propiedad</th>
|
||||
<th>Contrato</th>
|
||||
<th>Fecha Fin</th>
|
||||
<th>Días Restantes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{s.expiringContracts.map((c) => (
|
||||
<tr key={c.id} className="clickable-row" onClick={() => navigate('/contracts', { state: { openDetailId: c.id } })}>
|
||||
<td>{c.propertyName}</td>
|
||||
<td>{c.contractNumber ?? `#${c.id}`}</td>
|
||||
<td>{new Date(c.endDate).toLocaleDateString('es-ES')}</td>
|
||||
<td>
|
||||
<span className={`badge badge-${c.daysLeft <= 15 ? 'danger' : c.daysLeft <= 30 ? 'warning' : 'info'}`}>
|
||||
{c.daysLeft} días
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ─── FILA 7: Enlaces rápidos ─── */}
|
||||
<section className="dashboard-section">
|
||||
<h2 className="section-title">Accesos Rápidos</h2>
|
||||
<div className="quick-links">
|
||||
<QuickLink icon="💰" label="Nuevo Recibo Ingreso" path="/income-receipts?action=create" color="green" />
|
||||
<QuickLink icon="📋" label="Plantillas Gastos" path="/expense-templates" color="blue" />
|
||||
<QuickLink icon="📉" label="Nuevo Recibo Gasto" path="/expense-receipts?action=create" color="red" />
|
||||
<QuickLink icon="⚠️" label="Nueva Incidencia" path="/incidents?action=create" color="yellow" />
|
||||
<QuickLink icon="🔧" label="Nuevo Mantenimiento" path="/maintenance?action=create" color="cyan" />
|
||||
<QuickLink icon="🏠" label="Nueva Propiedad" path="/properties?action=create" color="blue" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Componentes internos ───
|
||||
|
||||
interface KpiCardProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
color: 'blue' | 'green' | 'red' | 'yellow' | 'cyan';
|
||||
tooltip?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function KpiCard({ icon, label, value, sub, color, tooltip, onClick }: KpiCardProps) {
|
||||
return (
|
||||
<div className={`card ${onClick ? 'card-clickable' : ''}`} onClick={onClick} title={tooltip}>
|
||||
<div className="card-header">
|
||||
<h3>
|
||||
{label}
|
||||
{tooltip && <span className="help-icon" title={tooltip}>i</span>}
|
||||
</h3>
|
||||
<div className={`card-icon card-icon-${color}`}>{icon}</div>
|
||||
</div>
|
||||
<div className="card-value">{value}</div>
|
||||
{sub && <div className="card-change">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface QuickLinkProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
path: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function QuickLink({ icon, label, path, color }: QuickLinkProps) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className={`quick-link quick-link-${color}`} onClick={() => navigate(path)}>
|
||||
<span className="quick-link-icon">{icon}</span>
|
||||
<span className="quick-link-label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { documentApi } from '../api/resources';
|
||||
import type { Document, DocumentType } from '../types/api';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import Pagination from '../components/Pagination';
|
||||
import EntityLink from '../components/EntityLink';
|
||||
import { useToast } from '../components/Toast';
|
||||
import SortableHeader from '../components/SortableHeader';
|
||||
import { useSort } from '../hooks/useSort';
|
||||
import type { EntityType } from '../hooks/useEntityNavigation';
|
||||
|
||||
type ViewMode = 'list' | 'detail';
|
||||
|
||||
const ENTITY_TYPES = [
|
||||
{ value: '', label: 'Todos' },
|
||||
{ value: 'PROPERTY', label: 'Propiedad' },
|
||||
{ value: 'TENANT', label: 'Inquilino' },
|
||||
{ value: 'CONTRACT', label: 'Contrato' },
|
||||
{ value: 'INCOME', label: 'Ingreso' },
|
||||
{ value: 'EXPENSE', label: 'Gasto' },
|
||||
{ value: 'INCIDENT', label: 'Incidencia' },
|
||||
];
|
||||
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
PROPERTY: 'Propiedad',
|
||||
TENANT: 'Inquilino',
|
||||
CONTRACT: 'Contrato',
|
||||
INCOME: 'Ingreso',
|
||||
EXPENSE: 'Gasto',
|
||||
INCIDENT: 'Incidencia',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function Documents() {
|
||||
const location = useLocation();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
|
||||
const [items, setItems] = useState<Document[]>([]);
|
||||
const [documentTypes, setDocumentTypes] = useState<DocumentType[]>([]);
|
||||
const [allowedDocTypes, setAllowedDocTypes] = useState<Set<number>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchOpen, setSearchOpen] = useState(true);
|
||||
const [filters, setFilters] = useState({
|
||||
entityType: '',
|
||||
documentTypeId: 0,
|
||||
originalName: '',
|
||||
});
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const buildSearchParams = () => {
|
||||
const params: {
|
||||
entityType?: string;
|
||||
entityId?: number;
|
||||
documentTypeId?: number;
|
||||
} = {};
|
||||
if (filters.entityType) params.entityType = filters.entityType;
|
||||
if (filters.documentTypeId) params.documentTypeId = filters.documentTypeId;
|
||||
return params;
|
||||
};
|
||||
const [page, setPage] = useState(0);
|
||||
const { toast } = useToast();
|
||||
const [deleteTarget, setDeleteTarget] = useState<Document | null>(null);
|
||||
// const [associateTarget, setAssociateTarget] = useState<Document | null>(null);
|
||||
// const [associateForm, setAssociateForm] = useState({ entityType: '', entityId: '' });
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
Promise.all([documentApi.search(buildSearchParams()), documentApi.getTypes()])
|
||||
.then(([docs, types]) => {
|
||||
setItems(docs.data);
|
||||
setDocumentTypes(types.data);
|
||||
})
|
||||
.catch(() => toast('Error al cargar documentos', 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
load();
|
||||
}, [filters]);
|
||||
|
||||
// Cargar tipos de documento permitidos según la entidad seleccionada
|
||||
useEffect(() => {
|
||||
if (!filters.entityType) {
|
||||
// Sin entidad seleccionada, todos los tipos están disponibles
|
||||
setAllowedDocTypes(new Set());
|
||||
return;
|
||||
}
|
||||
documentApi.getTypesForEntity(filters.entityType)
|
||||
.then((res) => {
|
||||
if (res.success && res.data) {
|
||||
const allowed = new Set<number>(res.data.map((t) => t.documentTypeId));
|
||||
setAllowedDocTypes(allowed);
|
||||
// Si el documentTypeId seleccionado no está permitido, resetearlo
|
||||
setFilters((prev) => {
|
||||
if (prev.documentTypeId && !allowed.has(prev.documentTypeId)) {
|
||||
return { ...prev, documentTypeId: 0 };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} else {
|
||||
setAllowedDocTypes(new Set());
|
||||
}
|
||||
})
|
||||
.catch(() => setAllowedDocTypes(new Set()));
|
||||
}, [filters.entityType]);
|
||||
|
||||
// Detectar si llegamos con un openDetailId en el state (navegación desde otra página)
|
||||
useEffect(() => {
|
||||
const state = location.state as { openDetailId?: number } | null;
|
||||
if (state?.openDetailId) {
|
||||
// Cargar el documento real desde la API antes de cambiar a detail
|
||||
documentApi.search({})
|
||||
.then((res) => {
|
||||
const found = res.data.find((d) => d.id === state.openDetailId);
|
||||
if (found) {
|
||||
setSelectedDoc(found);
|
||||
setViewMode('detail');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
// Cargar previsualización automáticamente cuando se selecciona un documento
|
||||
useEffect(() => {
|
||||
if (selectedDoc && isPreviewable(selectedDoc.mimeType)) {
|
||||
loadPreview(selectedDoc);
|
||||
} else {
|
||||
clearPreview();
|
||||
}
|
||||
}, [selectedDoc]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Navegación
|
||||
// ---------------------------------------------------------------------------
|
||||
const goToDetail = (doc: Document) => {
|
||||
setSelectedDoc(doc);
|
||||
setViewMode('detail');
|
||||
};
|
||||
|
||||
const goToList = () => {
|
||||
setViewMode('list');
|
||||
setSelectedDoc(null);
|
||||
setPreviewError(null);
|
||||
clearPreview();
|
||||
load();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Acciones
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await documentApi.remove(deleteTarget.id);
|
||||
toast('Documento eliminado', 'success');
|
||||
setDeleteTarget(null);
|
||||
load();
|
||||
} catch {
|
||||
toast('Error al eliminar el documento', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (doc: Document) => {
|
||||
try {
|
||||
const blob = await documentApi.download(doc.id);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = doc.originalName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast('Error al descargar el documento', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const loadPreview = async (doc: Document) => {
|
||||
// Limpiar previsualización anterior
|
||||
clearPreview();
|
||||
setPreviewLoading(true);
|
||||
setPreviewError(null);
|
||||
try {
|
||||
const blob = await documentApi.download(doc.id);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
setPreviewUrl(url);
|
||||
} catch {
|
||||
setPreviewError('No se pudo cargar la previsualización. El archivo podría no existir en el servidor.');
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearPreview = () => {
|
||||
if (previewUrl) {
|
||||
window.URL.revokeObjectURL(previewUrl);
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
setPreviewError(null);
|
||||
};
|
||||
|
||||
const isPreviewable = (mimeType?: string) => {
|
||||
if (!mimeType) return false;
|
||||
return mimeType.startsWith('image/') || mimeType === 'application/pdf';
|
||||
};
|
||||
|
||||
// Función temporalmente deshabilitada - Associar a otra entidad
|
||||
// const handleAssociate = async () => {
|
||||
// if (!associateTarget || !associateForm.entityType || !associateForm.entityId) return;
|
||||
// try {
|
||||
// await documentApi.addEntity(associateTarget.id, associateForm.entityType, Number(associateForm.entityId));
|
||||
// toast('Documento asociado correctamente', 'success');
|
||||
// setAssociateTarget(null);
|
||||
// setAssociateForm({ entityType: '', entityId: '' });
|
||||
// load();
|
||||
// } catch (e: unknown) {
|
||||
// const err = e as { response?: { data?: { message?: string } } };
|
||||
// toast(err.response?.data?.message ?? 'Error al asociar el documento', 'error');
|
||||
// }
|
||||
// };
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return '-';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const getMimeTypeLabel = (mimeType?: string) => {
|
||||
if (!mimeType) return '-';
|
||||
const labels: Record<string, string> = {
|
||||
'application/pdf': 'PDF',
|
||||
'application/msword': 'Word',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word',
|
||||
'application/vnd.ms-excel': 'Excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel',
|
||||
'application/vnd.ms-powerpoint': 'PowerPoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint',
|
||||
'image/jpeg': 'Imagen JPEG',
|
||||
'image/png': 'Imagen PNG',
|
||||
'image/gif': 'Imagen GIF',
|
||||
'image/webp': 'Imagen WebP',
|
||||
'text/plain': 'Texto plano',
|
||||
'text/html': 'HTML',
|
||||
'application/zip': 'ZIP',
|
||||
'application/x-rar-compressed': 'RAR',
|
||||
};
|
||||
return labels[mimeType] ?? mimeType;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paginación
|
||||
// ---------------------------------------------------------------------------
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchTerm) return items;
|
||||
const q = searchTerm.toLowerCase();
|
||||
return items.filter((doc) =>
|
||||
doc.originalName.toLowerCase().includes(q) ||
|
||||
(doc.description ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
}, [items, searchTerm]);
|
||||
|
||||
const { sorted, sort, toggleSort } = useSort(filteredItems, 'originalName');
|
||||
|
||||
const totalPages = useMemo(
|
||||
() => Math.ceil(filteredItems.length / PAGE_SIZE),
|
||||
[filteredItems.length, PAGE_SIZE]
|
||||
);
|
||||
|
||||
const displayItems = useMemo(
|
||||
() => sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
|
||||
[sorted, page, PAGE_SIZE]
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: DETAIL
|
||||
// ---------------------------------------------------------------------------
|
||||
if (viewMode === 'detail' && selectedDoc) {
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Volver al listado</button>
|
||||
<h1>Documento #{selectedDoc.id}</h1>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn btn-primary" onClick={() => handleDownload(selectedDoc)}>Descargar</button>
|
||||
<button className="btn btn-danger" onClick={() => setDeleteTarget(selectedDoc)}>Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">ID</span>
|
||||
<span className="detail-value font-mono">{selectedDoc.id}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Tipo documento</span>
|
||||
<span className="detail-value">{selectedDoc.documentType?.name ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Formato</span>
|
||||
<span className="detail-value">{getMimeTypeLabel(selectedDoc.mimeType)}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Nombre original</span>
|
||||
<span className="detail-value">{selectedDoc.originalName}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Tamaño</span>
|
||||
<span className="detail-value">{formatFileSize(selectedDoc.fileSize)}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Subido por</span>
|
||||
<span className="detail-value">{selectedDoc.uploadedBy?.fullName ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Fecha</span>
|
||||
<span className="detail-value">{selectedDoc.uploadedAt?.substring(0, 10)}</span>
|
||||
</div>
|
||||
{selectedDoc.description && (
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Descripción</span>
|
||||
<span className="detail-value">{selectedDoc.description}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Entidades asociadas</span>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '6px' }}>
|
||||
{(selectedDoc.entities ?? []).map((e) => {
|
||||
const entityType = e.entityType as EntityType;
|
||||
if (entityType === 'PROPERTY_GROUP') {
|
||||
return (
|
||||
<span key={`${e.entityType}-${e.entityId}`} className="badge badge-info">
|
||||
{ENTITY_TYPE_LABELS[e.entityType] ?? e.entityType} #{e.entityId}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EntityLink
|
||||
key={`${e.entityType}-${e.entityId}`}
|
||||
entityType={entityType}
|
||||
entityId={e.entityId}
|
||||
>
|
||||
{ENTITY_TYPE_LABELS[e.entityType] ?? e.entityType} #{e.entityId}
|
||||
</EntityLink>
|
||||
);
|
||||
})}
|
||||
{(!selectedDoc.entities || selectedDoc.entities.length === 0) && <span className="text-muted">Sin asociaciones</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Previsualizador del documento */}
|
||||
<div style={{ marginTop: '24px' }}>
|
||||
{isPreviewable(selectedDoc.mimeType) ? (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: '0.5rem', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '12px', background: 'var(--bg-secondary)', borderBottom: '1px solid var(--border)' }}>
|
||||
<span style={{ fontWeight: 500 }}>Previsualización</span>
|
||||
</div>
|
||||
<div style={{ background: '#f5f5f5' }}>
|
||||
{previewLoading && (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<div className="spinner" style={{ margin: '0 auto 16px' }} />
|
||||
<p>Cargando previsualización...</p>
|
||||
</div>
|
||||
)}
|
||||
{previewError && (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: 'var(--color-error)' }}>
|
||||
<span style={{ fontSize: '2rem' }}>⚠️</span>
|
||||
<p style={{ margin: '8px 0' }}>{previewError}</p>
|
||||
<small>Use el botón "Descargar" para ver el archivo</small>
|
||||
</div>
|
||||
)}
|
||||
{previewUrl && !previewLoading && !previewError && (
|
||||
selectedDoc.mimeType === 'application/pdf' ? (
|
||||
<iframe src={previewUrl} width="100%" height="800px" style={{ border: 'none', display: 'block' }} title={selectedDoc.originalName} />
|
||||
) : (
|
||||
<img src={previewUrl} alt={selectedDoc.originalName} style={{ maxWidth: '100%', height: 'auto', display: 'block', margin: '0 auto' }} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '16px', border: '1px solid var(--border)', borderRadius: '0.5rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<span style={{ fontSize: '2rem' }}>📄</span>
|
||||
<p style={{ margin: '8px 0 0 0' }}>Este tipo de archivo no se puede previsualizar</p>
|
||||
<small>Use el botón "Descargar" para ver el archivo</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
// Sección comentada: Asociar a otra entidad
|
||||
// Esta funcionalidad está temporalmente deshabilitada
|
||||
<div style={{ marginTop: '24px', padding: '16px', border: '1px solid var(--border)', borderRadius: '0.5rem' }}>
|
||||
<h3 style={{ marginBottom: '12px' }}>Asociar a otra entidad</h3>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Tipo de entidad</label>
|
||||
<select
|
||||
value={associateForm.entityType}
|
||||
onChange={(e) => setAssociateForm({ ...associateForm, entityType: e.target.value })}
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
{ENTITY_TYPES.filter(t => t.value).map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">ID de la entidad</label>
|
||||
<input
|
||||
type="number"
|
||||
value={associateForm.entityId}
|
||||
onChange={(e) => setAssociateForm({ ...associateForm, entityId: e.target.value })}
|
||||
placeholder="Ej: 1, 2, 3..."
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-buttons">
|
||||
<button className="btn btn-secondary" onClick={() => { setAssociateTarget(selectedDoc); setAssociateForm({ entityType: '', entityId: '' }); }}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!associateForm.entityType || !associateForm.entityId}
|
||||
onClick={handleAssociate}
|
||||
>
|
||||
Asociar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Eliminar Documento"
|
||||
message={`¿Estás seguro de eliminar el documento "${deleteTarget?.originalName}"? Esta acción no se puede deshacer y eliminará el archivo físico.`}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: LIST
|
||||
// ---------------------------------------------------------------------------
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Cargando documentos...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Documentos</h1>
|
||||
</div>
|
||||
|
||||
<div className="search-panel">
|
||||
<div className="search-panel-header">
|
||||
<h3>Búsqueda avanzada</h3>
|
||||
<button className="search-toggle" onClick={() => setSearchOpen(!searchOpen)}>
|
||||
{searchOpen ? 'Ocultar' : 'Mostrar'} {searchOpen ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
{searchOpen && (
|
||||
<div className="search-fields">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nombre archivo</label>
|
||||
<input
|
||||
className="search-field-input"
|
||||
type="text"
|
||||
placeholder="Buscar por nombre..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Entidad</label>
|
||||
<select
|
||||
value={filters.entityType}
|
||||
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
|
||||
>
|
||||
{ENTITY_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Tipo documento</label>
|
||||
<select
|
||||
value={filters.documentTypeId}
|
||||
onChange={(e) => setFilters({ ...filters, documentTypeId: Number(e.target.value) })}
|
||||
>
|
||||
<option value={0}>Todos</option>
|
||||
{documentTypes
|
||||
.filter((t) => !filters.entityType || allowedDocTypes.has(t.id))
|
||||
.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{filters.entityType && allowedDocTypes.size === 0 && (
|
||||
<small className="text-muted">No hay tipos específicos para esta entidad</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📎</div>
|
||||
<h3>{filters.entityType || filters.documentTypeId || searchTerm ? 'Sin resultados' : 'No hay documentos'}</h3>
|
||||
<p>{filters.entityType || filters.documentTypeId || searchTerm ? 'Prueba con otros filtros de búsqueda.' : 'Los documentos asociados a propiedades, inquilinos, contratos, etc. aparecerán aquí.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader label="ID" sortKey="id" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Nombre" sortKey="originalName" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Tipo" sortKey="documentType.name" sort={sort} onToggle={toggleSort} />
|
||||
<th>Descripción</th>
|
||||
<SortableHeader label="Entidades" sortKey="entityType" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Tamaño" sortKey="fileSize" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Subido" sortKey="createdAt" sort={sort} onToggle={toggleSort} />
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayItems.map((doc) => (
|
||||
<tr key={doc.id}>
|
||||
<td className="font-mono text-muted">{doc.id}</td>
|
||||
<td className="font-semibold">{doc.originalName}</td>
|
||||
<td><span className="badge badge-neutral">{doc.documentType?.name}</span></td>
|
||||
<td className="text-muted">{doc.description || '-'}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||
{(doc.entities ?? []).map((e) => {
|
||||
const entityType = e.entityType as EntityType;
|
||||
if (entityType === 'PROPERTY_GROUP') {
|
||||
return (
|
||||
<span key={`${e.entityType}-${e.entityId}`} className="badge badge-info">
|
||||
{ENTITY_TYPE_LABELS[e.entityType] ?? e.entityType} #{e.entityId}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EntityLink
|
||||
key={`${e.entityType}-${e.entityId}`}
|
||||
entityType={entityType}
|
||||
entityId={e.entityId}
|
||||
>
|
||||
{ENTITY_TYPE_LABELS[e.entityType] ?? e.entityType} #{e.entityId}
|
||||
</EntityLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-muted">{formatFileSize(doc.fileSize)}</td>
|
||||
<td className="text-secondary">{doc.uploadedAt?.substring(0, 10)}</td>
|
||||
<td>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => goToDetail(doc)}>Ver</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => handleDownload(doc)}>Descargar</button>
|
||||
<button className="btn btn-sm btn-danger" onClick={() => setDeleteTarget(doc)}>Eliminar</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} totalElements={filteredItems.length} size={PAGE_SIZE} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Eliminar Documento"
|
||||
message={`¿Estás seguro de eliminar el documento "${deleteTarget?.originalName}"? Esta acción no se puede deshacer y eliminará el archivo físico.`}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { expenseReceiptApi, propertyApi, expenseTemplateApi, bankAccountApi, groupApi } from '../api/resources';
|
||||
import type { ExpenseReceipt, Property, ExpenseTemplate, BankAccount, PropertyGroup } from '../types/api';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import SortableHeader from '../components/SortableHeader';
|
||||
import Pagination from '../components/Pagination';
|
||||
import DocumentUploader from '../components/DocumentUploader';
|
||||
import EntityLink from '../components/EntityLink';
|
||||
import { useSort } from '../hooks/useSort';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { fmtProp } from '../utils/propertyFormat';
|
||||
|
||||
type ViewMode = 'list' | 'detail' | 'edit' | 'create';
|
||||
|
||||
interface FormData {
|
||||
templateId: number;
|
||||
propertyId: number;
|
||||
propertyGroupId: number;
|
||||
bankAccountId: number;
|
||||
isDomiciled: boolean;
|
||||
categoryId: number;
|
||||
supplierName: string;
|
||||
supplierFiscalId: string;
|
||||
invoiceNumber: string;
|
||||
amount: string;
|
||||
taxAmount: string;
|
||||
description: string;
|
||||
notes: string;
|
||||
issueDate: string;
|
||||
dueDate: string;
|
||||
}
|
||||
|
||||
const emptyForm = (): FormData => ({
|
||||
templateId: 0, propertyId: 0, propertyGroupId: 0,
|
||||
bankAccountId: 0, isDomiciled: false,
|
||||
categoryId: 1, supplierName: '', supplierFiscalId: '',
|
||||
invoiceNumber: '', amount: '', taxAmount: '0',
|
||||
description: '', notes: '', issueDate: '', dueDate: '',
|
||||
});
|
||||
|
||||
const categories = [
|
||||
{ id: 1, name: 'Suministros' }, { id: 2, name: 'Comunidad' },
|
||||
{ id: 3, name: 'Reparaciones' }, { id: 4, name: 'Mantenimiento' },
|
||||
{ id: 5, name: 'Seguro' }, { id: 6, name: 'IBI' },
|
||||
{ id: 7, name: 'Basura' }, { id: 8, name: 'Gestión' },
|
||||
{ id: 9, name: 'Notaría/Registro' }, { id: 10, name: 'Reforma' },
|
||||
{ id: 11, name: 'Publicidad' }, { id: 12, name: 'Otros' },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function ExpenseReceipts() {
|
||||
const location = useLocation();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [items, setItems] = useState<ExpenseReceipt[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ExpenseReceipt | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(true);
|
||||
const [filters, setFilters] = useState({ q: '', statusId: 0, propertyId: 0, categoryId: 0 });
|
||||
const [page, setPage] = useState(0);
|
||||
const { sorted, sort, toggleSort } = useSort(items, 'issueDate');
|
||||
const [receipt, setReceipt] = useState<ExpenseReceipt | null>(null);
|
||||
const [properties, setProperties] = useState<Property[]>([]);
|
||||
const [groups, setGroups] = useState<PropertyGroup[]>([]);
|
||||
const [accounts, setAccounts] = useState<BankAccount[]>([]);
|
||||
const [templates, setTemplates] = useState<ExpenseTemplate[]>([]);
|
||||
const [form, setForm] = useState<FormData>(emptyForm());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formLoading, setFormLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
const [inlineAmounts, setInlineAmounts] = useState<Record<number, string>>({});
|
||||
const [showPayForm, setShowPayForm] = useState(false);
|
||||
const [payDate, setPayDate] = useState('');
|
||||
const [payMethod, setPayMethod] = useState('TRANSFERENCIA');
|
||||
const [detailAmount, setDetailAmount] = useState('');
|
||||
const [detailAmountLoading, setDetailAmountLoading] = useState(false);
|
||||
|
||||
const loadList = () => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
expenseReceiptApi.getAll({ size: 1000 }),
|
||||
propertyApi.getAll({ size: 1000 }),
|
||||
expenseTemplateApi.getActive(),
|
||||
])
|
||||
.then(([r, p, t]) => { setItems(r.data.content); setProperties(p.data.content); setTemplates(t.data); })
|
||||
.catch(() => toast('Error al cargar recibos de gasto', 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { loadList(); }, []);
|
||||
useEffect(() => { setPage(0); }, [filters]);
|
||||
useEffect(() => {
|
||||
const state = location.state as { openDetailId?: number } | null;
|
||||
if (state?.openDetailId) { goToDetail(state.openDetailId); }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.state]);
|
||||
|
||||
const loadForView = (id: number): Promise<void> => {
|
||||
return Promise.all([expenseReceiptApi.getById(id), bankAccountApi.getAll()])
|
||||
.then(([resp, baResp]) => {
|
||||
const r = resp.data;
|
||||
setReceipt(r);
|
||||
setAccounts(baResp.data);
|
||||
setDetailAmount(r.amount ? String(r.amount) : '');
|
||||
setForm({
|
||||
templateId: r.template?.id ?? 0, propertyId: r.property?.id ?? 0,
|
||||
propertyGroupId: r.propertyGroup?.id ?? 0, bankAccountId: r.bankAccount?.id ?? 0,
|
||||
isDomiciled: r.isDomiciled, categoryId: r.category?.id ?? 1,
|
||||
supplierName: r.supplierName ?? '', supplierFiscalId: r.supplierFiscalId ?? '',
|
||||
invoiceNumber: r.invoiceNumber ?? '', amount: String(r.amount),
|
||||
taxAmount: String(r.taxAmount ?? 0), description: r.description ?? '',
|
||||
notes: r.notes ?? '', issueDate: r.issueDate, dueDate: r.dueDate ?? '',
|
||||
});
|
||||
})
|
||||
.catch(() => { toast('Error al cargar el recibo', 'error'); throw new Error('Error loading'); });
|
||||
};
|
||||
|
||||
const loadForCreate = (): Promise<void> => {
|
||||
setReceipt(null);
|
||||
setForm(emptyForm());
|
||||
return Promise.all([
|
||||
propertyApi.getAll({ size: 1000 }), groupApi.getAll({ size: 1000 }),
|
||||
bankAccountApi.getAll(), expenseTemplateApi.getActive(),
|
||||
])
|
||||
.then(([propsResp, groupsResp, baResp, templatesResp]) => {
|
||||
setProperties(propsResp.data.content ?? []);
|
||||
setGroups(groupsResp.data.content ?? []);
|
||||
setAccounts(baResp.data);
|
||||
setTemplates(templatesResp.data);
|
||||
})
|
||||
.catch(() => toast('Error al cargar datos', 'error'))
|
||||
.finally(() => setFormLoading(false));
|
||||
};
|
||||
|
||||
const goToList = () => {
|
||||
setViewMode('list'); setSelectedId(null); setReceipt(null);
|
||||
setForm(emptyForm()); setShowPayForm(false); loadList();
|
||||
};
|
||||
|
||||
const goToDetail = (id: number) => { setSelectedId(id); setViewMode('detail'); loadForView(id); };
|
||||
|
||||
const goToEdit = () => {
|
||||
if (receipt && properties.length > 0) { setViewMode('edit'); }
|
||||
else { setFormLoading(true); loadForView(selectedId!).finally(() => { setFormLoading(false); setViewMode('edit'); }); }
|
||||
};
|
||||
|
||||
const goToCreate = (): Promise<void> => { setViewMode('create'); return loadForCreate(); };
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.issueDate || !form.amount) { toast('Completa los campos obligatorios', 'warning'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
property: form.propertyId ? properties.find(p => p.id === form.propertyId) : null,
|
||||
propertyGroup: form.propertyGroupId ? groups.find(g => g.id === form.propertyGroupId) : null,
|
||||
bankAccount: form.bankAccountId ? accounts.find(a => a.id === form.bankAccountId) : null,
|
||||
isDomiciled: form.isDomiciled, category: { id: form.categoryId },
|
||||
supplierName: form.supplierName || undefined, supplierFiscalId: form.supplierFiscalId || undefined,
|
||||
invoiceNumber: form.invoiceNumber || undefined, amount: Number(form.amount),
|
||||
taxAmount: Number(form.taxAmount) || 0, description: form.description,
|
||||
notes: form.notes || undefined, issueDate: form.issueDate,
|
||||
dueDate: form.dueDate || undefined, status: { id: receipt?.status?.id ?? 1 },
|
||||
};
|
||||
if (viewMode === 'edit' && selectedId) {
|
||||
await expenseReceiptApi.update(selectedId, payload as Partial<ExpenseReceipt>);
|
||||
toast('Recibo actualizado correctamente', 'success'); goToDetail(selectedId);
|
||||
} else {
|
||||
const created = await expenseReceiptApi.create(payload as Partial<ExpenseReceipt>);
|
||||
toast('Recibo creado correctamente', 'success'); goToDetail(created.data.id);
|
||||
}
|
||||
} catch { toast('Error al guardar el recibo', 'error'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try { await expenseReceiptApi.remove(deleteTarget.id); toast('Recibo de gasto eliminado', 'success'); setDeleteTarget(null); goToList(); }
|
||||
catch { toast('Error al eliminar el recibo', 'error'); }
|
||||
};
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!selectedId || !payDate) return;
|
||||
try { await expenseReceiptApi.registerPayment(selectedId, { paymentDate: payDate, paymentMethod: payMethod }); toast('Pago registrado correctamente', 'success'); setShowPayForm(false); loadForView(selectedId); }
|
||||
catch { toast('Error al registrar el pago', 'error'); }
|
||||
};
|
||||
|
||||
const handleUpdateAmount = async (id: number) => {
|
||||
const amount = detailAmount ? Number(detailAmount) : 0;
|
||||
if (amount <= 0) { toast('Introduce un importe válido', 'warning'); return; }
|
||||
setDetailAmountLoading(true);
|
||||
try { await expenseReceiptApi.updateAmount(id, { amount }); toast('Importe actualizado', 'success'); loadForView(id); }
|
||||
catch { toast('Error al actualizar el importe', 'error'); }
|
||||
finally { setDetailAmountLoading(false); }
|
||||
};
|
||||
|
||||
const handleInlineAmountSave = async (id: number) => {
|
||||
const val = inlineAmounts[id];
|
||||
if (!val || Number(val) <= 0) return;
|
||||
try {
|
||||
await expenseReceiptApi.updateAmount(id, { amount: Number(val) });
|
||||
toast('Importe actualizado', 'success');
|
||||
setInlineAmounts((prev) => { const next = { ...prev }; delete next[id]; return next; });
|
||||
loadList();
|
||||
} catch { toast('Error al actualizar importe', 'error'); }
|
||||
};
|
||||
|
||||
const handleTemplateSelect = (templateId: number) => {
|
||||
const t = templates.find((tm) => tm.id === templateId);
|
||||
if (t) {
|
||||
setForm({
|
||||
...form, templateId, propertyId: t.property?.id ?? 0,
|
||||
propertyGroupId: t.propertyGroup?.id ?? 0, bankAccountId: t.bankAccount?.id ?? 0,
|
||||
isDomiciled: t.isDomiciled, categoryId: t.category?.id ?? 1,
|
||||
supplierName: t.supplierName ?? '', supplierFiscalId: t.supplierFiscalId ?? '',
|
||||
amount: t.amount ? String(t.amount) : '', taxAmount: t.taxAmount ? String(t.taxAmount) : '0',
|
||||
description: t.description ?? '', notes: t.notes ?? '',
|
||||
});
|
||||
} else { setForm({ ...form, templateId: 0 }); }
|
||||
};
|
||||
|
||||
const statusBadge = (name?: string) => {
|
||||
const map: Record<string, string> = { 'PENDIENTE': 'badge-warning', 'PAGADO': 'badge-success', 'VENCIDO': 'badge-danger' };
|
||||
const cls = map[name ?? ''] ?? 'badge-neutral';
|
||||
return <span className={`badge ${cls}`}><span className={`badge-dot badge-dot-${cls.replace('badge-', '')}`} /> {name ?? '-'}</span>;
|
||||
};
|
||||
|
||||
const categoryBadge = (name?: string) => (
|
||||
<span className="badge badge-neutral">{name ?? '-'}</span>
|
||||
);
|
||||
|
||||
const filtered = sorted.filter((r) => {
|
||||
if (filters.q) {
|
||||
const q = filters.q.toLowerCase();
|
||||
if (!r.description.toLowerCase().includes(q) && !(r.supplierName ?? '').toLowerCase().includes(q) && !(r.invoiceNumber ?? '').toLowerCase().includes(q)) return false;
|
||||
}
|
||||
if (filters.statusId && r.status?.id !== filters.statusId) return false;
|
||||
if (filters.propertyId && r.property?.id !== filters.propertyId) return false;
|
||||
if (filters.categoryId && r.category?.id !== filters.categoryId) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const displayItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
if (viewMode === 'list') {
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Cargando recibos de gasto...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Recibos de Gasto</h1>
|
||||
<button className="btn btn-primary" onClick={goToCreate}>+ Nuevo Recibo</button>
|
||||
</div>
|
||||
<div className="search-panel">
|
||||
<div className="search-panel-header">
|
||||
<h3>Filtros de búsqueda</h3>
|
||||
<button className="search-toggle" onClick={() => setSearchOpen(!searchOpen)}>
|
||||
{searchOpen ? 'Ocultar' : 'Mostrar'} {searchOpen ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
{searchOpen && (
|
||||
<div className="search-fields">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Buscar</label>
|
||||
<input className="search-field-input" placeholder="Descripción, proveedor, factura..." value={filters.q} onChange={(e) => setFilters({ ...filters, q: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Estado</label>
|
||||
<select value={filters.statusId} onChange={(e) => setFilters({ ...filters, statusId: Number(e.target.value) })}>
|
||||
<option value={0}>Todos</option>
|
||||
<option value={1}>PENDIENTE</option>
|
||||
<option value={2}>PAGADO</option>
|
||||
<option value={3}>VENCIDO</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Propiedad</label>
|
||||
<select value={filters.propertyId} onChange={(e) => setFilters({ ...filters, propertyId: Number(e.target.value) })}>
|
||||
<option value={0}>Todas</option>
|
||||
{properties.map((p) => <option key={p.id} value={p.id}>{fmtProp(p)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Categoría</label>
|
||||
<select value={filters.categoryId} onChange={(e) => setFilters({ ...filters, categoryId: Number(e.target.value) })}>
|
||||
<option value={0}>Todas</option>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📄</div>
|
||||
<h3>{filters.q || filters.statusId || filters.propertyId || filters.categoryId ? 'Sin resultados' : 'No hay recibos de gasto'}</h3>
|
||||
<p>{filters.q || filters.statusId || filters.propertyId || filters.categoryId ? 'Prueba con otros filtros de búsqueda.' : 'Crea recibos de gasto desde plantillas o manualmente.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader label="ID" sortKey="id" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Descripción" sortKey="description" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Proveedor" sortKey="supplierName" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Categoría" sortKey="category.name" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Importe" sortKey="amount" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Estado" sortKey="status.name" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Emisión" sortKey="issueDate" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Vencimiento" sortKey="dueDate" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Pago" sortKey="paymentDate" sort={sort} onToggle={toggleSort} />
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayItems.map((r) => {
|
||||
const isVariableZero = r.isVariable && r.amount === 0;
|
||||
return (
|
||||
<tr key={r.id} className={isVariableZero ? 'row-warning' : ''}>
|
||||
<td className="font-mono text-muted">{r.id}</td>
|
||||
<td className="font-semibold">{r.description ? (r.description.length > 50 ? r.description.substring(0, 50) + '...' : r.description) : '-'}</td>
|
||||
<td className="text-secondary">{r.supplierName ?? '-'}</td>
|
||||
<td>{categoryBadge(r.category?.name)}</td>
|
||||
<td className="amount">
|
||||
{isVariableZero ? (
|
||||
<span className="inline-amount-edit">
|
||||
<input type="number" className="inline-input" placeholder="Importe" value={inlineAmounts[r.id] ?? ''} onChange={(e) => setInlineAmounts({ ...inlineAmounts, [r.id]: e.target.value })} onBlur={() => handleInlineAmountSave(r.id)} onKeyDown={(e) => { if (e.key === 'Enter') handleInlineAmountSave(r.id); }} step="0.01" />
|
||||
<span className="badge badge-warning">Pendiente rellenar</span>
|
||||
</span>
|
||||
) : <>{r.totalAmount ?? r.amount} €</>}
|
||||
</td>
|
||||
<td>{statusBadge(r.status?.name)}</td>
|
||||
<td className="text-secondary">{r.issueDate}</td>
|
||||
<td className="text-secondary">{r.dueDate ?? '-'}</td>
|
||||
<td className="text-secondary">{r.paymentDate ?? '-'}</td>
|
||||
<td>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => goToDetail(r.id)}>Ver</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={async () => { setSelectedId(r.id); setFormLoading(true); setViewMode('detail'); try { await loadForView(r.id); } finally { setFormLoading(false); } setViewMode('edit'); }}>Editar</button>
|
||||
<button className="btn btn-sm btn-danger" onClick={() => setDeleteTarget(r)}>Eliminar</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} totalElements={filtered.length} size={PAGE_SIZE} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
<ConfirmDialog open={!!deleteTarget} title="Eliminar Recibo de Gasto" message={`¿Estás seguro de eliminar el recibo de "${deleteTarget?.supplierName ?? 'proveedor desconocido'}"? Esta acción no se puede deshacer.`} variant="danger" onConfirm={handleDelete} onCancel={() => setDeleteTarget(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'detail') {
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Cargando recibo de gasto...</div>;
|
||||
if (!receipt) return <div className="loading">Recibo no encontrado</div>;
|
||||
|
||||
const isVariableZero = receipt.isVariable && receipt.amount === 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Volver al listado</button>
|
||||
<h1>Recibo de Gasto #{receipt.id}</h1>
|
||||
<div className="page-header-spacer" />
|
||||
<div className="page-header-actions" style={{ display: 'flex', gap: '8px' }}>
|
||||
{receipt.status?.name === 'PENDIENTE' && (<button className="btn btn-success" onClick={() => { setPayDate(new Date().toISOString().substring(0, 10)); setShowPayForm(true); }}>Registrar Pago</button>)}
|
||||
<button className="btn btn-primary" onClick={goToEdit}>Editar</button>
|
||||
<button className="btn btn-danger" onClick={() => setDeleteTarget(receipt)}>Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="detail-grid">
|
||||
<div className="detail-field"><span className="detail-label">ID</span><span className="detail-value font-mono">{receipt.id}</span></div>
|
||||
<div className="detail-field"><span className="detail-label">Estado</span><span className="detail-value">{statusBadge(receipt.status?.name)}</span></div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Plantilla</span>
|
||||
<span className="detail-value">{receipt.template ? <span className="text-secondary">{receipt.template.description}</span> : <span className="text-muted">Gasto sin plantilla</span>}</span>
|
||||
</div>
|
||||
<div className="detail-field"><span className="detail-label">Categoría</span><span className="detail-value">{categoryBadge(receipt.category?.name)}</span></div>
|
||||
<div className="detail-field"><span className="detail-label">Proveedor</span><span className="detail-value">{receipt.supplierName ?? '-'}</span></div>
|
||||
<div className="detail-field"><span className="detail-label">NIF Proveedor</span><span className="detail-value">{receipt.supplierFiscalId ?? '-'}</span></div>
|
||||
<div className="detail-field"><span className="detail-label">Nº Factura</span><span className="detail-value font-mono">{receipt.invoiceNumber ?? '-'}</span></div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Propiedad</span>
|
||||
<span className="detail-value">{receipt.property ? <EntityLink entityType="PROPERTY" entityId={receipt.property.id}>{fmtProp(receipt.property)}</EntityLink> : '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Conjunto</span>
|
||||
<span className="detail-value">{receipt.propertyGroup ? <EntityLink entityType="PROPERTY_GROUP" entityId={receipt.propertyGroup.id}>{receipt.propertyGroup.name}</EntityLink> : '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Cuenta Bancaria</span>
|
||||
<span className="detail-value">{receipt.bankAccount ? <EntityLink entityType="BANK_ACCOUNT" entityId={receipt.bankAccount.id}>{receipt.bankAccount.name}</EntityLink> : '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field"><span className="detail-label">Domiciliado</span><span className="detail-value">{receipt.isDomiciled ? 'Sí' : 'No'}</span></div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Gasto Variable</span>
|
||||
<span className="detail-value">
|
||||
{receipt.isVariable ? 'Sí' : 'No'}
|
||||
{receipt.isVariable && receipt.previousAmount != null && <span className="text-warning"> (Anterior: {receipt.previousAmount} €)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-field"><span className="detail-label">Base Imponible</span><span className="detail-value amount">{receipt.amount} €</span></div>
|
||||
<div className="detail-field"><span className="detail-label">IVA</span><span className="detail-value amount">{receipt.taxAmount ? `${receipt.taxAmount} €` : '0 €'}</span></div>
|
||||
<div className="detail-field"><span className="detail-label">Total</span><span className="detail-value amount">{receipt.totalAmount ?? receipt.amount} €</span></div>
|
||||
<div className="detail-field"><span className="detail-label">Fecha Emisión</span><span className="detail-value">{receipt.issueDate}</span></div>
|
||||
<div className="detail-field"><span className="detail-label">Fecha Vencimiento</span><span className="detail-value">{receipt.dueDate ?? '-'}</span></div>
|
||||
<div className="detail-field"><span className="detail-label">Fecha Pago</span><span className="detail-value">{receipt.paymentDate ?? 'Pendiente'}</span></div>
|
||||
<div className="detail-field"><span className="detail-label">Método Pago</span><span className="detail-value">{receipt.paymentMethod ?? '-'}</span></div>
|
||||
{receipt.description && <div className="detail-field full-width"><span className="detail-label">Descripción</span><span className="detail-value detail-description">{receipt.description}</span></div>}
|
||||
{receipt.notes && <div className="detail-field full-width"><span className="detail-label">Notas</span><span className="detail-value detail-description">{receipt.notes}</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
{isVariableZero && (
|
||||
<div className="card">
|
||||
<div className="form-section-title">Importe Variable Pendiente</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Importe (€)</label>
|
||||
<input type="number" value={detailAmount} onChange={(e) => setDetailAmount(e.target.value)} placeholder="Introduce el importe real" step="0.01" />
|
||||
</div>
|
||||
<div className="form-group form-group-submit">
|
||||
<label className="form-label"> </label>
|
||||
<button className="btn btn-primary" onClick={() => handleUpdateAmount(receipt.id)} disabled={detailAmountLoading}>
|
||||
{detailAmountLoading ? 'Actualizando...' : 'Actualizar Importe'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showPayForm && receipt.status?.name === 'PENDIENTE' && (
|
||||
<div className="card">
|
||||
<div className="form-section-title">Registrar Pago</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Fecha de Pago</label>
|
||||
<input type="date" value={payDate} onChange={(e) => setPayDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Método de Pago</label>
|
||||
<select value={payMethod} onChange={(e) => setPayMethod(e.target.value)}>
|
||||
<option value="TRANSFERENCIA">Transferencia</option>
|
||||
<option value="EFECTIVO">Efectivo</option>
|
||||
<option value="BIZUM">Bizum</option>
|
||||
<option value="RECIBO">Recibo</option>
|
||||
<option value="TARJETA">Tarjeta</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group form-group-submit">
|
||||
<label className="form-label"> </label>
|
||||
<button className="btn btn-success" onClick={handlePay}>Confirmar Pago</button>
|
||||
</div>
|
||||
<div className="form-group form-group-submit">
|
||||
<label className="form-label"> </label>
|
||||
<button className="btn btn-secondary" onClick={() => setShowPayForm(false)}>Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="card">
|
||||
<DocumentUploader entityType="EXPENSE_RECEIPT" entityId={receipt.id} />
|
||||
</div>
|
||||
<ConfirmDialog open={!!deleteTarget} title="Eliminar Recibo de Gasto" message={`¿Estás seguro de eliminar el recibo de "${receipt?.supplierName ?? 'proveedor desconocido'}"?`} variant="danger" onConfirm={handleDelete} onCancel={() => setDeleteTarget(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isCreate = viewMode === 'create';
|
||||
const formTitle = isCreate ? 'Nuevo Recibo de Gasto' : `Editar Recibo de Gasto #${selectedId}`;
|
||||
|
||||
if (formLoading) return <div className="loading"><div className="spinner" /> Cargando...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>{formTitle}</h1>
|
||||
</div>
|
||||
<div className="form-sticky-bar">
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Cancelar</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving} form="expense-receipt-form">
|
||||
{saving ? 'Guardando...' : (isCreate ? 'Crear Recibo' : 'Guardar Cambios')}
|
||||
</button>
|
||||
</div>
|
||||
<form id="expense-receipt-form" onSubmit={handleSubmit} className="card form">
|
||||
<div className="form-section-title">Plantilla (opcional)</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group full-width">
|
||||
<label className="form-label">Plantilla de gasto</label>
|
||||
<select value={form.templateId} onChange={(e) => handleTemplateSelect(Number(e.target.value))}>
|
||||
<option value={0}>Gasto sin plantilla</option>
|
||||
{templates.map((t) => (<option key={t.id} value={t.id}>{t.description} {t.supplierName ? `- ${t.supplierName}` : ''}</option>))}
|
||||
</select>
|
||||
{form.templateId > 0 && <span className="field-hint">Los datos se autocompletan según la plantilla seleccionada</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-section-title">Asignación</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Propiedad</label>
|
||||
<select value={form.propertyId} onChange={(e) => setForm({ ...form, propertyId: Number(e.target.value) })}>
|
||||
<option value={0}>Seleccionar...</option>
|
||||
{properties.map((p) => <option key={p.id} value={p.id}>{fmtProp(p)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Conjunto</label>
|
||||
<select value={form.propertyGroupId} onChange={(e) => setForm({ ...form, propertyGroupId: Number(e.target.value) })}>
|
||||
<option value={0}>Seleccionar...</option>
|
||||
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
<span className="field-hint">Selecciona propiedad o conjunto</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Cuenta Bancaria</label>
|
||||
<select value={form.bankAccountId} onChange={(e) => setForm({ ...form, bankAccountId: Number(e.target.value) })}>
|
||||
<option value={0}>Seleccionar cuenta...</option>
|
||||
{accounts.filter((a) => a.active).map((a) => (<option key={a.id} value={a.id}>{a.name} {a.isDefault ? '(Ppal)' : ''}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label"> </label>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={form.isDomiciled} onChange={(e) => setForm({ ...form, isDomiciled: e.target.checked })} />
|
||||
Domiciliado
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Categoría</label>
|
||||
<select value={form.categoryId} onChange={(e) => setForm({ ...form, categoryId: Number(e.target.value) })}>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-section-title">Proveedor y Factura</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Proveedor</label>
|
||||
<input type="text" value={form.supplierName} onChange={(e) => setForm({ ...form, supplierName: e.target.value })} placeholder="Nombre del proveedor" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">NIF Proveedor</label>
|
||||
<input type="text" value={form.supplierFiscalId} onChange={(e) => setForm({ ...form, supplierFiscalId: e.target.value })} placeholder="12345678A" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nº Factura</label>
|
||||
<input type="text" value={form.invoiceNumber} onChange={(e) => setForm({ ...form, invoiceNumber: e.target.value })} placeholder="Ej: F-2024-001" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-section-title">Importes</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Base Imponible (€) *</label>
|
||||
<input type="number" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value })} required placeholder="Ej: 500" step="0.01" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">IVA (€)</label>
|
||||
<input type="number" value={form.taxAmount} onChange={(e) => setForm({ ...form, taxAmount: e.target.value })} placeholder="Ej: 105" step="0.01" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Fecha Emisión *</label>
|
||||
<input type="date" value={form.issueDate} onChange={(e) => setForm({ ...form, issueDate: e.target.value })} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Fecha Vencimiento</label>
|
||||
<input type="date" value={form.dueDate} onChange={(e) => setForm({ ...form, dueDate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-section-title">Descripción</div>
|
||||
<div className="form-group full-width">
|
||||
<textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Descripción del gasto..." rows={4} />
|
||||
</div>
|
||||
<div className="form-group full-width">
|
||||
<label className="form-label">Notas (opcional)</label>
|
||||
<textarea value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder="Notas internas..." rows={3} />
|
||||
</div>
|
||||
{!isCreate && selectedId && (
|
||||
<>
|
||||
<div className="form-section-title">Documentos Adjuntos</div>
|
||||
<DocumentUploader entityType="EXPENSE_RECEIPT" entityId={selectedId} />
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { expenseTemplateApi, propertyApi, groupApi, bankAccountApi } from '../api/resources';
|
||||
import type { ExpenseTemplate, Property, PropertyGroup, BankAccount } from '../types/api';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import SortableHeader from '../components/SortableHeader';
|
||||
import Pagination from '../components/Pagination';
|
||||
import EntityLink from '../components/EntityLink';
|
||||
import { useSort } from '../hooks/useSort';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { fmtProp } from '../utils/propertyFormat';
|
||||
|
||||
type ViewMode = 'list' | 'detail' | 'edit' | 'create';
|
||||
|
||||
interface FormData {
|
||||
propertyId: number;
|
||||
propertyGroupId: number;
|
||||
bankAccountId: number;
|
||||
isDomiciled: boolean;
|
||||
categoryId: number;
|
||||
periodId: number;
|
||||
paymentDay: number;
|
||||
supplierName: string;
|
||||
supplierFiscalId: string;
|
||||
amount: string;
|
||||
taxAmount: string;
|
||||
description: string;
|
||||
notes: string;
|
||||
isVariable: boolean;
|
||||
}
|
||||
|
||||
const emptyForm = (): FormData => ({
|
||||
propertyId: 0, propertyGroupId: 0, bankAccountId: 0, isDomiciled: false,
|
||||
categoryId: 1, periodId: 1, paymentDay: 1,
|
||||
supplierName: '', supplierFiscalId: '', amount: '', taxAmount: '0',
|
||||
description: '', notes: '', isVariable: false,
|
||||
});
|
||||
|
||||
const categories = [
|
||||
{ id: 1, name: 'Suministros' }, { id: 2, name: 'Comunidad' },
|
||||
{ id: 3, name: 'Reparaciones' }, { id: 4, name: 'Seguros' },
|
||||
{ id: 5, name: 'IBI' }, { id: 6, name: 'Tasas' },
|
||||
{ id: 7, name: 'Mantenimiento' }, { id: 8, name: 'Otros' },
|
||||
];
|
||||
|
||||
const periods = [
|
||||
{ id: 1, name: 'Mensual' }, { id: 2, name: 'Trimestral' },
|
||||
{ id: 3, name: 'Semestral' }, { id: 4, name: 'Anual' },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function ExpenseTemplates() {
|
||||
const location = useLocation();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
const [items, setItems] = useState<ExpenseTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ExpenseTemplate | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(true);
|
||||
const [filters, setFilters] = useState({ q: '', activeFilter: '', categoryId: 0 });
|
||||
const [page, setPage] = useState(0);
|
||||
const { sorted, sort, toggleSort } = useSort(items, 'description');
|
||||
|
||||
const [template, setTemplate] = useState<ExpenseTemplate | null>(null);
|
||||
const [properties, setProperties] = useState<Property[]>([]);
|
||||
const [groups, setGroups] = useState<PropertyGroup[]>([]);
|
||||
const [accounts, setAccounts] = useState<BankAccount[]>([]);
|
||||
const [form, setForm] = useState<FormData>(emptyForm());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formLoading, setFormLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
const loadList = () => {
|
||||
setLoading(true);
|
||||
expenseTemplateApi.getAll({ size: 1000 })
|
||||
.then((resp) => setItems(resp.data.content))
|
||||
.catch(() => toast('Error al cargar plantillas', 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { loadList(); }, []);
|
||||
|
||||
useEffect(() => { setPage(0); }, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
const state = location.state as { openDetailId?: number } | null;
|
||||
if (state?.openDetailId) {
|
||||
goToDetail(state.openDetailId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.state]);
|
||||
|
||||
const loadForView = (id: number): Promise<void> => {
|
||||
return Promise.all([
|
||||
expenseTemplateApi.getById(id),
|
||||
propertyApi.getAll({ size: 1000 }),
|
||||
groupApi.getAll({ size: 1000 }),
|
||||
bankAccountApi.getAll(),
|
||||
])
|
||||
.then(([tmplResp, propsResp, groupsResp, baResp]) => {
|
||||
const t = tmplResp.data;
|
||||
setTemplate(t);
|
||||
setProperties(propsResp.data.content ?? []);
|
||||
setGroups(groupsResp.data.content ?? []);
|
||||
setAccounts(baResp.data);
|
||||
setForm({
|
||||
propertyId: t.property?.id ?? 0,
|
||||
propertyGroupId: t.propertyGroup?.id ?? 0,
|
||||
bankAccountId: t.bankAccount?.id ?? 0,
|
||||
isDomiciled: t.isDomiciled ?? false,
|
||||
categoryId: t.category?.id ?? 1,
|
||||
periodId: t.period?.id ?? 1,
|
||||
paymentDay: t.paymentDay ?? 1,
|
||||
supplierName: t.supplierName ?? '',
|
||||
supplierFiscalId: t.supplierFiscalId ?? '',
|
||||
amount: t.amount?.toString() ?? '',
|
||||
taxAmount: t.taxAmount?.toString() ?? '0',
|
||||
description: t.description ?? '',
|
||||
notes: t.notes ?? '',
|
||||
isVariable: t.isVariable ?? false,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast('Error al cargar la plantilla', 'error');
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
const loadForCreate = (): Promise<void> => {
|
||||
setTemplate(null);
|
||||
setForm(emptyForm());
|
||||
return Promise.all([
|
||||
propertyApi.getAll({ size: 1000 }),
|
||||
groupApi.getAll({ size: 1000 }),
|
||||
bankAccountApi.getAll(),
|
||||
])
|
||||
.then(([propsResp, groupsResp, baResp]) => {
|
||||
setProperties(propsResp.data.content ?? []);
|
||||
setGroups(groupsResp.data.content ?? []);
|
||||
setAccounts(baResp.data);
|
||||
})
|
||||
.catch(() => toast('Error al cargar datos', 'error'))
|
||||
.finally(() => setFormLoading(false));
|
||||
};
|
||||
|
||||
const goToList = () => {
|
||||
setViewMode('list');
|
||||
setSelectedId(null);
|
||||
setTemplate(null);
|
||||
setForm(emptyForm());
|
||||
loadList();
|
||||
};
|
||||
|
||||
const goToDetail = (id: number) => {
|
||||
setSelectedId(id);
|
||||
setViewMode('detail');
|
||||
loadForView(id);
|
||||
};
|
||||
|
||||
const goToEdit = () => {
|
||||
if (template && properties.length > 0) {
|
||||
setViewMode('edit');
|
||||
} else {
|
||||
setFormLoading(true);
|
||||
loadForView(selectedId!).finally(() => {
|
||||
setFormLoading(false);
|
||||
setViewMode('edit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const goToCreate = (): Promise<void> => {
|
||||
setViewMode('create');
|
||||
return loadForCreate();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.description.trim()) {
|
||||
toast('Completa los campos obligatorios', 'warning');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
property: form.propertyId ? { id: form.propertyId } : null,
|
||||
propertyGroup: form.propertyGroupId ? { id: form.propertyGroupId } : null,
|
||||
bankAccount: form.bankAccountId ? { id: form.bankAccountId } : null,
|
||||
isDomiciled: form.isDomiciled,
|
||||
category: { id: form.categoryId },
|
||||
period: { id: form.periodId },
|
||||
paymentDay: form.paymentDay,
|
||||
supplierName: form.supplierName || undefined,
|
||||
supplierFiscalId: form.supplierFiscalId || undefined,
|
||||
amount: form.isVariable ? undefined : (form.amount ? Number(form.amount) : undefined),
|
||||
taxAmount: Number(form.taxAmount) || 0,
|
||||
description: form.description,
|
||||
notes: form.notes || undefined,
|
||||
isVariable: form.isVariable,
|
||||
};
|
||||
|
||||
if (viewMode === 'edit' && selectedId) {
|
||||
await expenseTemplateApi.update(selectedId, payload as Partial<ExpenseTemplate>);
|
||||
toast('Plantilla actualizada', 'success');
|
||||
goToDetail(selectedId);
|
||||
} else {
|
||||
const created = await expenseTemplateApi.create(payload as Partial<ExpenseTemplate>);
|
||||
toast('Plantilla creada', 'success');
|
||||
goToDetail(created.data.id);
|
||||
}
|
||||
} catch {
|
||||
toast('Error al guardar la plantilla', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await expenseTemplateApi.remove(deleteTarget.id);
|
||||
toast('Plantilla eliminada', 'success');
|
||||
setDeleteTarget(null);
|
||||
goToList();
|
||||
} catch {
|
||||
toast('Error al eliminar la plantilla', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (id: number) => {
|
||||
try {
|
||||
await expenseTemplateApi.toggleActive(id);
|
||||
toast('Estado actualizado', 'success');
|
||||
if (viewMode === 'list') {
|
||||
loadList();
|
||||
} else if (template?.id === id) {
|
||||
loadForView(id);
|
||||
}
|
||||
} catch {
|
||||
toast('Error al cambiar el estado', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const categoryBadge = (name?: string) => (
|
||||
<span className="badge badge-neutral">{name ?? '-'}</span>
|
||||
);
|
||||
|
||||
const periodBadge = (name?: string) => {
|
||||
if (!name) return <span className="text-muted">-</span>;
|
||||
const colors: Record<string, string> = {
|
||||
'Mensual': 'badge-info',
|
||||
'Trimestral': 'badge-primary',
|
||||
'Semestral': 'badge-secondary',
|
||||
'Anual': 'badge-neutral',
|
||||
};
|
||||
return <span className={`badge ${colors[name] ?? 'badge-neutral'}`}>{name}</span>;
|
||||
};
|
||||
|
||||
const filtered = sorted.filter((t) => {
|
||||
if (filters.q) {
|
||||
const q = filters.q.toLowerCase();
|
||||
if (!t.description.toLowerCase().includes(q) &&
|
||||
!(t.supplierName ?? '').toLowerCase().includes(q) &&
|
||||
!(t.notes ?? '').toLowerCase().includes(q)) return false;
|
||||
}
|
||||
if (filters.activeFilter === 'active' && !t.active) return false;
|
||||
if (filters.activeFilter === 'inactive' && t.active) return false;
|
||||
if (filters.categoryId && t.category?.id !== filters.categoryId) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const displayItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
if (viewMode === 'list') {
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Cargando plantillas...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Plantillas de Gastos</h1>
|
||||
<button className="btn btn-primary" onClick={goToCreate}>+ Nueva Plantilla</button>
|
||||
</div>
|
||||
|
||||
<div className="search-panel">
|
||||
<div className="search-panel-header">
|
||||
<h3>Filtros de búsqueda</h3>
|
||||
<button className="search-toggle" onClick={() => setSearchOpen(!searchOpen)}>
|
||||
{searchOpen ? 'Ocultar' : 'Mostrar'} {searchOpen ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
{searchOpen && (
|
||||
<div className="search-fields">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Buscar</label>
|
||||
<input className="search-field-input" type="text" placeholder="Descripción, proveedor, notas..." value={filters.q} onChange={(e) => setFilters({ ...filters, q: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Estado</label>
|
||||
<select value={filters.activeFilter} onChange={(e) => setFilters({ ...filters, activeFilter: e.target.value })}>
|
||||
<option value="">Todos</option>
|
||||
<option value="active">Activos</option>
|
||||
<option value="inactive">Inactivos</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Categoría</label>
|
||||
<select value={filters.categoryId} onChange={(e) => setFilters({ ...filters, categoryId: Number(e.target.value) })}>
|
||||
<option value={0}>Todas</option>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📋</div>
|
||||
<h3>{filters.q || filters.activeFilter || filters.categoryId ? 'Sin resultados' : 'No hay plantillas'}</h3>
|
||||
<p>{filters.q || filters.activeFilter || filters.categoryId ? 'Prueba con otros filtros de búsqueda.' : 'Crea plantillas para automatizar gastos recurrentes.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader label="ID" sortKey="id" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Descripción" sortKey="description" sort={sort} onToggle={toggleSort} />
|
||||
<th>Propiedad/Conjunto</th>
|
||||
<SortableHeader label="Categoría" sortKey="category.name" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Periodicidad" sortKey="period.name" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Importe" sortKey="amount" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Estado" sortKey="active" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Proveedor" sortKey="supplierName" sort={sort} onToggle={toggleSort} />
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayItems.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td className="font-mono text-muted">{t.id}</td>
|
||||
<td>
|
||||
<span className="font-semibold">{t.description}</span>
|
||||
</td>
|
||||
<td className="text-secondary">
|
||||
{t.property ? (
|
||||
<EntityLink entityType="PROPERTY" entityId={t.property.id}>
|
||||
{fmtProp(t.property)}
|
||||
</EntityLink>
|
||||
) : t.propertyGroup ? (
|
||||
<EntityLink entityType="PROPERTY_GROUP" entityId={t.propertyGroup.id}>
|
||||
{t.propertyGroup.name}
|
||||
</EntityLink>
|
||||
) : <span className="text-muted">-</span>}
|
||||
</td>
|
||||
<td>{categoryBadge(t.category?.name)}</td>
|
||||
<td>{periodBadge(t.period?.name)}</td>
|
||||
<td className="amount">
|
||||
{t.isVariable ? <span className="badge badge-warning"><span className="badge-dot badge-dot-warning" /> Variable</span> : t.amount ? `${t.amount} €` : '-'}
|
||||
</td>
|
||||
<td>
|
||||
{t.active ? (
|
||||
<span className="badge badge-success"><span className="badge-dot badge-dot-success" /> Activo</span>
|
||||
) : (
|
||||
<span className="badge badge-danger"><span className="badge-dot badge-dot-danger" /> Inactivo</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-secondary">{t.supplierName ?? '-'}</td>
|
||||
<td>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => goToDetail(t.id)}>Ver</button>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => handleToggleActive(t.id)}>
|
||||
{t.active ? 'Desactivar' : 'Activar'}
|
||||
</button>
|
||||
<button className="btn btn-sm btn-danger" onClick={() => setDeleteTarget(t)}>Eliminar</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} totalElements={filtered.length} size={PAGE_SIZE} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Eliminar Plantilla"
|
||||
message={`¿Estás seguro de eliminar la plantilla "${deleteTarget?.description}"? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'detail') {
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Cargando plantilla...</div>;
|
||||
if (!template) return <div className="loading">Plantilla no encontrada</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div className="page-header-left">
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Volver al listado</button>
|
||||
<h1>Plantilla #{template.id}</h1>
|
||||
</div>
|
||||
<div className="page-header-actions">
|
||||
<button className="btn btn-primary" onClick={goToEdit}>Editar</button>
|
||||
<button className={`btn ${template.active ? 'btn-warning' : 'btn-success'}`} onClick={() => handleToggleActive(template.id)}>
|
||||
{template.active ? 'Desactivar' : 'Activar'}
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={() => setDeleteTarget(template)}>Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card detail-card">
|
||||
<div className="detail-grid">
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">ID</span>
|
||||
<span className="detail-value font-mono">{template.id}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Estado</span>
|
||||
<span className="detail-value">
|
||||
{template.active ? (
|
||||
<span className="badge badge-success"><span className="badge-dot badge-dot-success" /> Activo</span>
|
||||
) : (
|
||||
<span className="badge badge-danger"><span className="badge-dot badge-dot-danger" /> Inactivo</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Descripción</span>
|
||||
<span className="detail-value">{template.description}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Propiedad</span>
|
||||
<span className="detail-value">
|
||||
{template.property ? (
|
||||
<EntityLink entityType="PROPERTY" entityId={template.property.id}>
|
||||
{fmtProp(template.property)}
|
||||
</EntityLink>
|
||||
) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Conjunto</span>
|
||||
<span className="detail-value">
|
||||
{template.propertyGroup ? (
|
||||
<EntityLink entityType="PROPERTY_GROUP" entityId={template.propertyGroup.id}>
|
||||
{template.propertyGroup.name}
|
||||
</EntityLink>
|
||||
) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Categoría</span>
|
||||
<span className="detail-value">{categoryBadge(template.category?.name)}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Periodicidad</span>
|
||||
<span className="detail-value">{periodBadge(template.period?.name)}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Día de pago</span>
|
||||
<span className="detail-value">{template.paymentDay ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Importe</span>
|
||||
<span className="detail-value amount">
|
||||
{template.isVariable ? (
|
||||
<span className="badge badge-warning"><span className="badge-dot badge-dot-warning" /> Importe variable</span>
|
||||
) : template.amount ? `${template.amount} €` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">IVA</span>
|
||||
<span className="detail-value amount">{template.taxAmount ? `${template.taxAmount} €` : '0 €'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Variable</span>
|
||||
<span className="detail-value">{template.isVariable ? 'Sí' : 'No'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Cuenta Bancaria</span>
|
||||
<span className="detail-value">
|
||||
{template.bankAccount ? (
|
||||
<EntityLink entityType="BANK_ACCOUNT" entityId={template.bankAccount.id}>
|
||||
{template.bankAccount.name}
|
||||
</EntityLink>
|
||||
) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Domiciliado</span>
|
||||
<span className="detail-value">{template.isDomiciled ? 'Sí' : 'No'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Proveedor</span>
|
||||
<span className="detail-value">{template.supplierName ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">NIF Proveedor</span>
|
||||
<span className="detail-value font-mono">{template.supplierFiscalId ?? '-'}</span>
|
||||
</div>
|
||||
{template.notes && (
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Notas</span>
|
||||
<span className="detail-value">{template.notes}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Creado</span>
|
||||
<span className="detail-value text-muted">{template.createdAt}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Actualizado</span>
|
||||
<span className="detail-value text-muted">{template.updatedAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Eliminar Plantilla"
|
||||
message={`¿Estás seguro de eliminar la plantilla "${template?.description}"? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isCreate = viewMode === 'create';
|
||||
const formTitle = isCreate ? 'Nueva Plantilla de Gasto' : `Editar Plantilla #${selectedId}`;
|
||||
|
||||
if (formLoading) return <div className="loading"><div className="spinner" /> Cargando...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>{formTitle}</h1>
|
||||
</div>
|
||||
|
||||
<div className="form-sticky-bar">
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Cancelar</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving} form="expense-template-form">
|
||||
{saving ? 'Guardando...' : (isCreate ? 'Crear Plantilla' : 'Guardar Cambios')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card form-card">
|
||||
<form id="expense-template-form" onSubmit={handleSubmit} className="form">
|
||||
<div className="form-section-title">Asignación</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Propiedad</label>
|
||||
<select value={form.propertyId} onChange={(e) => setForm({ ...form, propertyId: Number(e.target.value), propertyGroupId: 0 })}>
|
||||
<option value={0}>Ninguna</option>
|
||||
{properties.map((p) => <option key={p.id} value={p.id}>{fmtProp(p)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Conjunto</label>
|
||||
<select value={form.propertyGroupId} onChange={(e) => setForm({ ...form, propertyGroupId: Number(e.target.value), propertyId: 0 })}>
|
||||
<option value={0}>Ninguno</option>
|
||||
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
<span className="field-hint">Selecciona propiedad o conjunto</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section-title">Pago</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Cuenta Bancaria</label>
|
||||
<select value={form.bankAccountId} onChange={(e) => setForm({ ...form, bankAccountId: Number(e.target.value) })}>
|
||||
<option value={0}>Seleccionar cuenta...</option>
|
||||
{accounts.filter((a) => a.active).map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.name} {a.isDefault ? '(Ppal)' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label"> </label>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={form.isDomiciled} onChange={(e) => setForm({ ...form, isDomiciled: e.target.checked })} />
|
||||
Domiciliado
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section-title">Configuración</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Categoría</label>
|
||||
<select value={form.categoryId} onChange={(e) => setForm({ ...form, categoryId: Number(e.target.value) })}>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Periodicidad</label>
|
||||
<select value={form.periodId} onChange={(e) => setForm({ ...form, periodId: Number(e.target.value) })}>
|
||||
{periods.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Día de pago</label>
|
||||
<input type="number" min={1} max={31} value={form.paymentDay || ''} onChange={(e) => setForm({ ...form, paymentDay: Number(e.target.value) })} placeholder="1-31" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section-title">Proveedor</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nombre</label>
|
||||
<input type="text" value={form.supplierName} onChange={(e) => setForm({ ...form, supplierName: e.target.value })} placeholder="Nombre del proveedor" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">NIF/CIF</label>
|
||||
<input type="text" value={form.supplierFiscalId} onChange={(e) => setForm({ ...form, supplierFiscalId: e.target.value })} placeholder="Ej: B-12345678" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section-title">Importe</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Importe (€)</label>
|
||||
<input type="number" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value })} placeholder="Ej: 500" disabled={form.isVariable} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">IVA (€)</label>
|
||||
<input type="number" value={form.taxAmount} onChange={(e) => setForm({ ...form, taxAmount: e.target.value })} placeholder="Ej: 105" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label"> </label>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={form.isVariable} onChange={(e) => setForm({ ...form, isVariable: e.target.checked })} />
|
||||
Importe variable
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section-title">Descripción</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group full-width">
|
||||
<label className="form-label">Descripción *</label>
|
||||
<textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} required placeholder="Describe el concepto del gasto..." className="form-textarea" />
|
||||
</div>
|
||||
<div className="form-group full-width">
|
||||
<label className="form-label">Notas</label>
|
||||
<textarea value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder="Notas adicionales..." className="form-textarea" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { incidentApi, propertyApi, userApi, groupApi } from '../api/resources';
|
||||
import type { Incident, Property, User, PropertyGroup } from '../types/api';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import SortableHeader from '../components/SortableHeader';
|
||||
import Pagination from '../components/Pagination';
|
||||
import DocumentUploader from '../components/DocumentUploader';
|
||||
import EntityLink from '../components/EntityLink';
|
||||
import { useSort } from '../hooks/useSort';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { fmtProp } from '../utils/propertyFormat';
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tipos de vista
|
||||
// ---------------------------------------------------------------------------
|
||||
type ViewMode = 'list' | 'detail' | 'edit' | 'create';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Datos de formulario
|
||||
// ---------------------------------------------------------------------------
|
||||
interface FormData {
|
||||
propertyId: number;
|
||||
propertyGroupId: number;
|
||||
title: string;
|
||||
description: string;
|
||||
priorityId: number;
|
||||
statusId: number;
|
||||
assignedToId: number | null;
|
||||
scheduledDate: string;
|
||||
}
|
||||
|
||||
const emptyForm = (): FormData => ({
|
||||
propertyId: 0, propertyGroupId: 0, title: '', description: '', priorityId: 2, statusId: 1,
|
||||
assignedToId: null, scheduledDate: '',
|
||||
});
|
||||
|
||||
const priorities = [
|
||||
{ id: 1, name: 'Baja' }, { id: 2, name: 'Media' },
|
||||
{ id: 3, name: 'Alta' }, { id: 4, name: 'Urgente' },
|
||||
];
|
||||
|
||||
const statuses = [
|
||||
{ id: 1, name: 'Sin revisar' }, { id: 2, name: 'Técnico avisado' },
|
||||
{ id: 3, name: 'Reparación prevista' }, { id: 4, name: 'Reparado' },
|
||||
{ id: 5, name: 'Ignorado' }, { id: 6, name: 'Anulado' },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Componente principal
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function Incidents() {
|
||||
const location = useLocation();
|
||||
// -- Estado general --
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
// -- List view --
|
||||
const [items, setItems] = useState<Incident[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Incident | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(true);
|
||||
const [filters, setFilters] = useState({ q: '', priorityId: 0, statusId: 0 });
|
||||
const [page, setPage] = useState(0);
|
||||
const { sorted, sort, toggleSort } = useSort(items, 'reportedAt');
|
||||
|
||||
// -- Detail / Edit / Create view --
|
||||
const [incident, setIncident] = useState<Incident | null>(null);
|
||||
const [properties, setProperties] = useState<Property[]>([]);
|
||||
const [groups, setGroups] = useState<PropertyGroup[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [form, setForm] = useState<FormData>(emptyForm());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formLoading, setFormLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Carga de datos para el listado
|
||||
// ---------------------------------------------------------------------------
|
||||
const loadList = () => {
|
||||
setLoading(true);
|
||||
incidentApi.getAll({ size: 1000 })
|
||||
.then((resp) => setItems(resp.data.content))
|
||||
.catch(() => toast('Error al cargar incidencias', 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { loadList(); }, []);
|
||||
|
||||
useEffect(() => { setPage(0); }, [filters]);
|
||||
|
||||
// Detectar si llegamos con un openDetailId en el state (navegación desde otra página)
|
||||
useEffect(() => {
|
||||
const state = location.state as { openDetailId?: number } | null;
|
||||
if (state?.openDetailId) {
|
||||
goToDetail(state.openDetailId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.state]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Carga de datos para detail/edit/create
|
||||
// ---------------------------------------------------------------------------
|
||||
const loadForView = (id: number): Promise<void> => {
|
||||
return Promise.all([
|
||||
incidentApi.getById(id),
|
||||
propertyApi.getAll({ size: 1000 }),
|
||||
groupApi.getAll({ size: 1000 }),
|
||||
userApi.getAll({ size: 1000 }),
|
||||
])
|
||||
.then(([incResp, propsResp, groupsResp, usersResp]) => {
|
||||
const inc = incResp.data;
|
||||
setIncident(inc);
|
||||
setProperties(propsResp.data.content ?? []);
|
||||
setGroups(groupsResp.data.content ?? []);
|
||||
setUsers(usersResp.data.content ?? []);
|
||||
setForm({
|
||||
propertyId: inc.property?.id ?? 0,
|
||||
propertyGroupId: inc.propertyGroup?.id ?? 0,
|
||||
title: inc.title,
|
||||
description: inc.description,
|
||||
priorityId: inc.priority?.id ?? 2,
|
||||
statusId: inc.status?.id ?? 1,
|
||||
assignedToId: inc.assignedTo?.id ?? null,
|
||||
scheduledDate: inc.scheduledDate ?? '',
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast('Error al cargar la incidencia', 'error');
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
const loadForCreate = (): Promise<void> => {
|
||||
setIncident(null);
|
||||
setForm(emptyForm());
|
||||
return Promise.all([
|
||||
propertyApi.getAll({ size: 1000 }),
|
||||
groupApi.getAll({ size: 1000 }),
|
||||
userApi.getAll({ size: 1000 }),
|
||||
])
|
||||
.then(([propsResp, groupsResp, usersResp]) => {
|
||||
setProperties(propsResp.data.content ?? []);
|
||||
setGroups(groupsResp.data.content ?? []);
|
||||
setUsers(usersResp.data.content ?? []);
|
||||
})
|
||||
.then(() => setFormLoading(false))
|
||||
.catch(() => toast('Error al cargar datos', 'error'));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Navegación entre vistas
|
||||
// ---------------------------------------------------------------------------
|
||||
const goToList = () => {
|
||||
setViewMode('list');
|
||||
setSelectedId(null);
|
||||
setIncident(null);
|
||||
setForm(emptyForm());
|
||||
loadList();
|
||||
};
|
||||
|
||||
const goToDetail = (id: number) => {
|
||||
setSelectedId(id);
|
||||
setViewMode('detail');
|
||||
loadForView(id);
|
||||
};
|
||||
|
||||
const goToEdit = () => {
|
||||
// Si ya tenemos los datos en memoria (desde detail), solo cambiar modo
|
||||
if (incident && properties.length > 0 && users.length > 0) {
|
||||
setViewMode('edit');
|
||||
} else {
|
||||
// Cargar datos primero y esperar
|
||||
setFormLoading(true);
|
||||
loadForView(selectedId!).finally(() => {
|
||||
setFormLoading(false);
|
||||
setViewMode('edit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const goToCreate = (): Promise<void> => {
|
||||
setViewMode('create');
|
||||
return loadForCreate();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Acciones
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.propertyId || !form.title.trim()) {
|
||||
toast('Completa los campos obligatorios', 'warning');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
property: form.propertyId ? properties.find(p => p.id === form.propertyId) : null,
|
||||
propertyGroup: form.propertyGroupId ? groups.find(g => g.id === form.propertyGroupId) : null,
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
priority: { id: form.priorityId },
|
||||
status: { id: form.statusId },
|
||||
assignedTo: form.assignedToId ? users.find(u => u.id === form.assignedToId) : null,
|
||||
scheduledDate: form.scheduledDate || null,
|
||||
};
|
||||
|
||||
if (viewMode === 'edit' && selectedId) {
|
||||
await incidentApi.update(selectedId, payload as Partial<Incident>);
|
||||
toast('Incidencia actualizada', 'success');
|
||||
goToDetail(selectedId);
|
||||
} else {
|
||||
const created = await incidentApi.create(payload as Partial<Incident>);
|
||||
toast('Incidencia creada', 'success');
|
||||
goToDetail(created.data.id);
|
||||
}
|
||||
} catch {
|
||||
toast('Error al guardar la incidencia', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await incidentApi.remove(deleteTarget.id);
|
||||
toast('Incidencia eliminada', 'success');
|
||||
setDeleteTarget(null);
|
||||
goToList();
|
||||
} catch {
|
||||
toast('Error al eliminar la incidencia', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Badges
|
||||
// ---------------------------------------------------------------------------
|
||||
const priorityBadge = (name?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'Baja': 'badge-neutral', 'Media': 'badge-info',
|
||||
'Alta': 'badge-warning', 'Urgente': 'badge-danger',
|
||||
};
|
||||
return <span className={`badge ${map[name ?? ''] ?? 'badge-neutral'}`}>{name ?? '-'}</span>;
|
||||
};
|
||||
|
||||
const statusBadge = (name?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'Sin revisar': 'badge-neutral', 'Técnico avisado': 'badge-info',
|
||||
'Reparación prevista': 'badge-warning', 'Reparado': 'badge-success',
|
||||
'Ignorado': 'badge-neutral', 'Anulado': 'badge-danger',
|
||||
};
|
||||
const cls = map[name ?? ''] ?? 'badge-neutral';
|
||||
return <span className={`badge ${cls}`}><span className={`badge-dot badge-dot-${cls.replace('badge-', '')}`} /> {name ?? '-'}</span>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filtros del listado
|
||||
// ---------------------------------------------------------------------------
|
||||
const filtered = sorted.filter((inc) => {
|
||||
if (filters.q) {
|
||||
const q = filters.q.toLowerCase();
|
||||
if (!inc.title.toLowerCase().includes(q) &&
|
||||
!inc.description.toLowerCase().includes(q) &&
|
||||
!(inc.property?.name ?? '').toLowerCase().includes(q) &&
|
||||
!(inc.property?.addressStreet?.toLowerCase() ?? '').includes(q) &&
|
||||
!(inc.property?.addressCity?.toLowerCase() ?? '').includes(q)) return false;
|
||||
}
|
||||
if (filters.priorityId && inc.priority?.id !== filters.priorityId) return false;
|
||||
if (filters.statusId && inc.status?.id !== filters.statusId) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const displayItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: LIST
|
||||
// ---------------------------------------------------------------------------
|
||||
if (viewMode === 'list') {
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Cargando incidencias...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Incidencias</h1>
|
||||
<button className="btn btn-primary" onClick={goToCreate}>+ Nueva Incidencia</button>
|
||||
</div>
|
||||
|
||||
<div className="search-panel">
|
||||
<div className="search-panel-header">
|
||||
<h3>Filtros de búsqueda</h3>
|
||||
<button className="search-toggle" onClick={() => setSearchOpen(!searchOpen)}>
|
||||
{searchOpen ? 'Ocultar' : 'Mostrar'} {searchOpen ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
{searchOpen && (
|
||||
<div className="search-fields">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Buscar</label>
|
||||
<input className="search-field-input" type="text" placeholder="Título, descripción, propiedad..." value={filters.q} onChange={(e) => setFilters({ ...filters, q: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Prioridad</label>
|
||||
<select value={filters.priorityId} onChange={(e) => setFilters({ ...filters, priorityId: Number(e.target.value) })}>
|
||||
<option value={0}>Todas</option>
|
||||
{priorities.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Estado</label>
|
||||
<select value={filters.statusId} onChange={(e) => setFilters({ ...filters, statusId: Number(e.target.value) })}>
|
||||
<option value={0}>Todos</option>
|
||||
{statuses.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">⚠️</div>
|
||||
<h3>{filters.q || filters.priorityId || filters.statusId ? 'Sin resultados' : 'No hay incidencias'}</h3>
|
||||
<p>{filters.q || filters.priorityId || filters.statusId ? 'Prueba con otros filtros.' : 'Registra las incidencias que ocurran en tus propiedades.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader label="ID" sortKey="id" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Título" sortKey="title" sort={sort} onToggle={toggleSort} />
|
||||
<th>Prop./Conjunto</th>
|
||||
<SortableHeader label="Prioridad" sortKey="priority.name" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Asignado" sortKey="assignedTo.fullName" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Fecha" sortKey="reportedAt" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Estado" sortKey="status.name" sort={sort} onToggle={toggleSort} />
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayItems.map((inc) => (
|
||||
<tr key={inc.id}>
|
||||
<td className="font-mono text-muted">{inc.id}</td>
|
||||
<td className="font-semibold">{inc.title}</td>
|
||||
<td style={{ fontSize: '0.85em' }}>
|
||||
{inc.property ? (
|
||||
<EntityLink entityType="PROPERTY" entityId={inc.property.id}>
|
||||
{fmtProp(inc.property)}
|
||||
</EntityLink>
|
||||
) : inc.propertyGroup ? (
|
||||
<EntityLink entityType="PROPERTY_GROUP" entityId={inc.propertyGroup.id}>
|
||||
{inc.propertyGroup.name}
|
||||
</EntityLink>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td>{priorityBadge(inc.priority?.name)}</td>
|
||||
<td className="text-secondary">{inc.assignedTo?.fullName ?? '-'}</td>
|
||||
<td className="text-secondary">{inc.reportedAt?.substring(0, 10)}</td>
|
||||
<td>{statusBadge(inc.status?.name)}</td>
|
||||
<td>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => goToDetail(inc.id)}>Ver</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={async () => { setSelectedId(inc.id); setFormLoading(true); setViewMode('detail'); try { await loadForView(inc.id); } finally { setFormLoading(false); } setViewMode('edit'); }}>Editar</button>
|
||||
<button className="btn btn-sm btn-danger" onClick={() => setDeleteTarget(inc)}>Eliminar</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} totalElements={filtered.length} size={PAGE_SIZE} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Eliminar Incidencia"
|
||||
message={`¿Estás seguro de eliminar la incidencia "${deleteTarget?.title}"? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: DETAIL
|
||||
// ---------------------------------------------------------------------------
|
||||
if (viewMode === 'detail') {
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Cargando incidencia...</div>;
|
||||
if (!incident) return <div className="loading">Incidencia no encontrada</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Volver al listado</button>
|
||||
<h1>Incidencia #{incident.id}</h1>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn btn-primary" onClick={goToEdit}>Editar</button>
|
||||
<button className="btn btn-danger" onClick={() => setDeleteTarget(incident)}>Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">ID</span>
|
||||
<span className="detail-value font-mono">{incident.id}</span>
|
||||
</div>
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Título</span>
|
||||
<span className="detail-value font-semibold" style={{ fontSize: '1.1em' }}>{incident.title}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Propiedad</span>
|
||||
<span className="detail-value">
|
||||
{incident.property ? (
|
||||
<EntityLink entityType="PROPERTY" entityId={incident.property.id}>
|
||||
{fmtProp(incident.property)}
|
||||
</EntityLink>
|
||||
) : '-'}
|
||||
</span>
|
||||
{incident.property?.addressStreet && (
|
||||
<span className="text-muted" style={{ display: 'block', fontSize: '0.85em' }}>
|
||||
{incident.property.addressStreet}, {incident.property.addressCity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Conjunto</span>
|
||||
<span className="detail-value">
|
||||
{incident.propertyGroup ? (
|
||||
<EntityLink entityType="PROPERTY_GROUP" entityId={incident.propertyGroup.id}>
|
||||
{incident.propertyGroup.name}
|
||||
</EntityLink>
|
||||
) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Prioridad</span>
|
||||
<span className="detail-value">{priorityBadge(incident.priority?.name)}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Estado</span>
|
||||
<span className="detail-value">{statusBadge(incident.status?.name)}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Reportado por</span>
|
||||
<span className="detail-value">{incident.reportedBy?.fullName ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Asignado a</span>
|
||||
<span className="detail-value">{incident.assignedTo?.fullName ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Fecha Reporte</span>
|
||||
<span className="detail-value">{incident.reportedAt?.substring(0, 10)}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Fecha Programada</span>
|
||||
<span className="detail-value">{incident.scheduledDate ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Fecha Resolución</span>
|
||||
<span className="detail-value">{incident.resolvedAt?.substring(0, 10) ?? '-'}</span>
|
||||
</div>
|
||||
{incident.costEstimate && (
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Coste Estimado</span>
|
||||
<span className="detail-value">€{incident.costEstimate.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Descripción</span>
|
||||
<span className="detail-value" style={{ whiteSpace: 'pre-wrap' }}>{incident.description}</span>
|
||||
</div>
|
||||
{incident.resolutionNotes && (
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Notas de Resolución</span>
|
||||
<span className="detail-value" style={{ whiteSpace: 'pre-wrap' }}>{incident.resolutionNotes}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<DocumentUploader entityType="INCIDENT" entityId={incident.id} />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Eliminar Incidencia"
|
||||
message={`¿Estás seguro de eliminar la incidencia "${incident?.title}"? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: EDIT o CREATE
|
||||
// ---------------------------------------------------------------------------
|
||||
const isCreate = viewMode === 'create';
|
||||
const formTitle = isCreate ? 'Nueva Incidencia' : `Editar Incidencia #${selectedId}`;
|
||||
|
||||
if (formLoading) return <div className="loading"><div className="spinner" /> Cargando...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>{formTitle}</h1>
|
||||
</div>
|
||||
|
||||
<div className="form-sticky-bar">
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Cancelar</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving} form="incidents-form">
|
||||
{saving ? 'Guardando...' : (isCreate ? 'Crear Incidencia' : 'Guardar Cambios')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<form id="incidents-form" onSubmit={handleSubmit} className="form">
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Propiedad</label>
|
||||
<select
|
||||
value={form.propertyId}
|
||||
onChange={(e) => setForm({ ...form, propertyId: Number(e.target.value) })}
|
||||
>
|
||||
<option value={0}>Seleccionar propiedad...</option>
|
||||
{properties.map((p) => (
|
||||
<option key={p.id} value={p.id}>{fmtProp(p)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Conjunto</label>
|
||||
<select
|
||||
value={form.propertyGroupId}
|
||||
onChange={(e) => setForm({ ...form, propertyGroupId: Number(e.target.value) })}
|
||||
>
|
||||
<option value={0}>Seleccionar conjunto...</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-muted" style={{ fontSize: '0.75em' }}>Selecciona propiedad o conjunto</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Título *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
required
|
||||
placeholder="Ej: Filtración en baño"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Prioridad</label>
|
||||
<select
|
||||
value={form.priorityId}
|
||||
onChange={(e) => setForm({ ...form, priorityId: Number(e.target.value) })}
|
||||
>
|
||||
{priorities.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Estado</label>
|
||||
<select
|
||||
value={form.statusId}
|
||||
onChange={(e) => setForm({ ...form, statusId: Number(e.target.value) })}
|
||||
>
|
||||
{statuses.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Asignado a</label>
|
||||
<select
|
||||
value={form.assignedToId ?? ''}
|
||||
onChange={(e) => setForm({ ...form, assignedToId: e.target.value ? Number(e.target.value) : null })}
|
||||
>
|
||||
<option value="">Sin asignar</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.fullName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Fecha Programada</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.scheduledDate}
|
||||
onChange={(e) => setForm({ ...form, scheduledDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group full-width">
|
||||
<label className="form-label">Descripción *</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
required
|
||||
placeholder="Describe la incidencia en detalle..."
|
||||
style={{ minHeight: '120px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isCreate && selectedId && (
|
||||
<div className="form-section-title">Documentos Adjuntos</div>
|
||||
)}
|
||||
{!isCreate && selectedId && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<DocumentUploader entityType="INCIDENT" entityId={selectedId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,802 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { incomeReceiptApi, propertyApi, tenantApi, bankAccountApi, contractApi } from '../api/resources';
|
||||
import client from '../api/client';
|
||||
import type { IncomeReceipt, Property, Tenant, BankAccount, Contract } from '../types/api';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import SortableHeader from '../components/SortableHeader';
|
||||
import Pagination from '../components/Pagination';
|
||||
import DocumentUploader from '../components/DocumentUploader';
|
||||
import EntityLink from '../components/EntityLink';
|
||||
import { useSort } from '../hooks/useSort';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { fmtProp } from '../utils/propertyFormat';
|
||||
|
||||
type ViewMode = 'list' | 'detail' | 'edit' | 'create';
|
||||
|
||||
interface FormData {
|
||||
propertyId: number;
|
||||
tenantId: number;
|
||||
contractId: number;
|
||||
bankAccountId: number;
|
||||
isDomiciled: boolean;
|
||||
categoryId: number;
|
||||
amount: string;
|
||||
taxWithheld: string;
|
||||
issueDate: string;
|
||||
dueDate: string;
|
||||
description: string;
|
||||
periodLabel: string;
|
||||
}
|
||||
|
||||
const emptyForm = (): FormData => ({
|
||||
propertyId: 0, tenantId: 0, contractId: 0,
|
||||
bankAccountId: 0, isDomiciled: false,
|
||||
categoryId: 1, amount: '', taxWithheld: '0',
|
||||
issueDate: '', dueDate: '', description: '', periodLabel: '',
|
||||
});
|
||||
|
||||
const categories = [
|
||||
{ id: 1, name: 'Alquiler' }, { id: 2, name: 'Fianza' },
|
||||
{ id: 3, name: 'Gastos Comunidad' }, { id: 4, name: 'Intereses Demora' },
|
||||
{ id: 5, name: 'Indemnización' }, { id: 6, name: 'Otros Ingresos' },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function IncomeReceipts() {
|
||||
const location = useLocation();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
const [items, setItems] = useState<IncomeReceipt[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteTarget, setDeleteTarget] = useState<IncomeReceipt | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(true);
|
||||
const [filters, setFilters] = useState({ q: '', statusId: 0, periodLabel: '' });
|
||||
const [page, setPage] = useState(0);
|
||||
const { sorted, sort, toggleSort } = useSort(items, 'issueDate');
|
||||
|
||||
const [receipt, setReceipt] = useState<IncomeReceipt | null>(null);
|
||||
const [properties, setProperties] = useState<Property[]>([]);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [accounts, setAccounts] = useState<BankAccount[]>([]);
|
||||
const [contracts, setContracts] = useState<Contract[]>([]);
|
||||
const [form, setForm] = useState<FormData>(emptyForm());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formLoading, setFormLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [showGenerate, setShowGenerate] = useState(false);
|
||||
const [genContractId, setGenContractId] = useState(0);
|
||||
const [genIssueDate, setGenIssueDate] = useState('');
|
||||
const [genDueDate, setGenDueDate] = useState('');
|
||||
const [genDescription, setGenDescription] = useState('');
|
||||
|
||||
const [showPayForm, setShowPayForm] = useState(false);
|
||||
const [payDate, setPayDate] = useState('');
|
||||
const [payMethod, setPayMethod] = useState('TRANSFERENCIA');
|
||||
|
||||
const loadList = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
incomeReceiptApi.getAll({ size: 1000 }),
|
||||
propertyApi.getAll({ size: 1000 }),
|
||||
tenantApi.getAll({ size: 1000 }),
|
||||
bankAccountApi.getAll(),
|
||||
contractApi.getAll({ size: 1000 }),
|
||||
])
|
||||
.then(([r, p, t, ba, c]) => {
|
||||
setItems(r.data.content);
|
||||
setProperties(p.data.content);
|
||||
setTenants(t.data.content);
|
||||
setAccounts(ba.data);
|
||||
setContracts(c.data.content);
|
||||
})
|
||||
.catch(() => toast('Error al cargar recibos', 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => { loadList(); }, [loadList]);
|
||||
|
||||
useEffect(() => { setPage(0); }, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
const state = location.state as { openDetailId?: number } | null;
|
||||
if (state?.openDetailId) {
|
||||
goToDetail(state.openDetailId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (form.issueDate && form.periodLabel === '') {
|
||||
setForm(f => ({ ...f, periodLabel: form.issueDate.substring(0, 7) }));
|
||||
}
|
||||
}, [form.issueDate]);
|
||||
|
||||
const loadForView = (id: number): Promise<void> => {
|
||||
return Promise.all([
|
||||
incomeReceiptApi.getById(id),
|
||||
bankAccountApi.getAll(),
|
||||
])
|
||||
.then(([rResp, baResp]) => {
|
||||
const r = rResp.data;
|
||||
setReceipt(r);
|
||||
setAccounts(baResp.data);
|
||||
setForm({
|
||||
propertyId: r.property?.id ?? 0,
|
||||
tenantId: r.tenant?.id ?? 0,
|
||||
contractId: r.contract?.id ?? 0,
|
||||
bankAccountId: r.bankAccount?.id ?? 0,
|
||||
isDomiciled: r.isDomiciled,
|
||||
categoryId: r.category?.id ?? 1,
|
||||
amount: String(r.amount),
|
||||
taxWithheld: String(r.taxWithheld ?? 0),
|
||||
issueDate: r.issueDate,
|
||||
dueDate: r.dueDate ?? '',
|
||||
description: r.description ?? '',
|
||||
periodLabel: r.periodLabel ?? '',
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast('Error al cargar el recibo', 'error');
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
const loadForCreate = (): Promise<void> => {
|
||||
setReceipt(null);
|
||||
setForm(emptyForm());
|
||||
return Promise.all([
|
||||
propertyApi.getAll({ size: 1000 }),
|
||||
tenantApi.getAll({ size: 1000 }),
|
||||
bankAccountApi.getAll(),
|
||||
])
|
||||
.then(([propsResp, tenantsResp, baResp]) => {
|
||||
setProperties(propsResp.data.content ?? []);
|
||||
setTenants(tenantsResp.data.content ?? []);
|
||||
setAccounts(baResp.data);
|
||||
})
|
||||
.catch(() => toast('Error al cargar datos', 'error'))
|
||||
.finally(() => setFormLoading(false));
|
||||
};
|
||||
|
||||
const goToList = () => {
|
||||
setViewMode('list');
|
||||
setSelectedId(null);
|
||||
setReceipt(null);
|
||||
setForm(emptyForm());
|
||||
loadList();
|
||||
};
|
||||
|
||||
const goToDetail = (id: number) => {
|
||||
setSelectedId(id);
|
||||
setViewMode('detail');
|
||||
loadForView(id);
|
||||
};
|
||||
|
||||
const goToEdit = () => {
|
||||
if (receipt) {
|
||||
setViewMode('edit');
|
||||
} else {
|
||||
setFormLoading(true);
|
||||
loadForView(selectedId!).finally(() => {
|
||||
setFormLoading(false);
|
||||
setViewMode('edit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const goToCreate = (): Promise<void> => {
|
||||
setViewMode('create');
|
||||
return loadForCreate();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.propertyId || !form.amount || !form.issueDate) {
|
||||
toast('Completa los campos obligatorios', 'warning');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
property: properties.find(p => p.id === form.propertyId),
|
||||
tenant: form.tenantId ? tenants.find(t => t.id === form.tenantId) : undefined,
|
||||
bankAccount: form.bankAccountId ? accounts.find(a => a.id === form.bankAccountId) : null,
|
||||
isDomiciled: form.isDomiciled,
|
||||
category: { id: form.categoryId },
|
||||
amount: Number(form.amount),
|
||||
taxWithheld: Number(form.taxWithheld) || 0,
|
||||
issueDate: form.issueDate,
|
||||
dueDate: form.dueDate || undefined,
|
||||
description: form.description || undefined,
|
||||
periodLabel: form.periodLabel || undefined,
|
||||
status: { id: 1 },
|
||||
};
|
||||
if (viewMode === 'edit' && selectedId) {
|
||||
await incomeReceiptApi.update(selectedId, payload as Partial<IncomeReceipt>);
|
||||
toast('Recibo actualizado correctamente', 'success');
|
||||
goToDetail(selectedId);
|
||||
} else {
|
||||
const created = await incomeReceiptApi.create(payload as Partial<IncomeReceipt>);
|
||||
toast('Recibo creado correctamente', 'success');
|
||||
goToDetail(created.data.id);
|
||||
}
|
||||
} catch {
|
||||
toast('Error al guardar el recibo', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await incomeReceiptApi.remove(deleteTarget.id);
|
||||
toast('Recibo eliminado', 'success');
|
||||
setDeleteTarget(null);
|
||||
goToList();
|
||||
} catch {
|
||||
toast('Error al eliminar el recibo', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!receipt || !payDate || !payMethod) return;
|
||||
try {
|
||||
await incomeReceiptApi.registerPayment(receipt.id, { paymentDate: payDate, paymentMethod: payMethod });
|
||||
toast('Pago registrado', 'success');
|
||||
setShowPayForm(false);
|
||||
loadForView(receipt.id);
|
||||
} catch {
|
||||
toast('Error al registrar pago', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!genContractId || !genIssueDate) {
|
||||
toast('Completa los campos obligatorios', 'warning');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.post('/receipts/generate', {
|
||||
contractId: genContractId,
|
||||
issueDate: genIssueDate,
|
||||
dueDate: genDueDate || undefined,
|
||||
description: genDescription || undefined,
|
||||
});
|
||||
toast('Recibo generado correctamente', 'success');
|
||||
setShowGenerate(false);
|
||||
setGenContractId(0);
|
||||
setGenIssueDate('');
|
||||
setGenDueDate('');
|
||||
setGenDescription('');
|
||||
loadList();
|
||||
} catch {
|
||||
toast('Error al generar recibo', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadPdf = async (id: number) => {
|
||||
try {
|
||||
const resp = await client.get(`/receipts/${id}/pdf`, { responseType: 'blob' });
|
||||
const url = window.URL.createObjectURL(resp.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `recibo-${id}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast('Error al descargar PDF', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendEmail = async (id: number) => {
|
||||
try {
|
||||
await client.post(`/receipts/${id}/email`);
|
||||
toast('Email enviado correctamente', 'success');
|
||||
} catch {
|
||||
toast('Error al enviar email', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadge = (name?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'PENDIENTE': 'badge-warning',
|
||||
'COBRADO': 'badge-success',
|
||||
'VENCIDO': 'badge-danger',
|
||||
};
|
||||
const cls = map[name ?? ''] ?? 'badge-neutral';
|
||||
return <span className={`badge ${cls}`}><span className={`badge-dot badge-dot-${cls.replace('badge-', '')}`} /> {name ?? '-'}</span>;
|
||||
};
|
||||
|
||||
const filtered = sorted.filter((r) => {
|
||||
if (filters.q) {
|
||||
const q = filters.q.toLowerCase();
|
||||
if (!(r.receiptNumber ?? '').toLowerCase().includes(q) &&
|
||||
!(r.description ?? '').toLowerCase().includes(q) &&
|
||||
!r.property?.name.toLowerCase().includes(q) &&
|
||||
!(r.tenant?.fullName ?? '').toLowerCase().includes(q)) return false;
|
||||
}
|
||||
if (filters.statusId && r.status?.id !== filters.statusId) return false;
|
||||
if (filters.periodLabel && !(r.periodLabel ?? '').toLowerCase().includes(filters.periodLabel.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const displayItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
if (viewMode === 'list') {
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Cargando recibos...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Recibos de Ingreso</h1>
|
||||
<div className="actions">
|
||||
<button className="btn btn-secondary" onClick={() => setShowGenerate(true)}>Generar Recibo</button>
|
||||
<button className="btn btn-primary" onClick={goToCreate}>+ Nuevo Recibo</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-panel">
|
||||
<div className="search-panel-header">
|
||||
<h3>Filtros de búsqueda</h3>
|
||||
<button className="search-toggle" onClick={() => setSearchOpen(!searchOpen)}>
|
||||
{searchOpen ? 'Ocultar' : 'Mostrar'} {searchOpen ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
{searchOpen && (
|
||||
<div className="search-fields">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Buscar</label>
|
||||
<input className="search-field-input" type="text" placeholder="Nº recibo, descripción, propiedad, inquilino..." value={filters.q} onChange={(e) => setFilters({ ...filters, q: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Estado</label>
|
||||
<select value={filters.statusId} onChange={(e) => setFilters({ ...filters, statusId: Number(e.target.value) })}>
|
||||
<option value={0}>Todos</option>
|
||||
<option value={1}>PENDIENTE</option>
|
||||
<option value={2}>COBRADO</option>
|
||||
<option value={3}>VENCIDO</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Periodo</label>
|
||||
<input className="search-field-input" type="text" placeholder="Ej: 2026-06" value={filters.periodLabel} onChange={(e) => setFilters({ ...filters, periodLabel: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showGenerate && (
|
||||
<div className="card" style={{ marginBottom: '16px' }}>
|
||||
<div className="card-header">
|
||||
<h3>Generar Recibo desde Contrato</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Contrato *</label>
|
||||
<select value={genContractId} onChange={(e) => setGenContractId(Number(e.target.value))}>
|
||||
<option value={0}>Seleccionar contrato...</option>
|
||||
{contracts.filter(c => c.status?.name === 'ACTIVO').map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.contractNumber} - {fmtProp(c.property)} ({c.rentalAmount} €)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Fecha Emisión *</label>
|
||||
<input type="date" value={genIssueDate} onChange={(e) => setGenIssueDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Fecha Vencimiento</label>
|
||||
<input type="date" value={genDueDate} onChange={(e) => setGenDueDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group full-width">
|
||||
<label className="form-label">Descripción</label>
|
||||
<input type="text" value={genDescription} onChange={(e) => setGenDescription(e.target.value)} placeholder="Concepto del recibo..." />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-buttons">
|
||||
<button className="btn btn-secondary" onClick={() => setShowGenerate(false)}>Cancelar</button>
|
||||
<button className="btn btn-primary" onClick={handleGenerate}>Generar Recibo</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📄</div>
|
||||
<h3>{filters.q || filters.statusId || filters.periodLabel ? 'Sin resultados' : 'No hay recibos de ingreso'}</h3>
|
||||
<p>{filters.q || filters.statusId || filters.periodLabel ? 'Prueba con otros filtros de búsqueda.' : 'Genera recibos desde los contratos o créalos manualmente.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader label="ID" sortKey="id" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Recibo" sortKey="receiptNumber" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Propiedad" sortKey="property.name" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Inquilino" sortKey="tenant.fullName" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Periodo" sortKey="periodLabel" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Importe" sortKey="amount" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Estado" sortKey="status.name" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Emisión" sortKey="issueDate" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Vencimiento" sortKey="dueDate" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Pago" sortKey="paymentDate" sort={sort} onToggle={toggleSort} />
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayItems.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="font-mono text-muted">{r.id}</td>
|
||||
<td className="font-mono font-semibold">{r.receiptNumber ?? '-'}</td>
|
||||
<td>
|
||||
{r.property ? (
|
||||
<EntityLink entityType="PROPERTY" entityId={r.property.id}>
|
||||
{fmtProp(r.property)}
|
||||
</EntityLink>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td>
|
||||
{r.tenant ? (
|
||||
<EntityLink entityType="TENANT" entityId={r.tenant.id}>
|
||||
{r.tenant.fullName}
|
||||
</EntityLink>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="text-secondary">{r.periodLabel ?? '-'}</td>
|
||||
<td className="amount">{r.netAmount ?? r.amount} €</td>
|
||||
<td>{statusBadge(r.status?.name)}</td>
|
||||
<td className="text-secondary">{r.issueDate}</td>
|
||||
<td className="text-secondary">{r.dueDate ?? '-'}</td>
|
||||
<td className="text-secondary">{r.paymentDate ?? '-'}</td>
|
||||
<td>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => goToDetail(r.id)}>Ver</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => { setSelectedId(r.id); setFormLoading(true); setViewMode('detail'); loadForView(r.id).finally(() => { setFormLoading(false); setViewMode('edit'); }); }}>Editar</button>
|
||||
{r.status?.name === 'PENDIENTE' && (
|
||||
<button className="btn btn-sm btn-success" onClick={() => { setShowPayForm(true); setPayDate(''); setPayMethod('TRANSFERENCIA'); }}>Cobrar</button>
|
||||
)}
|
||||
<button className="btn btn-sm btn-danger" onClick={() => setDeleteTarget(r)}>Eliminar</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} totalElements={filtered.length} size={PAGE_SIZE} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Eliminar Recibo"
|
||||
message={`¿Estás seguro de eliminar el recibo "${deleteTarget?.receiptNumber ?? deleteTarget?.id}"? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'detail') {
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Cargando recibo...</div>;
|
||||
if (!receipt) return <div className="loading">Recibo no encontrado</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div className="page-header-row">
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Volver al listado</button>
|
||||
<h1>Recibo #{receipt.id}</h1>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{receipt.status?.name === 'PENDIENTE' && (
|
||||
<button className="btn btn-success" onClick={() => { setShowPayForm(true); setPayDate(''); setPayMethod('TRANSFERENCIA'); }}>Registrar Pago</button>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={goToEdit}>Editar</button>
|
||||
<button className="btn btn-secondary" onClick={() => handleDownloadPdf(receipt.id)}>PDF</button>
|
||||
<button className="btn btn-secondary" onClick={() => handleSendEmail(receipt.id)}>Enviar Email</button>
|
||||
<button className="btn btn-danger" onClick={() => setDeleteTarget(receipt)}>Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPayForm && (
|
||||
<div className="card">
|
||||
<div className="card-header"><h3>Registrar Pago</h3></div>
|
||||
<div className="card-body">
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Fecha de Pago *</label>
|
||||
<input type="date" value={payDate} onChange={(e) => setPayDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Método de Pago *</label>
|
||||
<select value={payMethod} onChange={(e) => setPayMethod(e.target.value)}>
|
||||
<option value="TRANSFERENCIA">TRANSFERENCIA</option>
|
||||
<option value="EFECTIVO">EFECTIVO</option>
|
||||
<option value="BIZUM">BIZUM</option>
|
||||
<option value="RECIBO">RECIBO</option>
|
||||
<option value="TARJETA">TARJETA</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-buttons">
|
||||
<button className="btn btn-secondary" onClick={() => setShowPayForm(false)}>Cancelar</button>
|
||||
<button className="btn btn-success" onClick={handlePay} disabled={!payDate || !payMethod}>Confirmar Pago</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">ID</span>
|
||||
<span className="detail-value font-mono">{receipt.id}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Nº Recibo</span>
|
||||
<span className="detail-value font-mono">{receipt.receiptNumber ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Estado</span>
|
||||
<span className="detail-value">{statusBadge(receipt.status?.name)}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Propiedad</span>
|
||||
<span className="detail-value">
|
||||
{receipt.property ? (
|
||||
<EntityLink entityType="PROPERTY" entityId={receipt.property.id}>
|
||||
{fmtProp(receipt.property)}
|
||||
</EntityLink>
|
||||
) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Inquilino</span>
|
||||
<span className="detail-value">
|
||||
{receipt.tenant ? (
|
||||
<EntityLink entityType="TENANT" entityId={receipt.tenant.id}>
|
||||
{receipt.tenant.fullName}
|
||||
</EntityLink>
|
||||
) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Cuenta Bancaria</span>
|
||||
<span className="detail-value">
|
||||
{receipt.bankAccount ? (
|
||||
<EntityLink entityType="BANK_ACCOUNT" entityId={receipt.bankAccount.id}>
|
||||
{receipt.bankAccount.name}
|
||||
</EntityLink>
|
||||
) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Domiciliado</span>
|
||||
<span className="detail-value">{receipt.isDomiciled ? 'Sí' : 'No'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Categoría</span>
|
||||
<span className="detail-value">{receipt.category?.name ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Importe</span>
|
||||
<span className="detail-value amount">{receipt.amount} €</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Retención IRPF</span>
|
||||
<span className="detail-value">{receipt.taxWithheld ? `${receipt.taxWithheld} €` : '0 €'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Importe Neto</span>
|
||||
<span className="detail-value amount">{receipt.netAmount ?? receipt.amount} €</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Período</span>
|
||||
<span className="detail-value">{receipt.periodLabel ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Fecha Emisión</span>
|
||||
<span className="detail-value">{receipt.issueDate}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Fecha Vencimiento</span>
|
||||
<span className="detail-value">{receipt.dueDate ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Fecha Pago</span>
|
||||
<span className="detail-value">{receipt.paymentDate ?? 'Pendiente'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Método Pago</span>
|
||||
<span className="detail-value">{receipt.paymentMethod ?? '-'}</span>
|
||||
</div>
|
||||
{receipt.description && (
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Descripción</span>
|
||||
<span className="detail-value" style={{ whiteSpace: 'pre-wrap' }}>{receipt.description}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<DocumentUploader entityType="INCOME" entityId={receipt.id} />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Eliminar Recibo"
|
||||
message={`¿Estás seguro de eliminar el recibo "${receipt?.receiptNumber ?? receipt?.id}"? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isCreate = viewMode === 'create';
|
||||
const formTitle = isCreate ? 'Nuevo Recibo de Ingreso' : `Editar Recibo #${selectedId}`;
|
||||
|
||||
if (formLoading) return <div className="loading"><div className="spinner" /> Cargando...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>{formTitle}</h1>
|
||||
</div>
|
||||
|
||||
<div className="form-sticky-bar">
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Cancelar</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving} form="income-form">
|
||||
{saving ? 'Guardando...' : (isCreate ? 'Crear Recibo' : 'Guardar Cambios')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<form id="income-form" onSubmit={handleSubmit} className="form">
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Propiedad *</label>
|
||||
<select
|
||||
value={form.propertyId}
|
||||
onChange={(e) => setForm({ ...form, propertyId: Number(e.target.value) })}
|
||||
required
|
||||
>
|
||||
<option value={0}>Seleccionar propiedad...</option>
|
||||
{properties.map((p) => (
|
||||
<option key={p.id} value={p.id}>{fmtProp(p)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Inquilino</label>
|
||||
<select
|
||||
value={form.tenantId}
|
||||
onChange={(e) => setForm({ ...form, tenantId: Number(e.target.value) })}
|
||||
>
|
||||
<option value={0}>Seleccionar inquilino...</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.fullName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Cuenta Bancaria</label>
|
||||
<select
|
||||
value={form.bankAccountId}
|
||||
onChange={(e) => setForm({ ...form, bankAccountId: Number(e.target.value) })}
|
||||
>
|
||||
<option value={0}>Seleccionar cuenta...</option>
|
||||
{accounts.filter(a => a.active).map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.name} {a.isDefault ? '(Ppal)' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label"> </label>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={form.isDomiciled} onChange={(e) => setForm({ ...form, isDomiciled: e.target.checked })} />
|
||||
Domiciliado
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Categoría</label>
|
||||
<select
|
||||
value={form.categoryId}
|
||||
onChange={(e) => setForm({ ...form, categoryId: Number(e.target.value) })}
|
||||
>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Importe (€) *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.amount}
|
||||
onChange={(e) => setForm({ ...form, amount: e.target.value })}
|
||||
required
|
||||
placeholder="Ej: 1200"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Retención IRPF (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.taxWithheld}
|
||||
onChange={(e) => setForm({ ...form, taxWithheld: e.target.value })}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Fecha Emisión *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.issueDate}
|
||||
onChange={(e) => setForm({ ...form, issueDate: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Fecha Vencimiento</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.dueDate}
|
||||
onChange={(e) => setForm({ ...form, dueDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Período</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.periodLabel}
|
||||
onChange={(e) => setForm({ ...form, periodLabel: e.target.value })}
|
||||
placeholder="Ej: 2026-06"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group full-width">
|
||||
<label className="form-label">Descripción</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="Concepto del ingreso..."
|
||||
style={{ minHeight: '100px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isCreate && selectedId && (
|
||||
<div className="form-section-title">Documentos Adjuntos</div>
|
||||
)}
|
||||
{!isCreate && selectedId && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<DocumentUploader entityType="INCOME" entityId={selectedId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
export default function Login() {
|
||||
const { isAuthenticated, loading: authLoading, login } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (authLoading) return null;
|
||||
if (isAuthenticated) return <Navigate to="/dashboard" replace />;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate('/dashboard');
|
||||
} catch {
|
||||
setError('Credenciales inválidas');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<div className="login-logo">
|
||||
<h1>Sa Polar</h1>
|
||||
<p>Gestión de Alquileres</p>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="login-error">
|
||||
<span>✕</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Usuario</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ingresa tu usuario"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Ingresa tu contraseña"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? 'Iniciando sesión...' : 'Iniciar sesión'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,876 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { maintenanceApi, propertyApi, groupApi } from '../api/resources';
|
||||
import type { ScheduledMaintenance, Property, PropertyGroup } from '../types/api';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import SortableHeader from '../components/SortableHeader';
|
||||
import Pagination from '../components/Pagination';
|
||||
import DocumentUploader from '../components/DocumentUploader';
|
||||
import EntityLink from '../components/EntityLink';
|
||||
import { useSort } from '../hooks/useSort';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { fmtProp } from '../utils/propertyFormat';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tipos de vista
|
||||
// ---------------------------------------------------------------------------
|
||||
type ViewMode = 'list' | 'detail' | 'edit' | 'create';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Datos de formulario
|
||||
// ---------------------------------------------------------------------------
|
||||
interface FormData {
|
||||
propertyId: number;
|
||||
propertyGroupId: number;
|
||||
title: string;
|
||||
description: string;
|
||||
periodId: number;
|
||||
estimatedCost: string;
|
||||
nextExecution: string;
|
||||
lastExecution: string;
|
||||
reminderDaysBefore: number;
|
||||
responsible: string;
|
||||
notes: string;
|
||||
generateExpense: boolean;
|
||||
expenseCategoryId: number;
|
||||
}
|
||||
|
||||
const emptyForm = (): FormData => ({
|
||||
propertyId: 0, propertyGroupId: 0, title: '', description: '', periodId: 1,
|
||||
estimatedCost: '', nextExecution: '', lastExecution: '',
|
||||
reminderDaysBefore: 30, responsible: '', notes: '',
|
||||
generateExpense: false, expenseCategoryId: 0,
|
||||
});
|
||||
|
||||
const periods = [
|
||||
{ id: 1, name: 'Única vez' },
|
||||
{ id: 2, name: 'Mensual' },
|
||||
{ id: 3, name: 'Trimestral' },
|
||||
{ id: 4, name: 'Semestral' },
|
||||
{ id: 5, name: 'Anual' },
|
||||
{ id: 6, name: 'Bienal' },
|
||||
{ id: 7, name: 'Trienal' },
|
||||
{ id: 8, name: 'Quinquenal' },
|
||||
];
|
||||
|
||||
const expenseCategories = [
|
||||
{ id: 1, name: 'Reparación' },
|
||||
{ id: 2, name: 'Mantenimiento' },
|
||||
{ id: 3, name: 'Comunidad' },
|
||||
{ id: 4, name: 'IBI' },
|
||||
{ id: 5, name: 'Basura' },
|
||||
{ id: 6, name: 'Suministros' },
|
||||
{ id: 7, name: 'Seguro' },
|
||||
{ id: 8, name: 'Reforma' },
|
||||
{ id: 9, name: 'Gestión' },
|
||||
{ id: 10, name: 'Notaría/Registro' },
|
||||
{ id: 11, name: 'Publicidad' },
|
||||
{ id: 12, name: 'Otros' },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Componente principal
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function Maintenance() {
|
||||
const location = useLocation();
|
||||
// -- Estado general --
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
// -- List view --
|
||||
const [items, setItems] = useState<ScheduledMaintenance[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ScheduledMaintenance | null>(null);
|
||||
const [completeTarget, setCompleteTarget] = useState<ScheduledMaintenance | null>(null);
|
||||
const [reopenTarget, setReopenTarget] = useState<ScheduledMaintenance | null>(null);
|
||||
const [showDocPrompt, setShowDocPrompt] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(true);
|
||||
const [filters, setFilters] = useState({ q: '', periodId: 0, completed: '' });
|
||||
const [page, setPage] = useState(0);
|
||||
const { sorted, sort, toggleSort } = useSort(items, 'nextExecution');
|
||||
|
||||
// -- Detail / Edit / Create view --
|
||||
const [maintenance, setMaintenance] = useState<ScheduledMaintenance | null>(null);
|
||||
const [properties, setProperties] = useState<Property[]>([]);
|
||||
const [groups, setGroups] = useState<PropertyGroup[]>([]);
|
||||
const [form, setForm] = useState<FormData>(emptyForm());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formLoading, setFormLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Carga de datos para el listado
|
||||
// ---------------------------------------------------------------------------
|
||||
const loadList = () => {
|
||||
setLoading(true);
|
||||
maintenanceApi.getAll()
|
||||
.then((resp) => setItems(resp.data))
|
||||
.catch(() => toast('Error al cargar mantenimientos', 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { loadList(); }, []);
|
||||
|
||||
useEffect(() => { setPage(0); }, [filters]);
|
||||
|
||||
// Detectar si llegamos con un openDetailId en el state (navegación desde otra página)
|
||||
useEffect(() => {
|
||||
const state = location.state as { openDetailId?: number } | null;
|
||||
if (state?.openDetailId) {
|
||||
goToDetail(state.openDetailId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.state]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Carga de datos para detail/edit/create
|
||||
// ---------------------------------------------------------------------------
|
||||
const loadForView = (id: number): Promise<void> => {
|
||||
setFormLoading(true);
|
||||
return Promise.all([
|
||||
maintenanceApi.getById(id),
|
||||
propertyApi.getAll({ size: 1000 }),
|
||||
groupApi.getAll({ size: 1000 }),
|
||||
])
|
||||
.then(([maintResp, propsResp, groupsResp]) => {
|
||||
const m = maintResp.data;
|
||||
setMaintenance(m);
|
||||
setProperties(propsResp.data.content ?? []);
|
||||
setGroups(groupsResp.data.content ?? []);
|
||||
setForm({
|
||||
propertyId: m.property?.id ?? 0,
|
||||
propertyGroupId: m.propertyGroup?.id ?? 0,
|
||||
title: m.title,
|
||||
description: m.description ?? '',
|
||||
periodId: m.period?.id ?? 1,
|
||||
estimatedCost: m.estimatedCost ? String(m.estimatedCost) : '',
|
||||
nextExecution: m.nextExecution ?? '',
|
||||
lastExecution: m.lastExecution ?? '',
|
||||
reminderDaysBefore: m.reminderDaysBefore ?? 30,
|
||||
responsible: m.responsible ?? '',
|
||||
notes: m.notes ?? '',
|
||||
generateExpense: m.generateExpense ?? false,
|
||||
expenseCategoryId: m.expenseCategory?.id ?? 0,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast('Error al cargar el mantenimiento', 'error');
|
||||
throw err;
|
||||
})
|
||||
.finally(() => setFormLoading(false));
|
||||
};
|
||||
|
||||
const loadForCreate = (): Promise<void> => {
|
||||
setMaintenance(null);
|
||||
setForm(emptyForm());
|
||||
setFormLoading(true);
|
||||
return Promise.all([
|
||||
propertyApi.getAll({ size: 1000 }),
|
||||
groupApi.getAll({ size: 1000 }),
|
||||
])
|
||||
.then(([propsResp, groupsResp]) => {
|
||||
setProperties(propsResp.data.content ?? []);
|
||||
setGroups(groupsResp.data.content ?? []);
|
||||
})
|
||||
.catch(() => toast('Error al cargar datos', 'error'))
|
||||
.finally(() => setFormLoading(false));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Navegación entre vistas
|
||||
// ---------------------------------------------------------------------------
|
||||
const goToList = () => {
|
||||
setViewMode('list');
|
||||
setSelectedId(null);
|
||||
setMaintenance(null);
|
||||
setForm(emptyForm());
|
||||
loadList();
|
||||
};
|
||||
|
||||
const goToDetail = (id: number) => {
|
||||
setSelectedId(id);
|
||||
setViewMode('detail');
|
||||
loadForView(id);
|
||||
};
|
||||
|
||||
const goToEdit = () => {
|
||||
if (maintenance && properties.length > 0) {
|
||||
setViewMode('edit');
|
||||
} else {
|
||||
setFormLoading(true);
|
||||
loadForView(selectedId!).finally(() => {
|
||||
setViewMode('edit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const goToCreate = (): Promise<void> => {
|
||||
setViewMode('create');
|
||||
return loadForCreate();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Acciones
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.propertyId && !form.propertyGroupId) {
|
||||
toast('Selecciona una propiedad o un conjunto', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!form.title.trim() || !form.nextExecution) {
|
||||
toast('Completa los campos obligatorios', 'warning');
|
||||
return;
|
||||
}
|
||||
// Validar categoría de gasto si generateExpense está activo
|
||||
if (form.generateExpense && !form.expenseCategoryId) {
|
||||
toast('Selecciona una categoría de gasto para la generación automática', 'warning');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
property: form.propertyId ? properties.find(p => p.id === form.propertyId) : null,
|
||||
propertyGroup: form.propertyGroupId ? groups.find(g => g.id === form.propertyGroupId) : null,
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
period: { id: form.periodId },
|
||||
estimatedCost: form.estimatedCost ? Number(form.estimatedCost) : null,
|
||||
nextExecution: form.nextExecution,
|
||||
lastExecution: form.lastExecution || null,
|
||||
reminderDaysBefore: form.reminderDaysBefore,
|
||||
responsible: form.responsible || null,
|
||||
notes: form.notes || null,
|
||||
generateExpense: form.generateExpense,
|
||||
expenseCategory: form.generateExpense && form.expenseCategoryId
|
||||
? { id: form.expenseCategoryId }
|
||||
: null,
|
||||
};
|
||||
|
||||
if (viewMode === 'edit' && selectedId) {
|
||||
await maintenanceApi.update(selectedId, payload as Partial<ScheduledMaintenance>);
|
||||
toast('Mantenimiento actualizado', 'success');
|
||||
goToDetail(selectedId);
|
||||
} else {
|
||||
const created = await maintenanceApi.create(payload as Partial<ScheduledMaintenance>);
|
||||
toast('Mantenimiento creado', 'success');
|
||||
goToDetail(created.data.id);
|
||||
}
|
||||
} catch {
|
||||
toast('Error al guardar el mantenimiento', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await maintenanceApi.remove(deleteTarget.id);
|
||||
toast('Mantenimiento eliminado', 'success');
|
||||
setDeleteTarget(null);
|
||||
goToList();
|
||||
} catch {
|
||||
toast('Error al eliminar el mantenimiento', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteClick = (m: ScheduledMaintenance) => {
|
||||
setCompleteTarget(m);
|
||||
setShowDocPrompt(true);
|
||||
};
|
||||
|
||||
const handleCompleteConfirm = async () => {
|
||||
if (!completeTarget) return;
|
||||
try {
|
||||
await maintenanceApi.markCompleted(completeTarget.id);
|
||||
toast('Mantenimiento marcado como completado', 'success');
|
||||
setCompleteTarget(null);
|
||||
setShowDocPrompt(false);
|
||||
if (viewMode === 'detail' && maintenance) {
|
||||
goToDetail(maintenance.id);
|
||||
} else {
|
||||
loadList();
|
||||
}
|
||||
} catch {
|
||||
toast('Error al marcar como completado', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteWithDocs = () => {
|
||||
setShowDocPrompt(false);
|
||||
if (completeTarget) {
|
||||
goToDetail(completeTarget.id);
|
||||
toast('Adjunta los documentos y luego pulsa "Marcar Completado" de nuevo', 'info');
|
||||
}
|
||||
setCompleteTarget(null);
|
||||
};
|
||||
|
||||
const handleReopen = async () => {
|
||||
if (!reopenTarget) return;
|
||||
try {
|
||||
await maintenanceApi.reopen(reopenTarget.id);
|
||||
toast('Mantenimiento reabierto', 'success');
|
||||
setReopenTarget(null);
|
||||
if (viewMode === 'detail' && maintenance) {
|
||||
goToDetail(maintenance.id);
|
||||
} else {
|
||||
loadList();
|
||||
}
|
||||
} catch {
|
||||
toast('Error al reabrir el mantenimiento', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Badges
|
||||
// ---------------------------------------------------------------------------
|
||||
const completedBadge = (completed: boolean) => {
|
||||
return completed
|
||||
? <span className="badge badge-success"><span className="badge-dot badge-dot-success" /> Completado</span>
|
||||
: <span className="badge badge-warning"><span className="badge-dot badge-dot-warning" /> Pendiente</span>;
|
||||
};
|
||||
|
||||
const periodName = (name?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'UNICA_VEZ': 'Única vez', 'MENSUAL': 'Mensual', 'TRIMESTRAL': 'Trimestral',
|
||||
'SEMESTRAL': 'Semestral', 'ANUAL': 'Anual', 'BIENAL': 'Bienal',
|
||||
'TRIENAL': 'Trienal', 'QUINQUENAL': 'Quinquenal',
|
||||
};
|
||||
return map[name ?? ''] ?? name ?? '-';
|
||||
};
|
||||
|
||||
const categoryName = (name?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'REPARACION': 'Reparación', 'MANTENIMIENTO': 'Mantenimiento',
|
||||
'COMUNIDAD': 'Comunidad', 'IBI': 'IBI', 'BASURA': 'Basura',
|
||||
'SUMINISTROS_LUZ': 'Suministros Luz', 'SUMINISTROS_GAS': 'Suministros Gas',
|
||||
'SUMINISTROS_OTROS': 'Suministros Otros', 'SEGURO': 'Seguro', 'REFORMA': 'Reforma',
|
||||
'GESTION': 'Gestión', 'NOTARIA_REGISTRO': 'Notaría/Registro',
|
||||
'PUBLICIDAD': 'Publicidad', 'OTROS_GASTOS': 'Otros',
|
||||
};
|
||||
return map[name ?? ''] ?? name ?? '-';
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filtros del listado
|
||||
// ---------------------------------------------------------------------------
|
||||
const filtered = sorted.filter((m) => {
|
||||
if (filters.q) {
|
||||
const q = filters.q.toLowerCase();
|
||||
if (!m.title.toLowerCase().includes(q) &&
|
||||
!(m.description ?? '').toLowerCase().includes(q) &&
|
||||
!(m.property?.name ?? '').toLowerCase().includes(q) &&
|
||||
!(m.responsible ?? '').toLowerCase().includes(q)) return false;
|
||||
}
|
||||
if (filters.periodId && m.period?.id !== filters.periodId) return false;
|
||||
if (filters.completed !== '') {
|
||||
const showCompleted = filters.completed === 'true';
|
||||
if (m.completed !== showCompleted) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const displayItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: LIST
|
||||
// ---------------------------------------------------------------------------
|
||||
if (viewMode === 'list') {
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Cargando mantenimientos...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Mantenimiento Programado</h1>
|
||||
<button className="btn btn-primary" onClick={goToCreate}>+ Nueva Tarea</button>
|
||||
</div>
|
||||
|
||||
<div className="search-panel">
|
||||
<div className="search-panel-header">
|
||||
<h3>Filtros de búsqueda</h3>
|
||||
<button className="search-toggle" onClick={() => setSearchOpen(!searchOpen)}>
|
||||
{searchOpen ? 'Ocultar' : 'Mostrar'} {searchOpen ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
{searchOpen && (
|
||||
<div className="search-fields">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Buscar</label>
|
||||
<input className="search-field-input" type="text" placeholder="Título, propiedad, responsable..." value={filters.q} onChange={(e) => setFilters({ ...filters, q: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Periodicidad</label>
|
||||
<select value={filters.periodId} onChange={(e) => setFilters({ ...filters, periodId: Number(e.target.value) })}>
|
||||
<option value={0}>Todas</option>
|
||||
{periods.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Estado</label>
|
||||
<select value={filters.completed} onChange={(e) => setFilters({ ...filters, completed: e.target.value })}>
|
||||
<option value="">Todos</option>
|
||||
<option value="false">Pendientes</option>
|
||||
<option value="true">Completados</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">🔧</div>
|
||||
<h3>{filters.q || filters.periodId || filters.completed !== '' ? 'Sin resultados' : 'No hay tareas de mantenimiento'}</h3>
|
||||
<p>{filters.q || filters.periodId || filters.completed !== '' ? 'Prueba con otros filtros.' : 'Programa el mantenimiento de tus propiedades.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader label="ID" sortKey="id" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Título" sortKey="title" sort={sort} onToggle={toggleSort} />
|
||||
<th>Prop./Conjunto</th>
|
||||
<SortableHeader label="Periodicidad" sortKey="period.name" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Próxima Ejec." sortKey="nextExecution" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Responsable" sortKey="responsible" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Estado" sortKey="completed" sort={sort} onToggle={toggleSort} />
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayItems.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td className="font-mono text-muted">{m.id}</td>
|
||||
<td className="font-semibold">{m.title}</td>
|
||||
<td style={{ fontSize: '0.85em' }}>
|
||||
{m.property ? (
|
||||
<EntityLink entityType="PROPERTY" entityId={m.property.id}>
|
||||
{fmtProp(m.property)}
|
||||
</EntityLink>
|
||||
) : m.propertyGroup ? (
|
||||
<EntityLink entityType="PROPERTY_GROUP" entityId={m.propertyGroup.id}>
|
||||
{m.propertyGroup.name}
|
||||
</EntityLink>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="text-secondary">{periodName(m.period?.name)}</td>
|
||||
<td className="text-secondary">{m.nextExecution}</td>
|
||||
<td className="text-secondary">{m.responsible ?? '-'}</td>
|
||||
<td>{completedBadge(m.completed)}</td>
|
||||
<td>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => goToDetail(m.id)}>Ver</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={async () => { setSelectedId(m.id); setFormLoading(true); setViewMode('detail'); try { await loadForView(m.id); } finally { setFormLoading(false); } setViewMode('edit'); }}>Editar</button>
|
||||
{!m.completed && (
|
||||
<button className="btn btn-sm btn-success" onClick={() => handleCompleteClick(m)}>Completar</button>
|
||||
)}
|
||||
{m.completed && (
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setReopenTarget(m)}>Reabrir</button>
|
||||
)}
|
||||
<button className="btn btn-sm btn-danger" onClick={() => setDeleteTarget(m)}>Eliminar</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} totalElements={filtered.length} size={PAGE_SIZE} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Diálogo de completar con opción de adjuntar documentos */}
|
||||
<ConfirmDialog
|
||||
open={showDocPrompt}
|
||||
title="Completar Mantenimiento"
|
||||
message={`¿Quieres marcar "${completeTarget?.title}" como completado? Puedes adjuntar documentos (facturas, informes) antes de finalizar.`}
|
||||
variant="success"
|
||||
confirmLabel="Sí, completar"
|
||||
cancelLabel="Adjuntar documentos primero"
|
||||
onConfirm={handleCompleteConfirm}
|
||||
onCancel={handleCompleteWithDocs}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Eliminar Mantenimiento"
|
||||
message={`¿Estás seguro de eliminar la tarea "${deleteTarget?.title}"? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!reopenTarget}
|
||||
title="Reabrir Mantenimiento"
|
||||
message={`¿Reabrir la tarea "${reopenTarget?.title}"? Volverá a quedar como pendiente.`}
|
||||
variant="warning"
|
||||
confirmLabel="Sí, reabrir"
|
||||
onConfirm={handleReopen}
|
||||
onCancel={() => setReopenTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: DETAIL
|
||||
// ---------------------------------------------------------------------------
|
||||
if (viewMode === 'detail') {
|
||||
if (formLoading) return <div className="loading"><div className="spinner" /> Cargando mantenimiento...</div>;
|
||||
if (!maintenance) return <div className="loading">Mantenimiento no encontrado</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Volver al listado</button>
|
||||
<h1>Mantenimiento #{maintenance.id}</h1>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{!maintenance.completed ? (
|
||||
<button className="btn btn-success" onClick={() => handleCompleteClick(maintenance)}>Marcar Completado</button>
|
||||
) : (
|
||||
<button className="btn btn-secondary" onClick={() => setReopenTarget(maintenance)}>Reabrir</button>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={goToEdit}>Editar</button>
|
||||
<button className="btn btn-danger" onClick={() => setDeleteTarget(maintenance)}>Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">ID</span>
|
||||
<span className="detail-value font-mono">{maintenance.id}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Estado</span>
|
||||
<span className="detail-value">{completedBadge(maintenance.completed)}</span>
|
||||
</div>
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Título</span>
|
||||
<span className="detail-value font-semibold" style={{ fontSize: '1.1em' }}>{maintenance.title}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Propiedad</span>
|
||||
<span className="detail-value">
|
||||
{maintenance.property ? (
|
||||
<EntityLink entityType="PROPERTY" entityId={maintenance.property.id}>
|
||||
{fmtProp(maintenance.property)}
|
||||
</EntityLink>
|
||||
) : '-'}
|
||||
</span>
|
||||
{maintenance.property?.addressStreet && (
|
||||
<span className="text-muted" style={{ display: 'block', fontSize: '0.85em' }}>
|
||||
{maintenance.property.addressStreet}, {maintenance.property.addressCity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Conjunto</span>
|
||||
<span className="detail-value">
|
||||
{maintenance.propertyGroup ? (
|
||||
<EntityLink entityType="PROPERTY_GROUP" entityId={maintenance.propertyGroup.id}>
|
||||
{maintenance.propertyGroup.name}
|
||||
</EntityLink>
|
||||
) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Periodicidad</span>
|
||||
<span className="detail-value">{periodName(maintenance.period?.name)}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Coste Estimado</span>
|
||||
<span className="detail-value">{maintenance.estimatedCost ? `€${maintenance.estimatedCost.toFixed(2)}` : '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Próxima Ejecución</span>
|
||||
<span className="detail-value">{maintenance.nextExecution}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Última Ejecución</span>
|
||||
<span className="detail-value">{maintenance.lastExecution ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Recordatorio (días)</span>
|
||||
<span className="detail-value">{maintenance.reminderDaysBefore} días</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Responsable</span>
|
||||
<span className="detail-value">{maintenance.responsible ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Creado por</span>
|
||||
<span className="detail-value">{maintenance.createdBy?.fullName ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Completado por</span>
|
||||
<span className="detail-value">{maintenance.completedBy?.fullName ?? '-'}</span>
|
||||
</div>
|
||||
{maintenance.completedAt && (
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Completado el</span>
|
||||
<span className="detail-value">{maintenance.completedAt}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Generar Gasto</span>
|
||||
<span className="detail-value">
|
||||
{maintenance.generateExpense
|
||||
? `Sí (${categoryName(maintenance.expenseCategory?.name)})`
|
||||
: 'No'}
|
||||
</span>
|
||||
</div>
|
||||
{maintenance.description && (
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Descripción</span>
|
||||
<span className="detail-value" style={{ whiteSpace: 'pre-wrap' }}>{maintenance.description}</span>
|
||||
</div>
|
||||
)}
|
||||
{maintenance.notes && (
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Notas</span>
|
||||
<span className="detail-value" style={{ whiteSpace: 'pre-wrap' }}>{maintenance.notes}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<DocumentUploader entityType="MAINTENANCE" entityId={maintenance.id} />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDocPrompt}
|
||||
title="Completar Mantenimiento"
|
||||
message={`¿Quieres marcar "${maintenance?.title}" como completado? Puedes adjuntar documentos (facturas, informes) antes de finalizar.`}
|
||||
variant="success"
|
||||
confirmLabel="Sí, completar"
|
||||
cancelLabel="Adjuntar documentos primero"
|
||||
onConfirm={handleCompleteConfirm}
|
||||
onCancel={handleCompleteWithDocs}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Eliminar Mantenimiento"
|
||||
message={`¿Estás seguro de eliminar la tarea "${maintenance?.title}"? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!reopenTarget}
|
||||
title="Reabrir Mantenimiento"
|
||||
message={`¿Reabrir la tarea "${reopenTarget?.title}"? Volverá a quedar como pendiente.`}
|
||||
variant="warning"
|
||||
confirmLabel="Sí, reabrir"
|
||||
onConfirm={handleReopen}
|
||||
onCancel={() => setReopenTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: EDIT o CREATE
|
||||
// ---------------------------------------------------------------------------
|
||||
const isCreate = viewMode === 'create';
|
||||
const formTitle = isCreate ? 'Nueva Tarea de Mantenimiento' : `Editar Mantenimiento #${selectedId}`;
|
||||
|
||||
if (formLoading) return <div className="loading"><div className="spinner" /> Cargando...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>{formTitle}</h1>
|
||||
</div>
|
||||
|
||||
<div className="form-sticky-bar">
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Cancelar</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving} form="maintenance-form">
|
||||
{saving ? 'Guardando...' : (isCreate ? 'Crear Tarea' : 'Guardar Cambios')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<form id="maintenance-form" onSubmit={handleSubmit} className="form">
|
||||
<div className="form-section-title">Información General</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Propiedad</label>
|
||||
<select
|
||||
value={form.propertyId}
|
||||
onChange={(e) => setForm({ ...form, propertyId: Number(e.target.value) })}
|
||||
>
|
||||
<option value={0}>Seleccionar propiedad...</option>
|
||||
{properties.map((p) => (
|
||||
<option key={p.id} value={p.id}>{fmtProp(p)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Conjunto</label>
|
||||
<select
|
||||
value={form.propertyGroupId}
|
||||
onChange={(e) => setForm({ ...form, propertyGroupId: Number(e.target.value) })}
|
||||
>
|
||||
<option value={0}>Seleccionar conjunto...</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-muted" style={{ fontSize: '0.75em' }}>Selecciona propiedad o conjunto</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Título *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
required
|
||||
placeholder="Ej: Revisión caldera"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Periodicidad *</label>
|
||||
<select
|
||||
value={form.periodId}
|
||||
onChange={(e) => setForm({ ...form, periodId: Number(e.target.value) })}
|
||||
required
|
||||
>
|
||||
{periods.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Coste Estimado (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={form.estimatedCost}
|
||||
onChange={(e) => setForm({ ...form, estimatedCost: e.target.value })}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Próxima Ejecución *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.nextExecution}
|
||||
onChange={(e) => setForm({ ...form, nextExecution: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Última Ejecución</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.lastExecution}
|
||||
onChange={(e) => setForm({ ...form, lastExecution: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Recordatorio (días antes)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.reminderDaysBefore}
|
||||
onChange={(e) => setForm({ ...form, reminderDaysBefore: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Responsable</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.responsible}
|
||||
onChange={(e) => setForm({ ...form, responsible: e.target.value })}
|
||||
placeholder="Empresa o técnico asignado"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section-title">Configuración de Gastos</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<div className="checkbox-wrapper" style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '24px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="generateExpense"
|
||||
checked={form.generateExpense}
|
||||
onChange={(e) => setForm({ ...form, generateExpense: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="generateExpense" style={{ margin: 0, cursor: 'pointer' }}>
|
||||
Generar gasto automáticamente
|
||||
</label>
|
||||
</div>
|
||||
<span className="text-muted" style={{ fontSize: '0.8em' }}>
|
||||
{form.periodId === 1
|
||||
? 'Se creará un gasto al crear la tarea.'
|
||||
: 'Se creará un gasto cada vez que se complete esta tarea periódica.'}
|
||||
</span>
|
||||
</div>
|
||||
{form.generateExpense && (
|
||||
<div className="form-group">
|
||||
<label className="form-label">Categoría del Gasto *</label>
|
||||
<select
|
||||
value={form.expenseCategoryId}
|
||||
onChange={(e) => setForm({ ...form, expenseCategoryId: Number(e.target.value) })}
|
||||
required={form.generateExpense}
|
||||
>
|
||||
<option value={0}>Seleccionar categoría...</option>
|
||||
{expenseCategories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-section-title">Descripción</div>
|
||||
<div className="form-group full-width">
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="Describe la tarea de mantenimiento en detalle..."
|
||||
style={{ minHeight: '100px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-section-title">Notas Adicionales</div>
|
||||
<div className="form-group full-width">
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
placeholder="Notas, observaciones o instrucciones adicionales..."
|
||||
style={{ minHeight: '80px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isCreate && selectedId && (
|
||||
<>
|
||||
<div className="form-section-title">Documentos Adjuntos</div>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<DocumentUploader entityType="MAINTENANCE" entityId={selectedId} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { propertyApi, groupApi, contractApi } from '../api/resources';
|
||||
import type { Property, PropertyGroup, Contract } from '../types/api';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import SortableHeader from '../components/SortableHeader';
|
||||
import Pagination from '../components/Pagination';
|
||||
import DocumentUploader from '../components/DocumentUploader';
|
||||
import EntityLink from '../components/EntityLink';
|
||||
import { useSort } from '../hooks/useSort';
|
||||
import { useToast } from '../components/Toast';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tipos de vista
|
||||
// ---------------------------------------------------------------------------
|
||||
type ViewMode = 'list' | 'detail' | 'edit' | 'create';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Datos de formulario
|
||||
// ---------------------------------------------------------------------------
|
||||
interface FormData {
|
||||
reference: string; name: string; description: string;
|
||||
addressStreet: string; addressNumber: string; addressCity: string;
|
||||
addressPostalCode: string; addressProvince: string;
|
||||
cadastralRef: string; surfaceM2: string; floor: string; door: string;
|
||||
rentalAmount: string; typeId: number; statusId: number; groupId: number;
|
||||
}
|
||||
|
||||
const emptyForm = (): FormData => ({
|
||||
reference: '', name: '', description: '', addressStreet: '', addressNumber: '',
|
||||
addressCity: '', addressPostalCode: '', addressProvince: '',
|
||||
cadastralRef: '', surfaceM2: '', floor: '', door: '', rentalAmount: '',
|
||||
typeId: 2, statusId: 3, groupId: 0,
|
||||
});
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper para colorizar badges de estado
|
||||
// ---------------------------------------------------------------------------
|
||||
const getStatusBadgeClass = (statusName?: string): string => {
|
||||
const name = statusName?.toUpperCase() ?? '';
|
||||
if (name === 'DISPONIBLE') return 'badge badge-success';
|
||||
if (name === 'VACIO' || name === 'VACÍO') return 'badge badge-warning';
|
||||
if (name === 'ALQUILADO') return 'badge badge-info';
|
||||
if (name === 'MANTENIMIENTO') return 'badge badge-danger';
|
||||
return 'badge badge-neutral';
|
||||
};
|
||||
|
||||
const getStatusDotClass = (statusName?: string): string => {
|
||||
const name = statusName?.toUpperCase() ?? '';
|
||||
if (name === 'DISPONIBLE') return 'badge-dot badge-dot-success';
|
||||
if (name === 'VACIO' || name === 'VACÍO') return 'badge-dot badge-dot-warning';
|
||||
if (name === 'ALQUILADO') return 'badge-dot badge-dot-info';
|
||||
if (name === 'MANTENIMIENTO') return 'badge-dot badge-dot-danger';
|
||||
return 'badge-dot badge-dot-neutral';
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Componente principal
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function Properties() {
|
||||
const location = useLocation();
|
||||
// -- Estado general --
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
// -- List view --
|
||||
const [items, setItems] = useState<Property[]>([]);
|
||||
const [contracts, setContracts] = useState<Contract[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Property | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(true);
|
||||
const [filters, setFilters] = useState({ q: '', typeId: 0, statusId: 0, groupId: 0 });
|
||||
const [page, setPage] = useState(0);
|
||||
const { sorted, sort, toggleSort } = useSort(items, 'name');
|
||||
|
||||
// -- Detail / Edit / Create view --
|
||||
const [property, setProperty] = useState<Property | null>(null);
|
||||
const [types, setTypes] = useState<{ id: number; name: string }[]>([]);
|
||||
const [statuses, setStatuses] = useState<{ id: number; name: string }[]>([]);
|
||||
const [groups, setGroups] = useState<PropertyGroup[]>([]);
|
||||
const [form, setForm] = useState<FormData>(emptyForm());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// -- Address autocomplete options --
|
||||
const addressOpts = useMemo(() => {
|
||||
const streets = new Set<string>(); const cities = new Set<string>(); const provinces = new Set<string>(); const postalCodes = new Set<string>(); const streetsWithNumber = new Set<string>();
|
||||
[...items, ...groups].forEach((x) => {
|
||||
if (x.addressStreet) streets.add(x.addressStreet);
|
||||
if (x.addressCity) cities.add(x.addressCity);
|
||||
if (x.addressProvince) provinces.add(x.addressProvince);
|
||||
if (x.addressPostalCode) postalCodes.add(x.addressPostalCode);
|
||||
if (x.addressStreet && x.addressNumber) streetsWithNumber.add(`${x.addressStreet} ${x.addressNumber}`);
|
||||
else if (x.addressStreet) streetsWithNumber.add(x.addressStreet);
|
||||
});
|
||||
return { streets: [...streets].sort(), cities: [...cities].sort(), provinces: [...provinces].sort(), postalCodes: [...postalCodes].sort(), streetsWithNumber: [...streetsWithNumber].sort() };
|
||||
}, [items, groups]);
|
||||
|
||||
// -- Parse street+number selection --
|
||||
const parseStreetWithNumber = (value: string) => {
|
||||
const match = value.match(/^(.+?)\s+(\d+[A-Z]?)$/);
|
||||
if (match) {
|
||||
return { street: match[1].trim(), number: match[2].trim() };
|
||||
}
|
||||
return { street: value, number: '' };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Carga de datos para el listado
|
||||
// ---------------------------------------------------------------------------
|
||||
const loadList = () => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
propertyApi.getAll({ size: 1000 }),
|
||||
contractApi.getAll({ size: 1000 }),
|
||||
])
|
||||
.then(([props, c]) => { setItems(props.data.content); setContracts(c.data.content); })
|
||||
.catch(() => toast('Error al cargar propiedades', 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { loadList(); }, []);
|
||||
|
||||
useEffect(() => { setPage(0); }, [filters]);
|
||||
|
||||
// Detectar si llegamos con un openDetailId en el state (navegación desde otra página)
|
||||
useEffect(() => {
|
||||
const state = location.state as { openDetailId?: number } | null;
|
||||
if (state?.openDetailId) {
|
||||
goToDetail(state.openDetailId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.state]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Carga de datos para detail/edit/create
|
||||
// ---------------------------------------------------------------------------
|
||||
const loadForView = (id: number): Promise<void> => {
|
||||
return Promise.all([
|
||||
propertyApi.getById(id),
|
||||
propertyApi.getTypes(),
|
||||
propertyApi.getStatuses(),
|
||||
groupApi.getAll({ size: 1000 }),
|
||||
])
|
||||
.then(([propResp, t, s, g]) => {
|
||||
const p = propResp.data;
|
||||
setProperty(p);
|
||||
setTypes(t.data);
|
||||
setStatuses(s.data);
|
||||
setGroups(g.data.content ?? []);
|
||||
setForm({
|
||||
reference: p.reference, name: p.name, description: p.description ?? '',
|
||||
addressStreet: p.addressStreet, addressNumber: p.addressNumber,
|
||||
addressCity: p.addressCity, addressPostalCode: p.addressPostalCode,
|
||||
addressProvince: p.addressProvince, cadastralRef: p.cadastralRef ?? '',
|
||||
surfaceM2: p.surfaceM2?.toString() ?? '', floor: p.floor ?? '', door: p.door ?? '',
|
||||
rentalAmount: p.rentalAmount?.toString() ?? '', typeId: p.type.id, statusId: p.status.id,
|
||||
groupId: p.group?.id ?? 0,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast('Error al cargar la propiedad', 'error');
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
const loadForCreate = (): Promise<void> => {
|
||||
setProperty(null);
|
||||
setForm(emptyForm());
|
||||
return Promise.all([
|
||||
propertyApi.getTypes(),
|
||||
propertyApi.getStatuses(),
|
||||
groupApi.getAll({ size: 1000 }),
|
||||
])
|
||||
.then(([t, s, g]) => {
|
||||
setTypes(t.data);
|
||||
setStatuses(s.data);
|
||||
setGroups(g.data.content ?? []);
|
||||
})
|
||||
.catch(() => toast('Error al cargar datos', 'error'));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Navegación entre vistas
|
||||
// ---------------------------------------------------------------------------
|
||||
const goToList = () => {
|
||||
setViewMode('list');
|
||||
setSelectedId(null);
|
||||
setProperty(null);
|
||||
setForm(emptyForm());
|
||||
loadList();
|
||||
};
|
||||
|
||||
const goToDetail = (id: number) => {
|
||||
setSelectedId(id);
|
||||
setViewMode('detail');
|
||||
loadForView(id);
|
||||
};
|
||||
|
||||
const goToEdit = () => {
|
||||
if (property && types.length > 0 && statuses.length > 0 && groups.length >= 0) {
|
||||
setViewMode('edit');
|
||||
} else {
|
||||
setFormLoading(true);
|
||||
loadForView(selectedId!).finally(() => {
|
||||
setFormLoading(false);
|
||||
setViewMode('edit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const goToCreate = (): Promise<void> => {
|
||||
setViewMode('create');
|
||||
setFormLoading(true);
|
||||
return loadForCreate()
|
||||
.finally(() => setFormLoading(false));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Acciones
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) {
|
||||
toast('Completa los campos obligatorios', 'warning');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
...form,
|
||||
surfaceM2: form.surfaceM2 ? Number(form.surfaceM2) : undefined,
|
||||
rentalAmount: form.rentalAmount ? Number(form.rentalAmount) : undefined,
|
||||
typeId: undefined, statusId: undefined, groupId: undefined,
|
||||
type: { id: form.typeId },
|
||||
status: { id: form.statusId },
|
||||
group: form.groupId ? { id: form.groupId } : null,
|
||||
};
|
||||
|
||||
if (viewMode === 'edit' && selectedId) {
|
||||
await propertyApi.update(selectedId, payload);
|
||||
toast('Propiedad actualizada', 'success');
|
||||
goToDetail(selectedId);
|
||||
} else {
|
||||
const created = await propertyApi.create(payload);
|
||||
toast('Propiedad creada', 'success');
|
||||
goToDetail(created.data.id);
|
||||
}
|
||||
} catch {
|
||||
toast('Error al guardar la propiedad', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await propertyApi.remove(deleteTarget.id);
|
||||
toast('Propiedad eliminada', 'success');
|
||||
setDeleteTarget(null);
|
||||
goToList();
|
||||
} catch {
|
||||
toast('Error al eliminar la propiedad', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const getActiveContract = (propertyId: number): Contract | undefined => {
|
||||
return contracts.find((c) => c.property.id === propertyId && c.status?.name === 'ACTIVO');
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filtros del listado
|
||||
// ---------------------------------------------------------------------------
|
||||
const filtered = sorted.filter((p) => {
|
||||
if (filters.q) {
|
||||
const q = filters.q.toLowerCase();
|
||||
if (!p.name.toLowerCase().includes(q) && !p.reference.toLowerCase().includes(q) &&
|
||||
!(p.addressCity?.toLowerCase() ?? '').includes(q) &&
|
||||
!(p.addressStreet?.toLowerCase() ?? '').includes(q) &&
|
||||
!(p.addressNumber?.toLowerCase() ?? '').includes(q) &&
|
||||
!(p.addressProvince?.toLowerCase() ?? '').includes(q)) return false;
|
||||
}
|
||||
if (filters.typeId && p.type.id !== filters.typeId) return false;
|
||||
if (filters.statusId && p.status.id !== filters.statusId) return false;
|
||||
if (filters.groupId && p.group?.id !== filters.groupId) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const displayItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: LIST
|
||||
// ---------------------------------------------------------------------------
|
||||
if (viewMode === 'list') {
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Cargando propiedades...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Propiedades</h1>
|
||||
<button className="btn btn-primary" onClick={goToCreate}>+ Nueva Propiedad</button>
|
||||
</div>
|
||||
|
||||
<div className="search-panel">
|
||||
<div className="search-panel-header">
|
||||
<h3>Filtros de búsqueda</h3>
|
||||
<button className="search-toggle" onClick={() => setSearchOpen(!searchOpen)}>
|
||||
{searchOpen ? 'Ocultar' : 'Mostrar'} {searchOpen ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
{searchOpen && (
|
||||
<div className="search-fields">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Buscar</label>
|
||||
<input className="search-field-input" type="text" placeholder="Nombre, referencia, dirección, ciudad..." value={filters.q} onChange={(e) => setFilters({ ...filters, q: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Tipo</label>
|
||||
<select value={filters.typeId} onChange={(e) => setFilters({ ...filters, typeId: Number(e.target.value) })}>
|
||||
<option value={0}>Todos</option>
|
||||
{types.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Estado</label>
|
||||
<select value={filters.statusId} onChange={(e) => setFilters({ ...filters, statusId: Number(e.target.value) })}>
|
||||
<option value={0}>Todos</option>
|
||||
{statuses.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Conjunto</label>
|
||||
<select value={filters.groupId} onChange={(e) => setFilters({ ...filters, groupId: Number(e.target.value) })}>
|
||||
<option value={0}>Todos</option>
|
||||
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">🏠</div>
|
||||
<h3>{filters.q || filters.typeId || filters.statusId || filters.groupId ? 'Sin resultados' : 'No hay propiedades'}</h3>
|
||||
<p>{filters.q || filters.typeId || filters.statusId || filters.groupId ? 'Prueba con otros filtros de búsqueda.' : 'Crea tu primera propiedad para empezar a gestionar tus alquileres.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader label="ID" sortKey="id" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Nombre" sortKey="name" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Conjunto" sortKey="group.name" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Ciudad" sortKey="addressCity" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Tipo" sortKey="type.name" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Estado" sortKey="status.name" sort={sort} onToggle={toggleSort} />
|
||||
<th>Inquilinos</th>
|
||||
<SortableHeader label="Alquiler" sortKey="rentalAmount" sort={sort} onToggle={toggleSort} />
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayItems.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td className="font-mono text-muted">{p.id}</td>
|
||||
<td className="font-semibold">{p.name}</td>
|
||||
<td>{p.group ? (
|
||||
<EntityLink entityType="PROPERTY_GROUP" entityId={p.group.id}>
|
||||
{p.group.name}
|
||||
</EntityLink>
|
||||
) : <span className="text-secondary">-</span>}</td>
|
||||
<td className="text-secondary">{p.addressCity ?? '-'}</td>
|
||||
<td><span className="badge badge-neutral">{p.type?.name ?? '-'}</span></td>
|
||||
<td><span className={getStatusBadgeClass(p.status?.name)}><span className={getStatusDotClass(p.status?.name)} /> {p.status.name}</span></td>
|
||||
<td className="text-secondary">
|
||||
{(() => {
|
||||
const ac = getActiveContract(p.id);
|
||||
if (!ac) return <span className="text-muted">-</span>;
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||
{ac.contractTenants?.map((ct) => (
|
||||
<EntityLink key={ct.tenant.id} entityType="TENANT" entityId={ct.tenant.id}>
|
||||
{ct.tenant.fullName}
|
||||
</EntityLink>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="amount">{p.rentalAmount ? `${p.rentalAmount} €` : '-'}</td>
|
||||
<td>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => goToDetail(p.id)}>Ver</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => { setSelectedId(p.id); setViewMode('detail'); loadForView(p.id).finally(() => setViewMode('edit')); }}>Editar</button>
|
||||
<button className="btn btn-sm btn-danger" onClick={() => setDeleteTarget(p)}>Eliminar</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} totalElements={filtered.length} size={PAGE_SIZE} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Eliminar Propiedad"
|
||||
message={`¿Estás seguro de eliminar "${deleteTarget?.name}"? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: DETAIL
|
||||
// ---------------------------------------------------------------------------
|
||||
if (viewMode === 'detail') {
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Cargando propiedad...</div>;
|
||||
if (!property) return <div className="loading">Propiedad no encontrada</div>;
|
||||
|
||||
const activeContract = getActiveContract(property.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Volver al listado</button>
|
||||
<h1>Propiedad #{property.id}</h1>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn btn-primary" onClick={goToEdit}>Editar</button>
|
||||
<button className="btn btn-danger" onClick={() => setDeleteTarget(property)}>Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">ID</span>
|
||||
<span className="detail-value font-mono">{property.id}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Referencia</span>
|
||||
<span className="detail-value font-mono">{property.reference}</span>
|
||||
</div>
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Nombre</span>
|
||||
<span className="detail-value font-semibold" style={{ fontSize: '1.1em' }}>{property.name}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Conjunto</span>
|
||||
<span className="detail-value">
|
||||
{property.group ? (
|
||||
<EntityLink entityType="PROPERTY_GROUP" entityId={property.group.id}>
|
||||
{property.group.name}
|
||||
</EntityLink>
|
||||
) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Tipo</span>
|
||||
<span className="detail-value"><span className="badge badge-neutral">{property.type?.name ?? '-'}</span></span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Estado</span>
|
||||
<span className="detail-value"><span className={getStatusBadgeClass(property.status?.name)}><span className={getStatusDotClass(property.status?.name)} /> {property.status?.name ?? '-'}</span></span>
|
||||
</div>
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Dirección</span>
|
||||
<span className="detail-value">{property.addressStreet} {property.addressNumber}</span>
|
||||
<span className="text-muted" style={{ display: 'block', fontSize: '0.85em' }}>
|
||||
{property.addressCity}, {property.addressPostalCode} ({property.addressProvince})
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Superficie</span>
|
||||
<span className="detail-value">{property.surfaceM2 ? `${property.surfaceM2} m²` : '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Alquiler</span>
|
||||
<span className="detail-value amount">{property.rentalAmount ? `${property.rentalAmount} €` : '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Planta / Puerta</span>
|
||||
<span className="detail-value">{property.floor ?? '-'}{property.door ? ` / ${property.door}` : ''}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Ref. Catastral</span>
|
||||
<span className="detail-value font-mono text-muted">{property.cadastralRef ?? '-'}</span>
|
||||
</div>
|
||||
{activeContract && (
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Contrato Activo</span>
|
||||
<div className="detail-value" style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
<EntityLink entityType="CONTRACT" entityId={activeContract.id}>
|
||||
{activeContract.contractNumber}
|
||||
</EntityLink>
|
||||
</div>
|
||||
<div className="text-muted" style={{ display: 'block', fontSize: '0.85em', marginTop: '6px' }}>
|
||||
Inquilinos:{' '}
|
||||
{activeContract.contractTenants?.length ? (
|
||||
<span style={{ display: 'inline-flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||
{activeContract.contractTenants.map((ct, i) => (
|
||||
<span key={ct.tenant.id}>
|
||||
<EntityLink entityType="TENANT" entityId={ct.tenant.id}>
|
||||
{ct.tenant.fullName} ({ct.role === 'TITULAR' ? 'Titular' : 'Conviviente'})
|
||||
</EntityLink>
|
||||
{i < (activeContract.contractTenants?.length ?? 0) - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{property.description && (
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Descripción</span>
|
||||
<span className="detail-value" style={{ whiteSpace: 'pre-wrap' }}>{property.description}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<DocumentUploader entityType="PROPERTY" entityId={property.id} />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Eliminar Propiedad"
|
||||
message={`¿Estás seguro de eliminar "${property?.name}"? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: EDIT o CREATE
|
||||
// ---------------------------------------------------------------------------
|
||||
const isCreate = viewMode === 'create';
|
||||
const formTitle = isCreate ? 'Nueva Propiedad' : `Editar Propiedad #${selectedId}`;
|
||||
|
||||
if (formLoading) return <div className="loading"><div className="spinner" /> Cargando...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>{formTitle}</h1>
|
||||
</div>
|
||||
|
||||
<div className="form-sticky-bar">
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Cancelar</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving} form="properties-form">
|
||||
{saving ? 'Guardando...' : (isCreate ? 'Crear Propiedad' : 'Guardar Cambios')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<form id="properties-form" onSubmit={handleSubmit} className="form">
|
||||
<div className="form-section-title">Información General</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Conjunto</label>
|
||||
<select value={form.groupId} onChange={(e) => {
|
||||
const groupId = Number(e.target.value);
|
||||
const selectedGroup = groups.find(g => g.id === groupId);
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
groupId,
|
||||
addressStreet: prev.addressStreet || (selectedGroup?.addressStreet ?? ''),
|
||||
addressNumber: prev.addressNumber || (selectedGroup?.addressNumber ?? ''),
|
||||
addressCity: prev.addressCity || (selectedGroup?.addressCity ?? ''),
|
||||
addressPostalCode: prev.addressPostalCode || (selectedGroup?.addressPostalCode ?? ''),
|
||||
addressProvince: prev.addressProvince || (selectedGroup?.addressProvince ?? ''),
|
||||
}));
|
||||
}}>
|
||||
<option value={0}>Ninguno</option>
|
||||
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nombre *</label>
|
||||
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required placeholder="Ej: Piso Centro" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Calle</label>
|
||||
<input
|
||||
type="text"
|
||||
list="prop-street-list"
|
||||
value={form.addressStreet}
|
||||
onChange={(e) => {
|
||||
const parsed = parseStreetWithNumber(e.target.value);
|
||||
setForm({ ...form, addressStreet: parsed.street, addressNumber: parsed.number || form.addressNumber });
|
||||
}}
|
||||
placeholder="Ej: Calle Mayor"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Número</label>
|
||||
<input type="text" value={form.addressNumber} onChange={(e) => setForm({ ...form, addressNumber: e.target.value })} placeholder="Ej: 15" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Ciudad</label>
|
||||
<input type="text" list="prop-city-list" value={form.addressCity} onChange={(e) => setForm({ ...form, addressCity: e.target.value })} placeholder="Ej: Madrid" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Código Postal</label>
|
||||
<input type="text" list="prop-postalcode-list" value={form.addressPostalCode} onChange={(e) => setForm({ ...form, addressPostalCode: e.target.value })} placeholder="Ej: 28001" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Provincia</label>
|
||||
<input type="text" list="prop-province-list" value={form.addressProvince} onChange={(e) => setForm({ ...form, addressProvince: e.target.value })} placeholder="Ej: Madrid" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Referencia</label>
|
||||
<input type="text" value={form.reference} onChange={(e) => setForm({ ...form, reference: e.target.value })} placeholder="Ej: P-001" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Tipo</label>
|
||||
<select value={form.typeId} onChange={(e) => setForm({ ...form, typeId: Number(e.target.value) })}>
|
||||
{types.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section-title">Detalles</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Superficie (m²)</label>
|
||||
<input type="number" value={form.surfaceM2} onChange={(e) => setForm({ ...form, surfaceM2: e.target.value })} placeholder="Ej: 85" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Importe Alquiler (€)</label>
|
||||
<input type="number" value={form.rentalAmount} onChange={(e) => setForm({ ...form, rentalAmount: e.target.value })} placeholder="Ej: 1200" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Planta</label>
|
||||
<input type="text" value={form.floor} onChange={(e) => setForm({ ...form, floor: e.target.value })} placeholder="Ej: 3" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Puerta</label>
|
||||
<input type="text" value={form.door} onChange={(e) => setForm({ ...form, door: e.target.value })} placeholder="Ej: A" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Ref. Catastral</label>
|
||||
<input type="text" value={form.cadastralRef} onChange={(e) => setForm({ ...form, cadastralRef: e.target.value })} placeholder="Ej: 1234567VK1234S" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Estado</label>
|
||||
<select value={form.statusId} onChange={(e) => setForm({ ...form, statusId: Number(e.target.value) })}>
|
||||
{statuses.filter((s) => s.name !== 'ALQUILADO').map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group full-width">
|
||||
<label className="form-label">Descripción</label>
|
||||
<textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Descripción de la propiedad..." style={{ minHeight: '100px' }} />
|
||||
</div>
|
||||
|
||||
<datalist id="prop-street-list">{addressOpts.streetsWithNumber.map(s => <option key={s} value={s} />)}</datalist>
|
||||
<datalist id="prop-city-list">{addressOpts.cities.map(c => <option key={c} value={c} />)}</datalist>
|
||||
<datalist id="prop-province-list">{addressOpts.provinces.map(p => <option key={p} value={p} />)}</datalist>
|
||||
<datalist id="prop-postalcode-list">{addressOpts.postalCodes.map(p => <option key={p} value={p} />)}</datalist>
|
||||
|
||||
{!isCreate && selectedId && (
|
||||
<div className="form-section-title">Documentos Adjuntos</div>
|
||||
)}
|
||||
{!isCreate && selectedId && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<DocumentUploader entityType="PROPERTY" entityId={selectedId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { groupApi, propertyApi } from '../api/resources';
|
||||
import type { PropertyGroup, Property } from '../types/api';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import SortableHeader from '../components/SortableHeader';
|
||||
import Pagination from '../components/Pagination';
|
||||
import EntityLink from '../components/EntityLink';
|
||||
import { useSort } from '../hooks/useSort';
|
||||
import { useToast } from '../components/Toast';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tipos de vista
|
||||
// ---------------------------------------------------------------------------
|
||||
type ViewMode = 'list' | 'detail' | 'edit' | 'create';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Datos de formulario
|
||||
// ---------------------------------------------------------------------------
|
||||
interface FormData {
|
||||
name: string;
|
||||
description: string;
|
||||
addressStreet: string;
|
||||
addressNumber: string;
|
||||
addressCity: string;
|
||||
addressPostalCode: string;
|
||||
addressProvince: string;
|
||||
}
|
||||
|
||||
const emptyForm = (): FormData => ({
|
||||
name: '', description: '', addressStreet: '', addressNumber: '',
|
||||
addressCity: '', addressPostalCode: '', addressProvince: '',
|
||||
});
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Componente principal
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function PropertyGroups() {
|
||||
const location = useLocation();
|
||||
// -- Estado general --
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
// -- List view --
|
||||
const [items, setItems] = useState<PropertyGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteTarget, setDeleteTarget] = useState<PropertyGroup | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(true);
|
||||
const [filters, setFilters] = useState({ q: '' });
|
||||
const [page, setPage] = useState(0);
|
||||
const { sorted, sort, toggleSort } = useSort(items, 'name');
|
||||
|
||||
// -- Detail / Edit / Create view --
|
||||
const [group, setGroup] = useState<PropertyGroup | null>(null);
|
||||
const [allProperties, setAllProperties] = useState<Property[]>([]);
|
||||
const [groupProps, setGroupProps] = useState<Property[]>([]);
|
||||
const [form, setForm] = useState<FormData>(emptyForm());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formLoading, setFormLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Carga de datos para el listado
|
||||
// ---------------------------------------------------------------------------
|
||||
const loadList = () => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
groupApi.getAll({ size: 1000 }),
|
||||
propertyApi.getAll({ size: 1000 }),
|
||||
])
|
||||
.then(([g, p]) => { setItems(g.data.content); setAllProperties(p.data.content); })
|
||||
.catch(() => toast('Error al cargar conjuntos', 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { loadList(); }, []);
|
||||
|
||||
useEffect(() => { setPage(0); }, [filters]);
|
||||
|
||||
// Detectar si llegamos con un openDetailId en el state (navegación desde otra página)
|
||||
useEffect(() => {
|
||||
const state = location.state as { openDetailId?: number } | null;
|
||||
if (state?.openDetailId) {
|
||||
goToDetail(state.openDetailId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.state]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Carga de datos para detail/edit/create
|
||||
// ---------------------------------------------------------------------------
|
||||
const loadForView = (id: number): Promise<void> => {
|
||||
return Promise.all([
|
||||
groupApi.getById(id),
|
||||
propertyApi.getAll({ size: 1000 }),
|
||||
])
|
||||
.then(([grpResp, propsResp]) => {
|
||||
const g = grpResp.data;
|
||||
setGroup(g);
|
||||
setAllProperties(propsResp.data.content ?? []);
|
||||
setGroupProps(propsResp.data.content.filter((p: Property) => p.group?.id === id));
|
||||
setForm({
|
||||
name: g.name,
|
||||
description: g.description ?? '',
|
||||
addressStreet: g.addressStreet,
|
||||
addressNumber: g.addressNumber,
|
||||
addressCity: g.addressCity,
|
||||
addressPostalCode: g.addressPostalCode,
|
||||
addressProvince: g.addressProvince,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast('Error al cargar el conjunto', 'error');
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
const loadForCreate = (): Promise<void> => {
|
||||
setGroup(null);
|
||||
setForm(emptyForm());
|
||||
return propertyApi.getAll({ size: 1000 })
|
||||
.then((propsResp) => {
|
||||
setAllProperties(propsResp.data.content ?? []);
|
||||
setGroupProps([]);
|
||||
})
|
||||
.then(() => setFormLoading(false))
|
||||
.catch(() => toast('Error al cargar datos', 'error'));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Navegación entre vistas
|
||||
// ---------------------------------------------------------------------------
|
||||
const goToList = () => {
|
||||
setViewMode('list');
|
||||
setSelectedId(null);
|
||||
setGroup(null);
|
||||
setForm(emptyForm());
|
||||
loadList();
|
||||
};
|
||||
|
||||
const goToDetail = (id: number) => {
|
||||
setSelectedId(id);
|
||||
setViewMode('detail');
|
||||
loadForView(id);
|
||||
};
|
||||
|
||||
const goToEdit = () => {
|
||||
if (group && allProperties.length > 0) {
|
||||
setViewMode('edit');
|
||||
} else {
|
||||
setFormLoading(true);
|
||||
loadForView(selectedId!).finally(() => {
|
||||
setFormLoading(false);
|
||||
setViewMode('edit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const goToCreate = (): Promise<void> => {
|
||||
setViewMode('create');
|
||||
return loadForCreate();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Acciones
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) {
|
||||
toast('Completa los campos obligatorios', 'warning');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (viewMode === 'edit' && selectedId) {
|
||||
await groupApi.update(selectedId, form);
|
||||
toast('Conjunto actualizado', 'success');
|
||||
goToDetail(selectedId);
|
||||
} else {
|
||||
const created = await groupApi.create(form);
|
||||
toast('Conjunto creado', 'success');
|
||||
goToDetail(created.data.id);
|
||||
}
|
||||
} catch {
|
||||
toast('Error al guardar el conjunto', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await groupApi.remove(deleteTarget.id);
|
||||
toast('Conjunto eliminado', 'success');
|
||||
setDeleteTarget(null);
|
||||
goToList();
|
||||
} catch {
|
||||
toast('Error al eliminar el conjunto', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Datalists para direcciones
|
||||
// ---------------------------------------------------------------------------
|
||||
const addressOpts = useMemo(() => {
|
||||
const streets = new Set<string>();
|
||||
const cities = new Set<string>();
|
||||
const provinces = new Set<string>();
|
||||
const postalCodes = new Set<string>();
|
||||
const streetsWithNumber = new Set<string>();
|
||||
[...items, ...allProperties].forEach((x: PropertyGroup | Property) => {
|
||||
if (x.addressStreet) streets.add(x.addressStreet);
|
||||
if (x.addressCity) cities.add(x.addressCity);
|
||||
if (x.addressProvince) provinces.add(x.addressProvince);
|
||||
if (x.addressPostalCode) postalCodes.add(x.addressPostalCode);
|
||||
if (x.addressStreet && x.addressNumber) streetsWithNumber.add(`${x.addressStreet} ${x.addressNumber}`);
|
||||
else if (x.addressStreet) streetsWithNumber.add(x.addressStreet);
|
||||
});
|
||||
return { streets: [...streets].sort(), cities: [...cities].sort(), provinces: [...provinces].sort(), postalCodes: [...postalCodes].sort(), streetsWithNumber: [...streetsWithNumber].sort() };
|
||||
}, [items, allProperties]);
|
||||
|
||||
// -- Parse street+number selection --
|
||||
const parseStreetWithNumber = (value: string) => {
|
||||
const match = value.match(/^(.+?)\s+(\d+[A-Z]?)$/);
|
||||
if (match) {
|
||||
return { street: match[1].trim(), number: match[2].trim() };
|
||||
}
|
||||
return { street: value, number: '' };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filtros del listado
|
||||
// ---------------------------------------------------------------------------
|
||||
const filtered = sorted.filter((g) => {
|
||||
if (filters.q) {
|
||||
const q = filters.q.toLowerCase();
|
||||
if (!g.name.toLowerCase().includes(q) &&
|
||||
!(g.addressCity ?? '').toLowerCase().includes(q) &&
|
||||
!(g.addressStreet?.toLowerCase() ?? '').includes(q) &&
|
||||
!(g.addressNumber?.toLowerCase() ?? '').includes(q) &&
|
||||
!(g.addressProvince?.toLowerCase() ?? '').includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const displayItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: LIST
|
||||
// ---------------------------------------------------------------------------
|
||||
if (viewMode === 'list') {
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Cargando conjuntos...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Conjuntos</h1>
|
||||
<button className="btn btn-primary" onClick={goToCreate}>+ Nuevo Conjunto</button>
|
||||
</div>
|
||||
|
||||
<div className="search-panel">
|
||||
<div className="search-panel-header">
|
||||
<h3>Filtros de búsqueda</h3>
|
||||
<button className="search-toggle" onClick={() => setSearchOpen(!searchOpen)}>
|
||||
{searchOpen ? 'Ocultar' : 'Mostrar'} {searchOpen ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
{searchOpen && (
|
||||
<div className="search-fields">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Buscar</label>
|
||||
<input className="search-field-input" type="text" placeholder="Nombre, dirección, ciudad..." value={filters.q} onChange={(e) => setFilters({ ...filters, q: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">🏢</div>
|
||||
<h3>{filters.q ? 'Sin resultados' : 'No hay conjuntos'}</h3>
|
||||
<p>{filters.q ? 'Prueba con otros filtros de búsqueda.' : 'Agrupa propiedades que pertenecen a un mismo edificio o urbanización.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader label="ID" sortKey="id" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Nombre" sortKey="name" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Dirección" sortKey="addressStreet" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Ciudad" sortKey="addressCity" sort={sort} onToggle={toggleSort} />
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayItems.map((g) => (
|
||||
<tr key={g.id}>
|
||||
<td className="font-mono text-muted">{g.id}</td>
|
||||
<td className="font-semibold">{g.name}</td>
|
||||
<td className="text-secondary">{g.addressStreet} {g.addressNumber}</td>
|
||||
<td className="text-secondary">{g.addressCity ?? '-'}</td>
|
||||
<td>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => goToDetail(g.id)}>Ver</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => { setSelectedId(g.id); setFormLoading(true); setViewMode('detail'); try { loadForView(g.id); } finally { setFormLoading(false); } setViewMode('edit'); }}>Editar</button>
|
||||
<button className="btn btn-sm btn-danger" onClick={() => setDeleteTarget(g)}>Eliminar</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} totalElements={filtered.length} size={PAGE_SIZE} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Eliminar Conjunto"
|
||||
message={`¿Estás seguro de eliminar "${deleteTarget?.name}"? Las propiedades del conjunto no se eliminarán.`}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: DETAIL
|
||||
// ---------------------------------------------------------------------------
|
||||
if (viewMode === 'detail') {
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Cargando conjunto...</div>;
|
||||
if (!group) return <div className="loading">Conjunto no encontrado</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Volver al listado</button>
|
||||
<h1>Conjunto #{group.id}</h1>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn btn-primary" onClick={goToEdit}>Editar</button>
|
||||
<button className="btn btn-danger" onClick={() => setDeleteTarget(group)}>Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">ID</span>
|
||||
<span className="detail-value font-mono">{group.id}</span>
|
||||
</div>
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Nombre</span>
|
||||
<span className="detail-value font-semibold" style={{ fontSize: '1.1em' }}>{group.name}</span>
|
||||
</div>
|
||||
{group.description && (
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Descripción</span>
|
||||
<span className="detail-value" style={{ whiteSpace: 'pre-wrap' }}>{group.description}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Dirección</span>
|
||||
<span className="detail-value">
|
||||
{group.addressStreet} {group.addressNumber}
|
||||
{group.addressCity && `, ${group.addressCity}`}
|
||||
{group.addressPostalCode && `, ${group.addressPostalCode}`}
|
||||
{group.addressProvince && ` (${group.addressProvince})`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<div className="form-section-title">Propiedades del Conjunto</div>
|
||||
{groupProps.length === 0 ? (
|
||||
<p className="text-secondary">No hay propiedades en este conjunto.</p>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Ref.</th>
|
||||
<th>Nombre</th>
|
||||
<th>Tipo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupProps.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td className="font-mono text-muted">{p.id}</td>
|
||||
<td className="font-mono text-secondary">{p.reference}</td>
|
||||
<td>
|
||||
<EntityLink entityType="PROPERTY" entityId={p.id}>
|
||||
{p.name}
|
||||
</EntityLink>
|
||||
</td>
|
||||
<td><span className="badge badge-neutral">{p.type?.name ?? '-'}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Eliminar Conjunto"
|
||||
message={`¿Estás seguro de eliminar "${group?.name}"? Las propiedades del conjunto no se eliminarán.`}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: EDIT o CREATE
|
||||
// ---------------------------------------------------------------------------
|
||||
const isCreate = viewMode === 'create';
|
||||
const formTitle = isCreate ? 'Nuevo Conjunto' : `Editar Conjunto #${selectedId}`;
|
||||
|
||||
if (formLoading) return <div className="loading"><div className="spinner" /> Cargando...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>{formTitle}</h1>
|
||||
</div>
|
||||
|
||||
<div className="form-sticky-bar">
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Cancelar</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving} form="property-groups-form">
|
||||
{saving ? 'Guardando...' : (isCreate ? 'Crear Conjunto' : 'Guardar Cambios')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<form id="property-groups-form" onSubmit={handleSubmit} className="form">
|
||||
<div className="form-section-title">Información General</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nombre *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
required
|
||||
placeholder="Ej: Edificio Centro"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Calle</label>
|
||||
<input
|
||||
type="text"
|
||||
list="grp-street-list"
|
||||
value={form.addressStreet}
|
||||
onChange={(e) => {
|
||||
const parsed = parseStreetWithNumber(e.target.value);
|
||||
setForm({ ...form, addressStreet: parsed.street, addressNumber: parsed.number || form.addressNumber });
|
||||
}}
|
||||
placeholder="Ej: Calle Mayor"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Número</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.addressNumber}
|
||||
onChange={(e) => setForm({ ...form, addressNumber: e.target.value })}
|
||||
placeholder="Ej: 15"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Ciudad</label>
|
||||
<input
|
||||
type="text"
|
||||
list="grp-city-list"
|
||||
value={form.addressCity}
|
||||
onChange={(e) => setForm({ ...form, addressCity: e.target.value })}
|
||||
placeholder="Ej: Madrid"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Código Postal</label>
|
||||
<input
|
||||
type="text"
|
||||
list="grp-postalcode-list"
|
||||
value={form.addressPostalCode}
|
||||
onChange={(e) => setForm({ ...form, addressPostalCode: e.target.value })}
|
||||
placeholder="Ej: 28001"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Provincia</label>
|
||||
<input
|
||||
type="text"
|
||||
list="grp-province-list"
|
||||
value={form.addressProvince}
|
||||
onChange={(e) => setForm({ ...form, addressProvince: e.target.value })}
|
||||
placeholder="Ej: Madrid"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group full-width">
|
||||
<label className="form-label">Descripción</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="Descripción del conjunto..."
|
||||
style={{ minHeight: '100px' }}
|
||||
/>
|
||||
</div>
|
||||
<datalist id="grp-street-list">{addressOpts.streetsWithNumber.map(s => <option key={s} value={s} />)}</datalist>
|
||||
<datalist id="grp-city-list">{addressOpts.cities.map(c => <option key={c} value={c} />)}</datalist>
|
||||
<datalist id="grp-province-list">{addressOpts.provinces.map(p => <option key={p} value={p} />)}</datalist>
|
||||
<datalist id="grp-postalcode-list">{addressOpts.postalCodes.map(p => <option key={p} value={p} />)}</datalist>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { tenantApi, contractApi, propertyApi, groupApi } from '../api/resources';
|
||||
import type { Tenant, Contract, Property, PropertyGroup } from '../types/api';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import SortableHeader from '../components/SortableHeader';
|
||||
import Pagination from '../components/Pagination';
|
||||
import DocumentUploader from '../components/DocumentUploader';
|
||||
import EntityLink from '../components/EntityLink';
|
||||
import BankDataManager from '../components/BankDataManager';
|
||||
import { useSort } from '../hooks/useSort';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { fmtProp } from '../utils/propertyFormat';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tipos de vista
|
||||
// ---------------------------------------------------------------------------
|
||||
type ViewMode = 'list' | 'detail' | 'edit' | 'create';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Datos de formulario
|
||||
// ---------------------------------------------------------------------------
|
||||
interface FormData {
|
||||
firstName: string; lastName: string; documentType: string;
|
||||
fiscalId: string; email: string; phone: string;
|
||||
address: string; iban: string; businessName: string; tenantTypeId: number;
|
||||
}
|
||||
|
||||
const emptyForm = (): FormData => ({
|
||||
firstName: '', lastName: '', documentType: 'DNI',
|
||||
fiscalId: '', email: '', phone: '',
|
||||
address: '', iban: '', businessName: '', tenantTypeId: 1,
|
||||
});
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Componente principal
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function Tenants() {
|
||||
const location = useLocation();
|
||||
// -- Estado general --
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
// -- List view --
|
||||
const [items, setItems] = useState<Tenant[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Tenant | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(true);
|
||||
const [filters, setFilters] = useState({ q: '', tenantTypeId: 0, propertyId: 0, groupId: 0 });
|
||||
const [page, setPage] = useState(0);
|
||||
const { sorted, sort, toggleSort } = useSort(items, 'fullName');
|
||||
|
||||
// -- Detail / Edit / Create view --
|
||||
const [tenant, setTenant] = useState<Tenant | null>(null);
|
||||
const [contracts, setContracts] = useState<Contract[]>([]);
|
||||
const [properties, setProperties] = useState<Property[]>([]);
|
||||
const [groups, setGroups] = useState<PropertyGroup[]>([]);
|
||||
const [form, setForm] = useState<FormData>(emptyForm());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formLoading, setFormLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Carga de datos para el listado
|
||||
// ---------------------------------------------------------------------------
|
||||
const loadList = () => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
tenantApi.getAll({ size: 1000 }),
|
||||
contractApi.getAll({ size: 1000 }),
|
||||
propertyApi.getAll({ size: 1000 }),
|
||||
groupApi.getAll({ size: 1000 }),
|
||||
])
|
||||
.then(([t, c, p, g]) => {
|
||||
setItems(t.data.content);
|
||||
setContracts(c.data.content);
|
||||
setProperties(p.data.content);
|
||||
setGroups(g.data.content);
|
||||
})
|
||||
.catch(() => toast('Error al cargar inquilinos', 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { loadList(); }, []);
|
||||
|
||||
useEffect(() => { setPage(0); }, [filters]);
|
||||
|
||||
// Detectar si llegamos con un openDetailId en el state (navegación desde otra página)
|
||||
useEffect(() => {
|
||||
const state = location.state as { openDetailId?: number } | null;
|
||||
if (state?.openDetailId) {
|
||||
goToDetail(state.openDetailId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.state]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Carga de datos para detail/edit/create
|
||||
// ---------------------------------------------------------------------------
|
||||
const loadForView = (id: number): Promise<void> => {
|
||||
return Promise.all([
|
||||
tenantApi.getById(id),
|
||||
contractApi.getAll({ size: 1000 }),
|
||||
propertyApi.getAll({ size: 1000 }),
|
||||
groupApi.getAll({ size: 1000 }),
|
||||
])
|
||||
.then(([tenResp, conResp, propsResp, groupsResp]) => {
|
||||
const ten = tenResp.data;
|
||||
setTenant(ten);
|
||||
setContracts(conResp.data.content ?? []);
|
||||
setProperties(propsResp.data.content ?? []);
|
||||
setGroups(groupsResp.data.content ?? []);
|
||||
setForm({
|
||||
firstName: ten.firstName,
|
||||
lastName: ten.lastName,
|
||||
documentType: ten.documentType ?? 'DNI',
|
||||
fiscalId: ten.fiscalId,
|
||||
email: ten.email,
|
||||
phone: ten.phone,
|
||||
address: ten.address ?? '',
|
||||
iban: ten.iban ?? '',
|
||||
businessName: ten.businessName ?? '',
|
||||
tenantTypeId: ten.tenantType?.id ?? 1,
|
||||
});
|
||||
})
|
||||
.then(() => setFormLoading(false))
|
||||
.catch((err) => {
|
||||
toast('Error al cargar el inquilino', 'error');
|
||||
setFormLoading(false);
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
const loadForCreate = (): Promise<void> => {
|
||||
setTenant(null);
|
||||
setForm(emptyForm());
|
||||
return Promise.all([
|
||||
propertyApi.getAll({ size: 1000 }),
|
||||
groupApi.getAll({ size: 1000 }),
|
||||
])
|
||||
.then(([propsResp, groupsResp]) => {
|
||||
setProperties(propsResp.data.content ?? []);
|
||||
setGroups(groupsResp.data.content ?? []);
|
||||
})
|
||||
.then(() => setFormLoading(false))
|
||||
.catch(() => toast('Error al cargar datos', 'error'));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Navegación entre vistas
|
||||
// ---------------------------------------------------------------------------
|
||||
const goToList = () => {
|
||||
setViewMode('list');
|
||||
setSelectedId(null);
|
||||
setTenant(null);
|
||||
setForm(emptyForm());
|
||||
loadList();
|
||||
};
|
||||
|
||||
const goToDetail = (id: number) => {
|
||||
setSelectedId(id);
|
||||
setViewMode('detail');
|
||||
loadForView(id);
|
||||
};
|
||||
|
||||
const goToEdit = () => {
|
||||
if (tenant && properties.length > 0 && groups.length > 0) {
|
||||
setViewMode('edit');
|
||||
} else {
|
||||
setFormLoading(true);
|
||||
loadForView(selectedId!).finally(() => {
|
||||
setFormLoading(false);
|
||||
setViewMode('edit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const goToCreate = (): Promise<void> => {
|
||||
setViewMode('create');
|
||||
return loadForCreate();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validación de documentos
|
||||
// ---------------------------------------------------------------------------
|
||||
const DNI_LETTERS = 'TRWAGMYFPDXBNJZSQVHLCKE';
|
||||
|
||||
const validateDoc = (docType: string, value: string): string | null => {
|
||||
const v = value.toUpperCase().trim();
|
||||
switch (docType) {
|
||||
case 'DNI': {
|
||||
if (!/^\d{8}[A-Z]$/.test(v)) return 'Formato: 8 dígitos + letra (ej: 12345678Z)';
|
||||
if (DNI_LETTERS[parseInt(v.substring(0, 8)) % 23] !== v[8]) return 'Letra del DNI incorrecta';
|
||||
return null;
|
||||
}
|
||||
case 'NIE': {
|
||||
if (!/^[XYZ]\d{7}[A-Z]$/.test(v)) return 'Formato: X/Y/Z + 7 dígitos + letra (ej: X1234567L)';
|
||||
const prefix = { X: '0', Y: '1', Z: '2' }[v[0]];
|
||||
if (DNI_LETTERS[parseInt(prefix + v.substring(1, 8)) % 23] !== v[8]) return 'Letra del NIE incorrecta';
|
||||
return null;
|
||||
}
|
||||
case 'CIF':
|
||||
if (!/^[ABCDEFGHJNPQRSUVW]\d{7}[0-9A-J]$/.test(v)) return 'Formato CIF incorrecto (ej: B12345678)';
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = () => {
|
||||
const err = validateDoc(form.documentType, form.fiscalId);
|
||||
if (err) toast(err, 'error');
|
||||
else toast('Formato de documento válido', 'success');
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Acciones
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const payload = {
|
||||
...form,
|
||||
tenantType: { id: form.tenantTypeId },
|
||||
tenantTypeId: undefined,
|
||||
};
|
||||
setSaving(true);
|
||||
try {
|
||||
if (viewMode === 'edit' && selectedId) {
|
||||
await tenantApi.update(selectedId, payload);
|
||||
toast('Inquilino actualizado correctamente', 'success');
|
||||
goToDetail(selectedId);
|
||||
} else {
|
||||
const created = await tenantApi.create(payload);
|
||||
toast('Inquilino creado correctamente', 'success');
|
||||
goToDetail(created.data.id);
|
||||
}
|
||||
} catch {
|
||||
toast('Error al guardar el inquilino', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await tenantApi.remove(deleteTarget.id);
|
||||
toast('Inquilino eliminado', 'success');
|
||||
setDeleteTarget(null);
|
||||
goToList();
|
||||
} catch {
|
||||
toast('Error al eliminar el inquilino', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: contratos del inquilino
|
||||
// ---------------------------------------------------------------------------
|
||||
const getTenantContracts = (tenantId: number): Contract[] => {
|
||||
return contracts.filter((c) => c.contractTenants?.some((ct) => ct.tenant.id === tenantId) ?? false);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filtros del listado
|
||||
// ---------------------------------------------------------------------------
|
||||
const filtered = sorted.filter((t) => {
|
||||
if (filters.q) {
|
||||
const q = filters.q.toLowerCase();
|
||||
if (!t.fullName.toLowerCase().includes(q) && !t.fiscalId.toLowerCase().includes(q) && !t.email.toLowerCase().includes(q)) return false;
|
||||
}
|
||||
if (filters.tenantTypeId && t.tenantType?.id !== filters.tenantTypeId) return false;
|
||||
if (filters.propertyId) {
|
||||
const hasContract = contracts.some((c) =>
|
||||
c.property.id === filters.propertyId &&
|
||||
c.contractTenants?.some((ct) => ct.tenant.id === t.id)
|
||||
);
|
||||
if (!hasContract) return false;
|
||||
}
|
||||
if (filters.groupId) {
|
||||
const hasContract = contracts.some((c) =>
|
||||
c.property.group?.id === filters.groupId &&
|
||||
c.contractTenants?.some((ct) => ct.tenant.id === t.id)
|
||||
);
|
||||
if (!hasContract) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const displayItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: LIST
|
||||
// ---------------------------------------------------------------------------
|
||||
if (viewMode === 'list') {
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Cargando inquilinos...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Inquilinos</h1>
|
||||
<button className="btn btn-primary" onClick={goToCreate}>+ Nuevo Inquilino</button>
|
||||
</div>
|
||||
|
||||
<div className="search-panel">
|
||||
<div className="search-panel-header">
|
||||
<h3>Filtros de búsqueda</h3>
|
||||
<button className="search-toggle" onClick={() => setSearchOpen(!searchOpen)}>
|
||||
{searchOpen ? 'Ocultar' : 'Mostrar'} {searchOpen ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
{searchOpen && (
|
||||
<div className="search-fields">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Buscar</label>
|
||||
<input className="search-field-input" type="text" placeholder="Nombre, NIF, email..." value={filters.q} onChange={(e) => setFilters({ ...filters, q: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Tipo</label>
|
||||
<select value={filters.tenantTypeId} onChange={(e) => setFilters({ ...filters, tenantTypeId: Number(e.target.value) })}>
|
||||
<option value={0}>Todos</option>
|
||||
<option value={1}>Persona Física</option>
|
||||
<option value={2}>Persona Jurídica</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Propiedad</label>
|
||||
<select value={filters.propertyId} onChange={(e) => setFilters({ ...filters, propertyId: Number(e.target.value), groupId: 0 })}>
|
||||
<option value={0}>Todas</option>
|
||||
{properties.map((p) => <option key={p.id} value={p.id}>{fmtProp(p)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Conjunto</label>
|
||||
<select value={filters.groupId} onChange={(e) => setFilters({ ...filters, groupId: Number(e.target.value), propertyId: 0 })}>
|
||||
<option value={0}>Todos</option>
|
||||
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">🤝</div>
|
||||
<h3>{filters.q || filters.tenantTypeId || filters.propertyId || filters.groupId ? 'Sin resultados' : 'No hay inquilinos'}</h3>
|
||||
<p>{filters.q || filters.tenantTypeId || filters.propertyId || filters.groupId ? 'Prueba con otros filtros de búsqueda.' : 'Añade tu primer inquilino para empezar.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortableHeader label="ID" sortKey="id" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Nombre / Razón Social" sortKey="fullName" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="NIF / CIF" sortKey="fiscalId" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Email" sortKey="email" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Teléfono" sortKey="phone" sort={sort} onToggle={toggleSort} />
|
||||
<SortableHeader label="Tipo" sortKey="tenantType.name" sort={sort} onToggle={toggleSort} />
|
||||
<th>Contratos</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayItems.map((t) => {
|
||||
const tenantContracts = getTenantContracts(t.id);
|
||||
return (
|
||||
<tr key={t.id}>
|
||||
<td className="font-mono text-muted">{t.id}</td>
|
||||
<td className="font-semibold">{t.fullName}</td>
|
||||
<td className="font-mono">{t.fiscalId}</td>
|
||||
<td className="text-secondary">{t.email}</td>
|
||||
<td className="text-secondary">{t.phone ?? '-'}</td>
|
||||
<td><span className="badge badge-neutral">{t.tenantType?.name === 'PERSONA_FISICA' ? 'Persona Física' : 'Persona Jurídica'}</span></td>
|
||||
<td>
|
||||
{tenantContracts.length > 0 ? (
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||
{tenantContracts.map((c) => (
|
||||
<EntityLink
|
||||
key={c.id}
|
||||
entityType="CONTRACT"
|
||||
entityId={c.id}
|
||||
title={c.contractNumber}
|
||||
>
|
||||
{c.contractNumber}
|
||||
</EntityLink>
|
||||
))}
|
||||
</div>
|
||||
) : <span className="text-muted">-</span>}
|
||||
</td>
|
||||
<td>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => goToDetail(t.id)}>Ver</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={async () => { setSelectedId(t.id); setFormLoading(true); try { await loadForView(t.id); } finally { setFormLoading(false); } setViewMode('edit'); }}>Editar</button>
|
||||
<button className="btn btn-sm btn-danger" onClick={() => setDeleteTarget(t)}>Eliminar</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} totalElements={filtered.length} size={PAGE_SIZE} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Eliminar Inquilino"
|
||||
message={`¿Estás seguro de eliminar a "${deleteTarget?.fullName}"? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: DETAIL
|
||||
// ---------------------------------------------------------------------------
|
||||
if (viewMode === 'detail') {
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Cargando inquilino...</div>;
|
||||
if (!tenant) return <div className="loading">Inquilino no encontrado</div>;
|
||||
|
||||
const tenantContracts = getTenantContracts(tenant.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Volver al listado</button>
|
||||
<h1>Inquilino #{tenant.id}</h1>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn btn-primary" onClick={goToEdit}>Editar</button>
|
||||
<button className="btn btn-danger" onClick={() => setDeleteTarget(tenant)}>Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">ID</span>
|
||||
<span className="detail-value font-mono">{tenant.id}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Nombre / Razón Social</span>
|
||||
<span className="detail-value font-semibold" style={{ fontSize: '1.1em' }}>{tenant.fullName}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">NIF / CIF</span>
|
||||
<span className="detail-value font-mono">{tenant.fiscalId}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Email</span>
|
||||
<span className="detail-value">{tenant.email}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Teléfono</span>
|
||||
<span className="detail-value">{tenant.phone ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Tipo</span>
|
||||
<span className="detail-value">{tenant.tenantType?.name === 'PERSONA_FISICA' ? 'Persona Física' : 'Persona Jurídica'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Nombre Comercial</span>
|
||||
<span className="detail-value">{tenant.businessName ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<span className="detail-label">Dirección</span>
|
||||
<span className="detail-value">{tenant.address ?? '-'}</span>
|
||||
</div>
|
||||
<div className="detail-field full-width">
|
||||
<span className="detail-label">Contratos</span>
|
||||
<div className="detail-value" style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
{tenantContracts.length > 0
|
||||
? tenantContracts.map((c) => (
|
||||
<EntityLink
|
||||
key={c.id}
|
||||
entityType="CONTRACT"
|
||||
entityId={c.id}
|
||||
title={c.contractNumber}
|
||||
>
|
||||
{c.contractNumber}
|
||||
</EntityLink>
|
||||
))
|
||||
: 'Ninguno'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<BankDataManager
|
||||
tenantId={tenant.id}
|
||||
tenantFullName={tenant.fullName}
|
||||
tenantBusinessName={tenant.businessName}
|
||||
tenantTypeId={tenant.tenantType?.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<DocumentUploader entityType="TENANT" entityId={tenant.id} />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Eliminar Inquilino"
|
||||
message={`¿Estás seguro de eliminar a "${tenant?.fullName}"? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: EDIT o CREATE
|
||||
// ---------------------------------------------------------------------------
|
||||
const isCreate = viewMode === 'create';
|
||||
const formTitle = isCreate ? 'Nuevo Inquilino' : `Editar Inquilino #${selectedId}`;
|
||||
|
||||
if (formLoading) return <div className="loading"><div className="spinner" /> Cargando...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>{formTitle}</h1>
|
||||
</div>
|
||||
|
||||
<div className="form-sticky-bar">
|
||||
<button className="btn btn-secondary" onClick={goToList}>← Cancelar</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving} form="tenants-form">
|
||||
{saving ? 'Guardando...' : (isCreate ? 'Crear Inquilino' : 'Guardar Cambios')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<form id="tenants-form" onSubmit={handleSubmit} className="form">
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Documento</label>
|
||||
<div style={{ display: 'flex', gap: 0 }}>
|
||||
<select
|
||||
value={form.documentType}
|
||||
onChange={(e) => {
|
||||
const newDocType = e.target.value;
|
||||
// Auto-ajustar tipo de inquilino según el documento
|
||||
const newTenantTypeId = newDocType === 'CIF' ? 2 : 1;
|
||||
setForm({ ...form, documentType: newDocType, tenantTypeId: newTenantTypeId });
|
||||
}}
|
||||
style={{ width: 'auto', minWidth: '85px', borderRight: 'none', borderRadius: '0.5rem 0 0 0.5rem' }}
|
||||
>
|
||||
<option value="DNI">DNI</option>
|
||||
<option value="NIE">NIE</option>
|
||||
<option value="CIF">CIF</option>
|
||||
<option value="PASAPORTE">Pasaporte</option>
|
||||
<option value="OTRO">Otro</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={form.fiscalId}
|
||||
onChange={(e) => setForm({ ...form, fiscalId: e.target.value })}
|
||||
required
|
||||
placeholder="12345678A"
|
||||
style={{ flex: 1, borderRadius: '0 0.5rem 0.5rem 0' }}
|
||||
/>
|
||||
<button type="button" className="btn btn-sm btn-secondary" onClick={handleValidate} style={{ marginLeft: '0.5rem' }}>Validar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Tipo</label>
|
||||
<select
|
||||
value={form.tenantTypeId}
|
||||
onChange={(e) => {
|
||||
const newTypeId = Number(e.target.value);
|
||||
// Auto-ajustar tipo de documento según el tipo de inquilino
|
||||
const newDocType = newTypeId === 2 ? 'CIF' : 'DNI';
|
||||
setForm({ ...form, tenantTypeId: newTypeId, documentType: newDocType });
|
||||
}}
|
||||
>
|
||||
<option value={1}>Persona Física</option>
|
||||
<option value={2}>Persona Jurídica</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.tenantTypeId === 1 ? (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nombre</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.firstName}
|
||||
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
|
||||
required
|
||||
placeholder="Ej: Juan"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Apellidos</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.lastName}
|
||||
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
|
||||
required
|
||||
placeholder="Ej: Pérez López"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="form-group">
|
||||
<label className="form-label">Razón Social</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.businessName}
|
||||
onChange={(e) => setForm({ ...form, businessName: e.target.value })}
|
||||
required
|
||||
placeholder="Ej: Empresa S.L."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label className="form-label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
placeholder="ejemplo@email.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Teléfono</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder="Ej: 612345678"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Dirección</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.address}
|
||||
onChange={(e) => setForm({ ...form, address: e.target.value })}
|
||||
placeholder="Dirección fiscal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
{!isCreate && selectedId && (
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<div className="form-section-title">Datos Bancarios</div>
|
||||
<BankDataManager
|
||||
tenantId={selectedId}
|
||||
tenantFullName={tenant?.fullName}
|
||||
tenantBusinessName={tenant?.businessName}
|
||||
tenantTypeId={tenant?.tenantType?.id}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCreate && selectedId && (
|
||||
<div className="card" style={{ marginTop: '24px' }}>
|
||||
<div className="form-section-title">Documentos Adjuntos</div>
|
||||
<DocumentUploader entityType="TENANT" entityId={selectedId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: T;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface PagedResponse<T> {
|
||||
content: T[];
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
last: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
phone?: string;
|
||||
role: { id: number; name: string; description?: string };
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastLogin?: string;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
tokenType: string;
|
||||
userId: number;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface PropertyGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
addressStreet: string;
|
||||
addressNumber: string;
|
||||
addressCity: string;
|
||||
addressPostalCode: string;
|
||||
addressProvince: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Property {
|
||||
id: number;
|
||||
group?: { id: number; name?: string };
|
||||
type: { id: number; name?: string; description?: string };
|
||||
status: { id: number; name?: string; description?: string };
|
||||
reference: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
addressStreet: string;
|
||||
addressNumber: string;
|
||||
addressCity: string;
|
||||
addressPostalCode: string;
|
||||
addressProvince: string;
|
||||
cadastralRef?: string;
|
||||
surfaceM2?: number;
|
||||
floor?: string;
|
||||
door?: string;
|
||||
rentalAmount?: number;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Tenant {
|
||||
id: number;
|
||||
tenantType: { id: number; name?: string };
|
||||
fiscalId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
fullName: string;
|
||||
documentType: string;
|
||||
businessName?: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address?: string;
|
||||
iban?: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TenantBankData {
|
||||
id: number;
|
||||
tenantId?: number;
|
||||
alias?: string;
|
||||
iban: string;
|
||||
bic?: string;
|
||||
bankName?: string;
|
||||
accountHolder?: string;
|
||||
isPrincipal: boolean;
|
||||
active: boolean;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ContractTenant {
|
||||
id: number;
|
||||
tenant: Tenant;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface Contract {
|
||||
id: number;
|
||||
property: Property;
|
||||
contractTenants: ContractTenant[];
|
||||
expenseRepercussions?: ExpenseRepercussion[];
|
||||
status: { id: number; name?: string };
|
||||
paymentPeriod: { id: number; name?: string };
|
||||
contractNumber: string;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
rentalAmount: number;
|
||||
depositAmount?: number;
|
||||
paymentDay: number;
|
||||
paymentDayEnd?: number;
|
||||
ibanCharge?: string;
|
||||
signedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PaymentPeriod {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ExpenseRepercussion {
|
||||
id: number;
|
||||
expenseType: string;
|
||||
amount: number;
|
||||
billingPeriod?: string;
|
||||
observations?: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IncomeReceipt {
|
||||
id: number;
|
||||
contract?: Contract;
|
||||
property: Property;
|
||||
tenant?: Tenant;
|
||||
bankAccount?: BankAccount;
|
||||
isDomiciled: boolean;
|
||||
category: { id: number; name?: string };
|
||||
status: { id: number; name?: string };
|
||||
amount: number;
|
||||
taxWithheld?: number;
|
||||
netAmount?: number;
|
||||
issueDate: string;
|
||||
dueDate?: string;
|
||||
paymentDate?: string;
|
||||
paymentMethod?: string;
|
||||
description?: string;
|
||||
receiptNumber?: string;
|
||||
periodLabel?: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ExpenseTemplate {
|
||||
id: number;
|
||||
property?: Property;
|
||||
propertyGroup?: PropertyGroup;
|
||||
bankAccount?: BankAccount;
|
||||
isDomiciled: boolean;
|
||||
category: { id: number; name?: string };
|
||||
period: PaymentPeriod;
|
||||
paymentDay: number;
|
||||
supplierName?: string;
|
||||
supplierFiscalId?: string;
|
||||
amount?: number;
|
||||
taxAmount?: number;
|
||||
description: string;
|
||||
notes?: string;
|
||||
isVariable: boolean;
|
||||
active: boolean;
|
||||
createdBy?: User;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ExpenseReceipt {
|
||||
id: number;
|
||||
template?: ExpenseTemplate;
|
||||
property?: Property;
|
||||
propertyGroup?: PropertyGroup;
|
||||
bankAccount?: BankAccount;
|
||||
isDomiciled: boolean;
|
||||
category: { id: number; name?: string };
|
||||
status: { id: number; name?: string };
|
||||
supplierName?: string;
|
||||
supplierFiscalId?: string;
|
||||
invoiceNumber?: string;
|
||||
amount: number;
|
||||
taxAmount?: number;
|
||||
totalAmount: number;
|
||||
isVariable: boolean;
|
||||
previousAmount?: number;
|
||||
issueDate: string;
|
||||
dueDate?: string;
|
||||
paymentDate?: string;
|
||||
paymentMethod?: string;
|
||||
description: string;
|
||||
notes?: string;
|
||||
createdBy?: User;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Incident {
|
||||
id: number;
|
||||
property?: Property;
|
||||
propertyGroup?: PropertyGroup;
|
||||
status: { id: number; name?: string };
|
||||
priority: { id: number; name?: string };
|
||||
title: string;
|
||||
description: string;
|
||||
reportedBy?: User;
|
||||
assignedTo?: User;
|
||||
reportedAt: string;
|
||||
scheduledDate?: string;
|
||||
resolvedAt?: string;
|
||||
costEstimate?: number;
|
||||
resolutionNotes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DocumentType {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BankAccount {
|
||||
id: number;
|
||||
name: string;
|
||||
holder: string;
|
||||
iban: string;
|
||||
bankName?: string;
|
||||
swiftBic?: string;
|
||||
currency: string;
|
||||
isDefault: boolean;
|
||||
active: boolean;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DocumentEntity {
|
||||
id: number;
|
||||
entityType: string;
|
||||
entityId: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id: number;
|
||||
documentType: DocumentType;
|
||||
entities: DocumentEntity[];
|
||||
originalName: string;
|
||||
storedName: string;
|
||||
mimeType?: string;
|
||||
fileSize?: number;
|
||||
description?: string;
|
||||
uploadedBy?: User;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
export interface MaintenancePeriod {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ExpenseCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface ScheduledMaintenance {
|
||||
id: number;
|
||||
property?: Property;
|
||||
propertyGroup?: PropertyGroup;
|
||||
period: MaintenancePeriod;
|
||||
title: string;
|
||||
description?: string;
|
||||
estimatedCost?: number;
|
||||
lastExecution?: string;
|
||||
nextExecution: string;
|
||||
reminderDaysBefore: number;
|
||||
responsible?: string;
|
||||
notes?: string;
|
||||
generateExpense: boolean;
|
||||
expenseCategory?: ExpenseCategory;
|
||||
completed: boolean;
|
||||
completedAt?: string;
|
||||
completedBy?: User;
|
||||
createdBy?: User;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DocumentTypeForEntity {
|
||||
documentTypeId: number;
|
||||
documentTypeName: string;
|
||||
entityType: string;
|
||||
canUpload: boolean;
|
||||
mustHave: boolean;
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Formatea el nombre de una propiedad para mostrarlo en la UI.
|
||||
* Si la propiedad pertenece a un conjunto, muestra "Conjunto - Propiedad".
|
||||
*/
|
||||
export function fmtProp(property: { name?: string; group?: { name?: string } | null } | null | undefined): string {
|
||||
if (!property) return '-';
|
||||
if (property.group?.name) {
|
||||
return `${property.group.name} - ${property.name ?? ''}`;
|
||||
}
|
||||
return property.name ?? '-';
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user