Initial commit: proyecto ContabilidadSaPolar completo

This commit is contained in:
root
2026-08-19 21:30:23 +00:00
commit 0d5c6f9512
232 changed files with 26730 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -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 }]
}
}
+32
View File
@@ -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.
+13
View File
@@ -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>
+2152
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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

+24
View File
@@ -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

+58
View File
@@ -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>
);
}
+10
View File
@@ -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 }),
};
+30
View File
@@ -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;
+201
View File
@@ -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

+1
View File
@@ -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

+343
View File
@@ -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>
);
}
+27
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+93
View File
@@ -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>
);
}
+25
View File
@@ -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}>&times;</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>
);
}
+64
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+66
View File
@@ -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;
}
+28
View File
@@ -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 };
}
+49
View File
@@ -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
+10
View File
@@ -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>,
)
+333
View File
@@ -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">&nbsp;</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
+553
View File
@@ -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>
);
}
+605
View File
@@ -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>
);
}
+596
View File
@@ -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"> 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">&nbsp;</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">&nbsp;</label>
<button className="btn btn-success" onClick={handlePay}>Confirmar Pago</button>
</div>
<div className="form-group form-group-submit">
<label className="form-label">&nbsp;</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">&nbsp;</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"> 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>
);
}
+669
View File
@@ -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">&nbsp;</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">&nbsp;</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>
);
}
+643
View File
@@ -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>
);
}
+802
View File
@@ -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"> 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">&nbsp;</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>
);
}
+72
View File
@@ -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>
);
}
+876
View File
@@ -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>
);
}
+701
View File
@@ -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}` : '-'}</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>
);
}
+538
View File
@@ -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>
);
}
+683
View File
@@ -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>
);
}
+337
View File
@@ -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;
}
+11
View File
@@ -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 ?? '-';
}
+26
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+15
View File
@@ -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,
},
},
},
})