[REPO REFACTOR]: changed to a better git repository structure with branches
33
eslint.config.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
56
index.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
|
||||
<head>
|
||||
<!-- Metas básicas del documento -->
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
||||
<!-- SEO básico -->
|
||||
<title>Huertos Bellavista</title>
|
||||
<meta name="description"
|
||||
content="Asociación de huertos urbanos 🌿 en Sevilla, promoviendo el cultivo ecológico y la sostenibilidad." />
|
||||
<meta name="keywords"
|
||||
content="huertos urbanos, Sevilla, sostenibilidad, ecológico, asociación, Bellavista, agricultura urbana" />
|
||||
<meta name="author" content="Asociación Huertos Bellavista" />
|
||||
|
||||
<!-- Open Graph (Facebook, WhatsApp, Discord, Telegram...) -->
|
||||
<meta property="og:title" content="Huertos Bellavista" />
|
||||
<meta property="og:description"
|
||||
content="Asociación de huertos urbanos 🌿 en Sevilla, promoviendo el cultivo ecológico y la sostenibilidad." />
|
||||
<meta property="og:image" content="https://www.huertosbellavista.es/images/logo.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="Logo de la Asociación Huertos Bellavista sobre fondo natural" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://www.huertosbellavista.es/" />
|
||||
<meta property="og:site_name" content="Huertos Bellavista" />
|
||||
<meta property="og:locale" content="es_ES" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Huertos Bellavista" />
|
||||
<meta name="twitter:description"
|
||||
content="Asociación de huertos urbanos 🌿 en Sevilla, promoviendo el cultivo ecológico y la sostenibilidad." />
|
||||
<meta name="twitter:image" content="https://www.huertosbellavista.es/images/logo.png" />
|
||||
<meta name="twitter:image:alt" content="Logo de la Asociación Huertos Bellavista sobre fondo natural" />
|
||||
|
||||
<!-- Iconos y app -->
|
||||
<link rel="icon" href="/images/favicon.ico" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
|
||||
<link rel="apple-touch-icon" href="https://www.huertosbellavista.es/images/logo.png" /> <!-- Pa cuando se guarda en iPhone -->
|
||||
<meta name="theme-color" content="#7BB661" />
|
||||
|
||||
<!-- Robots -->
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
<title>Huertos Bellavista</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
5445
package-lock.json
generated
Normal file
55
package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "huertos-react",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"@tiptap/pm": "^2.11.7",
|
||||
"@tiptap/react": "^2.11.7",
|
||||
"@tiptap/starter-kit": "^2.11.7",
|
||||
"axios": "^1.8.4",
|
||||
"bootstrap": "^5.3.3",
|
||||
"date-fns": "^2.30.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^12.6.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.487.0",
|
||||
"maplibre-gl": "^5.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-bootstrap": "^2.10.9",
|
||||
"react-datepicker": "^8.3.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-responsive": "^10.0.1",
|
||||
"react-router-dom": "^7.4.0",
|
||||
"react-simple-wysiwyg": "^3.2.2",
|
||||
"react-slick": "^0.30.3",
|
||||
"react-split": "^2.0.14",
|
||||
"slick-carousel": "^1.8.1",
|
||||
"vite-plugin-clean": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
78
public/config/settings.dev.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"apiConfig": {
|
||||
"baseUrl": "http://api.huertos.local",
|
||||
"coreUrl": "http://api.miarma.local",
|
||||
"endpoints": {
|
||||
"auth": {
|
||||
"login": "/v1/login",
|
||||
"validateToken": "/auth/v1/validate-token",
|
||||
"refreshToken": "/auth/v1/refresh-token",
|
||||
"changePassword": "/auth/v1/change-password",
|
||||
"loginValidate": "/auth/v1/login/validate"
|
||||
},
|
||||
"members": {
|
||||
"all": "/raw/v1/members",
|
||||
"byId": "/raw/v1/members/:user_id",
|
||||
"profile": "/v1/members/profile",
|
||||
"byMemberNumber": "/v1/members/number/:member_number",
|
||||
"byPlotNumber": "/v1/members/plot/:plot_number",
|
||||
"byDni": "/v1/members/dni/:dni",
|
||||
"payments": "/v1/members/number/:member_number/incomes",
|
||||
"hasPaid": "/v1/members/number/:member_number/has-paid",
|
||||
"waitlist": "/v1/members/waitlist",
|
||||
"limitedWaitlist": "/v1/members/waitlist/limited",
|
||||
"lastMemberNumber": "/v1/members/latest-number",
|
||||
"hasCollaborator": "/v1/members/number/:member_number/has-collaborator",
|
||||
"hasCollaboratorRequest": "/v1/members/number/:member_number/has-collaborator-request",
|
||||
"hasGreenHouse": "/v1/members/number/:member_number/has-greenhouse",
|
||||
"hasGreenHouseRequest": "/v1/members/number/:member_number/has-greenhouse-request",
|
||||
"changeType": "/v1/members/number/:user_id/type",
|
||||
"changeStatus": "/v1/members/number/:user_id/status"
|
||||
},
|
||||
"incomes": {
|
||||
"all": "/raw/v1/incomes",
|
||||
"allWithNames": "/raw/v1/incomes-with-names",
|
||||
"byId": "/raw/v1/incomes/:income_id",
|
||||
"myIncomes": "/v1/incomes/my-incomes"
|
||||
},
|
||||
"expenses": {
|
||||
"all": "/raw/v1/expenses",
|
||||
"byId": "/raw/v1/expenses/:expense_id"
|
||||
},
|
||||
"balance": {
|
||||
"all": "/raw/v1/balance"
|
||||
},
|
||||
"announces": {
|
||||
"all": "/raw/v1/announces",
|
||||
"byId": "/raw/v1/announces/:announce_id"
|
||||
},
|
||||
"requests": {
|
||||
"all": "/raw/v1/requests",
|
||||
"byId": "/raw/v1/requests/:request_id",
|
||||
"allWithPreUsers": "/v1/requests-full",
|
||||
"byIdWithPreUser": "/v1/requests-full/:request_id",
|
||||
"countPending": "/v1/requests/count",
|
||||
"myRequests": "/v1/requests/my-requests",
|
||||
"accept": "/v1/requests/:request_id/accept",
|
||||
"reject": "/v1/requests/:request_id/reject"
|
||||
},
|
||||
"pre_users": {
|
||||
"all": "/raw/v1/pre_users",
|
||||
"byId": "/raw/v1/pre_users/:pre_user_id",
|
||||
"validation": "/v1/pre_users/validate"
|
||||
},
|
||||
"files": {
|
||||
"all": "/raw/v1/files",
|
||||
"byId": "/raw/v1/files/:file_id",
|
||||
"upload": "/raw/v1/files/upload",
|
||||
"download": "/raw/v1/files/download/:file_id",
|
||||
"userFiles": "/raw/v1/files/myfiles"
|
||||
},
|
||||
"mail": {
|
||||
"all": "/v1/mails",
|
||||
"byIndex": "/v1/mails/:index",
|
||||
"send": "/v1/mails/send"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
78
public/config/settings.prod.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"apiConfig": {
|
||||
"baseUrl": "https://api.huertosbellavista.es",
|
||||
"coreUrl": "https://api.miarma.net",
|
||||
"endpoints": {
|
||||
"auth": {
|
||||
"login": "/v1/login",
|
||||
"validateToken": "/auth/v1/validate-token",
|
||||
"refreshToken": "/auth/v1/refresh-token",
|
||||
"changePassword": "/auth/v1/change-password",
|
||||
"loginValidate": "/auth/v1/login/validate"
|
||||
},
|
||||
"members": {
|
||||
"all": "/raw/v1/members",
|
||||
"byId": "/raw/v1/members/:user_id",
|
||||
"profile": "/v1/members/profile",
|
||||
"byMemberNumber": "/v1/members/number/:member_number",
|
||||
"byPlotNumber": "/v1/members/plot/:plot_number",
|
||||
"byDni": "/v1/members/dni/:dni",
|
||||
"payments": "/v1/members/number/:member_number/incomes",
|
||||
"hasPaid": "/v1/members/number/:member_number/has-paid",
|
||||
"waitlist": "/v1/members/waitlist",
|
||||
"limitedWaitlist": "/v1/members/waitlist/limited",
|
||||
"lastMemberNumber": "/v1/members/latest-number",
|
||||
"hasCollaborator": "/v1/members/number/:member_number/has-collaborator",
|
||||
"hasCollaboratorRequest": "/v1/members/number/:member_number/has-collaborator-request",
|
||||
"hasGreenHouse": "/v1/members/number/:member_number/has-greenhouse",
|
||||
"hasGreenHouseRequest": "/v1/members/number/:member_number/has-greenhouse-request",
|
||||
"changeType": "/v1/members/number/:user_id/type",
|
||||
"changeStatus": "/v1/members/number/:user_id/status"
|
||||
},
|
||||
"incomes": {
|
||||
"all": "/raw/v1/incomes",
|
||||
"allWithNames": "/raw/v1/incomes-with-names",
|
||||
"byId": "/raw/v1/incomes/:income_id",
|
||||
"myIncomes": "/v1/incomes/my-incomes"
|
||||
},
|
||||
"expenses": {
|
||||
"all": "/raw/v1/expenses",
|
||||
"byId": "/raw/v1/expenses/:expense_id"
|
||||
},
|
||||
"balance": {
|
||||
"all": "/raw/v1/balance"
|
||||
},
|
||||
"announces": {
|
||||
"all": "/raw/v1/announces",
|
||||
"byId": "/raw/v1/announces/:announce_id"
|
||||
},
|
||||
"requests": {
|
||||
"all": "/raw/v1/requests",
|
||||
"byId": "/raw/v1/requests/:request_id",
|
||||
"allWithPreUsers": "/v1/requests-full",
|
||||
"byIdWithPreUser": "/v1/requests-full/:request_id",
|
||||
"countPending": "/v1/requests/count",
|
||||
"myRequests": "/v1/requests/my-requests",
|
||||
"accept": "/v1/requests/:request_id/accept",
|
||||
"reject": "/v1/requests/:request_id/reject"
|
||||
},
|
||||
"pre_users": {
|
||||
"all": "/raw/v1/pre_users",
|
||||
"byId": "/raw/v1/pre_users/:pre_user_id",
|
||||
"validation": "/v1/pre_users/validate"
|
||||
},
|
||||
"files": {
|
||||
"all": "/raw/v1/files",
|
||||
"byId": "/raw/v1/files/:file_id",
|
||||
"upload": "/raw/v1/files/upload",
|
||||
"download": "/raw/v1/files/download/:file_id",
|
||||
"userFiles": "/raw/v1/files/myfiles"
|
||||
},
|
||||
"mail": {
|
||||
"all": "/v1/mails",
|
||||
"byIndex": "/v1/mails/:index",
|
||||
"send": "/v1/mails/send"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
public/fonts/OpenSans.ttf
Normal file
BIN
public/fonts/ProductSansBold.ttf
Normal file
BIN
public/fonts/ProductSansBoldItalic.ttf
Normal file
BIN
public/fonts/ProductSansItalic.ttf
Normal file
BIN
public/fonts/ProductSansRegular.ttf
Normal file
BIN
public/images/bg.png
Normal file
|
After Width: | Height: | Size: 5.3 MiB |
BIN
public/images/favicon.ico
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/images/huertos-1.jpg
Normal file
|
After Width: | Height: | Size: 383 KiB |
BIN
public/images/huertos-1.png
Normal file
|
After Width: | Height: | Size: 669 KiB |
BIN
public/images/huertos-2.jpg
Normal file
|
After Width: | Height: | Size: 470 KiB |
BIN
public/images/huertos-3.jpg
Normal file
|
After Width: | Height: | Size: 398 KiB |
BIN
public/images/huertos-4.jpg
Normal file
|
After Width: | Height: | Size: 286 KiB |
BIN
public/images/huertos-5.jpg
Normal file
|
After Width: | Height: | Size: 461 KiB |
BIN
public/images/huertos-6.jpg
Normal file
|
After Width: | Height: | Size: 409 KiB |
2
public/images/icons/bank.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--twemoji" preserveAspectRatio="xMidYMid meet"><path fill="#66757F" d="M3 16h30v18H3z"></path><path fill="#CCD6DD" d="M2 34h32a2 2 0 0 1 2 2H0a2 2 0 0 1 2-2z"></path><path fill="#292F33" d="M18 23a3 3 0 0 0-3 3v6h6v-6a3 3 0 0 0-3-3z"></path><path fill="#CCD6DD" d="M3 21h4v11H3zm6 0h4v11H9zm20 0h4v11h-4zm-6 0h4v11h-4z"></path><path fill="#AAB8C2" d="M2 32h32v2H2z"></path><path fill="#66757F" d="M36 11L18 0L0 11z"></path><path fill="#CCD6DD" d="M18 2.4L2 12v4h32v-4z"></path><path fill="#8899A6" d="M3 19h4v2H3zm6 0h4v2H9zm14 0h4v2h-4zm6 0h4v2h-4z"></path><path fill="#CCD6DD" d="M1 12h34v5H1z"></path><path fill="#AAB8C2" d="M36 12a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h34a1 1 0 0 1 1 1v1zm0 6a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h34a1 1 0 0 1 1 1v1z"></path><path fill="#E1E8ED" d="M13 32h10v2H13z"></path><path fill="#F5F8FA" d="M11 34h14v2H11z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
2
public/images/icons/cash.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--twemoji" preserveAspectRatio="xMidYMid meet"><path fill="#2A6797" d="M2 11c-2 0-2 2-2 2v21s0 2 2 2h32c2 0 2-2 2-2V13s0-2-2-2H2z"></path><path fill="#5DADEC" d="M2 6C0 6 0 8 0 8v20s0 2 2 2h32c2 0 2-2 2-2V8s0-2-2-2H2z"></path><circle fill="#4289C1" cx="25" cy="18" r="6.5"></circle><path fill="#2A6797" d="M33 28.5H3c-.827 0-1.5-.673-1.5-1.5V9c0-.827.673-1.5 1.5-1.5h30c.827 0 1.5.673 1.5 1.5v18c0 .827-.673 1.5-1.5 1.5zM3 8.5a.5.5 0 0 0-.5.5v18c0 .275.225.5.5.5h30c.275 0 .5-.225.5-.5V9a.5.5 0 0 0-.5-.5H3z"></path><path fill="#FFE8B6" d="M14 6h8v24.062h-8z"></path><path fill="#FFAC33" d="M14 30h8v6h-8z"></path><path fill="#2A6797" d="M12.764 21.84c0 .658-1.474 1.447-3.301 1.447c-2.42 0-3.877-1.681-4.361-3.742H3.808a.57.57 0 1 1 0-1.139h1.129c-.008-.136-.029-.27-.029-.406c0-.3.026-.597.063-.89H3.808a.57.57 0 1 1 0-1.14h1.416c.593-1.835 2.03-3.257 4.313-3.257c1.84 0 3.008.993 3.008 1.519c0 .336-.205.612-.526.612c-.584 0-.876-1.022-2.482-1.022c-1.51 0-2.428.942-2.891 2.147h3.327a.57.57 0 1 1 0 1.14H6.351a6.246 6.246 0 0 0-.072.891c0 .134.016.27.025.405h3.668a.57.57 0 1 1 0 1.139H6.485c.389 1.43 1.346 2.631 2.978 2.631c1.563 0 2.25-.934 2.79-.934c.307.001.511.235.511.599z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
2
public/images/icons/farmer.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--twemoji" preserveAspectRatio="xMidYMid meet"><path fill="#77B255" d="M36 36v-2a6 6 0 0 0-6-6H14a6 6 0 0 0-6 6v2h28z"></path><path fill="#3E721D" d="M22 34c.767 0 5-3 5-6H17c0 3 4.233 6 5 6z"></path><path fill="#FFDC5D" d="M17.64 28.038c0 2.846 8.72 2.962 8.72 0v-3.749h-8.72v3.749z"></path><path fill="#F9CA55" d="M17.632 25.973c1.216 1.374 2.724 1.746 4.364 1.746c1.639 0 3.147-.372 4.364-1.746v-3.491h-8.728v3.491z"></path><path fill="#FFAC33" d="M25.152 3.3c-1.925-.623-5.876-.46-7.008 1.012c-1.873.036-1.596 3.71-5.113 2.967c-.738 1.004-1.259 2.198-1.424 3.476c-.448 3.475.235 4.874.591 7.486c.403 2.96 2.067 3.907 3.397 4.303c1.914 2.529 3.949 2.421 7.366 2.421c6.672 0 9.271-4.458 9.552-12.04c.08-2.157-.473-4.067-1.584-5.649c-3.837 1.49-3.213-3.146-5.777-3.976z"></path><path fill="#FFDC5D" d="M29.547 13.243c-.646-.894-1.472-1.614-3.284-1.868c.68.311 1.874 2.202 1.959 2.797c.085.595.17 1.076-.368.481c-2.155-2.382-5.045-2.259-7.37-3.714c-1.624-1.016-2.119-2.141-2.119-2.141s-.198 1.5-2.661 3.029c-.714.443-1.566 1.43-2.038 2.888c-.34 1.048-.234 1.982-.234 3.578c0 4.66 3.841 8.578 8.578 8.578s8.578-3.953 8.578-8.578c-.002-2.898-.305-4.03-1.041-5.05z"></path><path fill="#C1694F" d="M22.961 20.677h-1.906a.477.477 0 1 1 0-.954h1.906a.477.477 0 1 1 0 .954z"></path><path fill="#662113" d="M18.195 17.341a.953.953 0 0 1-.953-.953v-.953a.953.953 0 0 1 1.906 0v.953a.953.953 0 0 1-.953.953zm7.626 0a.953.953 0 0 1-.953-.953v-.953a.953.953 0 0 1 1.906 0v.953a.953.953 0 0 1-.953.953z"></path><path fill="#C1694F" d="M22.134 24.657c-2.754 0-3.6-.705-3.741-.848a.655.655 0 0 1 .902-.95c.052.037.721.487 2.839.487c2.2 0 2.836-.485 2.842-.49a.638.638 0 0 1 .913.015a.67.67 0 0 1-.014.939c-.142.142-.987.847-3.741.847"></path><path fill="#3B88C3" d="M13 28h3v8h-3zm15 0h3v8h-3z"></path><path fill="#3B88C3" d="M13.125 35H31v1H13.125z"></path><path fill="#C1694F" d="M30 8.8c0 1.32-3.092 2.2-8 2.2c-4.909 0-8-.88-8-2.2C14 5.253 18 0 19.333 0C20.667 0 21.556 1.76 22 1.76S23.333 0 24.667 0C26 0 30 5.253 30 8.8z"></path><path fill="#C1694F" d="M35.941 8c0 1.657-3.5 6-14 6s-14-4.343-14-6s6.82-1 14-1s14-.657 14 1z"></path><path fill="#292F33" d="M30 8.8c0 1.32-3.092 2.2-8 2.2c-4.909 0-8-.88-8-2.2c0-.566.102-1.175.279-1.8c2.388 2 13.054 2 15.443.004c.175.623.278 1.231.278 1.796z"></path><path fill="#C1694F" d="M7 31.75a1.25 1.25 0 0 0-2.5 0V36H7v-4.25z"></path><path fill="#66757F" d="M10.003 22.75c0 2.35-1.904 4.253-4.253 4.253S1.497 25.1 1.497 22.75c0-.086-1.497-.084-1.497 0a5.75 5.75 0 0 0 11.5 0c0-.084-1.497-.086-1.497 0z"></path><path fill="#66757F" d="M1.5 22.75a.75.75 0 0 1-1.5 0v-8.5a.75.75 0 0 1 1.5 0v8.5zm10 0a.75.75 0 0 1-1.5 0v-8.5a.75.75 0 0 1 1.5 0v8.5zM6.5 31A.75.75 0 0 1 5 31V14.25a.75.75 0 0 1 1.5 0V31z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
32
public/images/icons/filetype/file_64.svg
Normal file
@@ -0,0 +1,32 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64" viewBox="0 0 512 512">
|
||||
<metadata><?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.6-c138 79.159824, 2016/09/14-01:09:01 ">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""/>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<?xpacket end="w"?></metadata>
|
||||
<image id="Capa_0" data-name="Capa 0" x="59" width="394" height="512" xlink:href="data:img/png;base64,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"/>
|
||||
<image id="file" x="59" width="394" height="512" xlink:href="data:img/png;base64,iVBORw0KGgoAAAANSUhEUgAAADEAAABACAMAAACEPG9KAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAByFBMVEXg39Ti4dbh4NXh39Tj4tfj4tji4NXj4dbh3tPp6eDp6eDp6eDq6uLh4dbp6eDZ2Mrp6eDc287p6eDc287c2s7Z18rh4NXp6eDp6eDv8Ofv8efb19Db1s/EuLTEt7PJv7rJvrnIvrnIvbjb1s/FubTHvLfIvbjFubTX0crp6eDr7OPd3NDf3tLV0sTW1MbX1cjZ18rT0cLW1MfV08Tq6+Li4dbc2s7e3NHe3NDc2s/q6uLr6+Pq6uHv8efa1s7b1s/b1s7BtbDDtrHDtbDHvLfIvbjIvLfJvrnFubS/sqzBta/As67AtK7IvrnBtK/Gu7bCtrHg2tft6ujq5uTUzMjAs62/s63Vzcnr5+Xt6ejd1tPCtbDEubPJvrrMwr3m4d/FurXEuLPy7+7////Z0c7Jvrjb1NDBtK7Nwr78+/u+savh29jFubPKv7rY0Mzx7u3v7OvWzsrY0M3n4uC9sKnEubT9/PvQx8PCtrDSycXh29m+sKvGurX59/fPxcDIvLjAtK/w7Ov9/f329PT+/v7q5eTEuLLDt7Ly8O/g29f7+vnHu7bRyMPOxL/Jv7rPxcHQx8LKwLvRyMTFurTDt7HHvbjFubUAAADnZPI4AAAAKXRSTlMAAAAAAAAAAABZ5/Lxo+uv7q/mrq6uouzq5enp7O7v7e7x8Xjr8vLui9xh21wAAAABYktHRJfmbhuvAAAACXBIWXMAAH6MAAB+jAH2GftsAAAAB3RJTUUH6AsbFycQGBDFNAAAAeJJREFUSMft0vdb00AYB/CUKYgylCEONspqKwIulHUv12sSWqhlNGXEAAVkqAwR2ShbFEX+XjKah9H69O7RH/P96fvk3k/uyV24lNTrMZJ2g7PFhRMfH8fdrIiVyqr0BNsFkRFbVNdkmkQTqRTC7jCJJrJohNMk9MIkDCJMWITTcUslTEIliTY2oe2SxCacjtvJSWxCIzYaUfXYbqTW/iT7GoWoq294aubZ8xwKUfGi8uV5GmnEpeQyizxLWMISlrDEBXHnFVvyubuvm1jSfI+739LKkrYHXAEClrQX/qtAKFoF9BeBXcTNCyBqFXihg3iw/thL3MSLowjc6urs8r3x6y/t7untC0hBda6fHxjsGhzg+yPFkPxWUTNMRkDsHtWqL6SuSWPjah0fkyLFxDtFD0yCPGXUaR6QMKPXGQFdFcj/3hj7wAP5aNTZOZjHC3pdwPORe/iMsU+L4P5s1KUvsBxc0etKcDlCSKv6Uh+/Bmh9Q6ubEAQgAf1xgEQ5K3/PlvL1m2sbA94Z6lWU3VVBPSsk7O0r+3vhz7h8H6L/4PB7aPtIrUc7HT9+yiHtErCHHP86Dt/N1TsXf0snSDQ2RN5FcwjkFvk//ldUougPmyjm4JRJeBBXUlpWTp+yh4/OAGRXYCGerI47AAAAAElFTkSuQmCC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.3 KiB |
31
public/images/icons/filetype/jpg_64.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64" viewBox="0 0 512 512">
|
||||
<metadata><?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.6-c138 79.159824, 2016/09/14-01:09:01 ">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""/>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<?xpacket end="w"?></metadata>
|
||||
<image id="Capa_0" data-name="Capa 0" x="59" width="394" height="512" xlink:href="data:img/png;base64,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"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
31
public/images/icons/filetype/mp4_64.svg
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
31
public/images/icons/filetype/pdf_64.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64" viewBox="0 0 512 512">
|
||||
<metadata><?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.6-c138 79.159824, 2016/09/14-01:09:01 ">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""/>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<?xpacket end="w"?></metadata>
|
||||
<image id="Capa_0" data-name="Capa 0" x="59" width="394" height="512" xlink:href="data:img/png;base64,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"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
31
public/images/icons/filetype/png_64.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64" viewBox="0 0 512 512">
|
||||
<metadata><?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.6-c138 79.159824, 2016/09/14-01:09:01 ">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""/>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<?xpacket end="w"?></metadata>
|
||||
<image id="Capa_0" data-name="Capa 0" x="59" width="394" height="512" xlink:href="data:img/png;base64,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"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
31
public/images/icons/filetype/txt_64.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64" viewBox="0 0 512 512">
|
||||
<metadata><?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.6-c138 79.159824, 2016/09/14-01:09:01 ">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""/>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<?xpacket end="w"?></metadata>
|
||||
<image id="Capa_0" data-name="Capa 0" x="59" width="394" height="512" xlink:href="data:img/png;base64,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"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
98
public/images/icons/green_house.svg
Normal file
@@ -0,0 +1,98 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--twemoji" preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<path fill="#C1694F" d="M17.094 24.242H7.56s1.653 6.469 4.254 9.581c1.771 2.118 4.75 2.282 5.81 2.282c2.118 0 4.818-.174 6.589-2.294c3.087-3.691 4.534-9.569 4.534-9.569H17.094z">
|
||||
|
||||
</path>
|
||||
|
||||
<path fill="#A04E3C" d="M27.628 27.666c.458-1.18.771-2.184.947-2.795a2.457 2.457 0 0 0-.432-.629H8.164a2.452 2.452 0 0 0-.432.628c.174.608.479 1.608.892 2.753c1.535 1.364 4.723 2.458 9.529 2.458c4.754 0 7.922-1.072 9.475-2.415z">
|
||||
|
||||
</path>
|
||||
|
||||
<ellipse fill="#A04E3C" cx="18" cy="19.716" rx="11.768" ry="2.716">
|
||||
|
||||
</ellipse>
|
||||
|
||||
<path fill="#662113" d="M7.819 21.073c2.036.811 5.828 1.358 10.181 1.358s8.145-.547 10.181-1.358c-2.036-.811-5.828-1.358-10.181-1.358s-8.145.548-10.181 1.358z">
|
||||
|
||||
</path>
|
||||
|
||||
<g fill="#3E721D">
|
||||
|
||||
<path d="M18.733 23.524a.532.532 0 0 1-.525-.452c-1.074-7.082.905-17.425 5.465-20.944a.533.533 0 0 1 .651.843c-4.145 3.198-6.091 13.166-5.064 19.941a.53.53 0 0 1-.527.612z">
|
||||
|
||||
</path>
|
||||
|
||||
<path d="M18.745 15.719c.061.401 1.265.552 2.69.335c1.424-.216 2.53-.717 2.469-1.118c-.061-.401-1.265-.552-2.69-.335c-1.425.215-2.53.716-2.469 1.118zm.095 1.739c.061.401 1.358.537 2.897.304c1.539-.234 2.737-.748 2.676-1.15c-.061-.401-1.358-.537-2.897-.304c-1.539.234-2.737.748-2.676 1.15z">
|
||||
|
||||
</path>
|
||||
|
||||
<ellipse transform="rotate(-8.632 21.821 18.732)" cx="21.824" cy="18.734" rx="3.083" ry=".735">
|
||||
|
||||
</ellipse>
|
||||
|
||||
<path d="M19.095 14.226c.103.393 1.236.435 2.531.095c1.294-.34 2.26-.935 2.157-1.327c-.103-.393-1.236-.435-2.531-.095c-1.294.341-2.26.935-2.157 1.327zm.178-1.765c.121.388 1.149.411 2.298.053c1.148-.358 1.98-.963 1.859-1.35c-.121-.388-1.149-.411-2.298-.053c-1.148.358-1.98.963-1.859 1.35zm.105-1.584c.127.325 1.068.263 2.102-.14c1.034-.403 1.77-.993 1.643-1.318c-.127-.325-1.068-.263-2.102.14c-1.034.402-1.769.992-1.643 1.318zm.802-1.738c.113.291.898.258 1.752-.075c.854-.332 1.455-.838 1.342-1.129c-.113-.291-.898-.258-1.752.075c-.855.333-1.456.838-1.342 1.129zm.652-1.918c.05.308.718.456 1.493.331c.774-.126 1.361-.478 1.311-.786c-.05-.308-.718-.456-1.493-.331c-.774.125-1.361.477-1.311.786zm.786-1.276c.052.322.787.47 1.641.331c.854-.139 1.504-.512 1.452-.834c-.052-.322-.787-.47-1.641-.331c-.854.139-1.504.512-1.452.834zm1.152-1.441c.047.287.714.417 1.491.291c.777-.126 1.369-.461 1.322-.748c-.047-.287-.714-.417-1.491-.291c-.776.127-1.368.462-1.322.748zm.949-1.358c.041.251.603.369 1.256.263c.653-.106 1.149-.396 1.108-.647c-.041-.251-.603-.369-1.256-.263s-1.149.396-1.108.647z">
|
||||
|
||||
</path>
|
||||
|
||||
<path d="M24.431 2.351c.129.219.696.124 1.266-.212c.57-.336.927-.786.798-1.005c-.129-.219-.696-.124-1.266.212c-.57.335-.927.786-.798 1.005z">
|
||||
|
||||
</path>
|
||||
|
||||
<path d="M24.066 2.291c.244.073.596-.381.786-1.014c.19-.634.147-1.206-.096-1.28c-.244-.073-.596.381-.786 1.014c-.19.634-.147 1.207.096 1.28zm-10.796 13.2c0 .406 1.168.735 2.609.735s2.609-.329 2.609-.735c0-.406-1.168-.735-2.609-.735s-2.609.329-2.609.735zm-.336 1.458c-.039.404 1.092.844 2.526.981c1.434.138 2.628-.078 2.667-.482c.039-.404-1.092-.844-2.526-.981c-1.434-.138-2.628.078-2.667.482zm-.537 1.653c-.039.404 1.22.856 2.811 1.009c1.591.153 2.913-.051 2.952-.455c.039-.404-1.22-.856-2.811-1.009c-1.592-.153-2.913.051-2.952.455zm1.64-4.934c-.034.405.968.818 2.237.924c1.269.106 2.325-.137 2.359-.541c.034-.405-.968-.818-2.237-.924s-2.325.136-2.359.541zm.778-1.404c-.018.406.907.776 2.065.828c1.159.051 2.113-.236 2.131-.641c.018-.406-.907-.776-2.065-.828c-1.159-.052-2.113.235-2.131.641z">
|
||||
|
||||
</path>
|
||||
|
||||
<path d="M15.825 10.677c-.068.352.654.788 1.612.973c.958.185 1.79.049 1.858-.303c.068-.352-.654-.788-1.612-.973c-.958-.185-1.79-.049-1.858.303zm.464-1.802c-.135.333.489.9 1.393 1.267c.904.367 1.747.395 1.882.063c.135-.333-.488-.9-1.393-1.267c-.905-.367-1.747-.395-1.882-.063zm.381-2.029c-.189.275.338.966 1.178 1.543c.84.577 1.673.821 1.863.546c.189-.275-.338-.966-1.178-1.543s-1.674-.821-1.863-.546zm1.131-.878c-.189.275.251.906.983 1.409s1.479.687 1.668.412c.189-.275-.251-.906-.983-1.409s-1.479-.687-1.668-.412zm.7-1.543c-.233.239.094.936.731 1.555c.637.619 1.341.927 1.574.688c.233-.239-.094-.936-.731-1.555c-.636-.619-1.341-.927-1.574-.688zm.914-1.103c-.233.239.054.896.64 1.466s1.25.839 1.483.599c.233-.239-.054-.896-.64-1.466s-1.25-.838-1.483-.599zm.94-.972c-.229.235.018.848.553 1.367s1.153.75 1.382.515c.229-.235-.018-.848-.553-1.367s-1.153-.751-1.382-.515z">
|
||||
|
||||
</path>
|
||||
|
||||
<ellipse transform="rotate(-32.542 22.187 2.44)" cx="22.186" cy="2.439" rx=".595" ry="1.349">
|
||||
|
||||
</ellipse>
|
||||
|
||||
<path d="M22.804.537c-.261.09-.305.65-.097 1.251c.208.6.588 1.014.849.923s.305-.65.097-1.251c-.208-.6-.588-1.013-.849-.923z">
|
||||
|
||||
</path>
|
||||
|
||||
</g>
|
||||
|
||||
<path fill="#3E721D" d="M16.885 23.63a.534.534 0 0 1-.453-.576c.753-8.173-2.365-13.97-8.781-16.321a.53.53 0 0 1-.316-.682a.529.529 0 0 1 .683-.317c6.912 2.532 10.277 8.718 9.475 17.418a.532.532 0 0 1-.608.478z">
|
||||
|
||||
</path>
|
||||
|
||||
<path fill="#3E721D" d="M16.664 16.81c-.008.406-1.181.713-2.622.686s-2.602-.378-2.595-.784c.008-.406 1.182-.713 2.622-.687c1.441.028 2.602.379 2.595.785zm-.002 1.758c-.008.406-1.181.713-2.622.687s-2.912-.478-2.904-.884c.007-.406 1.491-.613 2.932-.586c1.44.026 2.601.377 2.594.783z">
|
||||
|
||||
</path>
|
||||
|
||||
<path fill="#5C913B" d="M17.077 20.098c-.008.406-1.181.713-2.622.687c-1.44-.027-2.912-.478-2.904-.884c.008-.406 1.491-.613 2.932-.586s2.601.378 2.594.783z">
|
||||
|
||||
</path>
|
||||
|
||||
<path fill="#3E721D" d="M16.499 15.517c-.054.402-1.173.584-2.5.405c-1.326-.179-2.358-.65-2.304-1.052c.054-.402 1.174-.584 2.5-.405c1.327.179 2.358.65 2.304 1.052zm-.744-1.769c-.054.402-1.064.599-2.256.438c-1.192-.16-2.114-.617-2.06-1.019s1.064-.599 2.256-.438c1.192.161 2.114.617 2.06 1.019z">
|
||||
|
||||
</path>
|
||||
|
||||
<path fill="#3E721D" d="M15.314 12.1c.004.349-.893.642-2.002.654c-1.11.012-2.012-.261-2.016-.61c-.004-.349.893-.642 2.002-.654c1.109-.012 2.012.261 2.016.61zm-1.392-1.313c.003.312-.737.574-1.653.584c-.917.01-1.662-.235-1.666-.547c-.003-.312.737-.574 1.653-.584c.917-.011 1.663.234 1.666.547zM12.601 9.25c.068.305-.496.691-1.262.863c-.765.172-1.441.064-1.51-.24c-.068-.305.496-.691 1.262-.863c.766-.172 1.442-.065 1.51.24zm-1.204-.891c.071.318-.555.73-1.399.919c-.844.19-1.587.086-1.658-.232s.555-.73 1.399-.919c.844-.19 1.586-.086 1.658.232zM9.79 7.452c.064.283-.507.653-1.275.826c-.767.172-1.441.082-1.505-.201c-.064-.284.507-.653 1.275-.826c.768-.172 1.442-.082 1.505.201zm-1.387-.907c.056.248-.422.567-1.068.712c-.645.145-1.214.062-1.269-.187c-.056-.248.422-.567 1.067-.712c.646-.145 1.214-.061 1.27.187z">
|
||||
|
||||
</path>
|
||||
|
||||
<path fill="#3E721D" d="M7.446 6.073c-.038.251-.6.375-1.254.275s-1.153-.384-1.114-.636c.038-.251.6-.375 1.254-.275c.653.1 1.152.385 1.114.636z">
|
||||
|
||||
</path>
|
||||
|
||||
<path fill="#5C913B" d="M7.762 5.882c-.198.159-.694-.131-1.107-.648c-.413-.517-.587-1.065-.388-1.223c.199-.159.695.131 1.108.648c.412.517.586 1.064.387 1.223zm14.18 9.727c.065.401-1.036.912-2.458 1.141c-1.422.229-2.628.09-2.692-.311c-.065-.401 1.036-.912 2.458-1.141c1.422-.229 2.628-.09 2.692.311zm.884 1.279c.065.401-1.478 1.199-2.901 1.429c-1.422.229-2.628.09-2.693-.311c-.065-.401 1.036-.912 2.458-1.141c1.424-.229 3.071-.378 3.136.023zm.553 1.679c.065.401-1.877 1.301-3.299 1.53c-1.422.229-2.628.09-2.692-.311c-.065-.401 1.036-.912 2.458-1.141c1.422-.229 3.468-.479 3.533-.078zm-2.585-4.869c.124.387-.758 1.016-1.97 1.405c-1.212.39-2.296.392-2.42.005c-.124-.387.758-1.016 1.97-1.405s2.296-.391 2.42-.005zm-.799-1.416c.124.387-.67.988-1.774 1.342s-2.1.329-2.224-.057c-.124-.387.67-.988 1.774-1.342c1.104-.355 2.099-.33 2.224.057zm-1.459-1.692c.194.302-.313.975-1.133 1.504c-.82.529-1.643.713-1.837.411c-.194-.302.313-.975 1.133-1.504c.82-.528 1.642-.712 1.837-.411zm-1.102-1.499c.249.258-.118 1.017-.82 1.695c-.702.678-1.473 1.018-1.723.76c-.249-.258.118-1.017.82-1.695c.702-.678 1.474-1.018 1.723-.76zm-1.11-1.741c.278.185.047 1.022-.517 1.871c-.564.848-1.247 1.386-1.525 1.201c-.278-.185-.047-1.022.517-1.871c.564-.848 1.247-1.386 1.525-1.201zm-1.377-.393c.278.185.105.934-.387 1.674c-.492.74-1.116 1.189-1.394 1.004c-.278-.185-.105-.934.387-1.674c.491-.739 1.115-1.189 1.394-1.004zm-1.225-1.17c.305.135.261.903-.099 1.715s-.899 1.36-1.204 1.225c-.305-.135-.261-.903.099-1.715c.359-.812.899-1.361 1.204-1.225zm-1.259-.683c.305.135.284.851-.047 1.599s-.848 1.245-1.153 1.109s-.284-.851.047-1.599s.847-1.244 1.153-1.109zm-1.235-.552c.3.133.299.793-.003 1.475c-.302.681-.79 1.126-1.09.993c-.3-.133-.299-.793.003-1.475c.302-.681.79-1.126 1.09-.993z">
|
||||
|
||||
</path>
|
||||
|
||||
<path fill="#5C913B" d="M9.812 3.992c.323.061.473.704.335 1.436c-.138.732-.511 1.277-.834 1.216c-.323-.061-.473-.704-.335-1.436c.138-.733.511-1.277.834-1.216zM8.28 3.784c.276-.014.525.49.556 1.124c.031.635-.167 1.16-.443 1.173c-.276.014-.525-.49-.556-1.124c-.032-.634.167-1.16.443-1.173zm10.596 21.305a.532.532 0 0 1-.528-.603C19.829 13.45 26.63 8.516 30.569 7.998a.534.534 0 0 1 .597.458a.53.53 0 0 1-.458.597c-3.627.477-9.903 5.127-11.305 15.574a.532.532 0 0 1-.527.462z">
|
||||
|
||||
</path>
|
||||
|
||||
<path fill="#5C913B" d="M20.889 18.194c-.032.405 1.106.825 2.542.939c1.436.114 2.626-.122 2.659-.527c.032-.405-1.106-.825-2.542-.939c-1.437-.113-2.627.122-2.659.527z">
|
||||
|
||||
</path>
|
||||
|
||||
<ellipse transform="rotate(-2.063 23.613 16.845)" fill="#5C913B" cx="23.601" cy="16.836" rx="2.423" ry=".735">
|
||||
|
After Width: | Height: | Size: 21 KiB |
2
public/images/icons/join.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--twemoji" preserveAspectRatio="xMidYMid meet"><path fill="#EF9645" d="M17 18s-6.031 5.274-7.74 6.832c-1.323 1.203.88 3.498 2.135 2.452c3.333-2.785 6.493-4.85 6.493-4.85l.367.526c-1.309.79-4.693 2.858-6.449 4.438c-1.324 1.19.767 3.553 2.093 2.365c1.822-1.634 5.995-4.565 5.995-4.565l.368.384c-1.08.679-2.772 1.94-4.985 3.958c-1.332 1.215.619 2.485 1.764 1.534c3.116-2.586 3.232-4.383 6.661-5.85C28.935 22.986 17 18 17 18z"></path><path fill="#FFCC4D" d="M29.979 8.836c.881-.438 1.653.144 2.106 1.053c.448.911 2.532 5.015 2.953 5.869c.425.852.191 2.103-.719 2.553c-.911.453-2.234 1.054-3.092 2.512c-.465.794-2.396 2.887-7.629 5.129c-3.427 1.462-6.165 3.938-7.261 4.8c-.886.698-2.673-.637-1.343-1.85c2.214-2.02 3.906-3.281 4.985-3.958l-.368-.386s-4.381 3.056-6.238 4.65c-1.261 1.085-2.938-.953-1.613-2.142c1.757-1.58 4.902-3.954 6.211-4.744l-.366-.529s-3.43 2.231-6.765 5.02c-1.113.93-2.847-1.159-1.525-2.364c1.71-1.555 5.043-4.343 7.093-5.499l-.196-.609s-3.218 1.805-5.996 4.494c-.994.961-2.974-.818-1.525-2.293c3.462-3.522 7.958-5.609 9.154-6.413c1.606-1.08 2.451-1.754 1.606-2.133c-1.274-.573-3.396-2.582-4.21-4.21c-1.053-2.106.44-3.77 2.105-2.106c1.053 1.053 3.159 3.158 5.263 3.158c2.829 0 3.262 1.053 5.264 1.053c1.054-.002 2.106-1.055 2.106-1.055"></path><path fill="#EF9645" d="M25.048 15.167c-.334.001-1.439-.15-2.684-.725c-1.073-.494-1.905-1.39-2.597-2.142c.039.161-.065.354-.283.58c.681.792 1.723 1.696 2.654 2.146c1.082.525 2.315.79 2.871.767c.572-.024.385-.622.039-.626zm2.167 8.58c-2.264-1.054-4.062-1.934-.974-.66c1.277.525 3.194-2.06 1.315-2.917C23.774 18.444 12 12 12 12s-6.368 7.437-5.58 7.914c.789.474 1.118 3.849 5.665 6.818c4.298 2.808 6.975 4.015 7.981 4.518c2.107 1.053 3.477-1.678 1.833-2.412c-2.833-1.266-2.734-1.343 1.418.382c1.537.64 2.829-2.25 1.205-2.981c-2.422-1.089-2.64-1.221 1.239.527c1.435.643 3.073-2.264 1.454-3.019z"></path><path fill="#FFDC5D" d="M7.09 9.278a1.808 1.808 0 0 0-2.485.615c-.525.871-2.828 4.683-3.319 5.5c-.492.816-.364 2.079.506 2.604c.87.526 2.447 1.477 3.236 1.952c.789.477 4.011 4.477 8.662 7.279c4.648 2.808 6.366 3.286 7.412 3.697c1.05.41 2.437-1.683.793-2.416c-2.837-1.268-4.836-2.438-5.859-3.205l.318-.585s2.837 2.12 6.987 3.848c1.537.64 2.79-1.754 1.167-2.487c-2.423-1.089-5.412-2.894-6.651-3.79l.346-.571s3.946 2.494 7.825 4.242c1.431.645 2.632-1.786 1.013-2.542c-2.26-1.054-5.448-2.955-7.394-4.277l.338-.5s3.615 2.551 6.705 3.823c1.277.528 2.78-1.861.899-2.721C23.81 18.019 19.525 15 18.401 14.1c-.918-.737-1.181-2.413 1.052-3.158c3.158-1.053 4.211-3.158 4.211-4.211c0-1.489-1.376-2.146-2.104-1.053c-2.107 3.158-3.166 2.082-5.263 3.158c-2.514 1.291-5.466 2.662-7.179 1.628c-.733-.441-2.028-1.186-2.028-1.186"></path><path fill="#FFCC4D" d="M18.948 11.721l5.426-2.712a8.282 8.282 0 0 0-1.763-.172c-2.104 0-4.21-2.105-5.263-3.158c-1.665-1.664-3.158 0-2.105 2.106c.715 1.428 2.433 3.148 3.705 3.936z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
2
public/images/icons/list.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--twemoji" preserveAspectRatio="xMidYMid meet"><path fill="#C1694F" d="M32 34a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h24a2 2 0 0 1 2 2v27z"></path><path fill="#FFF" d="M29 32a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h20a1 1 0 0 1 1 1v23z"></path><path fill="#CCD6DD" d="M25 3h-4a3 3 0 1 0-6 0h-4a2 2 0 0 0-2 2v5h18V5a2 2 0 0 0-2-2z"></path><circle fill="#292F33" cx="18" cy="3" r="2"></circle><path fill="#99AAB5" d="M20 14a1 1 0 0 1-1 1h-9a1 1 0 0 1 0-2h9a1 1 0 0 1 1 1zm7 4a1 1 0 0 1-1 1H10a1 1 0 0 1 0-2h16a1 1 0 0 1 1 1zm0 4a1 1 0 0 1-1 1H10a1 1 0 1 1 0-2h16a1 1 0 0 1 1 1zm0 4a1 1 0 0 1-1 1H10a1 1 0 1 1 0-2h16a1 1 0 0 1 1 1zm0 4a1 1 0 0 1-1 1h-9a1 1 0 1 1 0-2h9a1 1 0 0 1 1 1z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1010 B |
2
public/images/icons/programmer.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--twemoji" preserveAspectRatio="xMidYMid meet"><path fill="#77B255" d="M35 36v-5a6 6 0 0 0-6-6H13a6 6 0 0 0-6 6v5h28z"></path><path fill="#FFDC5D" d="M16.64 25.106c0 .894 2.36 1.993 4.36 1.993s4.359-1.099 4.359-1.992V21.29h-8.72v3.816z"></path><path fill="#F9CA55" d="M16.632 22.973c1.216 1.374 2.724 1.746 4.364 1.746c1.639 0 3.146-.373 4.363-1.746v-3.491h-8.728v3.491z"></path><path fill="#FFDC5D" d="M14.444 12.936c0 1.448-.734 2.622-1.639 2.622s-1.639-1.174-1.639-2.622s.734-2.623 1.639-2.623c.905-.001 1.639 1.174 1.639 2.623m16.389 0c0 1.448-.733 2.622-1.639 2.622c-.905 0-1.639-1.174-1.639-2.622s.733-2.623 1.639-2.623c.906-.001 1.639 1.174 1.639 2.623"></path><path fill="#FFDC5D" d="M12.477 13.96c0-5.589 3.816-10.121 8.523-10.121s8.522 4.532 8.522 10.121S25.707 24.081 21 24.081c-4.706-.001-8.523-4.532-8.523-10.121"></path><path fill="#C1694F" d="M21 20.802c-2.754 0-3.6-.705-3.741-.848a.655.655 0 0 1 .902-.95c.052.037.721.487 2.839.487c2.2 0 2.836-.485 2.842-.49a.638.638 0 0 1 .913.015a.669.669 0 0 1-.014.938c-.141.143-.987.848-3.741.848"></path><path fill="#FFAC33" d="M21 0c5.648 0 9.178 4.648 9.178 8.121c0 3.473-.706 4.863-1.412 3.473l-1.412-2.778s-4.235 0-5.647-1.39c0 0 2.118 4.168-2.118 0c0 0 .706 2.779-3.53-.694c0 0-2.118 1.389-2.824 4.862c-.196.964-1.412 0-1.412-3.473C11.822 4.648 14.646 0 21 0"></path><path fill="#662113" d="M17 14c-.55 0-1-.45-1-1v-1c0-.55.45-1 1-1s1 .45 1 1v1c0 .55-.45 1-1 1m8 0c-.55 0-1-.45-1-1v-1c0-.55.45-1 1-1s1 .45 1 1v1c0 .55-.45 1-1 1"></path><path fill="#C1694F" d="M21.75 16.75h-1.5c-.413 0-.75-.337-.75-.75s.337-.75.75-.75h1.5c.413 0 .75.337.75.75s-.337.75-.75.75"></path><path fill="#E1E8ED" d="M33 35a1 1 0 0 1-1 1H22a1 1 0 1 1 0-2h10a1 1 0 0 1 1 1z"></path><path fill="#E1E8ED" d="M20.24 22H3.759c-1.524 0-3.478.771-2.478 3.531l3.072 8.475C4.354 34.006 4.75 36 7 36h20l-4-11.24c-.438-1.322-1.235-2.76-2.76-2.76z"></path><path fill="#99AAB5" d="M19.24 22H2.759c-1.524 0-3.478.771-2.478 3.531l3.072 8.475C3.354 34.006 3.75 36 6 36h20l-4-11.24c-.438-1.322-1.235-2.76-2.76-2.76z"></path><path fill="#E1E8ED" d="M14.019 29.283c.524 1.572.099 3.13-.949 3.479c-1.048.35-2.322-.641-2.846-2.213s-.099-3.13.949-3.479c1.048-.349 2.323.641 2.846 2.213zM19 24.75H3a.75.75 0 0 1 0-1.5h16a.75.75 0 0 1 0 1.5z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
2
public/images/icons/subvencion4.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--twemoji" preserveAspectRatio="xMidYMid meet"><path fill="#FDD888" d="M31.898 23.938C31.3 17.32 28 14 28 14l-6-8h-8l-6 8s-1.419 1.433-2.567 4.275C3.444 18.935 2 20.789 2 23a4.97 4.97 0 0 0 1.609 3.655A4.943 4.943 0 0 0 3 29c0 1.958 1.136 3.636 2.775 4.456C7.058 35.378 8.772 36 10 36h16c1.379 0 3.373-.779 4.678-3.31C32.609 31.999 34 30.17 34 28a4.988 4.988 0 0 0-2.102-4.062zM18 6c.55 0 1.058-.158 1.5-.416c.443.258.951.416 1.5.416c1.657 0 4-2.344 4-4c0 0 0-2-2-2c-.788 0-1 1-2 1s-1-1-3-1s-2 1-3 1s-1.211-1-2-1c-2 0-2 2-2 2c0 1.656 2.344 4 4 4c.549 0 1.057-.158 1.5-.416c.443.258.951.416 1.5.416z"></path><path fill="#BF6952" d="M24 6a1 1 0 0 1-1 1H13a1 1 0 0 1 0-2h10a1 1 0 0 1 1 1z"></path><path fill="#67757F" d="M23.901 24.542c0-4.477-8.581-4.185-8.581-6.886c0-1.308 1.301-1.947 2.811-1.947c2.538 0 2.99 1.569 4.139 1.569c.813 0 1.205-.493 1.205-1.046c0-1.284-2.024-2.256-3.965-2.592V12.4c0-.773-.65-1.4-1.454-1.4c-.805 0-1.456.627-1.456 1.4v1.283c-2.116.463-3.937 1.875-3.937 4.176c0 4.299 8.579 4.125 8.579 7.145c0 1.047-1.178 2.093-3.111 2.093c-2.901 0-3.867-1.889-5.045-1.889c-.574 0-1.087.464-1.087 1.164c0 1.113 1.938 2.451 4.603 2.824l-.001.01v1.398c0 .772.652 1.4 1.456 1.4c.804 0 1.455-.628 1.455-1.4v-1.398c0-.017-.008-.03-.009-.045c2.398-.43 4.398-1.932 4.398-4.619z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
2
public/images/icons/user.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--twemoji" preserveAspectRatio="xMidYMid meet"><path fill="#269" d="M24 26.799v-2.566c2-1.348 4.08-3.779 4.703-6.896c.186.103.206.17.413.17c.991 0 1.709-1.287 1.709-2.873c0-1.562-.823-2.827-1.794-2.865c.187-.674.293-1.577.293-2.735C29.324 5.168 26 .527 18.541.527c-6.629 0-10.777 4.641-10.777 8.507c0 1.123.069 2.043.188 2.755c-.911.137-1.629 1.352-1.629 2.845c0 1.587.804 2.873 1.796 2.873c.206 0 .025-.067.209-.17C8.952 20.453 11 22.885 13 24.232v2.414c-5 .645-12 3.437-12 6.23v1.061C1 35 2.076 35 3.137 35h29.725C33.924 35 35 35 35 33.938v-1.061c0-2.615-6-5.225-11-6.078z"></path></svg>
|
||||
|
After Width: | Height: | Size: 892 B |
BIN
public/images/logo.png
Normal file
|
After Width: | Height: | Size: 308 KiB |
50
public/images/logo.svg
Normal file
|
After Width: | Height: | Size: 764 KiB |
14
src/api/axiosInstance.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import axios from "axios";
|
||||
|
||||
const createAxiosInstance = (baseURL, token) => {
|
||||
const instance = axios.create({
|
||||
baseURL,
|
||||
headers: {
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
},
|
||||
});
|
||||
|
||||
return instance;
|
||||
};
|
||||
|
||||
export default createAxiosInstance;
|
||||
92
src/components/AnimatedDropdown.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useRef, useEffect, cloneElement } from 'react';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import { AnimatePresence, motion as _motion } from 'framer-motion';
|
||||
import '../css/AnimatedDropdown.css';
|
||||
|
||||
const AnimatedDropdown = ({
|
||||
trigger,
|
||||
icon,
|
||||
variant = "secondary",
|
||||
className = "",
|
||||
buttonStyle = "",
|
||||
show,
|
||||
onToggle,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
children
|
||||
}) => {
|
||||
const isControlled = show !== undefined;
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const actualOpen = isControlled ? show : open;
|
||||
|
||||
const toggle = () => {
|
||||
const newState = !actualOpen;
|
||||
if (!isControlled) setOpen(newState);
|
||||
onToggle?.(newState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target) &&
|
||||
!triggerRef.current?.contains(e.target)
|
||||
) {
|
||||
if (!isControlled) setOpen(false);
|
||||
onToggle?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isControlled, onToggle]);
|
||||
|
||||
const triggerElement = trigger
|
||||
? (typeof trigger === "function"
|
||||
? trigger({ onClick: toggle, ref: triggerRef })
|
||||
: cloneElement(trigger, { onClick: toggle, ref: triggerRef }))
|
||||
: (
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant={variant}
|
||||
className={`circle-btn ${buttonStyle}`}
|
||||
onClick={toggle}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`position-relative d-inline-block`}
|
||||
onClick={toggle}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
ref={triggerRef}
|
||||
>
|
||||
{triggerElement}
|
||||
|
||||
<AnimatePresence>
|
||||
{actualOpen && (
|
||||
<_motion.div
|
||||
ref={dropdownRef}
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={dropdownClasses}
|
||||
>
|
||||
{typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children}
|
||||
</_motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedDropdown;
|
||||
122
src/components/AnimatedDropend.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState, useRef, useEffect, cloneElement } from 'react';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import { AnimatePresence, motion as _motion } from 'framer-motion';
|
||||
import '../css/AnimatedDropdown.css';
|
||||
|
||||
const AnimatedDropend = ({
|
||||
trigger,
|
||||
icon,
|
||||
variant = "secondary",
|
||||
className = "",
|
||||
buttonStyle = "",
|
||||
show,
|
||||
onToggle,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
children
|
||||
}) => {
|
||||
const isControlled = show !== undefined;
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const actualOpen = isControlled ? show : open;
|
||||
|
||||
const toggle = (forceValue) => {
|
||||
const newState = typeof forceValue === "boolean" ? forceValue : !actualOpen;
|
||||
if (!isControlled) setOpen(newState);
|
||||
onToggle?.(newState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target) &&
|
||||
!triggerRef.current?.contains(e.target)
|
||||
) {
|
||||
if (!isControlled) setOpen(false);
|
||||
onToggle?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isControlled, onToggle]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isControlled) setOpen(true);
|
||||
onToggle?.(true);
|
||||
onMouseEnter?.();
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!isControlled) setOpen(false);
|
||||
onToggle?.(false);
|
||||
onMouseLeave?.();
|
||||
};
|
||||
|
||||
const triggerElement = trigger
|
||||
? (typeof trigger === "function"
|
||||
? trigger({
|
||||
onClick: e => {
|
||||
e.stopPropagation();
|
||||
toggle();
|
||||
},
|
||||
ref: triggerRef
|
||||
})
|
||||
: cloneElement(trigger, {
|
||||
onClick: e => {
|
||||
e.stopPropagation();
|
||||
toggle();
|
||||
},
|
||||
ref: triggerRef
|
||||
}))
|
||||
: (
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant={variant}
|
||||
className={`circle-btn ${buttonStyle}`}
|
||||
onClick={toggle}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="position-relative d-inline-block dropend"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
ref={triggerRef}
|
||||
>
|
||||
{triggerElement}
|
||||
|
||||
<AnimatePresence>
|
||||
{actualOpen && (
|
||||
<_motion.div
|
||||
ref={dropdownRef}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={dropdownClasses}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '100%',
|
||||
zIndex: 1000,
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children}
|
||||
</_motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedDropend;
|
||||
210
src/components/Anuncios/AnuncioCard.jsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Button, Form } from 'react-bootstrap';
|
||||
import AnimatedDropdown from '../../components/AnimatedDropdown';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEdit, faTrash, faEllipsisVertical } from '@fortawesome/free-solid-svg-icons';
|
||||
import '../../css/AnuncioCard.css';
|
||||
import { renderErrorAlert } from '../../util/alertHelpers';
|
||||
import {
|
||||
EditorProvider,
|
||||
Editor,
|
||||
} from 'react-simple-wysiwyg';
|
||||
import {
|
||||
Toolbar,
|
||||
Separator,
|
||||
BtnBold,
|
||||
BtnItalic,
|
||||
BtnUnderline,
|
||||
BtnStrikeThrough,
|
||||
BtnNumberedList,
|
||||
BtnBulletList,
|
||||
BtnLink,
|
||||
BtnClearFormatting,
|
||||
} from 'react-simple-wysiwyg';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
0: { label: 'BAJA', className: 'text-success' },
|
||||
1: { label: 'MEDIA', className: 'text-warning' },
|
||||
2: { label: 'ALTA', className: 'text-danger' },
|
||||
};
|
||||
|
||||
const formatDateTime = (iso) => {
|
||||
const date = new Date(iso);
|
||||
return {
|
||||
date: date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
||||
time: date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit', hour12: false }),
|
||||
};
|
||||
};
|
||||
|
||||
const AnuncioCard = ({ anuncio, isNew = false, onCreate, onUpdate, onDelete, onCancel, error, onClearError }) => {
|
||||
const createMode = isNew;
|
||||
const [editMode, setEditMode] = useState(createMode);
|
||||
const [showFullBody, setShowFullBody] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
body: anuncio.body || '',
|
||||
priority: anuncio.priority ?? 1,
|
||||
published_by: JSON.parse(localStorage.getItem('user'))?.user_id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setFormData({
|
||||
body: anuncio.body || '',
|
||||
priority: anuncio.priority ?? 1,
|
||||
published_by: JSON.parse(localStorage.getItem('user'))?.user_id,
|
||||
});
|
||||
}
|
||||
}, [anuncio, editMode]);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (onClearError) onClearError();
|
||||
setEditMode(true);
|
||||
};
|
||||
|
||||
const handleDelete = () => typeof onDelete === 'function' && onDelete(anuncio.announce_id);
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onClearError) onClearError();
|
||||
if (createMode && onCancel) return onCancel();
|
||||
setEditMode(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onClearError) onClearError();
|
||||
const sanitizedBody = DOMPurify.sanitize(formData.body);
|
||||
formData.body = sanitizedBody;
|
||||
const updated = { ...anuncio, ...formData };
|
||||
if (createMode && typeof onCreate === 'function') return onCreate(updated);
|
||||
if (typeof onUpdate === 'function') return onUpdate(updated, anuncio.announce_id);
|
||||
};
|
||||
|
||||
const handleChange = (field, value) => setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
const { date, time } = formatDateTime(anuncio.created_at);
|
||||
const priorityInfo = PRIORITY_CONFIG[formData.priority] || PRIORITY_CONFIG[1];
|
||||
const isLongBody = formData.body.length > 300;
|
||||
const displayBody = isLongBody && !showFullBody
|
||||
? `${formData.body.slice(0, 300)}...`
|
||||
: formData.body;
|
||||
|
||||
const insertImage = () => {
|
||||
const url = prompt('Introduce la URL de la imagen:');
|
||||
if (url) {
|
||||
const imgHTML = `<img src="${url}" alt="imagen" style="max-width: 100%;" />`;
|
||||
handleChange('body', formData.body + imgHTML);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="anuncio-card rounded-4 border-0 shadow-sm mb-4">
|
||||
<Card.Header className="d-flex justify-content-between align-items-center rounded-top-4 px-3 py-2">
|
||||
<div className="d-flex flex-column">
|
||||
<span className="fw-bold">📢 Anuncio #{anuncio.announce_id}</span>
|
||||
<small className="muted">
|
||||
Publicado el {date} a las {time} por{' '}
|
||||
<span className="fw-semibold">#{anuncio.published_by}</span>
|
||||
</small>
|
||||
</div>
|
||||
{!createMode && !editMode && (
|
||||
<AnimatedDropdown
|
||||
className="end-0"
|
||||
buttonStyle="bg-transparent border-0"
|
||||
icon={<FontAwesomeIcon icon={faEllipsisVertical} className="fa-xl" />}
|
||||
>
|
||||
{({ closeDropdown }) => (
|
||||
<>
|
||||
<div className="dropdown-item d-flex align-items-center" onClick={() => { handleEdit(); closeDropdown(); }}>
|
||||
<FontAwesomeIcon icon={faEdit} className="me-2" />Editar
|
||||
</div>
|
||||
<hr className="dropdown-divider" />
|
||||
<div className="dropdown-item d-flex align-items-center text-danger" onClick={() => { handleDelete(); closeDropdown(); }}>
|
||||
<FontAwesomeIcon icon={faTrash} className="me-2" />Eliminar
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatedDropdown>
|
||||
)}
|
||||
</Card.Header>
|
||||
|
||||
<Card.Body className="py-3">
|
||||
{(editMode || createMode) && renderErrorAlert(error)}
|
||||
|
||||
{editMode || createMode ? (
|
||||
<EditorProvider>
|
||||
<Editor
|
||||
value={formData.body}
|
||||
onChange={(e) => handleChange('body', e.target.value)}
|
||||
containerProps={{ className: 'mb-2' }}
|
||||
>
|
||||
<Toolbar>
|
||||
<BtnBold />
|
||||
<BtnItalic />
|
||||
<BtnUnderline />
|
||||
<BtnStrikeThrough />
|
||||
<BtnClearFormatting />
|
||||
<Separator />
|
||||
<BtnNumberedList />
|
||||
<BtnBulletList />
|
||||
<Separator />
|
||||
<BtnLink />
|
||||
<button
|
||||
type="button"
|
||||
onClick={insertImage}
|
||||
className="btn"
|
||||
title="Insertar imagen desde URL"
|
||||
>
|
||||
🖼️
|
||||
</button>
|
||||
</Toolbar>
|
||||
</Editor>
|
||||
</EditorProvider>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2" dangerouslySetInnerHTML={{ __html: displayBody }} />
|
||||
|
||||
{isLongBody && (
|
||||
<Button variant='info'
|
||||
className="fw-medium text-dark mt-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowFullBody((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
{showFullBody ? 'Leer menos' : 'Leer más'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{editMode && (
|
||||
<Form.Select
|
||||
className="mb-2 themed-input"
|
||||
value={formData.priority}
|
||||
onChange={(e) => handleChange('priority', parseInt(e.target.value))}
|
||||
>
|
||||
<option value={0}>Prioridad Baja</option>
|
||||
<option value={1}>Prioridad Media</option>
|
||||
<option value={2}>Prioridad Alta</option>
|
||||
</Form.Select>
|
||||
)}
|
||||
|
||||
{editMode && (
|
||||
<div className="d-flex justify-content-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={handleCancel}>Cancelar</Button>
|
||||
<Button variant="primary" size="sm" onClick={handleSave}>Guardar</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
|
||||
{!editMode && (
|
||||
<Card.Footer className="priority-footer text-center rounded-bottom-4 fw-medium py-2">
|
||||
Prioridad: <span className={`fw-bold ${priorityInfo.className}`}>{priorityInfo.label}</span>
|
||||
</Card.Footer>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnuncioCard;
|
||||
108
src/components/Anuncios/AnunciosFilter.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const AnunciosFilter = ({ filters, onChange }) => {
|
||||
const handleCheckboxChange = (key) => {
|
||||
const updated = { ...filters, [key]: !filters[key] };
|
||||
const allPrioridades = ['baja', 'media', 'alta'];
|
||||
const allFechas = ['ultimos7', 'esteMes'];
|
||||
|
||||
updated.todos = (
|
||||
allPrioridades.every(p => updated[p]) &&
|
||||
allFechas.every(f => updated[f])
|
||||
);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const handleTodosChange = () => {
|
||||
const newValue = !filters.todos;
|
||||
onChange({
|
||||
todos: newValue,
|
||||
baja: newValue,
|
||||
media: newValue,
|
||||
alta: newValue,
|
||||
ultimos7: newValue,
|
||||
esteMes: newValue
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="todosCheck"
|
||||
className="me-2"
|
||||
checked={filters.todos}
|
||||
onChange={handleTodosChange}
|
||||
/>
|
||||
<label htmlFor="todosCheck" className="m-0">Mostrar Todos</label>
|
||||
</div>
|
||||
|
||||
<hr className="dropdown-divider" />
|
||||
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="bajaCheck"
|
||||
className="me-2"
|
||||
checked={filters.baja}
|
||||
onChange={() => handleCheckboxChange('baja')}
|
||||
/>
|
||||
<label htmlFor="bajaCheck" className="m-0">Prioridad Baja</label>
|
||||
</div>
|
||||
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="mediaCheck"
|
||||
className="me-2"
|
||||
checked={filters.media}
|
||||
onChange={() => handleCheckboxChange('media')}
|
||||
/>
|
||||
<label htmlFor="mediaCheck" className="m-0">Prioridad Media</label>
|
||||
</div>
|
||||
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="altaCheck"
|
||||
className="me-2"
|
||||
checked={filters.alta}
|
||||
onChange={() => handleCheckboxChange('alta')}
|
||||
/>
|
||||
<label htmlFor="altaCheck" className="m-0">Prioridad Alta</label>
|
||||
</div>
|
||||
|
||||
<hr className="dropdown-divider" />
|
||||
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ultimos7Check"
|
||||
className="me-2"
|
||||
checked={filters.ultimos7}
|
||||
onChange={() => handleCheckboxChange('ultimos7')}
|
||||
/>
|
||||
<label htmlFor="ultimos7Check" className="m-0">Últimos 7 días</label>
|
||||
</div>
|
||||
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="esteMesCheck"
|
||||
className="me-2"
|
||||
checked={filters.esteMes}
|
||||
onChange={() => handleCheckboxChange('esteMes')}
|
||||
/>
|
||||
<label htmlFor="esteMesCheck" className="m-0">Este mes</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AnunciosFilter.propTypes = {
|
||||
filters: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AnunciosFilter;
|
||||
89
src/components/App.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import Header from './Header'
|
||||
import NavBar from './NavBar/NavBar'
|
||||
import Footer from './Footer'
|
||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
import ProtectedRoute from './Auth/ProtectedRoute.jsx'
|
||||
import useSessionRenewal from '../hooks/useSessionRenewal'
|
||||
|
||||
import Home from '../pages/Home'
|
||||
import Socios from '../pages/Socios'
|
||||
import Ingresos from '../pages/Ingresos'
|
||||
import Gastos from '../pages/Gastos'
|
||||
import Balance from '../pages/Balance'
|
||||
import Login from '../pages/Login'
|
||||
import Solicitudes from '../pages/Solicitudes'
|
||||
import Anuncios from '../pages/Anuncios'
|
||||
import ListaEspera from '../pages/ListaEspera'
|
||||
import Building from '../pages/Building'
|
||||
import Documentacion from '../pages/Documentacion'
|
||||
|
||||
import { CONSTANTS } from '../util/constants'
|
||||
import Perfil from '../pages/Perfil.jsx'
|
||||
import Correo from '../pages/Correo.jsx'
|
||||
|
||||
function App() {
|
||||
const { modal: sessionModal } = useSessionRenewal();
|
||||
const routesWithFooter = ["/", "/lista-espera", "/login", "/gestion/socios", "/gestion/ingresos", "/gestion/gastos", "/gestion/balance"];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<NavBar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/lista-espera" element={<ListaEspera />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/gestion/socios" element={
|
||||
<ProtectedRoute minimumRoles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
|
||||
<Socios />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/gestion/ingresos" element={
|
||||
<ProtectedRoute minimumRoles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
|
||||
<Ingresos />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/gestion/gastos" element={
|
||||
<ProtectedRoute minimumRoles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
|
||||
<Gastos />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/gestion/balance" element={
|
||||
<ProtectedRoute minimumRoles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
|
||||
<Balance />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/documentacion" element={
|
||||
<ProtectedRoute>
|
||||
<Documentacion />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/anuncios" element={
|
||||
<ProtectedRoute>
|
||||
<Anuncios />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/gestion/solicitudes" element={
|
||||
<ProtectedRoute minimumRoles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
|
||||
<Solicitudes />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/perfil" element={
|
||||
<ProtectedRoute>
|
||||
<Perfil />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/correo" element={
|
||||
<ProtectedRoute minimumRoles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
|
||||
<Correo />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/*" element={<Building />} />
|
||||
</Routes>
|
||||
{routesWithFooter.includes(useLocation().pathname) ? <Footer /> : null}
|
||||
{sessionModal}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
8
src/components/Auth/IfAuthenticated.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useAuth } from "../../hooks/useAuth.js";
|
||||
|
||||
const IfAuthenticated = ({ children }) => {
|
||||
const { authStatus } = useAuth();
|
||||
return authStatus === "authenticated" ? children : null;
|
||||
};
|
||||
|
||||
export default IfAuthenticated;
|
||||
8
src/components/Auth/IfNotAuthenticated.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useAuth } from "../../hooks/useAuth.js";
|
||||
|
||||
const IfNotAuthenticated = ({ children }) => {
|
||||
const { authStatus } = useAuth();
|
||||
return authStatus === "unauthenticated" ? children : null;
|
||||
};
|
||||
|
||||
export default IfNotAuthenticated;
|
||||
13
src/components/Auth/IfRole.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useAuth } from "../../hooks/useAuth.js";
|
||||
|
||||
const IfRole = ({ roles, children }) => {
|
||||
const { user, authStatus } = useAuth();
|
||||
|
||||
if (authStatus !== "authenticated") return null;
|
||||
|
||||
const userRole = user?.role;
|
||||
|
||||
return roles.includes(userRole) ? children : null;
|
||||
};
|
||||
|
||||
export default IfRole;
|
||||
120
src/components/Auth/LoginForm.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUser } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Form, Button, Alert, FloatingLabel, Row, Col } from 'react-bootstrap';
|
||||
import PasswordInput from './PasswordInput.jsx';
|
||||
|
||||
import { useContext, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { AuthContext } from "../../context/AuthContext.jsx";
|
||||
|
||||
import CustomContainer from '../CustomContainer.jsx';
|
||||
import ContentWrapper from '../ContentWrapper.jsx';
|
||||
|
||||
import '../../css/LoginForm.css';
|
||||
|
||||
const LoginForm = () => {
|
||||
const { login, error } = useContext(AuthContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [formState, setFormState] = useState({
|
||||
emailOrUserName: "",
|
||||
password: "",
|
||||
keepLoggedIn: false
|
||||
});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormState((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formState.emailOrUserName);
|
||||
|
||||
const loginBody = {
|
||||
password: formState.password,
|
||||
keepLoggedIn: Boolean(formState.keepLoggedIn),
|
||||
};
|
||||
|
||||
if (isEmail) {
|
||||
loginBody.email = formState.emailOrUserName;
|
||||
} else {
|
||||
loginBody.userName = formState.emailOrUserName;
|
||||
}
|
||||
|
||||
try {
|
||||
await login(loginBody);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
console.error("Error de login:", err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomContainer>
|
||||
<ContentWrapper>
|
||||
<div className="login-card card shadow p-5 rounded-5 mx-auto col-12 col-md-8 col-lg-6 col-xl-5 d-flex flex-column gap-4">
|
||||
<h1 className="text-center">Inicio de sesión</h1>
|
||||
<Form className="d-flex flex-column gap-5" onSubmit={handleSubmit}>
|
||||
<div className="d-flex flex-column gap-3">
|
||||
<FloatingLabel
|
||||
controlId="floatingUsuario"
|
||||
label={
|
||||
<>
|
||||
<FontAwesomeIcon icon={faUser} className="me-2" />
|
||||
Usuario o Email
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder=""
|
||||
name="emailOrUserName"
|
||||
value={formState.emailOrUserName}
|
||||
onChange={handleChange}
|
||||
className="rounded-4"
|
||||
/>
|
||||
</FloatingLabel>
|
||||
|
||||
<PasswordInput
|
||||
value={formState.password}
|
||||
onChange={handleChange}
|
||||
name="password"
|
||||
/>
|
||||
|
||||
<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center gap-2">
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
name="keepLoggedIn"
|
||||
label="Mantener sesión iniciada"
|
||||
className="text-secondary"
|
||||
value={formState.keepLoggedIn}
|
||||
onChange={(e) => { formState.keepLoggedIn = e.target.checked; setFormState({ ...formState }) }}
|
||||
/>
|
||||
{/*<Link disabled to="#" className="muted">
|
||||
Olvidé mi contraseña
|
||||
</Link>*/}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="danger" className="text-center py-2 mb-0">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<Button type="submit" className="w-75 padding-4 rounded-4 border-0 shadow-sm login-button">
|
||||
Iniciar sesión
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</ContentWrapper>
|
||||
</CustomContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default LoginForm;
|
||||
48
src/components/Auth/PasswordInput.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react';
|
||||
import { Form, FloatingLabel, Button } from 'react-bootstrap';
|
||||
import '../../css/PasswordInput.css';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEye, faEyeSlash, faKey } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
const PasswordInput = ({ value, onChange, name = "password" }) => {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const toggleShow = () => setShow(prev => !prev);
|
||||
|
||||
return (
|
||||
<div className="position-relative w-100">
|
||||
<FloatingLabel
|
||||
controlId="passwordInput"
|
||||
label={
|
||||
<>
|
||||
<FontAwesomeIcon icon={faKey} className="me-2" />
|
||||
Contraseña
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form.Control
|
||||
type={show ? "text" : "password"}
|
||||
name={name}
|
||||
value={value}
|
||||
placeholder=""
|
||||
onChange={onChange}
|
||||
className="rounded-4 pe-5"
|
||||
/>
|
||||
</FloatingLabel>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
className="show-button position-absolute end-0 top-50 translate-middle-y me-2"
|
||||
onClick={toggleShow}
|
||||
aria-label="Mostrar contraseña"
|
||||
tabIndex={-1}
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
<FontAwesomeIcon icon={show ? faEyeSlash : faEye} className='fa-lg' />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordInput;
|
||||
18
src/components/Auth/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "../../hooks/useAuth.js";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const ProtectedRoute = ({ minimumRoles, children }) => {
|
||||
const { authStatus } = useAuth();
|
||||
|
||||
if (authStatus === "checking") return <FontAwesomeIcon icon={faSpinner} />; // o un loader si quieres
|
||||
if (authStatus === "unauthenticated") return <Navigate to="/login" replace />;
|
||||
if (authStatus === "authenticated" && minimumRoles) {
|
||||
const userRole = JSON.parse(localStorage.getItem("user"))?.role;
|
||||
if (!minimumRoles.includes(userRole)) return <Navigate to="/" replace />;
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
114
src/components/Balance/BalancePDF.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Document, Page, Text, View, StyleSheet, Font, Image } from '@react-pdf/renderer';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
Font.register({
|
||||
family: 'Open Sans',
|
||||
fonts: [{ src: '/fonts/OpenSans.ttf', fontWeight: 'normal' }]
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
padding: 25,
|
||||
fontSize: 12,
|
||||
fontFamily: 'Open Sans',
|
||||
backgroundColor: '#F8F9FA',
|
||||
},
|
||||
headerContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 25,
|
||||
justifyContent: 'left',
|
||||
},
|
||||
logo: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
},
|
||||
headerText: {
|
||||
flexDirection: 'column',
|
||||
marginLeft: 25,
|
||||
},
|
||||
header: {
|
||||
fontSize: 26,
|
||||
fontWeight: 'bold',
|
||||
color: '#2C3E50',
|
||||
},
|
||||
subHeader: {
|
||||
fontSize: 12,
|
||||
marginTop: 5,
|
||||
color: '#34495E'
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
marginTop: 10,
|
||||
marginBottom: 5,
|
||||
color: '#2C3E50',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 4,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#D5D8DC',
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
color: '#566573',
|
||||
},
|
||||
value: {
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
color: '#2C3E50'
|
||||
}
|
||||
});
|
||||
|
||||
const formatCurrency = (value) =>
|
||||
new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(value);
|
||||
|
||||
export const BalancePDF = ({ balance }) => {
|
||||
const {
|
||||
initial_bank,
|
||||
initial_cash,
|
||||
total_bank_expenses,
|
||||
total_cash_expenses,
|
||||
total_bank_incomes,
|
||||
total_cash_incomes,
|
||||
created_at
|
||||
} = balance;
|
||||
|
||||
const final_bank = initial_bank + total_bank_incomes - total_bank_expenses;
|
||||
const final_cash = initial_cash + total_cash_incomes - total_cash_expenses;
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.headerContainer}>
|
||||
<Image src="/images/logo.png" style={styles.logo} />
|
||||
<View style={styles.headerText}>
|
||||
<Text style={styles.header}>Informe de Balance</Text>
|
||||
<Text style={styles.subHeader}>Asociación Huertos La Salud - Bellavista • Generado el {new Date().toLocaleDateString()} a las {new Date().toLocaleTimeString()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Banco</Text>
|
||||
<View style={styles.row}><Text style={styles.label}>Saldo inicial</Text><Text style={styles.value}>{formatCurrency(initial_bank)}</Text></View>
|
||||
<View style={styles.row}><Text style={styles.label}>Ingresos</Text><Text style={styles.value}>{formatCurrency(total_bank_incomes)}</Text></View>
|
||||
<View style={styles.row}><Text style={styles.label}>Gastos</Text><Text style={styles.value}>{formatCurrency(total_bank_expenses)}</Text></View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Caja</Text>
|
||||
<View style={styles.row}><Text style={styles.label}>Saldo inicial</Text><Text style={styles.value}>{formatCurrency(initial_cash)}</Text></View>
|
||||
<View style={styles.row}><Text style={styles.label}>Ingresos</Text><Text style={styles.value}>{formatCurrency(total_cash_incomes)}</Text></View>
|
||||
<View style={styles.row}><Text style={styles.label}>Gastos</Text><Text style={styles.value}>{formatCurrency(total_cash_expenses)}</Text></View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Total</Text>
|
||||
<View style={styles.row}><Text style={styles.label}>Banco</Text><Text style={styles.value}>{formatCurrency(final_bank)}</Text></View>
|
||||
<View style={styles.row}><Text style={styles.label}>Caja</Text><Text style={styles.value}>{formatCurrency(final_cash)}</Text></View>
|
||||
<View style={styles.row}><Text style={styles.label}>Total</Text><Text style={styles.value}>{formatCurrency(final_bank + final_cash)}</Text></View>
|
||||
|
||||
<Text style={[styles.label, { marginTop: 20 }]}>
|
||||
Última actualización: {format(new Date(created_at), 'dd/MM/yyyy HH:mm')}
|
||||
</Text>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
93
src/components/Balance/BalanceReport.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Button, Row, Col, Container } from 'react-bootstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faPiggyBank,
|
||||
faCoins,
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faPrint,
|
||||
faClock
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import PDFModal from '../PDFModal';
|
||||
import { BalancePDF } from './BalancePDF';
|
||||
import { format } from 'date-fns';
|
||||
import '../../css/BalanceReport.css';
|
||||
|
||||
const formatCurrency = (value) =>
|
||||
new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(value);
|
||||
|
||||
const BalanceReport = ({ balance }) => {
|
||||
const [showPDF, setShowPDF] = useState(false);
|
||||
|
||||
const showPDFModal = () => setShowPDF(true);
|
||||
const closePDFModal = () => setShowPDF(false);
|
||||
|
||||
const {
|
||||
initial_bank,
|
||||
initial_cash,
|
||||
total_bank_expenses,
|
||||
total_cash_expenses,
|
||||
total_bank_incomes,
|
||||
total_cash_incomes,
|
||||
created_at
|
||||
} = balance;
|
||||
|
||||
const final_bank = initial_bank + total_bank_incomes - total_bank_expenses;
|
||||
const final_cash = initial_cash + total_cash_incomes - total_cash_expenses;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container className="my-4">
|
||||
<Card className="balance-report px-4 py-5">
|
||||
<Row className="align-items-center justify-content-between mb-4">
|
||||
<Col xs="12" md="auto" className="text-center text-md-start mb-3 mb-md-0">
|
||||
<h1 className="report-title m-0">📊 Informe de Balance</h1>
|
||||
</Col>
|
||||
<Col xs="12" md="auto" className="text-center text-md-end">
|
||||
<Button className="print-btn" onClick={showPDFModal}>
|
||||
<FontAwesomeIcon icon={faPrint} className="me-2" />
|
||||
Imprimir PDF
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row className="gy-4">
|
||||
<Col md={6}>
|
||||
<div className="balance-box">
|
||||
<h4><FontAwesomeIcon icon={faPiggyBank} className="me-2" />Banco</h4>
|
||||
<p>Saldo inicial: <span className="balance-value">{formatCurrency(initial_bank)}</span></p>
|
||||
<p><FontAwesomeIcon icon={faArrowUp} className="me-1 text-success" />Ingresos: <span className="balance-value">{formatCurrency(total_bank_incomes)}</span></p>
|
||||
<p><FontAwesomeIcon icon={faArrowDown} className="me-1 text-danger" />Gastos: <span className="balance-value">{formatCurrency(total_bank_expenses)}</span></p>
|
||||
<p className="fw-bold mt-3">💰 Saldo final: {formatCurrency(final_bank)}</p>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col md={6}>
|
||||
<div className="balance-box">
|
||||
<h4><FontAwesomeIcon icon={faCoins} className="me-2" />Caja</h4>
|
||||
<p>Saldo inicial: <span className="balance-value">{formatCurrency(initial_cash)}</span></p>
|
||||
<p><FontAwesomeIcon icon={faArrowUp} className="me-1 text-success" />Ingresos: <span className="balance-value">{formatCurrency(total_cash_incomes)}</span></p>
|
||||
<p><FontAwesomeIcon icon={faArrowDown} className="me-1 text-danger" />Gastos: <span className="balance-value">{formatCurrency(total_cash_expenses)}</span></p>
|
||||
<p className="fw-bold mt-3">💵 Saldo final: {formatCurrency(final_cash)}</p>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row className="mt-4">
|
||||
<Col className="text-end balance-timestamp">
|
||||
<FontAwesomeIcon icon={faClock} className="me-2" />
|
||||
Última actualización: {format(new Date(created_at), 'dd/MM/yyyy HH:mm')}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Container>
|
||||
|
||||
<PDFModal show={showPDF} onClose={closePDFModal} title="Vista previa del PDF">
|
||||
<BalancePDF balance={balance} />
|
||||
</PDFModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BalanceReport;
|
||||
15
src/components/ContentWrapper.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const ContentWrapper = ({ children }) => {
|
||||
return (
|
||||
<div className="container-xl">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ContentWrapper.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export default ContentWrapper;
|
||||
105
src/components/Correo/ComposeMailModal.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, Button, Form } from 'react-bootstrap';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
|
||||
export default function ComposeMailModal({ isOpen, onClose, onSend }) {
|
||||
const [formData, setFormData] = useState({
|
||||
from: '',
|
||||
to: [],
|
||||
subject: '',
|
||||
content: '',
|
||||
attachments: []
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const user = JSON.parse(localStorage.getItem("user"));
|
||||
const email = user?.email || '';
|
||||
setFormData((prev) => ({ ...prev, from: email }));
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
if (name === 'to') {
|
||||
const toArray = value
|
||||
.split(',')
|
||||
.map((email) => email.trim())
|
||||
.filter((email) => email !== '');
|
||||
setFormData({ ...formData, to: toArray });
|
||||
} else {
|
||||
setFormData({ ...formData, [name]: value });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
setFormData({ ...formData, attachments: Array.from(e.target.files) });
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSend(formData);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} centered>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Redactar Correo</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3" controlId="formTo">
|
||||
<Form.Label>Para:</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
name="to"
|
||||
value={formData.to.join(', ')}
|
||||
onChange={handleChange}
|
||||
placeholder="Separar múltiples correos con comas"
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3" controlId="formSubject">
|
||||
<Form.Label>Asunto:</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3" controlId="formContent">
|
||||
<Form.Label>Contenido:</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={5}
|
||||
name="content"
|
||||
value={formData.content}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3" controlId="formAttachments">
|
||||
<Form.Label>Adjuntos:</Form.Label>
|
||||
<Form.Control
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSubmit}>
|
||||
Enviar
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
26
src/components/Correo/MailList.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import '../../css/MailList.css';
|
||||
|
||||
export default function MailList({ emails, onSelect, selectedEmail, className = '' }) {
|
||||
return (
|
||||
<div className={`mail-list ${className}`}>
|
||||
{emails.map((mail, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`mail-item rounded-4 mb-2 ${selectedEmail?.index === index ? 'active' : ''}`}
|
||||
onClick={() => onSelect(mail, index)}
|
||||
>
|
||||
<div className="subject">{mail.subject || "(Sin asunto)"}</div>
|
||||
<div className="preview">
|
||||
{!mail.content && "Sin contenido"}
|
||||
{mail.content.includes("<") ? (
|
||||
"Contenido HTML personalizado"
|
||||
) : (
|
||||
mail.content?.slice(0, 100)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/components/Correo/MailListMobile.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import '../../css/MailListMobile.css';
|
||||
|
||||
export default function MailListMobile({ emails, onSelect, selectedEmail, className = '' }) {
|
||||
return (
|
||||
<div className={`mail-list-mobile ${className}`}>
|
||||
{emails.map((mail, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`mail-item-mobile rounded-4 mb-2 ${selectedEmail?.index === index ? 'active' : ''}`}
|
||||
onClick={() => onSelect(mail, index)}
|
||||
>
|
||||
<div className="subject">{mail.subject || "(Sin asunto)"}</div>
|
||||
<div className="preview">{mail.content?.slice(0, 100) || "Sin contenido"}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
22
src/components/Correo/MailToolbar.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import '../../css/MailToolbar.css';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPlus, faInbox, faPaperPlane, faTrash, faPen } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default function MailToolbar({ onCompose }) {
|
||||
return (
|
||||
<div className="mail-toolbar-wrapper sticky-top">
|
||||
<div className="mail-toolbar">
|
||||
<div className="toolbar-icons">
|
||||
<FontAwesomeIcon icon={faInbox} title="Entrada" className='text-success' />
|
||||
<FontAwesomeIcon icon={faPaperPlane} title="Enviados" className='text-primary' />
|
||||
<FontAwesomeIcon icon={faPen} title="Borradores" className='text-warning' />
|
||||
<FontAwesomeIcon icon={faTrash} title="Spam" className='text-danger' />
|
||||
</div>
|
||||
<button className="toolbar-btn" onClick={onCompose}>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
src/components/Correo/MailView.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import '../../css/MailView.css';
|
||||
import { faEnvelopeOpenText } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
export default function MailView({ email }) {
|
||||
const mailContentRef = useRef(null);
|
||||
|
||||
const getFileIcon = (filename) => {
|
||||
if (!filename) return '/images/icons/filetype/file_64.svg';
|
||||
|
||||
const extension = filename.split('.').pop()?.toLowerCase();
|
||||
|
||||
switch (extension) {
|
||||
case 'pdf':
|
||||
return '/images/icons/filetype/pdf_64.svg';
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return '/images/icons/filetype/jpg_64.svg';
|
||||
case 'png':
|
||||
return '/images/icons/filetype/png_64.svg';
|
||||
case 'mp4':
|
||||
case 'avi':
|
||||
case 'mov':
|
||||
return '/images/icons/filetype/mp4_64.svg';
|
||||
case 'txt':
|
||||
case 'text':
|
||||
return '/images/icons/filetype/txt_64.svg';
|
||||
default:
|
||||
return '/images/icons/filetype/file_64.svg';
|
||||
}
|
||||
};
|
||||
|
||||
const processTextContent = (text) => {
|
||||
if (!text) return "Sin contenido";
|
||||
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
const emailRegex = /([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)/g;
|
||||
|
||||
return text
|
||||
.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(emailRegex, '<a href="mailto:$1">$1</a>')
|
||||
.replace(/\n/g, '<br>');
|
||||
};
|
||||
|
||||
const processHtmlContent = (html) => {
|
||||
if (!html) return "Sin contenido";
|
||||
|
||||
let processedHtml = html;
|
||||
|
||||
processedHtml = processedHtml.replace(
|
||||
/<a([^>]*?)(?:\s+target="[^"]*")?([^>]*?)>/g,
|
||||
'<a$1 target="_blank" rel="noopener noreferrer"$2>'
|
||||
);
|
||||
|
||||
processedHtml = processedHtml.replace(
|
||||
/<img([^>]*?)>/g,
|
||||
'<img$1 style="max-width: 100%; height: auto; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">'
|
||||
);
|
||||
|
||||
return processedHtml;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const currentRef = mailContentRef.current;
|
||||
|
||||
if (currentRef) {
|
||||
const handleLinkClick = (e) => {
|
||||
const link = e.target.closest('a');
|
||||
if (link && link.href) {
|
||||
e.preventDefault();
|
||||
window.open(link.href, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const images = currentRef.querySelectorAll('img');
|
||||
images.forEach(img => {
|
||||
img.style.maxWidth = '100%';
|
||||
img.style.height = 'auto';
|
||||
img.style.borderRadius = '4px';
|
||||
img.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
|
||||
|
||||
if (!img.getAttribute('loading')) {
|
||||
img.setAttribute('loading', 'lazy');
|
||||
}
|
||||
});
|
||||
|
||||
currentRef.addEventListener('click', handleLinkClick);
|
||||
|
||||
return () => {
|
||||
if (currentRef) {
|
||||
currentRef.removeEventListener('click', handleLinkClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [email]);
|
||||
|
||||
if (!email) {
|
||||
return (
|
||||
<div className='d-flex display-4 flex-column justify-content-center align-items-center vh-100 w-100'>
|
||||
<FontAwesomeIcon icon={faEnvelopeOpenText} className="me-2" />
|
||||
<h3 className='display-4'>No hay correo seleccionado</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const from = email.from || "Remitente desconocido";
|
||||
const date = new Date(email.date).toLocaleString();
|
||||
const to = (email.to || []).join(', ');
|
||||
|
||||
const isHtml = email.content && (
|
||||
email.content.includes('<html') ||
|
||||
email.content.includes('<body') ||
|
||||
email.content.includes('<div') ||
|
||||
email.content.includes('<p>') ||
|
||||
email.content.includes('<br') ||
|
||||
email.content.includes('<img') ||
|
||||
email.content.includes('<a href') ||
|
||||
email.content.includes('<table') ||
|
||||
email.content.includes('<ul') ||
|
||||
email.content.includes('<ol') ||
|
||||
/<[a-z][\s\S]*>/i.test(email.content)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mail-view">
|
||||
<div className="mail-header">
|
||||
<h2>{email.subject || "(Sin asunto)"}</h2>
|
||||
<div className="mail-meta">
|
||||
<span><strong>De:</strong> {from}</span><br />
|
||||
<span><strong>Para:</strong> {to}</span><br />
|
||||
<span><strong>Fecha:</strong> {date}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mail-body">
|
||||
<div
|
||||
ref={mailContentRef}
|
||||
className="mail-content"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: isHtml
|
||||
? processHtmlContent(email.content)
|
||||
: processTextContent(email.content || "")
|
||||
}}
|
||||
/>
|
||||
|
||||
{email.attachments && email.attachments.length > 0 && (
|
||||
<div className="mail-attachments mt-3">
|
||||
<h5>Adjuntos:</h5>
|
||||
<ul>
|
||||
{email.attachments.map((a, i) => (
|
||||
<li key={i}>
|
||||
<a href={a.url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={getFileIcon(a.name)}
|
||||
alt="File icon"
|
||||
width="16"
|
||||
height="16"
|
||||
style={{ marginRight: '8px' }}
|
||||
/>
|
||||
{a.name}
|
||||
{a.size && <span className="attachment-size"> ({a.size})</span>}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/components/Correo/MobileToolbar.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faInbox, faPaperPlane, faPenFancy, faTrash,
|
||||
faPlus, faArrowLeft
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import '../../css/MobileToolbar.css';
|
||||
|
||||
export default function MobileToolbar({ isViewingMail, onBack, onCompose, className }) {
|
||||
return (
|
||||
<div className={`search-toolbar-wrapper ${className}`}>
|
||||
<div className="search-toolbar mobile-toolbar-content">
|
||||
{!isViewingMail ? (
|
||||
<>
|
||||
<div className="toolbar-icons mobile-toolbar-icons">
|
||||
<button className="icon-btn" title="Entrada">
|
||||
<FontAwesomeIcon icon={faInbox} />
|
||||
</button>
|
||||
<button className="icon-btn" title="Enviados">
|
||||
<FontAwesomeIcon icon={faPaperPlane} />
|
||||
</button>
|
||||
<button className="icon-btn" title="Borradores">
|
||||
<FontAwesomeIcon icon={faPenFancy} />
|
||||
</button>
|
||||
<button className="icon-btn" title="Spam">
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-buttons">
|
||||
<button className="btn icon-btn" onClick={onCompose} title="Redactar">
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn icon-btn" onClick={onBack} title="Volver">
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
src/components/Correo/Sidebar.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useState } from 'react';
|
||||
import '../../css/Sidebar.css';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEdit, faExclamationCircle, faInbox, faPaperPlane, faPen, faTrash } from '@fortawesome/free-solid-svg-icons';
|
||||
import ComposeMailModal from './ComposeMailModal';
|
||||
|
||||
|
||||
export default function Sidebar({ onFolderChange, onMailSend }) {
|
||||
const [isComposeOpen, setIsComposeOpen] = useState(false);
|
||||
|
||||
const handleComposeOpen = () => setIsComposeOpen(true);
|
||||
const handleComposeClose = () => setIsComposeOpen(false);
|
||||
const handleSendMail = (mailData) => {
|
||||
onMailSend(mailData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<button className="compose-btn" onClick={handleComposeOpen}>
|
||||
<FontAwesomeIcon icon={faEdit} className="me-2" />
|
||||
Redactar
|
||||
</button>
|
||||
<nav>
|
||||
<a href="#" onClick={() => onFolderChange("INBOX")}>
|
||||
<FontAwesomeIcon icon={faInbox} className="me-2" />
|
||||
Bandeja de entrada
|
||||
</a>
|
||||
<a href="#" onClick={() => onFolderChange("Sent")}>
|
||||
<FontAwesomeIcon icon={faPaperPlane} className="me-2" />
|
||||
Enviados
|
||||
</a>
|
||||
<a className='disabled' href="#" onClick={() => onFolderChange("Drafts")}>
|
||||
<FontAwesomeIcon icon={faPen} className="me-2" />
|
||||
Borradores
|
||||
</a>
|
||||
<a className='disabled' href="#" onClick={() => onFolderChange("Spam")}>
|
||||
<FontAwesomeIcon icon={faExclamationCircle} className="me-2" />
|
||||
Spam
|
||||
</a>
|
||||
<a className='disabled' href="#" onClick={() => onFolderChange("Trash")}>
|
||||
<FontAwesomeIcon icon={faTrash} className="me-2" />
|
||||
Papelera
|
||||
</a>
|
||||
</nav>
|
||||
<ComposeMailModal
|
||||
isOpen={isComposeOpen}
|
||||
onClose={handleComposeClose}
|
||||
onSend={handleSendMail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/components/CustomCarousel.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import Slider from 'react-slick';
|
||||
import '../css/CustomCarousel.css';
|
||||
|
||||
const CustomCarousel = ({ images }) => {
|
||||
const settings = {
|
||||
dots: false,
|
||||
infinite: true,
|
||||
speed: 500,
|
||||
slidesToShow: 2,
|
||||
slidesToScroll: 1,
|
||||
arrows: false,
|
||||
autoplay: true,
|
||||
autoplaySpeed: 3000,
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 768, // móviles
|
||||
settings: {
|
||||
slidesToShow: 1,
|
||||
arrows: false,
|
||||
autoplay: true,
|
||||
autoplaySpeed: 3000,
|
||||
dots: false,
|
||||
infinite: true,
|
||||
speed: 500
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-4">
|
||||
<Slider {...settings}>
|
||||
{images.map((src, index) => (
|
||||
<div key={index} className='carousel-img-wrapper'>
|
||||
<img
|
||||
src={src}
|
||||
alt={`slide-${index}`}
|
||||
className="carousel-img"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Slider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomCarousel;
|
||||
15
src/components/CustomContainer.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const CustomContainer = ({ children }) => {
|
||||
return (
|
||||
<main className="px-4 py-5">
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
CustomContainer.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export default CustomContainer;
|
||||
26
src/components/CustomModal.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Modal, Button } from "react-bootstrap";
|
||||
|
||||
const CustomModal = ({ show, onClose, title, children }) => {
|
||||
return (
|
||||
<Modal show={show} onHide={onClose} size="xl" centered>
|
||||
<Modal.Header className='justify-content-between rounded-top-4'>
|
||||
<Modal.Title>{title}</Modal.Title>
|
||||
<Button variant='transparent' onClick={onClose}>
|
||||
<FontAwesomeIcon icon={faXmark} className='close-button fa-xl' />
|
||||
</Button>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="rounded-bottom-4 p-0"
|
||||
style={{
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
padding: '1rem',
|
||||
}}>
|
||||
{children}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomModal;
|
||||
60
src/components/Documentacion/File.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { faTrashAlt } from "@fortawesome/free-regular-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Card, Button, OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||
import '../../css/File.css';
|
||||
|
||||
const File = ({ file, onDelete }) => {
|
||||
const getIcon = (type) => {
|
||||
const dir = "/images/icons/filetype/";
|
||||
switch (type) {
|
||||
case "image/jpeg":
|
||||
return dir + "jpg_64.svg";
|
||||
case "image/png":
|
||||
return dir + "png_64.svg";
|
||||
case "video/mp4":
|
||||
return dir + "mp4_64.svg";
|
||||
case "application/pdf":
|
||||
return dir + "pdf_64.svg";
|
||||
case "text/plain":
|
||||
return dir + "txt_64.svg";
|
||||
default:
|
||||
return dir + "file_64.svg";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="file-card col-sm-3 col-lg-2 col-xxl-1 m-0 p-0 position-relative text-decoration-none bg-transparent"
|
||||
onClick={() => window.open(`https://miarma.net/files/huertos/${file.file_name}`, "_blank")}
|
||||
>
|
||||
<Card.Body className="text-center">
|
||||
<img
|
||||
src={getIcon(file.mime_type)}
|
||||
alt={file.file_name}
|
||||
className="img-fluid mb-2"
|
||||
/>
|
||||
<OverlayTrigger
|
||||
placement="bottom"
|
||||
overlay={<Tooltip>{file.file_name}</Tooltip>}
|
||||
>
|
||||
<p className="m-0 p-0 text-truncate">{file.file_name}</p>
|
||||
</OverlayTrigger>
|
||||
</Card.Body>
|
||||
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="md"
|
||||
color="text-danger"
|
||||
className="delete-btn position-absolute top-0 end-0 m-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.(file);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} />
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default File;
|
||||
106
src/components/Documentacion/FileUpload.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||
import { Card, CloseButton } from "react-bootstrap";
|
||||
import "../../css/FileUpload.css";
|
||||
|
||||
const MAX_FILE_SIZE_MB = 10;
|
||||
|
||||
const FileUpload = forwardRef(({ onFilesSelected }, ref) => {
|
||||
const fileInputRef = useRef();
|
||||
const [highlight, setHighlight] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState([]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetSelectedFiles: () => {
|
||||
setSelectedFiles([]);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = null; // limpia input real
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const handleFiles = (files) => {
|
||||
const validFiles = Array.from(files).filter(
|
||||
(file) => file.size <= MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
);
|
||||
setSelectedFiles(validFiles);
|
||||
if (onFilesSelected) onFilesSelected(validFiles);
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
handleFiles(e.target.files);
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setHighlight(false);
|
||||
handleFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setHighlight(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setHighlight(false);
|
||||
};
|
||||
|
||||
const openFileDialog = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const removeFile = (index) => {
|
||||
const updated = [...selectedFiles];
|
||||
updated.splice(index, 1);
|
||||
setSelectedFiles(updated);
|
||||
if (onFilesSelected) onFilesSelected(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`upload-card shadow-sm mb-4 ${highlight ? "highlight" : ""}`}
|
||||
onClick={openFileDialog}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
role="button"
|
||||
>
|
||||
<Card.Body className="text-center">
|
||||
<h2 className="mb-3">📎 Subir archivo</h2>
|
||||
<p>
|
||||
Arrastra o haz click para seleccionar archivos (Máx. 10MB)
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
multiple
|
||||
className="d-none"
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
{selectedFiles.length > 0 && (
|
||||
<ul className="file-list text-start mt-4 px-3">
|
||||
{selectedFiles.map((file, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="d-flex justify-content-between align-items-center mb-2"
|
||||
>
|
||||
<span>📄 {file.name}</span>
|
||||
<CloseButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(idx);
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileUpload;
|
||||
55
src/components/Footer.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faLocationDot, faEnvelope } from '@fortawesome/free-solid-svg-icons';
|
||||
import '../css/Footer.css';
|
||||
|
||||
const Footer = () => {
|
||||
const [heart, setHeart] = useState('💜');
|
||||
|
||||
useEffect(() => {
|
||||
const hearts = ["❤️", "💛", "🧡", "💚", "💙", "💜"];
|
||||
const randomHeart = () => hearts[Math.floor(Math.random() * hearts.length)];
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setHeart(randomHeart());
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<footer className="footer d-flex flex-column align-items-center gap-5 pt-5 px-4">
|
||||
<div className="footer-columns w-100" style={{ maxWidth: '900px' }}>
|
||||
<div className="footer-column">
|
||||
<h4 className="footer-title">Datos de Contacto</h4>
|
||||
<div className="contact-info p-4">
|
||||
<a
|
||||
href="https://www.google.com/maps?q=Calle+Cronos+S/N,+Bellavista,+Sevilla,+41014"
|
||||
target="_blank"
|
||||
className='text-break d-block'
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FontAwesomeIcon icon={faLocationDot} className="fa-icon me-2 " />
|
||||
Calle Cronos S/N, Bellavista, Sevilla, 41014
|
||||
</a>
|
||||
<a href="mailto:huertoslasaludbellavista@gmail.com" className="text-break d-block">
|
||||
<FontAwesomeIcon icon={faEnvelope} className="fa-icon me-2" />
|
||||
huertoslasaludbellavista@gmail.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="footer-bottom w-100 py-5 text-center">
|
||||
<h6 id="devd" className='m-0'>
|
||||
Hecho con <span className="heart-anim">{heart}</span> por{' '}
|
||||
<a href="https://gallardo.dev" target="_blank" rel="noopener noreferrer">
|
||||
Gallardo7761
|
||||
</a>
|
||||
</h6>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
196
src/components/Gastos/GastoCard.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Card, Badge, Button, Form
|
||||
} from 'react-bootstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faMoneyBillWave,
|
||||
faTruck,
|
||||
faReceipt,
|
||||
faTrash,
|
||||
faEdit,
|
||||
faTimes,
|
||||
faEllipsisVertical
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { motion as _motion } from 'framer-motion';
|
||||
import AnimatedDropdown from '../../components/AnimatedDropdown';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import '../../css/IngresoCard.css';
|
||||
import { CONSTANTS } from '../../util/constants';
|
||||
import { DateParser } from '../../util/parsers/dateParser';
|
||||
import { renderErrorAlert } from '../../util/alertHelpers';
|
||||
import { getNowAsLocalDatetime } from '../../util/date';
|
||||
import SpanishDateTimePicker from '../SpanishDateTimePicker';
|
||||
|
||||
const MotionCard = _motion.create(Card);
|
||||
|
||||
const getTypeLabel = (type) => type === CONSTANTS.PAYMENT_TYPE_BANK ? "Banco" : "Caja";
|
||||
const getTypeColor = (type, theme) => type === 0 ? "primary" : theme === "light" ? "dark" : "light";
|
||||
const getTypeTextColor = (type, theme) => type === 0 ? "light" : theme === "light" ? "light" : "dark";
|
||||
|
||||
const getPFP = (tipo) => {
|
||||
const base = '/images/icons/';
|
||||
const map = {
|
||||
1: 'cash.svg',
|
||||
0: 'bank.svg'
|
||||
};
|
||||
return base + (map[tipo] || 'farmer.svg');
|
||||
};
|
||||
|
||||
const GastoCard = ({ gasto, isNew = false, onCreate, onUpdate, onDelete, onCancel, error, onClearError }) => {
|
||||
const createMode = isNew;
|
||||
const [editMode, setEditMode] = useState(createMode);
|
||||
const { theme } = useTheme();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
concept: gasto.concept || '',
|
||||
amount: gasto.amount || 0,
|
||||
supplier: gasto.supplier || '',
|
||||
invoice: gasto.invoice || '',
|
||||
type: gasto.type ?? 0,
|
||||
created_at: gasto.created_at?.slice(0, 16) || (isNew ? getNowAsLocalDatetime() : ''),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setFormData({
|
||||
concept: gasto.concept || '',
|
||||
amount: gasto.amount || 0,
|
||||
supplier: gasto.supplier || '',
|
||||
invoice: gasto.invoice || '',
|
||||
type: gasto.type ?? 0,
|
||||
created_at: gasto.created_at?.slice(0, 16) || (isNew ? getNowAsLocalDatetime() : ''),
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gasto, editMode]);
|
||||
|
||||
const handleChange = (field, value) => setFormData(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
const handleDelete = () => typeof onDelete === 'function' && onDelete(gasto.expense_id);
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onClearError) onClearError();
|
||||
if (isNew && typeof onCancel === 'function') return onCancel();
|
||||
setEditMode(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onClearError) onClearError();
|
||||
const newExpense = { ...gasto, ...formData };
|
||||
if (createMode && typeof onCreate === 'function') return onCreate(newExpense);
|
||||
if (typeof onUpdate === 'function') return onUpdate(newExpense, gasto.expense_id);
|
||||
};
|
||||
|
||||
return (
|
||||
<MotionCard className="ingreso-card shadow-sm rounded-4 border-0 h-100">
|
||||
<Card.Header className="d-flex justify-content-between align-items-center rounded-top-4 bg-light-green">
|
||||
<div className="d-flex align-items-center">
|
||||
<img src={getPFP(formData.type)} width={36} alt="Tipo de gasto" className='me-3' />
|
||||
<div className="d-flex flex-column">
|
||||
<span className="fw-bold">
|
||||
{editMode ? (
|
||||
<Form.Control
|
||||
className="themed-input"
|
||||
size="sm"
|
||||
value={formData.concept}
|
||||
onChange={(e) => handleChange('concept', e.target.value.toUpperCase())}
|
||||
/>
|
||||
) : formData.concept}
|
||||
</span>
|
||||
<small>
|
||||
{editMode ? (
|
||||
<SpanishDateTimePicker
|
||||
selected={new Date(formData.created_at)}
|
||||
onChange={(date) =>
|
||||
handleChange('created_at', date.toISOString().slice(0, 16))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
DateParser.isoToStringWithTime(formData.created_at)
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!createMode && !editMode && (
|
||||
<AnimatedDropdown className='end-0' icon={<FontAwesomeIcon icon={faEllipsisVertical} className="fa-xl text-dark" />}>
|
||||
{({ closeDropdown }) => (
|
||||
<>
|
||||
<div className="dropdown-item d-flex align-items-center" onClick={() => { setEditMode(true); closeDropdown(); }}>
|
||||
<FontAwesomeIcon icon={faEdit} className="me-2" />Editar
|
||||
</div>
|
||||
<div className="dropdown-item d-flex align-items-center text-danger" onClick={() => { handleDelete(); closeDropdown(); }}>
|
||||
<FontAwesomeIcon icon={faTrash} className="me-2" />Eliminar
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatedDropdown>
|
||||
)}
|
||||
</Card.Header>
|
||||
|
||||
|
||||
<Card.Body>
|
||||
{(editMode || createMode) && renderErrorAlert(error)}
|
||||
|
||||
<Card.Text className="mb-2">
|
||||
<FontAwesomeIcon icon={faMoneyBillWave} className="me-2" />
|
||||
<strong>Importe:</strong>{' '}
|
||||
{editMode ? (
|
||||
<Form.Control className="themed-input" size="sm" type="number" step="0.01" value={formData.amount} onChange={(e) => handleChange('amount', parseFloat(e.target.value))} style={{ maxWidth: '150px', display: 'inline-block' }} />
|
||||
) : `${formData.amount.toFixed(2)} €`}
|
||||
</Card.Text>
|
||||
|
||||
<Card.Text className="mb-2">
|
||||
<FontAwesomeIcon icon={faTruck} className="me-2" />
|
||||
<strong>Proveedor:</strong>{' '}
|
||||
{editMode ? (
|
||||
<Form.Control className="themed-input" size="sm" type="text" value={formData.supplier} onChange={(e) => handleChange('supplier', e.target.value)} />
|
||||
) : formData.supplier}
|
||||
</Card.Text>
|
||||
|
||||
<Card.Text className="mb-2">
|
||||
<FontAwesomeIcon icon={faReceipt} className="me-2" />
|
||||
<strong>Factura:</strong>{' '}
|
||||
{editMode ? (
|
||||
<Form.Control className="themed-input" size="sm" type="text" value={formData.invoice} onChange={(e) => handleChange('invoice', e.target.value)} />
|
||||
) : formData.invoice}
|
||||
</Card.Text>
|
||||
|
||||
{editMode ? (
|
||||
<>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Tipo de gasto</Form.Label>
|
||||
<Form.Select className='themed-input' size="sm" value={formData.type} onChange={(e) => handleChange('type', parseInt(e.target.value))}>
|
||||
<option value={0}>Banco</option>
|
||||
<option value={1}>Caja</option>
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
<div className="d-flex justify-content-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={handleCancel}><FontAwesomeIcon icon={faTimes} /> Cancelar</Button>
|
||||
<Button variant="primary" size="sm" onClick={handleSave}>Guardar</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-end">
|
||||
<Badge bg={getTypeColor(formData.type, theme)} text={getTypeTextColor(formData.type, theme)}>
|
||||
{getTypeLabel(formData.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</MotionCard>
|
||||
);
|
||||
};
|
||||
|
||||
GastoCard.propTypes = {
|
||||
gasto: PropTypes.object.isRequired,
|
||||
isNew: PropTypes.bool,
|
||||
onCreate: PropTypes.func,
|
||||
onUpdate: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onCancel: PropTypes.func
|
||||
};
|
||||
|
||||
export default GastoCard;
|
||||
68
src/components/Gastos/GastosFilter.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const GastosFilter = ({ filters, onChange }) => {
|
||||
const handleCheckboxChange = (key) => {
|
||||
if (key === 'todos') {
|
||||
const newValue = !filters.todos;
|
||||
onChange({
|
||||
todos: newValue,
|
||||
banco: newValue,
|
||||
caja: newValue
|
||||
});
|
||||
} else {
|
||||
const updated = { ...filters, [key]: !filters[key] };
|
||||
const allTrue = Object.entries(updated)
|
||||
.filter(([k]) => k !== 'todos')
|
||||
.every(([, v]) => v === true);
|
||||
|
||||
updated.todos = allTrue;
|
||||
onChange(updated);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="todosCheck"
|
||||
className="me-2"
|
||||
checked={filters.todos}
|
||||
onChange={() => handleCheckboxChange('todos')}
|
||||
/>
|
||||
<label htmlFor="todosCheck" className="m-0">Mostrar Todos</label>
|
||||
</div>
|
||||
|
||||
<hr className="dropdown-divider" />
|
||||
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="bancoCheck"
|
||||
className="me-2"
|
||||
checked={filters.banco}
|
||||
onChange={() => handleCheckboxChange('banco')}
|
||||
/>
|
||||
<label htmlFor="bancoCheck" className="m-0">Banco</label>
|
||||
</div>
|
||||
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="cajaCheck"
|
||||
className="me-2"
|
||||
checked={filters.caja}
|
||||
onChange={() => handleCheckboxChange('caja')}
|
||||
/>
|
||||
<label htmlFor="cajaCheck" className="m-0">Caja</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
GastosFilter.propTypes = {
|
||||
filters: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default GastosFilter;
|
||||
117
src/components/Gastos/GastosPDF.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Document, Page, Text, View, StyleSheet, Font, Image } from '@react-pdf/renderer';
|
||||
import { CONSTANTS } from '../../util/constants';
|
||||
|
||||
Font.register({
|
||||
family: 'Open Sans',
|
||||
fonts: [{ src: '/fonts/OpenSans.ttf', fontWeight: 'normal' }]
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
padding: 25,
|
||||
fontSize: 12,
|
||||
fontFamily: 'Open Sans',
|
||||
backgroundColor: '#F8F9FA',
|
||||
},
|
||||
headerContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 25,
|
||||
justifyContent: 'left',
|
||||
},
|
||||
headerText: {
|
||||
flexDirection: 'column',
|
||||
marginLeft: 25,
|
||||
},
|
||||
logo: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
},
|
||||
header: {
|
||||
fontSize: 26,
|
||||
fontWeight: 'bold',
|
||||
color: '#2C3E50',
|
||||
},
|
||||
subHeader: {
|
||||
fontSize: 12,
|
||||
marginTop: 5,
|
||||
color: '#34495E'
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#3E8F5A',
|
||||
fontWeight: 'bold',
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 5,
|
||||
borderTopLeftRadius: 10,
|
||||
borderTopRightRadius: 10,
|
||||
},
|
||||
headerCell: {
|
||||
paddingHorizontal: 5,
|
||||
color: '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 10,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 5,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#D5D8DC'
|
||||
},
|
||||
cell: {
|
||||
paddingHorizontal: 5,
|
||||
fontSize: 9,
|
||||
color: '#2C3E50'
|
||||
}
|
||||
});
|
||||
|
||||
const parseDate = (iso) => {
|
||||
if (!iso) return '';
|
||||
const [y, m, d] = iso.split('T')[0].split('-');
|
||||
return `${d}/${m}/${y}`;
|
||||
};
|
||||
|
||||
const getTypeLabel = (type) => type === CONSTANTS.PAYMENT_TYPE_BANK ? 'Banco' : 'Caja';
|
||||
|
||||
export const GastosPDF = ({ gastos }) => (
|
||||
<Document>
|
||||
<Page size="A4" orientation="landscape" style={styles.page}>
|
||||
<View style={styles.headerContainer}>
|
||||
<Image src="/images/logo.png" style={styles.logo} />
|
||||
<View style={styles.headerText}>
|
||||
<Text style={styles.header}>Listado de Gastos</Text>
|
||||
<Text style={styles.subHeader}>Asociación Huertos La Salud - Bellavista • Generado el {new Date().toLocaleDateString()} a las {new Date().toLocaleTimeString()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={[styles.headerCell, { flex: 2 }]}>Fecha</Text>
|
||||
<Text style={[styles.headerCell, { flex: 4 }]}>Concepto</Text>
|
||||
<Text style={[styles.headerCell, { flex: 2 }]}>Importe</Text>
|
||||
<Text style={[styles.headerCell, { flex: 3 }]}>Proveedor</Text>
|
||||
<Text style={[styles.headerCell, { flex: 2 }]}>Factura</Text>
|
||||
<Text style={[styles.headerCell, { flex: 1 }]}>Tipo</Text>
|
||||
</View>
|
||||
|
||||
{gastos.map((gasto, idx) => (
|
||||
<View
|
||||
key={idx}
|
||||
style={[
|
||||
styles.row,
|
||||
{ backgroundColor: idx % 2 === 0 ? '#ECF0F1' : '#FDFEFE' },
|
||||
{ borderBottomLeftRadius: idx === gastos.length - 1 ? 10 : 0 },
|
||||
{ borderBottomRightRadius: idx === gastos.length - 1 ? 10 : 0 },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.cell, { flex: 2 }]}>{parseDate(gasto.created_at)}</Text>
|
||||
<Text style={[styles.cell, { flex: 4 }]}>{gasto.concept}</Text>
|
||||
<Text style={[styles.cell, { flex: 2 }]}>{gasto.amount.toFixed(2)} €</Text>
|
||||
<Text style={[styles.cell, { flex: 3 }]}>{gasto.supplier}</Text>
|
||||
<Text style={[styles.cell, { flex: 2 }]}>{gasto.invoice}</Text>
|
||||
<Text style={[styles.cell, { flex: 1 }]}>{getTypeLabel(gasto.type)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
19
src/components/Header.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import '../css/Header.css';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const Header = () => {
|
||||
|
||||
return (
|
||||
<header className={`text-center bg-img`}>
|
||||
<div className="m-0 p-5 mask">
|
||||
<div className="d-flex flex-column justify-content-center align-items-center h-100">
|
||||
<Link to='/' className='text-decoration-none'>
|
||||
<h1 className='header-title m-0 text-white shadowed'>Asociación Huertos La Salud - Bellavista</h1>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
283
src/components/Ingresos/IngresoCard.jsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Card, Badge, Button, Form, OverlayTrigger, Tooltip
|
||||
} from 'react-bootstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faUser,
|
||||
faMoneyBillWave,
|
||||
faTrash,
|
||||
faEdit,
|
||||
faTimes,
|
||||
faEllipsisVertical
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { motion as _motion } from 'framer-motion';
|
||||
import AnimatedDropdown from '../../components/AnimatedDropdown';
|
||||
import { CONSTANTS } from '../../util/constants';
|
||||
import '../../css/IngresoCard.css';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { DateParser } from '../../util/parsers/dateParser';
|
||||
import { renderErrorAlert } from '../../util/alertHelpers';
|
||||
import { getNowAsLocalDatetime } from '../../util/date';
|
||||
import SpanishDateTimePicker from '../SpanishDateTimePicker';
|
||||
|
||||
const MotionCard = _motion.create(Card);
|
||||
|
||||
const getTypeLabel = (type) => type === CONSTANTS.PAYMENT_TYPE_BANK ? "Banco" : "Caja";
|
||||
const getFrequencyLabel = (freq) => freq === CONSTANTS.PAYMENT_FREQUENCY_BIYEARLY ? "Semestral" : "Anual";
|
||||
|
||||
const getTypeColor = (type, theme) => type === CONSTANTS.PAYMENT_TYPE_BANK ? "primary" : theme === "light" ? "dark" : "light";
|
||||
const getTypeTextColor = (type, theme) => type === CONSTANTS.PAYMENT_TYPE_BANK ? "light" : theme === "light" ? "light" : "dark";
|
||||
const getFreqColor = (freq) => freq === CONSTANTS.PAYMENT_FREQUENCY_BIYEARLY ? "warning" : "danger";
|
||||
const getFreqTextColor = (freq) => freq === CONSTANTS.PAYMENT_FREQUENCY_BIYEARLY ? "dark" : "light";
|
||||
|
||||
const getPFP = (tipo) => {
|
||||
const base = '/images/icons/';
|
||||
const map = {
|
||||
1: 'cash.svg',
|
||||
0: 'bank.svg'
|
||||
};
|
||||
return base + (map[tipo] || 'farmer.svg');
|
||||
};
|
||||
|
||||
const IngresoCard = ({
|
||||
income,
|
||||
isNew = false,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onCancel,
|
||||
className = '',
|
||||
editable = true,
|
||||
error,
|
||||
onClearError,
|
||||
members = []
|
||||
}) => {
|
||||
const createMode = isNew;
|
||||
const [editMode, setEditMode] = useState(createMode);
|
||||
const { theme } = useTheme();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
concept: income.concept || '',
|
||||
amount: income.amount || 0,
|
||||
type: income.type ?? CONSTANTS.PAYMENT_TYPE_CASH,
|
||||
frequency: income.frequency ?? CONSTANTS.PAYMENT_FREQUENCY_YEARLY,
|
||||
member_number: income.member_number,
|
||||
created_at: income.created_at?.slice(0, 16) || (isNew ? getNowAsLocalDatetime() : ''),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setFormData({
|
||||
concept: income.concept || '',
|
||||
amount: income.amount || 0,
|
||||
type: income.type ?? CONSTANTS.PAYMENT_TYPE_CASH,
|
||||
frequency: income.frequency ?? CONSTANTS.PAYMENT_FREQUENCY_YEARLY,
|
||||
display_name: income.display_name,
|
||||
member_number: income.member_number,
|
||||
created_at: income.created_at?.slice(0, 16) || (isNew ? getNowAsLocalDatetime() : ''),
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [income, editMode]);
|
||||
|
||||
const handleChange = (field, value) =>
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onClearError) onClearError();
|
||||
if (isNew && typeof onCancel === 'function') return onCancel();
|
||||
setEditMode(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onClearError) onClearError();
|
||||
const newIncome = { ...income, ...formData };
|
||||
if (createMode && typeof onCreate === 'function') return onCreate(newIncome);
|
||||
if (typeof onUpdate === 'function') return onUpdate(newIncome, income.income_id);
|
||||
};
|
||||
|
||||
const handleDelete = () => typeof onDelete === 'function' && onDelete(income.income_id);
|
||||
|
||||
const uniqueMembers = Array.from(
|
||||
new Map(members.map(item => [item.member_number, item])).values()
|
||||
).sort((a, b) => a.member_number - b.member_number);
|
||||
|
||||
return (
|
||||
<MotionCard className={`ingreso-card shadow-sm rounded-4 border-0 h-100 ${className}`}>
|
||||
<Card.Header className="rounded-top-4 bg-light-green">
|
||||
<div className="d-flex justify-content-between align-items-center w-100">
|
||||
<div className="d-flex align-items-center">
|
||||
<img src={getPFP(formData.type)} width={36} alt="Ingreso" className='me-3' />
|
||||
<div className="d-flex flex-column">
|
||||
<span className="fw-bold">
|
||||
{editMode ? (
|
||||
<Form.Control
|
||||
className="themed-input"
|
||||
size="sm"
|
||||
value={formData.concept}
|
||||
onChange={(e) => handleChange('concept', e.target.value.toUpperCase())}
|
||||
/>
|
||||
) : formData.concept}
|
||||
</span>
|
||||
|
||||
<small>
|
||||
{editMode ? (
|
||||
<SpanishDateTimePicker
|
||||
selected={new Date(formData.created_at)}
|
||||
onChange={(date) =>
|
||||
handleChange('created_at', date.toISOString().slice(0, 16))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
DateParser.isoToStringWithTime(formData.created_at)
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editable && !createMode && !editMode && (
|
||||
<AnimatedDropdown
|
||||
className='ms-3'
|
||||
icon={<FontAwesomeIcon icon={faEllipsisVertical} className="fa-xl" />}
|
||||
>
|
||||
{({ closeDropdown }) => (
|
||||
<>
|
||||
<div className="dropdown-item d-flex align-items-center" onClick={() => { setEditMode(true); onClearError && onClearError(); closeDropdown(); }}>
|
||||
<FontAwesomeIcon icon={faEdit} className="me-2" />Editar
|
||||
</div>
|
||||
<div className="dropdown-item d-flex align-items-center text-danger" onClick={() => { handleDelete(); closeDropdown(); }}>
|
||||
<FontAwesomeIcon icon={faTrash} className="me-2" />Eliminar
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatedDropdown>
|
||||
)}
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Body>
|
||||
{(editMode || createMode) && renderErrorAlert(error)}
|
||||
|
||||
<Card.Text className="mb-2">
|
||||
<FontAwesomeIcon icon={faUser} className="me-2" />
|
||||
<strong>Socio:</strong>{' '}
|
||||
{createMode ? (
|
||||
<Form.Select
|
||||
className="themed-input"
|
||||
size="sm"
|
||||
value={formData.member_number}
|
||||
onChange={(e) => handleChange('member_number', parseInt(e.target.value))}
|
||||
style={{ maxWidth: '300px', display: 'inline-block' }}
|
||||
>
|
||||
{uniqueMembers.map((m) => (
|
||||
<option key={m.member_number} value={m.member_number}>
|
||||
{`${m.display_name} (${m.member_number})`}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
) : editMode ? (
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={<Tooltip>Este campo no se puede editar. Para cambiar el socio, elimina y vuelve a crear el ingreso.</Tooltip>}
|
||||
>
|
||||
<Form.Control
|
||||
className="themed-input"
|
||||
disabled
|
||||
size="sm"
|
||||
type="text"
|
||||
value={`${formData.display_name || 'Socio'} (${formData.member_number})`}
|
||||
style={{ maxWidth: '300px', display: 'inline-block' }}
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
) : (
|
||||
formData.display_name ? (
|
||||
<>
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={<Tooltip>{formData.display_name}</Tooltip>}
|
||||
>
|
||||
<span className="text-truncate d-inline-block" style={{ maxWidth: '200px', verticalAlign: 'middle' }}>
|
||||
{formData.display_name}
|
||||
</span>
|
||||
</OverlayTrigger>
|
||||
({formData.member_number})
|
||||
</>
|
||||
) : formData.member_number
|
||||
)}
|
||||
</Card.Text>
|
||||
|
||||
<Card.Text className="mb-2">
|
||||
<FontAwesomeIcon icon={faMoneyBillWave} className="me-2" />
|
||||
<strong>Importe:</strong>{' '}
|
||||
{editMode ? (
|
||||
<Form.Control
|
||||
className="themed-input"
|
||||
size="sm"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.amount}
|
||||
onChange={(e) => handleChange('amount', parseFloat(e.target.value))}
|
||||
style={{ maxWidth: '150px', display: 'inline-block' }}
|
||||
/>
|
||||
) : `${formData.amount.toFixed(2)} €`}
|
||||
</Card.Text>
|
||||
|
||||
{editMode ? (
|
||||
<>
|
||||
<Form.Group className="mb-2">
|
||||
<Form.Label>Tipo de pago</Form.Label>
|
||||
<Form.Select
|
||||
className='themed-input'
|
||||
size="sm"
|
||||
value={formData.type}
|
||||
onChange={(e) => handleChange('type', parseInt(e.target.value))}
|
||||
>
|
||||
<option value={CONSTANTS.PAYMENT_TYPE_CASH}>Caja</option>
|
||||
<option value={CONSTANTS.PAYMENT_TYPE_BANK}>Banco</option>
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Frecuencia</Form.Label>
|
||||
<Form.Select
|
||||
className='themed-input'
|
||||
size="sm"
|
||||
value={formData.frequency}
|
||||
onChange={(e) => handleChange('frequency', parseInt(e.target.value))}
|
||||
>
|
||||
<option value={CONSTANTS.PAYMENT_FREQUENCY_YEARLY}>Anual</option>
|
||||
<option value={CONSTANTS.PAYMENT_FREQUENCY_BIYEARLY}>Semestral</option>
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
<div className="d-flex justify-content-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={handleCancel}><FontAwesomeIcon icon={faTimes} /> Cancelar</Button>
|
||||
<Button variant="primary" size="sm" onClick={handleSave}>Guardar</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-end">
|
||||
<Badge bg={getTypeColor(formData.type, theme)} text={getTypeTextColor(formData.type, theme)} className="me-1">{getTypeLabel(formData.type)}</Badge>
|
||||
<Badge bg={getFreqColor(formData.frequency)} text={getFreqTextColor(formData.frequency)}>{getFrequencyLabel(formData.frequency)}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</MotionCard>
|
||||
);
|
||||
};
|
||||
|
||||
IngresoCard.propTypes = {
|
||||
income: PropTypes.object.isRequired,
|
||||
isNew: PropTypes.bool,
|
||||
onCreate: PropTypes.func,
|
||||
onUpdate: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onCancel: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
editable: PropTypes.bool,
|
||||
error: PropTypes.string,
|
||||
onClearError: PropTypes.func,
|
||||
members: PropTypes.array
|
||||
};
|
||||
|
||||
export default IngresoCard;
|
||||
92
src/components/Ingresos/IngresosFilter.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const IngresosFilter = ({ filters, onChange }) => {
|
||||
const handleCheckboxChange = (key) => {
|
||||
if (key === 'todos') {
|
||||
const newValue = !filters.todos;
|
||||
onChange({
|
||||
todos: newValue,
|
||||
banco: newValue,
|
||||
caja: newValue,
|
||||
semestral: newValue,
|
||||
anual: newValue
|
||||
});
|
||||
} else {
|
||||
const updated = { ...filters, [key]: !filters[key] };
|
||||
const allTrue = Object.entries(updated)
|
||||
.filter(([k]) => k !== 'todos')
|
||||
.every(([, v]) => v === true);
|
||||
|
||||
updated.todos = allTrue;
|
||||
onChange(updated);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="todosCheck"
|
||||
className="me-2"
|
||||
checked={filters.todos}
|
||||
onChange={() => handleCheckboxChange('todos')}
|
||||
/>
|
||||
<label htmlFor="todosCheck" className="m-0">Mostrar Todos</label>
|
||||
</div>
|
||||
|
||||
<hr className="dropdown-divider" />
|
||||
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="bancoCheck"
|
||||
className="me-2"
|
||||
checked={filters.banco}
|
||||
onChange={() => handleCheckboxChange('banco')}
|
||||
/>
|
||||
<label htmlFor="bancoCheck" className="m-0">Banco</label>
|
||||
</div>
|
||||
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="cajaCheck"
|
||||
className="me-2"
|
||||
checked={filters.caja}
|
||||
onChange={() => handleCheckboxChange('caja')}
|
||||
/>
|
||||
<label htmlFor="cajaCheck" className="m-0">Caja</label>
|
||||
</div>
|
||||
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="semestralCheck"
|
||||
className="me-2"
|
||||
checked={filters.semestral}
|
||||
onChange={() => handleCheckboxChange('semestral')}
|
||||
/>
|
||||
<label htmlFor="semestralCheck" className="m-0">Semestral</label>
|
||||
</div>
|
||||
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="anualCheck"
|
||||
className="me-2"
|
||||
checked={filters.anual}
|
||||
onChange={() => handleCheckboxChange('anual')}
|
||||
/>
|
||||
<label htmlFor="anualCheck" className="m-0">Anual</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
IngresosFilter.propTypes = {
|
||||
filters: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default IngresosFilter;
|
||||
118
src/components/Ingresos/IngresosPDF.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Document, Page, Text, View, StyleSheet, Font, Image } from '@react-pdf/renderer';
|
||||
import { CONSTANTS } from '../../util/constants';
|
||||
|
||||
Font.register({
|
||||
family: 'Open Sans',
|
||||
fonts: [{ src: '/fonts/OpenSans.ttf', fontWeight: 'normal' }]
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
padding: 25,
|
||||
fontSize: 12,
|
||||
fontFamily: 'Open Sans',
|
||||
backgroundColor: '#F8F9FA',
|
||||
},
|
||||
headerContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 25,
|
||||
justifyContent: 'left',
|
||||
},
|
||||
headerText: {
|
||||
flexDirection: 'column',
|
||||
marginLeft: 25,
|
||||
},
|
||||
logo: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
},
|
||||
header: {
|
||||
fontSize: 26,
|
||||
fontWeight: 'bold',
|
||||
color: '#2C3E50',
|
||||
},
|
||||
subHeader: {
|
||||
fontSize: 12,
|
||||
marginTop: 5,
|
||||
color: '#34495E'
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#3E8F5A',
|
||||
fontWeight: 'bold',
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 5,
|
||||
borderTopLeftRadius: 10,
|
||||
borderTopRightRadius: 10,
|
||||
},
|
||||
headerCell: {
|
||||
paddingHorizontal: 5,
|
||||
color: '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 10,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 5,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#D5D8DC'
|
||||
},
|
||||
cell: {
|
||||
paddingHorizontal: 5,
|
||||
fontSize: 9,
|
||||
color: '#2C3E50'
|
||||
}
|
||||
});
|
||||
|
||||
const parseDate = (iso) => {
|
||||
if (!iso) return '';
|
||||
const [y, m, d] = iso.split('T')[0].split('-');
|
||||
return `${d}/${m}/${y}`;
|
||||
};
|
||||
|
||||
const getTypeLabel = (type) => type === CONSTANTS.PAYMENT_TYPE_BANK ? 'Banco' : 'Caja';
|
||||
const getFreqLabel = (freq) => freq === CONSTANTS.PAYMENT_FREQUENCY_BIYEARLY ? 'Semestral' : 'Anual';
|
||||
|
||||
export const IngresosPDF = ({ ingresos }) => (
|
||||
<Document>
|
||||
<Page size="A4" orientation="landscape" style={styles.page}>
|
||||
<View style={styles.headerContainer}>
|
||||
<Image src="/images/logo.png" style={styles.logo} />
|
||||
<View style={styles.headerText}>
|
||||
<Text style={styles.header}>Listado de ingresos</Text>
|
||||
<Text style={styles.subHeader}>Asociación Huertos La Salud - Bellavista • Generado el {new Date().toLocaleDateString()} a las {new Date().toLocaleTimeString()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={[styles.headerCell, { flex: 1 }]}>Socio Nº</Text>
|
||||
<Text style={[styles.headerCell, { flex: 4 }]}>Concepto</Text>
|
||||
<Text style={[styles.headerCell, { flex: 1 }]}>Importe</Text>
|
||||
<Text style={[styles.headerCell, { flex: 1 }]}>Tipo</Text>
|
||||
<Text style={[styles.headerCell, { flex: 1 }]}>Frecuencia</Text>
|
||||
<Text style={[styles.headerCell, { flex: 2 }]}>Fecha</Text>
|
||||
</View>
|
||||
|
||||
{ingresos.map((ing, idx) => (
|
||||
<View
|
||||
key={idx}
|
||||
style={[
|
||||
styles.row,
|
||||
{ backgroundColor: idx % 2 === 0 ? '#ECF0F1' : '#FDFEFE' },
|
||||
{ borderBottomLeftRadius: idx === ingresos.length - 1 ? 10 : 0 },
|
||||
{ borderBottomRightRadius: idx === ingresos.length - 1 ? 10 : 0 },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.cell, { flex: 1 }]}>{ing.member_number}</Text>
|
||||
<Text style={[styles.cell, { flex: 3 }]}>{ing.concept}</Text>
|
||||
<Text style={[styles.cell, { flex: 1 }]}>{ing.amount.toFixed(2)} €</Text>
|
||||
<Text style={[styles.cell, { flex: 1 }]}>{getTypeLabel(ing.type)}</Text>
|
||||
<Text style={[styles.cell, { flex: 1 }]}>{getFreqLabel(ing.frequency)}</Text>
|
||||
<Text style={[styles.cell, { flex: 2 }]}>{parseDate(ing.created_at)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
15
src/components/List.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import ListItem from "./ListItem";
|
||||
import {ListGroup} from 'react-bootstrap';
|
||||
import '../css/List.css';
|
||||
|
||||
const List = ({ datos, config }) => {
|
||||
return (
|
||||
<ListGroup className="gap-2">
|
||||
{datos.map((item, index) => (
|
||||
<ListItem key={index} item={item} config={config} index={index} />
|
||||
))}
|
||||
</ListGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default List;
|
||||
57
src/components/ListItem.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { motion as _motion } from "framer-motion";
|
||||
import { ListGroup } from "react-bootstrap";
|
||||
import '../css/ListItem.css';
|
||||
|
||||
const MotionListGroupItem = _motion.create(ListGroup.Item);
|
||||
|
||||
const ListItem = ({ item, config, index }) => {
|
||||
const {
|
||||
title,
|
||||
subtitle,
|
||||
numericField,
|
||||
pfp,
|
||||
showIndex,
|
||||
} = config;
|
||||
|
||||
return (
|
||||
<MotionListGroupItem
|
||||
className="custom-list-item d-flex justify-content-between rounded-4 align-items-center"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="d-flex align-items-center gap-3">
|
||||
{showIndex && (
|
||||
<div className="list-item-index">
|
||||
{index + 1}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pfp && item[pfp] && (
|
||||
<img
|
||||
src={item[pfp]}
|
||||
alt="pfp"
|
||||
className="list-item-avatar"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="d-flex flex-column">
|
||||
{title && item[title] && (
|
||||
<h5 className="fw-bold m-0">{item[title]}</h5>
|
||||
)}
|
||||
{subtitle && item[subtitle] && (
|
||||
<div className="subtitle m-0">{item[subtitle]}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{numericField && item[numericField] !== undefined && (
|
||||
<span className="badge bg-primary rounded-pill">
|
||||
{item[numericField]}
|
||||
</span>
|
||||
)}
|
||||
</MotionListGroupItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListItem;
|
||||
10
src/components/LoadingIcon.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
const LoadingIcon = () => {
|
||||
return (
|
||||
<FontAwesomeIcon icon={faSpinner} className='fa-spin fa-lg' />
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingIcon;
|
||||
57
src/components/Mapa3D.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// Mapa3D.jsx
|
||||
import { useEffect, useRef } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
export default function Mapa3D() {
|
||||
const mapRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const map = new maplibregl.Map({
|
||||
container: mapRef.current,
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
satellite: {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://api.maptiler.com/maps/satellite/{z}/{x}/{y}@2x.jpg?key=Ie0BAF3X6PIp1aV260ar'
|
||||
],
|
||||
tileSize: 512,
|
||||
attribution: '© <a href="https://www.maptiler.com/">MapTiler</a>'
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'satellite',
|
||||
type: 'raster',
|
||||
source: 'satellite',
|
||||
minzoom: 0,
|
||||
maxzoom: 22
|
||||
}
|
||||
]
|
||||
},
|
||||
center: [-5.9648, 37.3282],
|
||||
zoom: 17,
|
||||
pitch: 30,
|
||||
bearing: -10,
|
||||
antialias: true,
|
||||
scrollZoom: false
|
||||
});
|
||||
|
||||
map.addControl(new maplibregl.NavigationControl());
|
||||
|
||||
new maplibregl.Marker()
|
||||
.setLngLat([-5.9648, 37.3282])
|
||||
.addTo(map);
|
||||
|
||||
return () => map.remove();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mapRef}
|
||||
style={{ width: '100%', height: '60vh', borderRadius: '10px', overflow: 'hidden' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
169
src/components/NavBar/NavBar.jsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from "../../hooks/useAuth";
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faSignIn,
|
||||
faUser,
|
||||
faSignOut,
|
||||
faHouse,
|
||||
faList,
|
||||
faBullhorn,
|
||||
faFile
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import '../../css/NavBar.css';
|
||||
|
||||
import NavGestion from './NavGestion';
|
||||
import ThemeButton from '../ThemeButton.jsx';
|
||||
|
||||
import IfAuthenticated from '../Auth/IfAuthenticated.jsx';
|
||||
import IfNotAuthenticated from '../Auth/IfNotAuthenticated.jsx';
|
||||
import IfRole from '../Auth/IfRole.jsx';
|
||||
|
||||
import { Navbar, Nav, Container } from 'react-bootstrap';
|
||||
import AnimatedDropdown from '../AnimatedDropdown.jsx';
|
||||
|
||||
import { CONSTANTS } from '../../util/constants.js';
|
||||
|
||||
const NavBar = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const [showingUserDropdown, setShowingUserDropdown] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [isLg, setIsLg] = useState(window.innerWidth >= 992);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsLg(window.innerWidth >= 992 && window.innerWidth < 1200);
|
||||
};
|
||||
|
||||
handleResize(); // inicializar
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth >= 992) {
|
||||
setExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Navbar expand="lg" sticky="top" expanded={expanded} onToggle={() => setExpanded(!expanded)}>
|
||||
<Container fluid>
|
||||
<Navbar.Toggle aria-controls="navbar" className="custom-toggler">
|
||||
<svg width="30" height="30" viewBox="0 0 30 30">
|
||||
<path
|
||||
d="M4 7h22M4 15h22M4 23h22"
|
||||
stroke="var(--navbar-link-color)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeMiterlimit="10"
|
||||
/>
|
||||
</svg>
|
||||
</Navbar.Toggle>
|
||||
|
||||
<Navbar.Collapse id="main-navbar">
|
||||
<Nav className="me-auto gap-2">
|
||||
<Nav.Link
|
||||
as={Link}
|
||||
to="/"
|
||||
title="Inicio"
|
||||
href="/"
|
||||
className={`text-truncate ${expanded ? "mt-3" : ""}`}
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHouse} className="me-2" />
|
||||
Inicio
|
||||
</Nav.Link>
|
||||
<Nav.Link
|
||||
as={Link}
|
||||
to="/lista-espera"
|
||||
title="Lista de espera"
|
||||
className={`text-truncate ${expanded ? "mt-3" : ""}`}
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faList} className="me-2" />
|
||||
Lista de espera
|
||||
</Nav.Link>
|
||||
|
||||
<IfAuthenticated>
|
||||
<Nav.Link
|
||||
as={Link}
|
||||
to="/anuncios"
|
||||
title="Anuncios"
|
||||
className={`text-truncate ${expanded ? "mt-3" : ""}`}
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBullhorn} className="me-2" />Anuncios
|
||||
</Nav.Link>
|
||||
|
||||
<Nav.Link
|
||||
as={Link}
|
||||
to="/documentacion"
|
||||
title="Documentación"
|
||||
className={`text-truncate ${expanded ? "mt-3" : ""}`}
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFile} className="me-2" />Documentación
|
||||
</Nav.Link>
|
||||
</IfAuthenticated>
|
||||
|
||||
<IfRole roles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
|
||||
<NavGestion onNavigate={() => setExpanded(false)} externalExpanded={expanded} />
|
||||
</IfRole>
|
||||
<div className="d-lg-none mt-2 ms-2">
|
||||
<ThemeButton onlyIcon={isLg} />
|
||||
</div>
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
|
||||
<div className="d-none d-lg-block me-3">
|
||||
<ThemeButton onlyIcon={isLg} />
|
||||
</div>
|
||||
|
||||
<Nav className="d-flex flex-md-row flex-column gap-2 ms-auto align-items-center">
|
||||
<IfAuthenticated>
|
||||
<AnimatedDropdown
|
||||
className='end-0 position-absolute'
|
||||
show={showingUserDropdown}
|
||||
onMouseEnter={() => setShowingUserDropdown(true)}
|
||||
onMouseLeave={() => setShowingUserDropdown(false)}
|
||||
onToggle={(isOpen) => setShowingUserDropdown(isOpen)}
|
||||
trigger={
|
||||
<Link className="nav-link dropdown-toggle fw-bold">
|
||||
@{user?.user_name}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<Link to="/perfil" className="text-muted dropdown-item nav-link">
|
||||
<FontAwesomeIcon icon={faUser} className="me-2" />
|
||||
Mi perfil
|
||||
</Link>
|
||||
<hr className="dropdown-divider" />
|
||||
<Link to="#" className="dropdown-item nav-link" onClick={logout}>
|
||||
<FontAwesomeIcon icon={faSignOut} className="me-2" />
|
||||
Cerrar sesión
|
||||
</Link>
|
||||
</AnimatedDropdown>
|
||||
</IfAuthenticated>
|
||||
|
||||
<IfNotAuthenticated>
|
||||
<Nav.Link as={Link} to="/login" title="Iniciar sesión">
|
||||
<FontAwesomeIcon icon={faSignIn} className="me-2" />
|
||||
Iniciar sesión
|
||||
</Nav.Link>
|
||||
</IfNotAuthenticated>
|
||||
</Nav>
|
||||
</Container>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavBar;
|
||||
67
src/components/NavBar/NavGestion.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import AnimatedDropdown from '../../components/AnimatedDropdown';
|
||||
import AnimatedDropend from '../../components/AnimatedDropend';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faGear, faUsers, faMoneyBill, faWallet, faFileInvoice,
|
||||
faEnvelope,
|
||||
faBellConcierge,
|
||||
faPeopleGroup
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import useRequestCount from '../../hooks/useRequestCount';
|
||||
|
||||
const NavGestion = ({ onNavigate, externalExpanded }) => {
|
||||
const [showing, setShowing] = useState(false);
|
||||
const count = useRequestCount();
|
||||
|
||||
return (
|
||||
<AnimatedDropdown
|
||||
show={showing}
|
||||
onMouseEnter={() => setShowing(true)}
|
||||
onMouseLeave={() => setShowing(false)}
|
||||
onToggle={(isOpen) => setShowing(isOpen)}
|
||||
trigger={
|
||||
<Link className={`nav-link dropdown-toggle ${externalExpanded ? "mt-3" : ""}`} role="button">
|
||||
<FontAwesomeIcon icon={faGear} className="me-2" />Gestión
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{/* Submenú lateral: Asociación */}
|
||||
<AnimatedDropend
|
||||
trigger={
|
||||
<Link className="nav-link dropdown-toggle" role='button'>
|
||||
<FontAwesomeIcon icon={faPeopleGroup} className="me-2" />Asociación
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<Link to="/gestion/socios" className="dropdown-item nav-link" onClick={onNavigate}>
|
||||
<FontAwesomeIcon icon={faUsers} className="me-2" />Socios
|
||||
</Link>
|
||||
<Link to="/gestion/ingresos" className="dropdown-item nav-link" onClick={onNavigate}>
|
||||
<FontAwesomeIcon icon={faMoneyBill} className="me-2" />Ingresos
|
||||
</Link>
|
||||
<Link to="/gestion/gastos" className="dropdown-item nav-link" onClick={onNavigate}>
|
||||
<FontAwesomeIcon icon={faWallet} className="me-2" />Gastos
|
||||
</Link>
|
||||
<Link to="/gestion/balance" className="dropdown-item nav-link" onClick={onNavigate}>
|
||||
<FontAwesomeIcon icon={faFileInvoice} className="me-2" />Balance
|
||||
</Link>
|
||||
</AnimatedDropend>
|
||||
|
||||
<Link to="/gestion/solicitudes" className="dropdown-item nav-link" onClick={onNavigate}>
|
||||
<FontAwesomeIcon icon={faBellConcierge} />
|
||||
<span className="icon-with-badge">
|
||||
{count > 0 && <span className="icon-badge">{count}</span>}
|
||||
</span>
|
||||
Solicitudes
|
||||
</Link>
|
||||
|
||||
<Link to="/correo" className="dropdown-item nav-link" onClick={onNavigate}>
|
||||
<FontAwesomeIcon icon={faEnvelope} className="me-2" />Correo
|
||||
</Link>
|
||||
</AnimatedDropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavGestion;
|
||||
69
src/components/NotificationModal.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Modal, Button } from 'react-bootstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faCircleCheck,
|
||||
faCircleXmark,
|
||||
faCircleExclamation,
|
||||
faCircleInfo
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
const iconMap = {
|
||||
success: faCircleCheck,
|
||||
danger: faCircleXmark,
|
||||
warning: faCircleExclamation,
|
||||
info: faCircleInfo
|
||||
};
|
||||
|
||||
const NotificationModal = ({
|
||||
show,
|
||||
onClose,
|
||||
title,
|
||||
message,
|
||||
variant = "info",
|
||||
buttons = [{ label: "Aceptar", variant: "primary", onClick: onClose }]
|
||||
}) => {
|
||||
return (
|
||||
<Modal show={show} onHide={onClose} centered>
|
||||
<Modal.Header closeButton className={`bg-${variant} ${variant === 'info' ? 'text-dark' : 'text-white'}`}>
|
||||
<Modal.Title>
|
||||
<FontAwesomeIcon icon={iconMap[variant] || faCircleInfo} className="me-2" />
|
||||
{title}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body>
|
||||
<p className="mb-0">{message}</p>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
{buttons.map((btn, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={btn.variant || "primary"}
|
||||
onClick={btn.onClick || onClose}
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
))}
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
NotificationModal.propTypes = {
|
||||
show: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
message: PropTypes.string.isRequired,
|
||||
variant: PropTypes.oneOf(['success', 'danger', 'warning', 'info']),
|
||||
buttons: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.string.isRequired,
|
||||
variant: PropTypes.string,
|
||||
onClick: PropTypes.func
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
export default NotificationModal;
|
||||
22
src/components/PDFModal.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Modal, Button } from "react-bootstrap";
|
||||
import { PDFViewer } from "@react-pdf/renderer";
|
||||
|
||||
const PDFModal = ({ show, onClose, title, children }) => (
|
||||
<Modal show={show} onHide={onClose} size="xl" centered>
|
||||
<Modal.Header className='justify-content-between'>
|
||||
<Modal.Title>{title}</Modal.Title>
|
||||
<Button variant='transparent' onClick={onClose}>
|
||||
<FontAwesomeIcon icon={faXmark} className='close-button fa-xl' />
|
||||
</Button>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="rounded-bottom-4 p-0" style={{ height: '80vh' }}>
|
||||
<PDFViewer width="100%" height="100%">
|
||||
{children}
|
||||
</PDFViewer>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export default PDFModal;
|
||||
24
src/components/PaginatedCardGrid.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import LoadingIcon from './LoadingIcon';
|
||||
|
||||
const PaginatedCardGrid = ({
|
||||
items = [],
|
||||
renderCard,
|
||||
creatingItem = null,
|
||||
renderCreatingCard = null,
|
||||
loaderRef,
|
||||
loading = false
|
||||
}) => {
|
||||
return (
|
||||
<div className="cards-grid">
|
||||
{creatingItem && renderCreatingCard && renderCreatingCard()}
|
||||
|
||||
{items.map((item, i) => renderCard(item, i))}
|
||||
|
||||
<div ref={loaderRef} className="loading-trigger d-flex justify-content-center align-items-center">
|
||||
{loading && <LoadingIcon />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaginatedCardGrid;
|
||||
43
src/components/SearchToolbar.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { faFilter, faFilePdf, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import AnimatedDropdown from './AnimatedDropdown';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
import { CONSTANTS } from '../util/constants';
|
||||
import IfRole from './Auth/IfRole';
|
||||
|
||||
const SearchToolbar = ({ searchTerm, onSearchChange, filtersComponent, onCreate, onPDF }) => (
|
||||
<div className="sticky-toolbar search-toolbar-wrapper">
|
||||
<div className="search-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Buscar..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
<div className="toolbar-buttons">
|
||||
{filtersComponent && (
|
||||
<AnimatedDropdown variant="transparent" icon={<FontAwesomeIcon icon={faFilter} className='fa-md' />}>
|
||||
{filtersComponent}
|
||||
</AnimatedDropdown>
|
||||
)}
|
||||
{onPDF && (
|
||||
<IfRole roles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
|
||||
<Button variant="transparent" onClick={onPDF}>
|
||||
<FontAwesomeIcon icon={faFilePdf} className='fa-md' />
|
||||
</Button>
|
||||
</IfRole>
|
||||
)}
|
||||
{onCreate && (
|
||||
<IfRole roles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
|
||||
<Button variant="transparent" onClick={onCreate}>
|
||||
<FontAwesomeIcon icon={faPlus} className='fa-md' />
|
||||
</Button>
|
||||
</IfRole>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SearchToolbar;
|
||||
364
src/components/Socios/SocioCard.jsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Card, ListGroup, Badge, Button, Form,
|
||||
Tooltip, OverlayTrigger
|
||||
} from 'react-bootstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faIdCard, faUser, faSunPlantWilt, faPhone, faClipboard, faAt,
|
||||
faEllipsisVertical, faEdit, faTrash, faMoneyBill,
|
||||
faCheck,
|
||||
faXmark,
|
||||
faCalendar,
|
||||
faKey
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { motion as _motion } from 'framer-motion';
|
||||
import PropTypes from 'prop-types';
|
||||
import AnimatedDropdown from '../../components/AnimatedDropdown';
|
||||
import '../../css/SocioCard.css';
|
||||
import TipoSocioDropdown from './TipoSocioDropdown';
|
||||
import { getNowAsLocalDatetime } from '../../util/date';
|
||||
import { generateSecurePassword } from '../../util/passwordGenerator';
|
||||
import { DateParser } from '../../util/parsers/dateParser';
|
||||
import { renderErrorAlert } from '../../util/alertHelpers';
|
||||
import { useDataContext } from "../../hooks/useDataContext";
|
||||
import SpanishDateTimePicker from '../SpanishDateTimePicker';
|
||||
|
||||
const renderDateField = (label, icon, dateValue, editMode, fieldKey, handleChange) => {
|
||||
if (!editMode && !dateValue) return null;
|
||||
|
||||
return (
|
||||
<ListGroup.Item className="d-flex justify-content-between align-items-center">
|
||||
<span><FontAwesomeIcon icon={icon} className="me-2" />{label}</span>
|
||||
{editMode ? (
|
||||
<SpanishDateTimePicker
|
||||
selected={dateValue ? new Date(dateValue) : null}
|
||||
onChange={(date) =>
|
||||
date ? handleChange(fieldKey, date.toISOString().slice(0, 16)) : handleChange(fieldKey, null)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<strong>{DateParser.isoToStringWithTime(dateValue)}</strong>
|
||||
)}
|
||||
</ListGroup.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const getFechas = (formData, editMode, handleChange) => {
|
||||
const { created_at, assigned_at, deactivated_at } = formData;
|
||||
|
||||
// Si no hay fechas y no está en modo edición, no muestres nada
|
||||
if (!editMode && !created_at && !assigned_at && !deactivated_at) return null;
|
||||
|
||||
return (
|
||||
<ListGroup className="mt-2 border-1 rounded-3 shadow-sm">
|
||||
{renderDateField("ALTA", faCalendar, created_at, editMode, "created_at", handleChange)}
|
||||
{renderDateField("ENTREGA", faCalendar, assigned_at, editMode, "assigned_at", handleChange)}
|
||||
{renderDateField("BAJA", faCalendar, deactivated_at, editMode, "deactivated_at", handleChange)}
|
||||
</ListGroup>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const getBadgeColor = (estado) => estado === 1 ? 'success' : 'danger';
|
||||
const getHeaderColor = (estado) => estado === 1 ? 'bg-light-green' : 'bg-light-red';
|
||||
const getEstado = (estado) =>
|
||||
estado === 1 ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faCheck} className="me-2" />
|
||||
ACTIVO
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faXmark} className="me-2" />
|
||||
INACTIVO
|
||||
</>
|
||||
);
|
||||
|
||||
const parseNull = (attr) => attr === null || attr === '' ? 'NO' : attr;
|
||||
const getPFP = (tipo) => {
|
||||
const base = '/images/icons/';
|
||||
const map = {
|
||||
1: 'farmer.svg',
|
||||
2: 'green_house.svg',
|
||||
0: 'list.svg',
|
||||
3: 'join.svg',
|
||||
4: 'subvencion4.svg',
|
||||
5: 'programmer.svg'
|
||||
};
|
||||
return base + (map[tipo] || 'farmer.svg');
|
||||
};
|
||||
|
||||
const MotionCard = _motion.create(Card);
|
||||
|
||||
const SocioCard = ({ socio, isNew = false, onCreate, onUpdate, onDelete, onCancel, onViewIncomes, error, onClearError, positionIfWaitlist }) => {
|
||||
const createMode = isNew;
|
||||
const [editMode, setEditMode] = useState(isNew);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [latestNumber, setLatestNumber] = useState(null);
|
||||
const { getData } = useDataContext();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
display_name: socio.display_name,
|
||||
user_name: socio.user_name,
|
||||
email: socio.email || '',
|
||||
dni: socio.dni,
|
||||
phone: socio.phone,
|
||||
member_number: socio.member_number || latestNumber,
|
||||
plot_number: socio.plot_number,
|
||||
notes: socio.notes || '',
|
||||
status: socio.status,
|
||||
type: socio.type,
|
||||
created_at: socio.created_at?.slice(0, 16) || (isNew ? getNowAsLocalDatetime() : ''),
|
||||
assigned_at: socio.assigned_at?.slice(0, 16) || undefined,
|
||||
deactivated_at: socio.deactivated_at?.slice(0, 16) || undefined,
|
||||
global_role: 0,
|
||||
password: createMode && !editMode ? generateSecurePassword() : null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setFormData({
|
||||
display_name: socio.display_name,
|
||||
user_name: socio.user_name,
|
||||
email: socio.email || '',
|
||||
dni: socio.dni,
|
||||
phone: socio.phone,
|
||||
member_number: socio.member_number,
|
||||
plot_number: socio.plot_number,
|
||||
notes: socio.notes || '',
|
||||
status: socio.status,
|
||||
type: socio.type,
|
||||
created_at: socio.created_at?.slice(0, 16) || (isNew ? getNowAsLocalDatetime() : ''),
|
||||
assigned_at: socio.assigned_at?.slice(0, 16) || undefined,
|
||||
deactivated_at: socio.deactivated_at?.slice(0, 16) || undefined,
|
||||
global_role: 0,
|
||||
password: createMode ? generateSecurePassword() : ''
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [socio, editMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLastNumber = async () => {
|
||||
try {
|
||||
if (!(createMode || editMode)) return;
|
||||
|
||||
const { data, error } = await getData("https://api.huertosbellavista.es/v1/members/latest-number");
|
||||
if (error) throw new Error(error);
|
||||
|
||||
const nuevoNumero = data.lastMemberNumber + 1;
|
||||
setLatestNumber(nuevoNumero);
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
member_number: prev.member_number || nuevoNumero
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("Error al obtener el número de socio:", err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLastNumber();
|
||||
}, [createMode, editMode, getData]);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (onClearError) onClearError();
|
||||
setEditMode(true);
|
||||
};
|
||||
|
||||
const handleDelete = () => typeof onDelete === "function" && onDelete(socio.user_id);
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onClearError) onClearError();
|
||||
if (isNew && typeof onCancel === 'function') return onCancel();
|
||||
setEditMode(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onClearError) onClearError();
|
||||
const newSocio = { ...socio, ...formData };
|
||||
if (createMode && typeof onCreate === 'function') return onCreate(newSocio);
|
||||
if (typeof onUpdate === 'function') return onUpdate(newSocio, socio.user_id);
|
||||
};
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
if (["member_number"].includes(field)) {
|
||||
value = value === "" ? latestNumber : parseInt(value);
|
||||
}
|
||||
if (field === "display_name") {
|
||||
value = value.toUpperCase();
|
||||
}
|
||||
if (field === "dni") {
|
||||
value = value.toUpperCase();
|
||||
}
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleViewIncomes = () => {
|
||||
onViewIncomes(socio.user_id);
|
||||
}
|
||||
|
||||
return (
|
||||
<MotionCard className="socio-card shadow-sm rounded-4 h-100">
|
||||
<Card.Header className={`d-flex align-items-center rounded-4 rounded-bottom-0 justify-content-between ${getHeaderColor(formData.status)}`}>
|
||||
<div className="d-flex align-items-center p-1 m-0">
|
||||
{editMode ? (
|
||||
<TipoSocioDropdown value={formData.type} onChange={(val) => handleChange('type', val)} />
|
||||
) : (
|
||||
positionIfWaitlist && socio.type === 0 ? (
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
<Tooltip>
|
||||
Nº <strong>{positionIfWaitlist}</strong> en la lista de espera
|
||||
</Tooltip>
|
||||
}>
|
||||
<span className="me-3">
|
||||
<img src={getPFP(formData.type)} width="36" className="rounded" alt="PFP" />
|
||||
</span>
|
||||
</OverlayTrigger>
|
||||
) : (
|
||||
<img src={getPFP(formData.type)} width="36" className="rounded me-3" alt="PFP" />
|
||||
)
|
||||
)}
|
||||
<div className='d-flex flex-column gap-1'>
|
||||
<Card.Title className="m-0">
|
||||
{editMode ? (
|
||||
<Form.Control className="themed-input" size="sm" value={formData.display_name} onChange={(e) => handleChange('display_name', e.target.value)} style={{ maxWidth: '220px' }} />
|
||||
) : formData.display_name}
|
||||
</Card.Title>
|
||||
{editMode ? (
|
||||
<Form.Select className="themed-input" size="sm" value={formData.status} onChange={(e) => handleChange('status', parseInt(e.target.value))} style={{ maxWidth: '8rem' }}>
|
||||
<option value={1}>ACTIVO</option>
|
||||
<option value={0}>INACTIVO</option>
|
||||
</Form.Select>
|
||||
) : (
|
||||
<Badge style={{ width: 'fit-content' }} bg={getBadgeColor(formData.status)}>{getEstado(formData.status)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!createMode && !editMode && (
|
||||
<AnimatedDropdown
|
||||
className='end-0'
|
||||
buttonStyle='card-button'
|
||||
icon={<FontAwesomeIcon icon={faEllipsisVertical} className="fa-xl" />}>
|
||||
{({ closeDropdown }) => (
|
||||
<>
|
||||
<div className="dropdown-item d-flex align-items-center" onClick={() => { handleEdit(); closeDropdown(); }}>
|
||||
<FontAwesomeIcon icon={faEdit} className="me-2" />Editar
|
||||
</div>
|
||||
<div className="dropdown-item d-flex align-items-center" onClick={() => { handleViewIncomes(); closeDropdown(); }}>
|
||||
<FontAwesomeIcon icon={faMoneyBill} className="me-2" />Ver ingresos
|
||||
</div>
|
||||
<hr className="dropdown-divider" />
|
||||
<div className="dropdown-item d-flex align-items-center text-danger" onClick={() => { handleDelete(); closeDropdown(); }}>
|
||||
<FontAwesomeIcon icon={faTrash} className="me-2" />Eliminar
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatedDropdown>
|
||||
)}
|
||||
</Card.Header>
|
||||
|
||||
<Card.Body>
|
||||
{(editMode || createMode) && renderErrorAlert(error)}
|
||||
|
||||
<ListGroup className="mt-2 border-1 rounded-3 shadow-sm">
|
||||
{[{
|
||||
label: 'DNI', clazz: '', icon: faIdCard, value: formData.dni, field: 'dni', type: 'text', maxWidth: '180px'
|
||||
}, {
|
||||
label: 'SOCIO Nº', clazz: '', icon: faUser, value: formData.member_number || latestNumber, field: 'member_number', type: 'number', maxWidth: '100px'
|
||||
}, {
|
||||
label: 'HUERTO Nº', clazz: '', icon: faSunPlantWilt, value: formData.plot_number, field: 'plot_number', type: 'number', maxWidth: '100px'
|
||||
}, {
|
||||
label: 'TLF.', clazz: '', icon: faPhone, value: formData.phone, field: 'phone', type: 'number', maxWidth: '200px'
|
||||
}, {
|
||||
label: 'EMAIL', clazz: 'text-truncate', icon: faAt, value: formData.email, field: 'email', type: 'text', maxWidth: '250px'
|
||||
}].map(({ label, clazz, icon, value, field, type, maxWidth }) => (
|
||||
<ListGroup.Item key={field} className="d-flex justify-content-between align-items-center">
|
||||
<span><FontAwesomeIcon icon={icon} className="me-2" />{label}</span>
|
||||
{editMode ? (
|
||||
<Form.Control className="themed-input" size="sm" type={type} value={value} onChange={(e) => handleChange(field, e.target.value)} style={{ maxWidth }} />
|
||||
) : (
|
||||
<strong className={clazz}>{parseNull(value)}</strong>
|
||||
)}
|
||||
</ListGroup.Item>
|
||||
))}
|
||||
{editMode && (
|
||||
<ListGroup.Item className="d-flex justify-content-between align-items-center">
|
||||
<span><FontAwesomeIcon icon={faKey} className="me-2" />CONTRASEÑA</span>
|
||||
<div className="d-flex align-items-center gap-2" style={{ maxWidth: 'fit-content' }}>
|
||||
<Form.Control
|
||||
className="themed-input"
|
||||
size="sm"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
style={{ maxWidth: '200px' }}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline-secondary"
|
||||
onClick={() => setShowPassword(prev => !prev)}
|
||||
>
|
||||
{showPassword ? "Ocultar" : "Mostrar"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline-secondary"
|
||||
onClick={() => handleChange('password', generateSecurePassword())}
|
||||
>
|
||||
Generar
|
||||
</Button>
|
||||
</div>
|
||||
</ListGroup.Item>
|
||||
)}
|
||||
|
||||
</ListGroup>
|
||||
|
||||
{getFechas(formData, editMode, handleChange)}
|
||||
|
||||
<Card className="mt-2 border-1 rounded-3 notas-card">
|
||||
<Card.Body>
|
||||
<Card.Subtitle className="mb-2">
|
||||
{editMode ? (
|
||||
<><FontAwesomeIcon icon={faClipboard} className="me-2" />NOTAS (máx. 256)</>
|
||||
) : (
|
||||
<><FontAwesomeIcon icon={faClipboard} className="me-2" />NOTAS</>
|
||||
)}
|
||||
</Card.Subtitle>
|
||||
{editMode ? (
|
||||
<Form.Control className="themed-input" as="textarea" rows={3} value={formData.notes} onChange={(e) => handleChange('notes', e.target.value)} />
|
||||
) : (
|
||||
<Card.Text>{parseNull(formData.notes)}</Card.Text>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{editMode && (
|
||||
<div className="d-flex justify-content-end gap-2 mt-3">
|
||||
<Button variant="danger" size="sm" onClick={handleCancel}>Cancelar</Button>
|
||||
<Button variant="success" size="sm" onClick={handleSave}>Guardar</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</MotionCard>
|
||||
);
|
||||
};
|
||||
|
||||
SocioCard.propTypes = {
|
||||
socio: PropTypes.object.isRequired,
|
||||
isNew: PropTypes.bool,
|
||||
onCancel: PropTypes.func,
|
||||
onCreate: PropTypes.func,
|
||||
onUpdate: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onViewIncomes: PropTypes.func,
|
||||
error: PropTypes.string,
|
||||
onClearError: PropTypes.func,
|
||||
positionIfWaitlist: PropTypes.number
|
||||
};
|
||||
|
||||
export default SocioCard;
|
||||
104
src/components/Socios/SociosFilter.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const SociosFilter = ({ filters, onChange }) => {
|
||||
const handleCheckboxChange = (key) => {
|
||||
if (key === 'todos') {
|
||||
const newValue = !filters.todos;
|
||||
onChange({
|
||||
todos: newValue,
|
||||
listaEspera: newValue,
|
||||
invernadero: newValue,
|
||||
inactivos: newValue,
|
||||
colaboradores: newValue,
|
||||
hortelanos: newValue
|
||||
});
|
||||
} else {
|
||||
const updated = { ...filters, [key]: !filters[key] };
|
||||
const allTrue = Object.entries(updated)
|
||||
.filter(([k]) => k !== 'todos')
|
||||
.every(([, v]) => v === true);
|
||||
|
||||
updated.todos = allTrue;
|
||||
onChange(updated);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="mostrarTodosCheck"
|
||||
className="me-2"
|
||||
checked={filters.todos}
|
||||
onChange={() => handleCheckboxChange('todos')}
|
||||
/>
|
||||
<label htmlFor="mostrarTodosCheck" className="m-0">Mostrar Todos</label>
|
||||
</div>
|
||||
|
||||
<hr className="dropdown-divider" />
|
||||
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="esperaCheck"
|
||||
className="me-2"
|
||||
checked={filters.listaEspera}
|
||||
onChange={() => handleCheckboxChange('listaEspera')}
|
||||
/>
|
||||
<label htmlFor="esperaCheck" className="m-0">Lista de Espera</label>
|
||||
</div>
|
||||
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="invernaderosCheck"
|
||||
className="me-2"
|
||||
checked={filters.invernadero}
|
||||
onChange={() => handleCheckboxChange('invernadero')}
|
||||
/>
|
||||
<label htmlFor="invernaderosCheck" className="m-0">Invernadero</label>
|
||||
</div>
|
||||
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="inactivosCheck"
|
||||
className="me-2"
|
||||
checked={filters.inactivos}
|
||||
onChange={() => handleCheckboxChange('inactivos')}
|
||||
/>
|
||||
<label htmlFor="inactivosCheck" className="m-0">Inactivos</label>
|
||||
</div>
|
||||
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="colaboradoresCheck"
|
||||
className="me-2"
|
||||
checked={filters.colaboradores}
|
||||
onChange={() => handleCheckboxChange('colaboradores')}
|
||||
/>
|
||||
<label htmlFor="colaboradoresCheck" className="m-0">Colaboradores</label>
|
||||
</div>
|
||||
|
||||
<div className="dropdown-item d-flex align-items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="hortelanosCheck"
|
||||
className="me-2"
|
||||
checked={filters.hortelanos}
|
||||
onChange={() => handleCheckboxChange('hortelanos')}
|
||||
/>
|
||||
<label htmlFor="hortelanosCheck" className="m-0">Hortelanos</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
SociosFilter.propTypes = {
|
||||
filters: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default SociosFilter;
|
||||
132
src/components/Socios/SociosPDF.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { Document, Page, Text, View, StyleSheet, Font, Image } from '@react-pdf/renderer';
|
||||
|
||||
Font.register({
|
||||
family: 'Open Sans',
|
||||
fonts: [{ src: '/fonts/OpenSans.ttf', fontWeight: 'normal' }]
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
padding: 25,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Open Sans',
|
||||
backgroundColor: '#F8F9FA',
|
||||
},
|
||||
headerContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 25,
|
||||
justifyContent: 'left',
|
||||
},
|
||||
headerText: {
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'left',
|
||||
alignItems: 'center',
|
||||
marginLeft: 25,
|
||||
},
|
||||
logo: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
},
|
||||
header: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#2C3E50',
|
||||
letterSpacing: 1.5,
|
||||
},
|
||||
subHeader: {
|
||||
fontSize: 14,
|
||||
marginTop: 5,
|
||||
color: '#34495E'
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#3E8F5A',
|
||||
fontWeight: 'bold',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 5,
|
||||
borderTopLeftRadius: 10,
|
||||
borderTopRightRadius: 10,
|
||||
},
|
||||
headerCell: {
|
||||
paddingHorizontal: 5,
|
||||
color: '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 12,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 5,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#D5D8DC'
|
||||
},
|
||||
cell: {
|
||||
paddingHorizontal: 5,
|
||||
fontSize: 9,
|
||||
color: '#2C3E50'
|
||||
}
|
||||
});
|
||||
|
||||
const parseDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const [y, m, d] = dateStr.split('-');
|
||||
return `${d}/${m}/${y}`;
|
||||
};
|
||||
|
||||
export const SociosPDF = ({ socios }) => (
|
||||
<Document>
|
||||
<Page size="A4" orientation="landscape" style={styles.page}>
|
||||
<View style={styles.headerContainer}>
|
||||
<Image src={"/images/logo.png"} style={styles.logo} />
|
||||
<View style={styles.headerText}>
|
||||
<Text style={styles.header}>Listado de socios</Text>
|
||||
<Text style={styles.subHeader}>Asociación Huertos La Salud - Bellavista • Generado el {new Date().toLocaleDateString()} a las {new Date().toLocaleTimeString()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={[styles.headerCell, { flex: 0.2 }]}>S</Text>
|
||||
<Text style={[styles.headerCell, { flex: 0.2 }]}>H</Text>
|
||||
<Text style={[styles.headerCell, { flex: 3 }]}>Nombre</Text>
|
||||
<Text style={[styles.headerCell, { flex: 1 }]}>DNI</Text>
|
||||
<Text style={[styles.headerCell, { flex: 1 }]}>Teléfono</Text>
|
||||
<Text style={[styles.headerCell, { flex: 3 }]}>Email</Text>
|
||||
<Text style={[styles.headerCell, { flex: 1 }]}>Alta</Text>
|
||||
<Text style={[styles.headerCell, { flex: 1 }]}>Tipo</Text>
|
||||
</View>
|
||||
|
||||
{socios.map((socio, idx) => (
|
||||
<View
|
||||
key={idx}
|
||||
style={[
|
||||
styles.row,
|
||||
{ backgroundColor: idx % 2 === 0 ? '#ECF0F1' : '#FDFEFE' },
|
||||
{ borderBottomLeftRadius: idx === socios.length - 1 ? 10 : 0 },
|
||||
{ borderBottomRightRadius: idx === socios.length - 1 ? 10 : 0 },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.cell, { flex: 0.2 }]}>{socio?.member_number}</Text>
|
||||
<Text style={[styles.cell, { flex: 0.2 }]}>{socio?.plot_number}</Text>
|
||||
<Text style={[styles.cell, { flex: 3 }]}>{socio?.display_name}</Text>
|
||||
<Text style={[styles.cell, { flex: 1 }]}>{socio?.dni}</Text>
|
||||
<Text style={[styles.cell, { flex: 1 }]}>{socio?.phone}</Text>
|
||||
<Text style={[styles.cell, { flex: 3 }]}>{socio?.email || ''}</Text>
|
||||
<Text style={[styles.cell, { flex: 1 }]}>{parseDate(socio?.created_at?.split('T')[0] || '')}</Text>
|
||||
<Text style={[styles.cell, { flex: 1 }]}>
|
||||
{(() => {
|
||||
switch (socio?.type) {
|
||||
case 0: return 'L. Espera';
|
||||
case 1: return 'Hortelano';
|
||||
case 2: return 'Invernadero';
|
||||
case 3: return 'Colaborador';
|
||||
default: return 'Desconocido';
|
||||
}
|
||||
})()}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
54
src/components/Socios/TipoSocioDropdown.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import AnimatedDropdown from '../AnimatedDropdown';
|
||||
import { Image } from 'react-bootstrap';
|
||||
|
||||
const tipos = [
|
||||
{ value: 0, label: 'Lista de espera', icon: 'list.svg' },
|
||||
{ value: 1, label: 'Hortelano', icon: 'farmer.svg' },
|
||||
{ value: 2, label: 'Hortelano+Invernadero', icon: 'green_house.svg' },
|
||||
{ value: 3, label: 'Colaborador', icon: 'join.svg' },
|
||||
{ value: 4, label: 'Subvención', icon: 'subvencion4.svg' },
|
||||
{ value: 5, label: 'Informático', icon: 'programmer.svg' }
|
||||
];
|
||||
|
||||
const basePath = '/images/icons/';
|
||||
|
||||
const TipoSocioDropdown = ({ value, onChange }) => {
|
||||
const selected = tipos.find(t => t.value === value) || tipos[0];
|
||||
|
||||
return (
|
||||
<AnimatedDropdown
|
||||
trigger={
|
||||
<button className="btn p-0 border-0 bg-transparent">
|
||||
<Image
|
||||
src={basePath + selected.icon}
|
||||
width={36}
|
||||
className="rounded me-3"
|
||||
alt={selected.label}
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
className="w-auto"
|
||||
>
|
||||
{({ closeDropdown }) => (
|
||||
<>
|
||||
{tipos.map(t => (
|
||||
<div
|
||||
key={t.value}
|
||||
className={`dropdown-item d-flex align-items-center`}
|
||||
style={{ width: '100%', minWidth: '160px' }}
|
||||
onClick={() => {
|
||||
onChange(t.value);
|
||||
closeDropdown();
|
||||
}}
|
||||
>
|
||||
<img src={basePath + t.icon} width={24} height={24} alt={t.label} className='me-3' />
|
||||
{t.label}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</AnimatedDropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default TipoSocioDropdown;
|
||||
148
src/components/Solicitudes/PreUserForm.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Form, Row, Col, Button } from 'react-bootstrap';
|
||||
import { useDataContext } from '../../hooks/useDataContext';
|
||||
import { Alert } from 'react-bootstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const PreUserForm = ({ onSubmit, userType, plotNumber, errors = {} }) => {
|
||||
const { getData } = useDataContext();
|
||||
const fetchedOnce = useRef(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
user_name: '',
|
||||
display_name: '',
|
||||
dni: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
zip_code: '',
|
||||
city: '',
|
||||
member_number: '',
|
||||
plot_number: plotNumber,
|
||||
type: userType,
|
||||
status: 1,
|
||||
role: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLastNumber = async () => {
|
||||
if (fetchedOnce.current) return;
|
||||
fetchedOnce.current = true;
|
||||
|
||||
try {
|
||||
const { data, error } = await getData("https://api.huertosbellavista.es/v1/members/latest-number");
|
||||
if (error) throw new Error(error);
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
member_number: data.lastMemberNumber + 1
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("Error al obtener el número de socio:", err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLastNumber();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmedName = form.display_name?.trim() ?? "";
|
||||
|
||||
const nuevoUsername = trimmedName
|
||||
? trimmedName.split(' ')[0].toLowerCase() : "";
|
||||
|
||||
if (form.user_name !== nuevoUsername) {
|
||||
setForm(prev => ({ ...prev, user_name: nuevoUsername }));
|
||||
}
|
||||
}, [form.member_number, form.display_name, form.user_name]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type } = e.target;
|
||||
let updatedValue = value;
|
||||
|
||||
if (name === 'display_name' || name === 'dni') {
|
||||
updatedValue = value.toUpperCase();
|
||||
}
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'number' ? parseInt(updatedValue) || '' : updatedValue
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (onSubmit) onSubmit(form);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{errors.general && <Alert variant="danger" className="my-2">{errors.general}</Alert>}
|
||||
|
||||
<Form onSubmit={handleSubmit} className="p-3 px-md-4">
|
||||
<Row className="gy-3">
|
||||
|
||||
{[
|
||||
{ label: 'Nombre completo', name: 'display_name', type: 'text', required: true },
|
||||
{ label: 'Nombre de usuario', name: 'user_name', type: 'text', required: true },
|
||||
{ label: 'DNI', name: 'dni', type: 'text', required: true, maxLength: 9 },
|
||||
{ label: 'Teléfono', name: 'phone', type: 'tel', required: true },
|
||||
{ label: 'Correo electrónico', name: 'email', type: 'email', required: true },
|
||||
{ label: 'Domicilio', name: 'address', type: 'text' },
|
||||
{ label: 'Código Postal', name: 'zip_code', type: 'text' },
|
||||
{ label: 'Ciudad', name: 'city', type: 'text' }
|
||||
].map(({ label, name, type, required, maxLength }) => (
|
||||
<Col md={4} key={name}>
|
||||
<Form.Group>
|
||||
<Form.Label className="fw-semibold">{label}</Form.Label>
|
||||
<Form.Control
|
||||
className="themed-input shadow-sm"
|
||||
type={type}
|
||||
name={name}
|
||||
value={form[name]}
|
||||
onChange={handleChange}
|
||||
required={required}
|
||||
maxLength={maxLength}
|
||||
isInvalid={!!errors[name]}
|
||||
/>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{errors[name]}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
))}
|
||||
|
||||
<Col md={4}>
|
||||
<Form.Group>
|
||||
<Form.Label className="fw-semibold">Nº Socio</Form.Label>
|
||||
<Form.Control
|
||||
className="shadow-sm"
|
||||
disabled
|
||||
type="number"
|
||||
name="member_number"
|
||||
value={form.member_number}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
|
||||
<Col xs={12} className="text-center mt-3">
|
||||
<Button type="submit" variant="success" size="lg" className="px-5 shadow-sm">
|
||||
Enviar solicitud
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
PreUserForm.propTypes = {
|
||||
userType: PropTypes.number.isRequired,
|
||||
plotNumber: PropTypes.number.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
errors: PropTypes.object
|
||||
};
|
||||
|
||||
export default PreUserForm;
|
||||
197
src/components/Solicitudes/SolicitudCard.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { Card, ListGroup, Button } from 'react-bootstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faUser, faIdCard, faEnvelope, faPhone, faHome, faMapMarkerAlt, faHashtag,
|
||||
faSeedling, faUserShield, faCalendar,
|
||||
faTrash, faEllipsisVertical
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { motion as _motion } from 'framer-motion';
|
||||
import AnimatedDropdown from '../../components/AnimatedDropdown';
|
||||
import '../../css/SolicitudCard.css';
|
||||
|
||||
const MotionCard = _motion.create(Card);
|
||||
|
||||
const parseDate = (date) => {
|
||||
if (!date) return 'NO';
|
||||
const d = new Date(date);
|
||||
return `${d.getDate().toString().padStart(2, '0')}/${(d.getMonth() + 1).toString().padStart(2, '0')}/${d.getFullYear()}`;
|
||||
};
|
||||
|
||||
const getTipoSolicitud = (tipo) => ['Alta', 'Baja', 'Añadir Colaborador', 'Quitar Colaborador', 'Añadir parcela invernadero', 'Dejar parcela invernadero'][tipo] ?? 'Desconocido';
|
||||
const getEstadoSolicitud = (estado) => ['Pendiente', 'Aceptada', 'Rechazada'][estado] ?? 'Desconocido';
|
||||
|
||||
const getPFP = (tipo) => {
|
||||
const base = '/images/icons/';
|
||||
const map = {
|
||||
1: 'farmer.svg',
|
||||
2: 'green_house.svg',
|
||||
0: 'list.svg',
|
||||
3: 'join.svg',
|
||||
4: 'subvencion4.svg',
|
||||
5: 'programmer.svg'
|
||||
};
|
||||
return base + (map[tipo] || 'farmer.svg');
|
||||
};
|
||||
|
||||
const renderDescripcionSolicitud = (data, onProfile) => {
|
||||
const { request_type, request_status, requested_by_name, pre_display_name } = data;
|
||||
|
||||
switch (request_type) {
|
||||
case 0:
|
||||
if (requested_by_name) {
|
||||
return `${requested_by_name} quiere darse de alta.`;
|
||||
} else if (request_status !== 1 && pre_display_name) {
|
||||
return `${pre_display_name} quiere darse de alta.`;
|
||||
} else if (request_status !== 1) {
|
||||
return `Alguien quiere darse de alta.`;
|
||||
} else {
|
||||
return `Se ha aceptado esta solicitud de alta.`;
|
||||
}
|
||||
|
||||
case 1:
|
||||
return onProfile
|
||||
? "Has solicitado darte de baja."
|
||||
: requested_by_name
|
||||
? `${requested_by_name} quiere darse de baja.`
|
||||
: request_status !== 1
|
||||
? `Alguien quiere darse de baja.`
|
||||
: `Se ha aceptado esta solicitud de baja.`;
|
||||
|
||||
case 2:
|
||||
if (onProfile) {
|
||||
switch (request_status) {
|
||||
case 0: return "Has solicitado añadir un colaborador.";
|
||||
case 1: return "Tu solicitud de colaborador ha sido aceptada.";
|
||||
case 2: return "Tu solicitud de colaborador ha sido rechazada.";
|
||||
default: return "Solicitud de colaborador desconocida.";
|
||||
}
|
||||
} else {
|
||||
switch (request_status) {
|
||||
case 0:
|
||||
return requested_by_name
|
||||
? `${requested_by_name} quiere añadir a ${pre_display_name || "un colaborador"} como colaborador.`
|
||||
: `Alguien quiere añadir a ${pre_display_name || "un colaborador"} como colaborador.`;
|
||||
case 1:
|
||||
return `La solicitud de colaborador de ${requested_by_name || "alguien"} ha sido aceptada.`;
|
||||
case 2:
|
||||
return `La solicitud de colaborador de ${requested_by_name || "alguien"} ha sido rechazada.`;
|
||||
default:
|
||||
return "Solicitud de colaborador desconocida.";
|
||||
}
|
||||
}
|
||||
|
||||
case 3:
|
||||
return onProfile
|
||||
? "Has solicitado quitar tu colaborador."
|
||||
: requested_by_name
|
||||
? `${requested_by_name} quiere quitar su colaborador.`
|
||||
: request_status !== 1
|
||||
? `Alguien quiere quitar su colaborador.`
|
||||
: `Se ha aceptado esta solicitud de baja de colaborador.`;
|
||||
|
||||
case 4:
|
||||
return onProfile
|
||||
? "Has solicitado una parcela en el invernadero."
|
||||
: requested_by_name
|
||||
? `${requested_by_name} quiere una parcela en el invernadero.`
|
||||
: request_status !== 1
|
||||
? `Alguien quiere una parcela en el invernadero.`
|
||||
: `Se ha aceptado esta solicitud de parcela en el invernadero.`;
|
||||
|
||||
case 5:
|
||||
return onProfile
|
||||
? "Has solicitado dejar tu parcela del invernadero."
|
||||
: requested_by_name
|
||||
? `${requested_by_name} quiere dejar su parcela del invernadero.`
|
||||
: request_status !== 1
|
||||
? `Alguien quiere dejar su parcela del invernadero.`
|
||||
: `Se ha aceptado esta solicitud de salida del invernadero.`;
|
||||
|
||||
default:
|
||||
return "Tipo de solicitud desconocido.";
|
||||
}
|
||||
};
|
||||
|
||||
const SolicitudCard = ({ data, onAccept, onReject, onDelete, editable = true, onProfile = false }) => {
|
||||
const handleDelete = () => typeof onDelete === "function" && onDelete(data.request_id);
|
||||
|
||||
return (
|
||||
<MotionCard className="solicitud-card shadow-sm rounded-4 h-100">
|
||||
<Card.Header className="rounded-top-4 d-flex justify-content-between align-items-center">
|
||||
<div className="d-flex align-items-center">
|
||||
<img src={getPFP(data.pre_type)} width="36" className="rounded me-3" alt="PFP" />
|
||||
<div>
|
||||
<Card.Title className="mb-0">
|
||||
Solicitud #{data.request_id} - {getTipoSolicitud(data.request_type)}
|
||||
</Card.Title>
|
||||
<small className='state-small'>Estado: <strong>{getEstadoSolicitud(data.request_status)}</strong></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!onProfile && (
|
||||
<AnimatedDropdown
|
||||
className="end-0"
|
||||
buttonStyle="card-button"
|
||||
icon={<FontAwesomeIcon icon={faEllipsisVertical} className="fa-xl" />}>
|
||||
{({ closeDropdown }) => (
|
||||
<div className="dropdown-item d-flex align-items-center text-danger" onClick={() => { handleDelete(); closeDropdown(); }}>
|
||||
<FontAwesomeIcon icon={faTrash} className="me-2" />Eliminar
|
||||
</div>
|
||||
)}
|
||||
</AnimatedDropdown>
|
||||
)}
|
||||
</Card.Header>
|
||||
|
||||
<Card.Body>
|
||||
<ListGroup variant="flush" className="border rounded-3 mb-3">
|
||||
<ListGroup.Item>
|
||||
<FontAwesomeIcon icon={faCalendar} className="me-2" />
|
||||
Fecha de solicitud: <strong>{parseDate(data.request_created_at)}</strong>
|
||||
</ListGroup.Item>
|
||||
</ListGroup>
|
||||
|
||||
<ListGroup variant="flush" className="border rounded-3 mb-3">
|
||||
<ListGroup.Item>
|
||||
{renderDescripcionSolicitud(data, onProfile)}
|
||||
</ListGroup.Item>
|
||||
</ListGroup>
|
||||
|
||||
{data.pre_display_name && (
|
||||
<>
|
||||
<Card.Subtitle className="card-subtitle mt-3 mb-2">Datos del futuro socio</Card.Subtitle>
|
||||
<ListGroup variant="flush" className="border rounded-3">
|
||||
<ListGroup.Item><FontAwesomeIcon icon={faUser} className="me-2" />Nombre: <strong>{data.pre_display_name}</strong></ListGroup.Item>
|
||||
<ListGroup.Item><FontAwesomeIcon icon={faIdCard} className="me-2" />DNI: <strong>{data.pre_dni}</strong></ListGroup.Item>
|
||||
<ListGroup.Item><FontAwesomeIcon icon={faPhone} className="me-2" />Teléfono: <strong>{data.pre_phone}</strong></ListGroup.Item>
|
||||
<ListGroup.Item><FontAwesomeIcon icon={faEnvelope} className="me-2" />Email: <strong>{data.pre_email}</strong></ListGroup.Item>
|
||||
<ListGroup.Item><FontAwesomeIcon icon={faHome} className="me-2" />Dirección: <strong>{data.pre_address ?? 'NO'}</strong></ListGroup.Item>
|
||||
<ListGroup.Item><FontAwesomeIcon icon={faMapMarkerAlt} className="me-2" />Ciudad: <strong>{data.pre_city ?? 'NO'} ({data.pre_zip_code ?? 'NO'})</strong></ListGroup.Item>
|
||||
<ListGroup.Item><FontAwesomeIcon icon={faHashtag} className="me-2" />Nº socio: <strong>{data.pre_member_number ?? 'NO'}</strong> | Nº huerto: <strong>{data.pre_plot_number ?? 'NO'}</strong></ListGroup.Item>
|
||||
<ListGroup.Item><FontAwesomeIcon icon={faSeedling} className="me-2" />Tipo: <strong>{['Lista de Espera', 'Hortelano', 'Hortelano + Invernadero', 'Colaborador'][data.pre_type]}</strong></ListGroup.Item>
|
||||
<ListGroup.Item><FontAwesomeIcon icon={faUserShield} className="me-2" />Rol: <strong>{['Usuario', 'Admin', 'Desarrollador'][data.pre_role]}</strong></ListGroup.Item>
|
||||
</ListGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editable && data.request_status === 0 && (
|
||||
<div className="d-flex justify-content-end gap-2 mt-3">
|
||||
<Button variant="danger" size="sm" onClick={() => onReject?.(data)}>Rechazar</Button>
|
||||
<Button variant="success" size="sm" onClick={() => onAccept?.(data)}>Aceptar</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</MotionCard>
|
||||
);
|
||||
};
|
||||
|
||||
SolicitudCard.propTypes = {
|
||||
data: PropTypes.object.isRequired,
|
||||
onAccept: PropTypes.func,
|
||||
onReject: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
editable: PropTypes.bool,
|
||||
onProfile: PropTypes.bool
|
||||
};
|
||||
|
||||
export default SolicitudCard;
|
||||
24
src/components/SpanishDateTimePicker.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import DatePicker, { registerLocale } from 'react-datepicker';
|
||||
import es from 'date-fns/locale/es';
|
||||
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
|
||||
registerLocale('es', es);
|
||||
|
||||
const SpanishDateTimePicker = ({ selected, onChange }) => {
|
||||
return (
|
||||
<DatePicker
|
||||
selected={selected}
|
||||
onChange={onChange}
|
||||
showTimeSelect
|
||||
timeFormat="HH:mm"
|
||||
timeIntervals={15}
|
||||
dateFormat="dd/MM/yyyy HH:mm"
|
||||
timeCaption="Hora"
|
||||
locale="es"
|
||||
className="form-control themed-input"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpanishDateTimePicker;
|
||||
18
src/components/ThemeButton.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useTheme } from "../hooks/useTheme.js";
|
||||
import "../css/ThemeButton.css";
|
||||
|
||||
export default function ThemeButton({ className, onlyIcon}) {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button className={`theme-toggle ${className}`} onClick={toggleTheme}>
|
||||
{
|
||||
onlyIcon ? (
|
||||
theme === "dark" ? ("🌞") : ("🌙")
|
||||
) : (
|
||||
theme === "dark" ? ("🌞 tema claro") : ("🌙 tema oscuro")
|
||||
)
|
||||
}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
98
src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect, createContext } from "react";
|
||||
import createAxiosInstance from "../api/axiosInstance";
|
||||
import { useConfig } from "../hooks/useConfig";
|
||||
|
||||
export const AuthContext = createContext();
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const axios = createAxiosInstance();
|
||||
const { config } = useConfig();
|
||||
|
||||
const [user, setUser] = useState(() => JSON.parse(localStorage.getItem("user")) || null);
|
||||
const [token, setToken] = useState(() => localStorage.getItem("token"));
|
||||
const [authStatus, setAuthStatus] = useState("checking");
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
|
||||
if (!token) {
|
||||
setAuthStatus("unauthenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
const BASE_URL = config.apiConfig.baseUrl;
|
||||
const VALIDATE_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.validateToken}`;
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await axios.get(VALIDATE_URL, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.status === 200) {
|
||||
setAuthStatus("authenticated");
|
||||
} else {
|
||||
logout();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error validando token:", err);
|
||||
logout();
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [token, config]);
|
||||
|
||||
const login = async (formData) => {
|
||||
setError(null);
|
||||
const BASE_URL = config.apiConfig.baseUrl;
|
||||
const LOGIN_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.login}`;
|
||||
|
||||
try {
|
||||
const res = await axios.post(LOGIN_URL, formData);
|
||||
const { token, member, tokenTime } = res.data.data;
|
||||
|
||||
localStorage.setItem("token", token);
|
||||
localStorage.setItem("user", JSON.stringify(member));
|
||||
localStorage.setItem("tokenTime", tokenTime);
|
||||
|
||||
setToken(token);
|
||||
setUser(member);
|
||||
setAuthStatus("authenticated");
|
||||
} catch (err) {
|
||||
console.error("Error al iniciar sesión:", err);
|
||||
|
||||
let message = "Ha ocurrido un error inesperado.";
|
||||
|
||||
if (err.response) {
|
||||
const { status, data } = err.response;
|
||||
|
||||
if (status === 400) {
|
||||
message = "Usuario o contraseña incorrectos.";
|
||||
} else if (status === 403) {
|
||||
message = "Tu cuenta está inactiva o ha sido suspendida.";
|
||||
} else if (status === 404) {
|
||||
message = "Usuario no encontrado.";
|
||||
} else if (data?.message) {
|
||||
message = data.message;
|
||||
}
|
||||
}
|
||||
|
||||
setError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.clear();
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
setAuthStatus("unauthenticated");
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, authStatus, login, logout, error }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
41
src/context/ConfigContext.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createContext, useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const ConfigContext = createContext();
|
||||
|
||||
export const ConfigProvider = ({ children }) => {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [configLoading, setLoading] = useState(true);
|
||||
const [configError, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = import.meta.env.MODE === 'production'
|
||||
? await fetch("/config/settings.prod.json")
|
||||
: await fetch("/config/settings.dev.json");
|
||||
if (!response.ok) throw new Error("Error al cargar settings.*.json");
|
||||
const json = await response.json();
|
||||
setConfig(json);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigContext.Provider value={{ config, configLoading, configError }}>
|
||||
{children}
|
||||
</ConfigContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
ConfigProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export {ConfigContext};
|
||||
23
src/context/DataContext.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createContext } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { useData } from "../hooks/useData";
|
||||
|
||||
export const DataContext = createContext();
|
||||
|
||||
export const DataProvider = ({ config, children }) => {
|
||||
const data = useData(config);
|
||||
|
||||
return (
|
||||
<DataContext.Provider value={data}>
|
||||
{children}
|
||||
</DataContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
DataProvider.propTypes = {
|
||||
config: PropTypes.shape({
|
||||
baseUrl: PropTypes.string.isRequired,
|
||||
params: PropTypes.object,
|
||||
}).isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
31
src/context/ThemeContext.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
|
||||
export const ThemeContext = createContext();
|
||||
|
||||
export const ThemeProvider = ({ children }) => {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
return (
|
||||
localStorage.getItem("theme") ||
|
||||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
document.body.classList.remove("light", "dark");
|
||||
document.body.classList.add(theme);
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
28
src/css/AnimatedDropdown.css
Normal file
@@ -0,0 +1,28 @@
|
||||
.dropdown-menu .dropdown-divider {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
background-color: var(--bg-color) !important;
|
||||
color: var(--text-color) !important;
|
||||
}
|
||||
|
||||
.dropdown-menu.show {
|
||||
background-color: var(--navbar-bg) !important;
|
||||
box-shadow: 0 5px 10px var(--shadow-color);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
background-color: var(--navbar-bg) !important;
|
||||
color: var(--navbar-dropdown-item-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--navbar-bg) !important;
|
||||
color: var(--secondary-color) !important;
|
||||
}
|
||||
|
||||
.disabled.text-muted {
|
||||
color: var(--muted-color) !important;
|
||||
}
|
||||
151
src/css/AnuncioCard.css
Normal file
@@ -0,0 +1,151 @@
|
||||
.anuncio-card {
|
||||
background-color: var(--card-bg) !important;
|
||||
color: var(--card-text);
|
||||
border: none !important;
|
||||
font-family: "Open Sans", sans-serif;
|
||||
}
|
||||
|
||||
.anuncio-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 0.75rem 1.5rem var(--shadow-color);
|
||||
}
|
||||
|
||||
.anuncio-card .card-header {
|
||||
background-color: var(--secondary-color) !important;
|
||||
color: var(--card-text-secondary) !important;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.anuncio-card .card-header button {
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0.5rem;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
background-color: transparent;
|
||||
color: var(--card-button) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.anuncio-card .card-header button:hover {
|
||||
background-color: var(--header-btn-hover);
|
||||
}
|
||||
|
||||
.anuncio-card .leer-mas-link {
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.anuncio-card .leer-mas-link:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--btn-bg-hover);
|
||||
}
|
||||
|
||||
.anuncio-card .text-muted {
|
||||
color: var(--card-muted-text) !important;
|
||||
}
|
||||
|
||||
.anuncio-card .priority-footer {
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: bold;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
color: var(--card-text);
|
||||
}
|
||||
|
||||
.rsw-toolbar {
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding-bottom: 0.3rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rsw-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.rsw-ce {
|
||||
padding: 8px;
|
||||
min-height: 120px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rsw-dd {
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.rsw-ce ul {
|
||||
list-style: disc;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.rsw-ce ol {
|
||||
list-style: decimal;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
/* Estilo base del contenedor del editor */
|
||||
.rsw-editor {
|
||||
background-color: var(--input-bg) !important;
|
||||
border: 1px solid var(--input-border) !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 0.5rem !important;
|
||||
color: var(--input-text) !important;
|
||||
font-family: "Open Sans", sans-serif !important;
|
||||
font-size: 0.95rem !important;
|
||||
}
|
||||
|
||||
/* Estilo del área editable */
|
||||
.rsw-editor textarea {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
color: var(--input-text) !important;
|
||||
font-family: "Open Sans", sans-serif !important;
|
||||
font-size: 0.95rem !important;
|
||||
resize: vertical !important;
|
||||
min-height: 120px !important;
|
||||
}
|
||||
|
||||
/* Placeholder del textarea si lo añades dinámicamente */
|
||||
.rsw-editor textarea::placeholder {
|
||||
color: var(--placeholder-color) !important;
|
||||
}
|
||||
|
||||
/* Enfoque del editor */
|
||||
.rsw-editor:focus-within {
|
||||
border-color: var(--accent-color) !important;
|
||||
box-shadow: 0 0 0 0.15rem rgba(67, 167, 72, 0.25);
|
||||
}
|
||||
|
||||
/* Toolbar del editor */
|
||||
.rsw-toolbar {
|
||||
background-color: transparent !important;
|
||||
border-bottom: 1px solid var(--input-border) !important;
|
||||
padding-bottom: 0.25rem !important;
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
|
||||
/* Botones del toolbar */
|
||||
.rsw-toolbar button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
color: var(--toolbar-btn-color) !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 0.35rem !important;
|
||||
}
|
||||
|
||||
.rsw-toolbar button:hover {
|
||||
background-color: var(--toolbar-btn-hover) !important;
|
||||
}
|
||||
|
||||
.rsw-toolbar button:disabled {
|
||||
opacity: 0.4 !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
55
src/css/BalanceReport.css
Normal file
@@ -0,0 +1,55 @@
|
||||
.balance-report.card {
|
||||
border-radius: 1.75rem;
|
||||
box-shadow: 0 6px 24px var(--shadow-color);
|
||||
|
||||
background-color: var(--balance-report-bg);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.report-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.balance-box {
|
||||
border-radius: 1rem;
|
||||
background-color: var(--bg-color);
|
||||
padding: 1.25rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.balance-box p {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.balance-value {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.balance-timestamp {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.print-btn {
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 1.2rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background-color: var(--btn-bg);
|
||||
color: var(--btn-text);
|
||||
|
||||
}
|
||||
|
||||
.print-btn:hover {
|
||||
background-color: var(--btn-bg-hover);
|
||||
color: var(--btn-text-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
51
src/css/Building.css
Normal file
@@ -0,0 +1,51 @@
|
||||
/* ================================
|
||||
BUILDING COMPONENT - VISUAL ONLY
|
||||
================================== */
|
||||
|
||||
.building-container {
|
||||
font-family: 'Product Sans', sans-serif;
|
||||
color: var(--fg-color);
|
||||
animation: fadeInScale 0.5s ease;
|
||||
}
|
||||
|
||||
.building-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
.building-title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.building-subtitle {
|
||||
font-size: 1.2rem;
|
||||
margin-top: 0.5rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Animaciones */
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
60
src/css/ComposeMailModal.css
Normal file
@@ -0,0 +1,60 @@
|
||||
.compose-mail-modal {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-width: 500px;
|
||||
margin: auto;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.compose-mail-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ccc;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
background: #007bff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-send:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
65
src/css/Correo.css
Normal file
@@ -0,0 +1,65 @@
|
||||
/* Layout general */
|
||||
.correo-page {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-wrapper {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.split-wrapper > * {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Gutter (barra de resize entre paneles) */
|
||||
.gutter {
|
||||
background-color: var(--divider-color);
|
||||
background-clip: content-box;
|
||||
cursor: col-resize;
|
||||
width: 1px !important;
|
||||
}
|
||||
|
||||
.gutter:hover {
|
||||
background-color: var(--highlight-border);
|
||||
width: 8px !important;
|
||||
transition: width 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Panel de navegación (Sidebar + MailList) */
|
||||
.mail-nav-pane {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mail-nav-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ====================
|
||||
Modo móvil (correo-mobile)
|
||||
==================== */
|
||||
@media screen and (max-width: 900px) {
|
||||
.split-wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.split-wrapper > * {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.correo-page.viewing-mail .split-wrapper > :nth-child(1) {
|
||||
display: none; /* Oculta panel de navegación */
|
||||
}
|
||||
|
||||
.correo-page.viewing-mail .split-wrapper > :nth-child(2) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||