Compare commits

...

9 Commits

Author SHA1 Message Date
b6d7759b30 sincronizar cambios
Some checks failed
build-and-deploy / filter (push) Successful in 2s
Sync to GitHub / sync (push) Failing after 2s
build-and-deploy / build (push) Failing after 27s
build-and-deploy / deploy (push) Has been skipped
2025-05-30 02:13:01 -06:00
josedario87
98f6105373 Merge pull request #6 from josedario87/feature/docker-container-linking
I've configured the container names and linked the UI to the API.
2025-05-30 02:09:29 -06:00
google-labs-jules[bot]
2441fb9066 I've configured the container names and linked the UI to the API.
Here's what I did:
- Added `container_name` for `api` and `ui` services in `docker-compose.yml`.
- Created `ui/src/apiClient.js` to configure the API base URL using the API container name (`http://planilla-api:4000/api`).
- Added `axios` as a dependency to the UI project.
- Ensured UI store files correctly import the new `apiClient.js`.

This will allow your UI to reliably connect to the API service using Docker's internal DNS resolution via container names.
2025-05-30 08:08:59 +00:00
josedario87
2538aafc5c Merge pull request #5 from josedario87/feat/daily-employee-sync
feat: Add daily employee data synchronization
2025-05-30 00:48:45 -06:00
google-labs-jules[bot]
3fdba1fe89 feat: Add daily employee data synchronization
I've added a new capability to synchronize employee data from the local database to an external database on a daily basis.

Key changes:

-   **`worker/sync-empleados.js`**: This new script:
    -   Connects to the local Prisma database to fetch `Cliente` records where `empleado` is true.
    -   Provides clear placeholders and guidance for connecting to an external database (you must configure details like host, credentials, DB type, and table name, and implement specific DB client logic).
    -   Outlines a conceptual "upsert" logic (update existing, insert new) for the external database.
    -   Includes extensive comments on configuration, operation, and testing considerations.

-   **`worker/cron-worker.js`**:
    -   I modified this to import and schedule the `syncEmpleadosToExternalDB` function from the new script.
    -   The synchronization is scheduled to run daily at midnight.
    -   The existing example 5-second cron job has been commented as an example.

-   **Documentation**:
    -   I added in-code comments to both modified/new files to explain functionality.
    -   `sync-empleados.js` includes sections on external DB configuration and detailed testing considerations (manual and automated).

You will need to provide the actual connection details and logic for your specific external database system.
2025-05-30 06:48:19 +00:00
josedario87
80b8886762 Merge pull request #4 from josedario87/feat/api-crud-endpoints
feat: Implement CRUD API endpoints for core modules
2025-05-30 00:47:07 -06:00
google-labs-jules[bot]
a394c25245 feat: Implement CRUD API endpoints for core modules
Adds Express.js routes and Prisma-based handlers for common database operations (Create, Read, Update, Delete) for the following modules:

- Empleados (subset of Cliente model)
- Asistencias
- Tareas (TareaRealizada model)
- Planillas

Each module's routes are separated into their own files within `api/routes/`. The new routes are registered in `api/server.js`.

Basic error handling, including try-catch blocks and checks for common Prisma errors (e.g., P2025 for record not found, P2003 for foreign key violations), has been implemented in each endpoint.
2025-05-30 06:45:54 +00:00
josedario87
b2b7a38f0e Merge pull request #3 from josedario87/jules_wip_11748544748520558008
Jules was unable to complete the task in time. Please review the work…
2025-05-30 00:43:41 -06:00
google-labs-jules[bot]
fe014b677b Jules was unable to complete the task in time. Please review the work done so far and provide feedback for Jules to continue. 2025-05-30 06:41:49 +00:00
34 changed files with 3707 additions and 662 deletions

View File

@@ -14,10 +14,6 @@ jobs:
- name: Clonar el repo
uses: actions/checkout@v3
- name: Agregar remoto de GitHub
run: |
git remote add github https://josedario87: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@github.com/josedario87/planilla.git
- name: Forzar limpieza y retry
run: |
git remote remove github
@@ -25,8 +21,3 @@ jobs:
git fetch github
git push github +HEAD:main --force
- name: Forzar subida a GitHub (sobrescribe lo que haya)
run: |
git fetch github
git push github +main

176
api/node_modules/.package-lock.json generated vendored
View File

@@ -4,25 +4,10 @@
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/@esbuild/win32-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz",
"integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@prisma/client": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.7.0.tgz",
"integrity": "sha512-+k61zZn1XHjbZul8q6TdQLpuI/cvyfil87zqK2zpreNIXyXtpUv3+H/oM69hcsFcZXaokHJIzPAt5Z8C8eK2QA==",
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.8.2.tgz",
"integrity": "sha512-5II+vbyzv4si6Yunwgkj0qT/iY0zyspttoDrL3R4BYgLdp42/d2C8xdi9vqkrYtKt9H32oFIukvyw3Koz5JoDg==",
"hasInstallScript": true,
"engines": {
"node": ">=18.18"
@@ -41,52 +26,57 @@
}
},
"node_modules/@prisma/config": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.7.0.tgz",
"integrity": "sha512-di8QDdvSz7DLUi3OOcCHSwxRNeW7jtGRUD2+Z3SdNE3A+pPiNT8WgUJoUyOwJmUr5t+JA2W15P78C/N+8RXrOA==",
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.8.2.tgz",
"integrity": "sha512-ZJY1fF4qRBPdLQ/60wxNtX+eu89c3AkYEcP7L3jkp0IPXCNphCYxikTg55kPJLDOG6P0X+QG5tCv6CmsBRZWFQ==",
"devOptional": true,
"dependencies": {
"esbuild": ">=0.12 <1",
"esbuild-register": "3.6.0"
"jiti": "2.4.2"
}
},
"node_modules/@prisma/debug": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.7.0.tgz",
"integrity": "sha512-RabHn9emKoYFsv99RLxvfG2GHzWk2ZI1BuVzqYtmMSIcuGboHY5uFt3Q3boOREM9de6z5s3bQoyKeWnq8Fz22w=="
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.8.2.tgz",
"integrity": "sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==",
"devOptional": true
},
"node_modules/@prisma/engines": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.7.0.tgz",
"integrity": "sha512-3wDMesnOxPrOsq++e5oKV9LmIiEazFTRFZrlULDQ8fxdub5w4NgRBoxtWbvXmj2nJVCnzuz6eFix3OhIqsZ1jw==",
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.8.2.tgz",
"integrity": "sha512-XqAJ//LXjqYRQ1RRabs79KOY4+v6gZOGzbcwDQl0D6n9WBKjV7qdrbd042CwSK0v0lM9MSHsbcFnU2Yn7z8Zlw==",
"devOptional": true,
"hasInstallScript": true,
"dependencies": {
"@prisma/debug": "6.7.0",
"@prisma/engines-version": "6.7.0-36.3cff47a7f5d65c3ea74883f1d736e41d68ce91ed",
"@prisma/fetch-engine": "6.7.0",
"@prisma/get-platform": "6.7.0"
"@prisma/debug": "6.8.2",
"@prisma/engines-version": "6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e",
"@prisma/fetch-engine": "6.8.2",
"@prisma/get-platform": "6.8.2"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.7.0-36.3cff47a7f5d65c3ea74883f1d736e41d68ce91ed",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.7.0-36.3cff47a7f5d65c3ea74883f1d736e41d68ce91ed.tgz",
"integrity": "sha512-EvpOFEWf1KkJpDsBCrih0kg3HdHuaCnXmMn7XFPObpFTzagK1N0Q0FMnYPsEhvARfANP5Ok11QyoTIRA2hgJTA=="
"version": "6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e.tgz",
"integrity": "sha512-Rkik9lMyHpFNGaLpPF3H5q5TQTkm/aE7DsGM5m92FZTvWQsvmi6Va8On3pWvqLHOt5aPUvFb/FeZTmphI4CPiQ==",
"devOptional": true
},
"node_modules/@prisma/fetch-engine": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.7.0.tgz",
"integrity": "sha512-zLlAGnrkmioPKJR4Yf7NfW3hftcvqeNNEHleMZK9yX7RZSkhmxacAYyfGsCcqRt47jiZ7RKdgE0Wh2fWnm7WsQ==",
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.8.2.tgz",
"integrity": "sha512-lCvikWOgaLOfqXGacEKSNeenvj0n3qR5QvZUOmPE2e1Eh8cMYSobxonCg9rqM6FSdTfbpqp9xwhSAOYfNqSW0g==",
"devOptional": true,
"dependencies": {
"@prisma/debug": "6.7.0",
"@prisma/engines-version": "6.7.0-36.3cff47a7f5d65c3ea74883f1d736e41d68ce91ed",
"@prisma/get-platform": "6.7.0"
"@prisma/debug": "6.8.2",
"@prisma/engines-version": "6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e",
"@prisma/get-platform": "6.8.2"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.7.0.tgz",
"integrity": "sha512-i9IH5lO4fQwnMLvQLYNdgVh9TK3PuWBfQd7QLk/YurnAIg+VeADcZDbmhAi4XBBDD+hDif9hrKyASu0hbjwabw==",
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.8.2.tgz",
"integrity": "sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==",
"devOptional": true,
"dependencies": {
"@prisma/debug": "6.7.0"
"@prisma/debug": "6.8.2"
}
},
"node_modules/accepts": {
@@ -274,77 +264,6 @@
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
"integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==",
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.4",
"@esbuild/android-arm": "0.25.4",
"@esbuild/android-arm64": "0.25.4",
"@esbuild/android-x64": "0.25.4",
"@esbuild/darwin-arm64": "0.25.4",
"@esbuild/darwin-x64": "0.25.4",
"@esbuild/freebsd-arm64": "0.25.4",
"@esbuild/freebsd-x64": "0.25.4",
"@esbuild/linux-arm": "0.25.4",
"@esbuild/linux-arm64": "0.25.4",
"@esbuild/linux-ia32": "0.25.4",
"@esbuild/linux-loong64": "0.25.4",
"@esbuild/linux-mips64el": "0.25.4",
"@esbuild/linux-ppc64": "0.25.4",
"@esbuild/linux-riscv64": "0.25.4",
"@esbuild/linux-s390x": "0.25.4",
"@esbuild/linux-x64": "0.25.4",
"@esbuild/netbsd-arm64": "0.25.4",
"@esbuild/netbsd-x64": "0.25.4",
"@esbuild/openbsd-arm64": "0.25.4",
"@esbuild/openbsd-x64": "0.25.4",
"@esbuild/sunos-x64": "0.25.4",
"@esbuild/win32-arm64": "0.25.4",
"@esbuild/win32-ia32": "0.25.4",
"@esbuild/win32-x64": "0.25.4"
}
},
"node_modules/esbuild-register": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
"integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
"dependencies": {
"debug": "^4.3.4"
},
"peerDependencies": {
"esbuild": ">=0.12 <1"
}
},
"node_modules/esbuild-register/node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/esbuild-register/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -551,6 +470,15 @@
"node": ">= 0.10"
}
},
"node_modules/jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"devOptional": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -786,13 +714,14 @@
}
},
"node_modules/prisma": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.7.0.tgz",
"integrity": "sha512-vArg+4UqnQ13CVhc2WUosemwh6hr6cr6FY2uzDvCIFwH8pu8BXVv38PktoMLVjtX7sbYThxbnZF5YiR8sN2clw==",
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.8.2.tgz",
"integrity": "sha512-JNricTXQxzDtRS7lCGGOB4g5DJ91eg3nozdubXze3LpcMl1oWwcFddrj++Up3jnRE6X/3gB/xz3V+ecBk/eEGA==",
"devOptional": true,
"hasInstallScript": true,
"dependencies": {
"@prisma/config": "6.7.0",
"@prisma/engines": "6.7.0"
"@prisma/config": "6.8.2",
"@prisma/engines": "6.8.2"
},
"bin": {
"prisma": "build/index.js"
@@ -800,9 +729,6 @@
"engines": {
"node": ">=18.18"
},
"optionalDependencies": {
"fsevents": "2.3.3"
},
"peerDependencies": {
"typescript": ">=5.1.0"
},

558
api/package-lock.json generated
View File

@@ -8,391 +8,19 @@
"name": "planilla-api",
"version": "1.0.0",
"dependencies": {
"@prisma/client": "^6.7.0",
"@prisma/client": "^6.8.2",
"express": "^4.18.2",
"pg": "^8.8.0",
"prisma": "^6.7.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
"integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==",
"cpu": [
"ppc64"
],
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz",
"integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz",
"integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz",
"integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz",
"integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz",
"integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz",
"integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz",
"integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz",
"integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz",
"integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz",
"integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz",
"integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==",
"cpu": [
"loong64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz",
"integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==",
"cpu": [
"mips64el"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz",
"integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==",
"cpu": [
"ppc64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz",
"integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==",
"cpu": [
"riscv64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz",
"integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz",
"integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz",
"integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz",
"integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz",
"integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz",
"integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz",
"integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz",
"integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz",
"integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz",
"integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
"node-cron": "^4.0.5",
"pg": "^8.8.0"
},
"devDependencies": {
"prisma": "^6.8.2"
}
},
"node_modules/@prisma/client": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.7.0.tgz",
"integrity": "sha512-+k61zZn1XHjbZul8q6TdQLpuI/cvyfil87zqK2zpreNIXyXtpUv3+H/oM69hcsFcZXaokHJIzPAt5Z8C8eK2QA==",
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.8.2.tgz",
"integrity": "sha512-5II+vbyzv4si6Yunwgkj0qT/iY0zyspttoDrL3R4BYgLdp42/d2C8xdi9vqkrYtKt9H32oFIukvyw3Koz5JoDg==",
"hasInstallScript": true,
"engines": {
"node": ">=18.18"
@@ -411,52 +39,57 @@
}
},
"node_modules/@prisma/config": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.7.0.tgz",
"integrity": "sha512-di8QDdvSz7DLUi3OOcCHSwxRNeW7jtGRUD2+Z3SdNE3A+pPiNT8WgUJoUyOwJmUr5t+JA2W15P78C/N+8RXrOA==",
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.8.2.tgz",
"integrity": "sha512-ZJY1fF4qRBPdLQ/60wxNtX+eu89c3AkYEcP7L3jkp0IPXCNphCYxikTg55kPJLDOG6P0X+QG5tCv6CmsBRZWFQ==",
"devOptional": true,
"dependencies": {
"esbuild": ">=0.12 <1",
"esbuild-register": "3.6.0"
"jiti": "2.4.2"
}
},
"node_modules/@prisma/debug": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.7.0.tgz",
"integrity": "sha512-RabHn9emKoYFsv99RLxvfG2GHzWk2ZI1BuVzqYtmMSIcuGboHY5uFt3Q3boOREM9de6z5s3bQoyKeWnq8Fz22w=="
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.8.2.tgz",
"integrity": "sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==",
"devOptional": true
},
"node_modules/@prisma/engines": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.7.0.tgz",
"integrity": "sha512-3wDMesnOxPrOsq++e5oKV9LmIiEazFTRFZrlULDQ8fxdub5w4NgRBoxtWbvXmj2nJVCnzuz6eFix3OhIqsZ1jw==",
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.8.2.tgz",
"integrity": "sha512-XqAJ//LXjqYRQ1RRabs79KOY4+v6gZOGzbcwDQl0D6n9WBKjV7qdrbd042CwSK0v0lM9MSHsbcFnU2Yn7z8Zlw==",
"devOptional": true,
"hasInstallScript": true,
"dependencies": {
"@prisma/debug": "6.7.0",
"@prisma/engines-version": "6.7.0-36.3cff47a7f5d65c3ea74883f1d736e41d68ce91ed",
"@prisma/fetch-engine": "6.7.0",
"@prisma/get-platform": "6.7.0"
"@prisma/debug": "6.8.2",
"@prisma/engines-version": "6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e",
"@prisma/fetch-engine": "6.8.2",
"@prisma/get-platform": "6.8.2"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.7.0-36.3cff47a7f5d65c3ea74883f1d736e41d68ce91ed",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.7.0-36.3cff47a7f5d65c3ea74883f1d736e41d68ce91ed.tgz",
"integrity": "sha512-EvpOFEWf1KkJpDsBCrih0kg3HdHuaCnXmMn7XFPObpFTzagK1N0Q0FMnYPsEhvARfANP5Ok11QyoTIRA2hgJTA=="
"version": "6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e.tgz",
"integrity": "sha512-Rkik9lMyHpFNGaLpPF3H5q5TQTkm/aE7DsGM5m92FZTvWQsvmi6Va8On3pWvqLHOt5aPUvFb/FeZTmphI4CPiQ==",
"devOptional": true
},
"node_modules/@prisma/fetch-engine": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.7.0.tgz",
"integrity": "sha512-zLlAGnrkmioPKJR4Yf7NfW3hftcvqeNNEHleMZK9yX7RZSkhmxacAYyfGsCcqRt47jiZ7RKdgE0Wh2fWnm7WsQ==",
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.8.2.tgz",
"integrity": "sha512-lCvikWOgaLOfqXGacEKSNeenvj0n3qR5QvZUOmPE2e1Eh8cMYSobxonCg9rqM6FSdTfbpqp9xwhSAOYfNqSW0g==",
"devOptional": true,
"dependencies": {
"@prisma/debug": "6.7.0",
"@prisma/engines-version": "6.7.0-36.3cff47a7f5d65c3ea74883f1d736e41d68ce91ed",
"@prisma/get-platform": "6.7.0"
"@prisma/debug": "6.8.2",
"@prisma/engines-version": "6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e",
"@prisma/get-platform": "6.8.2"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.7.0.tgz",
"integrity": "sha512-i9IH5lO4fQwnMLvQLYNdgVh9TK3PuWBfQd7QLk/YurnAIg+VeADcZDbmhAi4XBBDD+hDif9hrKyASu0hbjwabw==",
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.8.2.tgz",
"integrity": "sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==",
"devOptional": true,
"dependencies": {
"@prisma/debug": "6.7.0"
"@prisma/debug": "6.8.2"
}
},
"node_modules/accepts": {
@@ -644,77 +277,6 @@
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
"integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==",
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.4",
"@esbuild/android-arm": "0.25.4",
"@esbuild/android-arm64": "0.25.4",
"@esbuild/android-x64": "0.25.4",
"@esbuild/darwin-arm64": "0.25.4",
"@esbuild/darwin-x64": "0.25.4",
"@esbuild/freebsd-arm64": "0.25.4",
"@esbuild/freebsd-x64": "0.25.4",
"@esbuild/linux-arm": "0.25.4",
"@esbuild/linux-arm64": "0.25.4",
"@esbuild/linux-ia32": "0.25.4",
"@esbuild/linux-loong64": "0.25.4",
"@esbuild/linux-mips64el": "0.25.4",
"@esbuild/linux-ppc64": "0.25.4",
"@esbuild/linux-riscv64": "0.25.4",
"@esbuild/linux-s390x": "0.25.4",
"@esbuild/linux-x64": "0.25.4",
"@esbuild/netbsd-arm64": "0.25.4",
"@esbuild/netbsd-x64": "0.25.4",
"@esbuild/openbsd-arm64": "0.25.4",
"@esbuild/openbsd-x64": "0.25.4",
"@esbuild/sunos-x64": "0.25.4",
"@esbuild/win32-arm64": "0.25.4",
"@esbuild/win32-ia32": "0.25.4",
"@esbuild/win32-x64": "0.25.4"
}
},
"node_modules/esbuild-register": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
"integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
"dependencies": {
"debug": "^4.3.4"
},
"peerDependencies": {
"esbuild": ">=0.12 <1"
}
},
"node_modules/esbuild-register/node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/esbuild-register/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -806,19 +368,6 @@
"node": ">= 0.6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -934,6 +483,15 @@
"node": ">= 0.10"
}
},
"node_modules/jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"devOptional": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1169,13 +727,14 @@
}
},
"node_modules/prisma": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.7.0.tgz",
"integrity": "sha512-vArg+4UqnQ13CVhc2WUosemwh6hr6cr6FY2uzDvCIFwH8pu8BXVv38PktoMLVjtX7sbYThxbnZF5YiR8sN2clw==",
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.8.2.tgz",
"integrity": "sha512-JNricTXQxzDtRS7lCGGOB4g5DJ91eg3nozdubXze3LpcMl1oWwcFddrj++Up3jnRE6X/3gB/xz3V+ecBk/eEGA==",
"devOptional": true,
"hasInstallScript": true,
"dependencies": {
"@prisma/config": "6.7.0",
"@prisma/engines": "6.7.0"
"@prisma/config": "6.8.2",
"@prisma/engines": "6.8.2"
},
"bin": {
"prisma": "build/index.js"
@@ -1183,9 +742,6 @@
"engines": {
"node": ">=18.18"
},
"optionalDependencies": {
"fsevents": "2.3.3"
},
"peerDependencies": {
"typescript": ">=5.1.0"
},

View File

@@ -7,10 +7,12 @@
"start": "node server.js"
},
"dependencies": {
"@prisma/client": "^6.7.0",
"@prisma/client": "^6.8.2",
"express": "^4.18.2",
"node-cron": "^4.0.5",
"pg": "^8.8.0",
"prisma": "^6.7.0"
"pg": "^8.8.0"
},
"devDependencies": {
"prisma": "^6.8.2"
}
}

View File

View File

@@ -0,0 +1,129 @@
import express from 'express';
const router = express.Router();
import { PrismaClient } from '../../prisma/generated/client/index.js';
const prisma = new PrismaClient();
// GET all asistencias
router.get('/', async (req, res) => {
try {
const asistencias = await prisma.asistencia.findMany();
res.json(asistencias);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Error al obtener asistencias.' });
}
});
// GET asistencia by ID
router.get('/:id', async (req, res) => {
const { id } = req.params;
try {
const asistencia = await prisma.asistencia.findUnique({
where: { id: parseInt(id) },
});
if (asistencia) {
res.json(asistencia);
} else {
res.status(404).json({ error: 'Asistencia no encontrada.' });
}
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Error al obtener asistencia.' });
}
});
// POST create new asistencia
router.post('/', async (req, res) => {
const { empleado_id, entrada, salida, historial, observacion, estado, fecha_anulado, creador_id, modificado_id, anulador_id } = req.body;
try {
// Basic validation: empleado_id is required
if (!empleado_id) {
return res.status(400).json({ error: 'El campo empleado_id es obligatorio.' });
}
const nuevaAsistencia = await prisma.asistencia.create({
data: {
empleado_id: parseInt(empleado_id),
entrada: entrada ? new Date(entrada) : null,
salida: salida ? new Date(salida) : null,
historial, // Assuming historial is already in JSON format or Prisma handles it
observacion,
estado,
fecha_anulado: fecha_anulado ? new Date(fecha_anulado) : null,
creador_id, // Should ideally be taken from authenticated user
modificado_id, // Should ideally be taken from authenticated user
anulador_id
},
});
res.status(201).json(nuevaAsistencia);
} catch (error) {
console.error(error);
if (error.code === 'P2003') { // Foreign key constraint failed
if (error.meta?.field_name?.includes('empleado_id')) {
return res.status(400).json({ error: 'El empleado_id proporcionado no existe.' });
}
}
res.status(500).json({ error: 'Error al crear asistencia.' });
}
});
// PUT update asistencia by ID
router.put('/:id', async (req, res) => {
const { id } = req.params;
const { empleado_id, entrada, salida, historial, observacion, estado, fecha_anulado, modificado_id, anulador_id } = req.body;
try {
const updateData = {
entrada: entrada ? new Date(entrada) : undefined,
salida: salida ? new Date(salida) : undefined,
historial,
observacion,
estado,
fecha_anulado: fecha_anulado ? new Date(fecha_anulado) : undefined,
modificado_id, // Should ideally be taken from authenticated user
anulador_id
};
// If empleado_id is provided, include it in updateData.
// Be cautious: changing empleado_id might not be a typical operation for an existing attendance record.
if (empleado_id !== undefined) {
updateData.empleado_id = parseInt(empleado_id);
}
const asistenciaActualizada = await prisma.asistencia.update({
where: { id: parseInt(id) },
data: updateData,
});
res.json(asistenciaActualizada);
} catch (error) {
console.error(error);
if (error.code === 'P2025') { // Record to update not found
return res.status(404).json({ error: 'Asistencia no encontrada para actualizar.' });
}
if (error.code === 'P2003') { // Foreign key constraint failed
if (error.meta?.field_name?.includes('empleado_id')) {
return res.status(400).json({ error: 'El empleado_id proporcionado no existe.' });
}
}
res.status(500).json({ error: 'Error al actualizar asistencia.' });
}
});
// DELETE asistencia by ID
router.delete('/:id', async (req, res) => {
const { id } = req.params;
try {
await prisma.asistencia.delete({
where: { id: parseInt(id) },
});
res.status(204).send(); // No content
} catch (error) {
console.error(error);
if (error.code === 'P2025') { // Record to delete not found
return res.status(404).json({ error: 'Asistencia no encontrada para eliminar.' });
}
res.status(500).json({ error: 'Error al eliminar asistencia.' });
}
});
export default router;

View File

View File

@@ -0,0 +1,142 @@
import express from 'express';
const router = express.Router();
import { PrismaClient } from '../../prisma/generated/client/index.js';
const prisma = new PrismaClient();
// GET all empleados
router.get('/', async (req, res) => {
try {
const empleados = await prisma.cliente.findMany({
where: { empleado: true },
});
res.json(empleados);
} catch (error) {
console.error(error); // Log the error for debugging
res.status(500).json({ error: 'Error al obtener empleados.' });
}
});
// GET empleado by ID
router.get('/:id', async (req, res) => {
const { id } = req.params;
try {
const empleado = await prisma.cliente.findFirst({
where: {
id: parseInt(id),
empleado: true
},
});
if (empleado) {
res.json(empleado);
} else {
res.status(404).json({ error: 'Empleado no encontrado.' });
}
} catch (error) {
console.error(error); // Log the error for debugging
res.status(500).json({ error: 'Error al obtener empleado.' });
}
});
// POST create new empleado
router.post('/', async (req, res) => {
const { nombre, apellido, dni, telefono, direccion, email } = req.body;
try {
const nuevoEmpleado = await prisma.cliente.create({
data: {
nombre,
apellido,
dni,
telefono,
direccion,
email,
empleado: true, // Ensure empleado is set to true
},
});
res.status(201).json(nuevoEmpleado);
} catch (error) {
console.error(error); // Log the error for debugging
if (error.code === 'P2002' && error.meta?.target?.includes('dni')) {
return res.status(400).json({ error: 'Ya existe un cliente con este DNI.' });
}
if (error.code === 'P2002' && error.meta?.target?.includes('email')) {
return res.status(400).json({ error: 'Ya existe un cliente con este Email.' });
}
res.status(500).json({ error: 'Error al crear empleado.' });
}
});
// PUT update empleado by ID
router.put('/:id', async (req, res) => {
const { id } = req.params;
const { nombre, apellido, dni, telefono, direccion, email } = req.body;
try {
// First, check if the employee exists and is an employee
const existingEmpleado = await prisma.cliente.findFirst({
where: {
id: parseInt(id),
empleado: true,
},
});
if (!existingEmpleado) {
return res.status(404).json({ error: 'Empleado no encontrado.' });
}
const empleadoActualizado = await prisma.cliente.update({
where: { id: parseInt(id) },
data: {
nombre,
apellido,
dni,
telefono,
direccion,
email,
// empleado: true, // Keep it as an employee, or allow changing this? For now, keep as true.
},
});
res.json(empleadoActualizado);
} catch (error) {
console.error(error); // Log the error for debugging
if (error.code === 'P2002' && error.meta?.target?.includes('dni')) {
return res.status(400).json({ error: 'Ya existe un cliente con este DNI.' });
}
if (error.code === 'P2002' && error.meta?.target?.includes('email')) {
return res.status(400).json({ error: 'Ya existe un cliente con este Email.' });
}
if (error.code === 'P2025') { // Record to update not found
return res.status(404).json({ error: 'Empleado no encontrado para actualizar.' });
}
res.status(500).json({ error: 'Error al actualizar empleado.' });
}
});
// DELETE empleado by ID
router.delete('/:id', async (req, res) => {
const { id } = req.params;
try {
// First, check if the employee exists and is an employee
const existingEmpleado = await prisma.cliente.findFirst({
where: {
id: parseInt(id),
empleado: true,
},
});
if (!existingEmpleado) {
return res.status(404).json({ error: 'Empleado no encontrado para eliminar.' });
}
await prisma.cliente.delete({
where: { id: parseInt(id) },
});
res.status(204).send(); // No content
} catch (error) {
console.error(error); // Log the error for debugging
if (error.code === 'P2025') { // Record to delete not found
return res.status(404).json({ error: 'Empleado no encontrado para eliminar.' });
}
res.status(500).json({ error: 'Error al eliminar empleado.' });
}
});
export default router;

View File

View File

@@ -0,0 +1,142 @@
import express from 'express';
const router = express.Router();
import { PrismaClient } from '../../prisma/generated/client/index.js';
const prisma = new PrismaClient();
// GET all planillas
router.get('/', async (req, res) => {
try {
const planillas = await prisma.planilla.findMany();
res.json(planillas);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Error al obtener planillas.' });
}
});
// GET planilla by ID
router.get('/:id', async (req, res) => {
const { id } = req.params;
try {
const planilla = await prisma.planilla.findUnique({
where: { id: parseInt(id) },
});
if (planilla) {
res.json(planilla);
} else {
res.status(404).json({ error: 'Planilla no encontrada.' });
}
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Error al obtener planilla.' });
}
});
// POST create new planilla
router.post('/', async (req, res) => {
const {
empleado_id,
fecha_desde,
fecha_hasta,
titulo,
total,
estado,
fecha_anulado,
creador_id, // Should ideally be from authenticated user
anulador_id,
} = req.body;
try {
// Basic validation
if (!empleado_id || !fecha_desde || !fecha_hasta || !titulo) {
return res.status(400).json({ error: 'Los campos empleado_id, fecha_desde, fecha_hasta y titulo son obligatorios.' });
}
const nuevaPlanilla = await prisma.planilla.create({
data: {
empleado_id: parseInt(empleado_id),
fecha_desde: new Date(fecha_desde),
fecha_hasta: new Date(fecha_hasta),
titulo,
total: total ? parseFloat(total) : null, // Prisma expects Decimal to be passed as number or string
estado: estado || 'pagado', // Default to 'pagado' if not provided
fecha_anulado: fecha_anulado ? new Date(fecha_anulado) : null,
creador_id,
anulador_id,
},
});
res.status(201).json(nuevaPlanilla);
} catch (error) {
console.error(error);
if (error.code === 'P2003') { // Foreign key constraint failed
if (error.meta?.field_name?.includes('empleado_id')) {
return res.status(400).json({ error: 'El empleado_id proporcionado no existe.' });
}
}
res.status(500).json({ error: 'Error al crear planilla.' });
}
});
// PUT update planilla by ID
router.put('/:id', async (req, res) => {
const { id } = req.params;
const {
empleado_id,
fecha_desde,
fecha_hasta,
titulo,
total,
estado,
fecha_anulado,
anulador_id,
} = req.body;
try {
const updateData = {};
if (empleado_id !== undefined) updateData.empleado_id = parseInt(empleado_id);
if (fecha_desde !== undefined) updateData.fecha_desde = new Date(fecha_desde);
if (fecha_hasta !== undefined) updateData.fecha_hasta = new Date(fecha_hasta);
if (titulo !== undefined) updateData.titulo = titulo;
if (total !== undefined) updateData.total = total ? parseFloat(total) : null;
if (estado !== undefined) updateData.estado = estado;
if (fecha_anulado !== undefined) updateData.fecha_anulado = fecha_anulado ? new Date(fecha_anulado) : null;
if (anulador_id !== undefined) updateData.anulador_id = anulador_id;
// creador_id is typically not updated.
const planillaActualizada = await prisma.planilla.update({
where: { id: parseInt(id) },
data: updateData,
});
res.json(planillaActualizada);
} catch (error) {
console.error(error);
if (error.code === 'P2025') { // Record to update not found
return res.status(404).json({ error: 'Planilla no encontrada para actualizar.' });
}
if (error.code === 'P2003') { // Foreign key constraint failed
if (error.meta?.field_name?.includes('empleado_id')) {
return res.status(400).json({ error: 'El empleado_id proporcionado no existe.' });
}
}
res.status(500).json({ error: 'Error al actualizar planilla.' });
}
});
// DELETE planilla by ID
router.delete('/:id', async (req, res) => {
const { id } = req.params;
try {
await prisma.planilla.delete({
where: { id: parseInt(id) },
});
res.status(204).send(); // No content
} catch (error) {
console.error(error);
if (error.code === 'P2025') { // Record to delete not found
return res.status(404).json({ error: 'Planilla no encontrada para eliminar.' });
}
res.status(500).json({ error: 'Error al eliminar planilla.' });
}
});
export default router;

View File

156
api/routes/tareas/tareas.js Normal file
View File

@@ -0,0 +1,156 @@
import express from 'express';
const router = express.Router();
import { PrismaClient } from '../../prisma/generated/client/index.js';
const prisma = new PrismaClient();
// GET all tareas
router.get('/', async (req, res) => {
try {
const tareas = await prisma.tareaRealizada.findMany();
res.json(tareas);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Error al obtener tareas.' });
}
});
// GET tarea by ID
router.get('/:id', async (req, res) => {
const { id } = req.params;
try {
const tarea = await prisma.tareaRealizada.findUnique({
where: { id: parseInt(id) },
});
if (tarea) {
res.json(tarea);
} else {
res.status(404).json({ error: 'Tarea no encontrada.' });
}
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Error al obtener tarea.' });
}
});
// POST create new tarea
router.post('/', async (req, res) => {
const {
empleado_id,
planilla_id,
titulo,
precio,
estado,
observacion,
fecha,
tipo,
fecha_anulado,
creador_id, // Should ideally be from authenticated user
anulador_id,
} = req.body;
try {
// Basic validation
if (!empleado_id || !titulo || !fecha) {
return res.status(400).json({ error: 'Los campos empleado_id, titulo y fecha son obligatorios.' });
}
const nuevaTarea = await prisma.tareaRealizada.create({
data: {
empleado_id: parseInt(empleado_id),
planilla_id: planilla_id ? parseInt(planilla_id) : null,
titulo,
precio: precio ? parseFloat(precio) : null,
estado: estado || 'pendiente', // Default to 'pendiente' if not provided
observacion,
fecha: new Date(fecha),
tipo: tipo || '', // Default to empty string if not provided
fecha_anulado: fecha_anulado ? new Date(fecha_anulado) : null,
creador_id,
anulador_id,
},
});
res.status(201).json(nuevaTarea);
} catch (error) {
console.error(error);
if (error.code === 'P2003') { // Foreign key constraint failed
if (error.meta?.field_name?.includes('empleado_id')) {
return res.status(400).json({ error: 'El empleado_id proporcionado no existe.' });
}
if (error.meta?.field_name?.includes('planilla_id')) {
return res.status(400).json({ error: 'El planilla_id proporcionado no existe.' });
}
}
res.status(500).json({ error: 'Error al crear tarea.' });
}
});
// PUT update tarea by ID
router.put('/:id', async (req, res) => {
const { id } = req.params;
const {
empleado_id,
planilla_id,
titulo,
precio,
estado,
observacion,
fecha,
tipo,
fecha_anulado,
anulador_id,
} = req.body;
try {
const updateData = {};
if (empleado_id !== undefined) updateData.empleado_id = parseInt(empleado_id);
if (planilla_id !== undefined) updateData.planilla_id = planilla_id ? parseInt(planilla_id) : null;
if (titulo !== undefined) updateData.titulo = titulo;
if (precio !== undefined) updateData.precio = precio ? parseFloat(precio) : null;
if (estado !== undefined) updateData.estado = estado;
if (observacion !== undefined) updateData.observacion = observacion;
if (fecha !== undefined) updateData.fecha = new Date(fecha);
if (tipo !== undefined) updateData.tipo = tipo;
if (fecha_anulado !== undefined) updateData.fecha_anulado = fecha_anulado ? new Date(fecha_anulado) : null;
if (anulador_id !== undefined) updateData.anulador_id = anulador_id;
// creador_id is typically not updated.
const tareaActualizada = await prisma.tareaRealizada.update({
where: { id: parseInt(id) },
data: updateData,
});
res.json(tareaActualizada);
} catch (error) {
console.error(error);
if (error.code === 'P2025') { // Record to update not found
return res.status(404).json({ error: 'Tarea no encontrada para actualizar.' });
}
if (error.code === 'P2003') { // Foreign key constraint failed
if (error.meta?.field_name?.includes('empleado_id')) {
return res.status(400).json({ error: 'El empleado_id proporcionado no existe.' });
}
if (error.meta?.field_name?.includes('planilla_id')) {
return res.status(400).json({ error: 'El planilla_id proporcionado no existe.' });
}
}
res.status(500).json({ error: 'Error al actualizar tarea.' });
}
});
// DELETE tarea by ID
router.delete('/:id', async (req, res) => {
const { id } = req.params;
try {
await prisma.tareaRealizada.delete({
where: { id: parseInt(id) },
});
res.status(204).send(); // No content
} catch (error) {
console.error(error);
if (error.code === 'P2025') { // Record to delete not found
return res.status(404).json({ error: 'Tarea no encontrada para eliminar.' });
}
res.status(500).json({ error: 'Error al eliminar tarea.' });
}
});
export default router;

View File

@@ -1,6 +1,13 @@
import express from 'express';
import { PrismaClient } from './prisma/generated/client/index.js';
import { Decimal } from '@prisma/client/runtime/library.js';
// Import new routers
import empleadosRouter from './routes/empleados/empleados.js';
import asistenciasRouter from './routes/asistencias/asistencias.js';
import tareasRouter from './routes/tareas/tareas.js';
import planillasRouter from './routes/planillas/planillas.js';
BigInt.prototype.toJSON = function () { return this.toString(); };
Decimal.prototype.toJSON = function () { return this.toString(); };
@@ -8,6 +15,12 @@ const prisma = new PrismaClient();
export const app = express();
app.use(express.json());
// Mount new routers
app.use('/api/empleados', empleadosRouter);
app.use('/api/asistencias', asistenciasRouter);
app.use('/api/tareas', tareasRouter);
app.use('/api/planillas', planillasRouter);
app.get('/api/test', (req, res) => res.json({ message: 'Hello World' }));
app.post('/api/clientes/random', async (_req, res) => {

View File

@@ -8,6 +8,7 @@ services:
networks: [planilla]
api:
container_name: planilla-api
image: gitea.interno.com/nucleo000/planilla-api:latest
build: ./api
restart: unless-stopped
@@ -20,6 +21,7 @@ services:
networks: [planilla, principal]
ui:
container_name: planilla-ui
image: gitea.interno.com/nucleo000/planilla-ui:latest
build: ./ui
restart: unless-stopped

256
ui/package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.9.0",
"pinia": "^3.0.2",
"vue": "^3.5.13",
"vue-router": "^4.5.1"
@@ -1130,6 +1131,11 @@
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.14.tgz",
"integrity": "sha512-oXTwNxVfc9EtP1zzXAlSlgARLXNC84frFYkS0HHz0h3E4WZSP9sywqjqzGCP9Y34M8ipNmd380pVgmMuwELDyQ=="
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@@ -1167,6 +1173,16 @@
"postcss": "^8.1.0"
}
},
"node_modules/axios": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/birpc": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.3.0.tgz",
@@ -1207,6 +1223,18 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001718",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz",
@@ -1235,6 +1263,17 @@
"node": ">=18"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/copy-anything": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
@@ -1254,6 +1293,14 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@@ -1262,6 +1309,19 @@
"node": ">=8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.155",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz",
@@ -1291,6 +1351,47 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
@@ -1357,6 +1458,39 @@
}
}
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -1383,11 +1517,101 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
@@ -1637,6 +1861,33 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
@@ -1781,6 +2032,11 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.9.0",
"pinia": "^3.0.2",
"vue": "^3.5.13",
"vue-router": "^4.5.1"

10
ui/src/apiClient.js Normal file
View File

@@ -0,0 +1,10 @@
import axios from 'axios';
const apiClient = axios.create({
baseURL: 'http://planilla-api:4000/api', // Using the container name and API port
headers: {
'Content-Type': 'application/json',
},
});
export default apiClient;

View File

@@ -1,2 +1,181 @@
<template>
<div class="asistencia-card">
<div class="card-header">
<span class="empleado-id">Empleado ID: {{ asistencia.empleado_id }}</span>
<span :class="`estado-asistencia estado-${asistencia.estado?.toLowerCase().replace(/\s+/g, '-')}`">
{{ asistencia.estado || 'N/A' }}
</span>
</div>
<div class="card-body">
<p><strong>Entrada:</strong> {{ formatDateTime(asistencia.entrada) }}</p>
<p><strong>Salida:</strong> {{ asistencia.salida ? formatDateTime(asistencia.salida) : 'No registrada' }}</p>
<p v-if="asistencia.observacion" class="observacion">
<strong>Observación:</strong> {{ asistencia.observacion }}
</p>
<!-- Historial might be too complex for a card, but can be added if needed -->
<!-- <p v-if="asistencia.historial"><strong>Historial:</strong> {{ asistencia.historial }}</p> -->
</div>
<div class="card-actions">
<button @click="editAsistencia" class="action-button edit-button">Editar</button>
<button @click="confirmDeleteAsistencia" class="action-button delete-button">Eliminar</button>
</div>
</div>
</template>
<template></template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import { useAsistenciasStore } from '../../stores/useAsistencias';
const props = defineProps({
asistencia: {
type: Object,
required: true,
},
});
const emit = defineEmits(['edit']);
const asistenciasStore = useAsistenciasStore();
const formatDateTime = (dateTimeString) => {
if (!dateTimeString) return 'N/A';
const date = new Date(dateTimeString);
return date.toLocaleString('es-ES', { // Spanish locale
year: 'numeric',
month: '2-digit', // Using 2-digit for month for brevity in card
day: '2-digit', // Using 2-digit for day
hour: '2-digit',
minute: '2-digit',
// second: '2-digit', // Optional: include seconds
});
};
const editAsistencia = () => {
emit('edit', props.asistencia.id);
};
const confirmDeleteAsistencia = () => {
if (confirm(`¿Está seguro de que desea eliminar esta asistencia (ID: ${props.asistencia.id})?`)) {
deleteAsistenciaInternal();
}
};
const deleteAsistenciaInternal = async () => {
try {
await asistenciasStore.deleteAsistencia(props.asistencia.id);
// Optionally, emit 'deleted' or show notification
} catch (error) {
console.error('Error deleting asistencia:', error);
alert('Ocurrió un error al eliminar la asistencia.');
}
};
</script>
<style scoped>
.asistencia-card {
border: 1px solid #e0e0e0; /* Lighter border for a softer look */
padding: 16px;
margin-bottom: 16px;
border-radius: 8px;
background-color: #fff;
box-shadow: 0 2px 5px rgba(0,0,0,0.05); /* Softer shadow */
display: flex;
flex-direction: column;
transition: box-shadow 0.3s ease-in-out;
}
.asistencia-card:hover {
box-shadow: 0 4px 10px rgba(0,0,0,0.1); /* Enhanced shadow on hover */
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 10px; /* Increased padding */
border-bottom: 1px solid #f0f0f0; /* Even lighter border */
}
.empleado-id {
font-weight: bold;
color: #2c3e50; /* Dark blue/grey */
font-size: 1.05em;
}
.estado-asistencia {
padding: 5px 10px; /* Slightly more padding */
border-radius: 15px; /* Pill shape */
font-size: 0.8em;
font-weight: bold;
color: white;
text-transform: uppercase; /* Uppercase status */
letter-spacing: 0.5px;
}
.card-body p {
margin: 8px 0;
color: #555;
font-size: 0.95em;
line-height: 1.6; /* Improved line spacing */
}
.card-body p strong {
color: #333;
}
.observacion {
font-style: italic;
color: #666;
background-color: #f8f9fa; /* Very light grey background */
padding: 10px; /* More padding */
border-left: 3px solid #007bff; /* Blue accent line */
border-radius: 4px;
margin-top:10px;
font-size: 0.9em; /* Slightly smaller for observation */
}
.card-actions {
margin-top: auto;
padding-top: 16px; /* More space before actions */
display: flex;
justify-content: flex-end;
gap: 10px;
}
.action-button {
padding: 8px 15px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 0.9em;
font-weight: 500; /* Slightly bolder text on buttons */
transition: background-color 0.2s ease, transform 0.1s ease, box-shadow 0.2s ease;
}
.action-button:hover{
transform: translateY(-2px); /* More pronounced lift */
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.edit-button {
background-color: #007bff;
color: white;
}
.edit-button:hover {
background-color: #0056b3;
}
.delete-button {
background-color: #dc3545;
color: white;
}
.delete-button:hover {
background-color: #c82333;
}
/* Estado specific styling */
.estado-pendiente { background-color: #ffc107; color: #212529;} /* Amber, dark text */
.estado-presente,
.estado-confirmada { background-color: #28a745; color: white;} /* Green */
.estado-ausente { background-color: #dc3545; color: white;} /* Red */
.estado-justificada { background-color: #17a2b8; color: white;} /* Info Blue */
.estado-cancelada,
.estado-anulada { background-color: #6c757d; color: white;} /* Gray */
</style>

View File

@@ -1,2 +1,163 @@
<template>
<div class="tabla-asistencias-container">
<table class="tabla-asistencias">
<thead>
<tr>
<th>ID</th>
<th>Empleado ID</th>
<th>Entrada</th>
<th>Salida</th>
<th>Estado</th>
<th>Observación</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<tr v-if="!asistencias || asistencias.length === 0">
<td :colspan="7" style="text-align: center;">No hay asistencias para mostrar.</td>
</tr>
<tr v-for="asistencia in asistencias" :key="asistencia.id">
<td>{{ asistencia.id }}</td>
<td>{{ asistencia.empleado_id }}</td>
<td>{{ formatDateTime(asistencia.entrada) }}</td>
<td>{{ asistencia.salida ? formatDateTime(asistencia.salida) : 'N/A' }}</td>
<td><span :class="`estado-${asistencia.estado?.toLowerCase().replace(/\s+/g, '-')}`">{{ asistencia.estado }}</span></td>
<td :title="asistencia.observacion">{{ truncateText(asistencia.observacion, 50) }}</td>
<td>
<button @click="editAsistencia(asistencia.id)" class="action-button edit-button">Editar</button>
<button @click="confirmDeleteAsistencia(asistencia)" class="action-button delete-button">Eliminar</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<template></template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import { useAsistenciasStore } from '../../stores/useAsistencias';
const props = defineProps({
asistencias: {
type: Array,
required: true,
default: () => [],
},
});
const emit = defineEmits(['edit']);
const asistenciasStore = useAsistenciasStore();
const formatDateTime = (dateTimeString) => {
if (!dateTimeString) return 'N/A';
const date = new Date(dateTimeString);
// Using UTC methods to ensure consistency if dates are stored in UTC
const day = String(date.getUTCDate()).padStart(2, '0');
const month = String(date.getUTCMonth() + 1).padStart(2, '0'); // Months are 0-indexed
const year = date.getUTCFullYear();
const hours = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
return `${day}/${month}/${year} ${hours}:${minutes}`;
};
const truncateText = (text, maxLength) => {
if (!text) return 'N/A';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
};
const editAsistencia = (id) => {
emit('edit', id);
};
const confirmDeleteAsistencia = (asistencia) => {
if (confirm(`¿Está seguro de que desea eliminar la asistencia ID: ${asistencia.id} (Empleado: ${asistencia.empleado_id})?`)) {
deleteAsistenciaInternal(asistencia.id);
}
};
const deleteAsistenciaInternal = async (id) => {
try {
await asistenciasStore.deleteAsistencia(id);
// Optional: Show success notification or emit 'deleted' event
} catch (error) {
console.error(`Error deleting asistencia with id ${id}:`, error);
alert('Ocurrió un error al eliminar la asistencia.');
}
};
</script
<style scoped>
.tabla-asistencias-container {
overflow-x: auto;
}
.tabla-asistencias {
width: 100%;
border-collapse: collapse;
margin-top: 1em;
font-size: 0.9em;
}
.tabla-asistencias th,
.tabla-asistencias td {
border: 1px solid #ddd;
padding: 10px;
text-align: left;
vertical-align: middle; /* Good for table cells */
}
.tabla-asistencias th {
background-color: #f4f6f8; /* Light grey for header */
font-weight: 600; /* Bolder text for header */
color: #333;
}
.tabla-asistencias tr:nth-child(even) {
background-color: #f9fafb; /* Very light alternating row color */
}
.tabla-asistencias tr:hover {
background-color: #f0f0f0; /* Hover effect */
}
.action-button {
padding: 6px 10px;
margin-right: 6px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
transition: background-color 0.2s ease, transform 0.1s ease;
}
.action-button:hover {
transform: translateY(-1px); /* Slight lift effect */
}
.edit-button {
background-color: #007bff; /* Blue */
color: white;
}
.edit-button:hover {
background-color: #0056b3;
}
.delete-button {
background-color: #dc3545; /* Red */
color: white;
}
.delete-button:hover {
background-color: #c82333;
}
/* Estado specific styling (using text color for tables is often cleaner) */
.estado-pendiente { color: #ffc107; font-weight: bold; } /* Amber */
.estado-presente,
.estado-confirmada { color: #28a745; font-weight: bold; } /* Green */
.estado-ausente { color: #dc3545; font-weight: bold; } /* Red */
.estado-justificada { color: #17a2b8; font-weight: bold; } /* Info Blue */
.estado-cancelada,
.estado-anulada { color: #6c757d; font-weight: bold; } /* Gray */
/* If you prefer background colors like in cards, copy those styles here, but they can be visually heavy in tables. */
</style>

View File

@@ -1,2 +1,134 @@
<template>
<div class="planilla-card">
<h3>{{ planilla.titulo }}</h3>
<p><strong>Empleado ID:</strong> {{ planilla.empleado_id }}</p>
<p><strong>Desde:</strong> {{ formatDate(planilla.fecha_desde) }}</p>
<p><strong>Hasta:</strong> {{ formatDate(planilla.fecha_hasta) }}</p>
<p><strong>Total:</strong> {{ formatCurrency(planilla.total) }}</p>
<p><strong>Estado:</strong> <span :class="`estado-${planilla.estado?.toLowerCase()}`">{{ planilla.estado }}</span></p>
<div class="actions">
<button @click="editPlanilla">Editar</button>
<button @click="confirmDeletePlanilla">Eliminar</button>
</div>
</div>
</template>
<template></template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import { usePlanillasStore } from '../../stores/usePlanillas';
const props = defineProps({
planilla: {
type: Object,
required: true,
},
});
const emit = defineEmits(['edit']);
const planillasStore = usePlanillasStore();
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleDateString('es-ES', { // Using Spanish locale for date
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatCurrency = (value) => {
if (value == null) return 'N/A'; // Handle null or undefined totals
// Assuming the value is a number or can be converted to one.
// Adjust 'es-PY' and currency 'PYG' (Paraguayan Guarani) as needed.
return Number(value).toLocaleString('es-PY', {
style: 'currency',
currency: 'PYG'
});
};
const editPlanilla = () => {
emit('edit', props.planilla.id);
};
const confirmDeletePlanilla = () => {
// In a real app, you'd use a confirmation dialog here
if (confirm(`¿Está seguro de que desea eliminar la planilla "${props.planilla.titulo}"?`)) {
deletePlanilla();
}
};
const deletePlanilla = async () => {
try {
await planillasStore.deletePlanilla(props.planilla.id);
// Optionally, emit an event or show a notification upon successful deletion
// For example: emit('deleted', props.planilla.id);
} catch (error) {
console.error('Error deleting planilla:', error);
// Handle error (e.g., show a notification to the user)
}
};
</script>
<style scoped>
.planilla-card {
border: 1px solid #ccc;
padding: 16px;
margin-bottom: 16px;
border-radius: 8px;
background-color: #f9f9f9;
}
.planilla-card h3 {
margin-top: 0;
color: #333;
}
.planilla-card p {
margin: 8px 0;
color: #555;
}
.planilla-card .actions {
margin-top: 12px;
display: flex;
gap: 8px;
}
.planilla-card button {
padding: 8px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.planilla-card button:hover {
opacity: 0.8;
}
.actions button:first-child { /* Edit button */
background-color: #007bff;
color: white;
}
.actions button:last-child { /* Delete button */
background-color: #dc3545;
color: white;
}
/* Example status styling */
.estado-pagado {
color: green;
font-weight: bold;
}
.estado-pendiente {
color: orange;
font-weight: bold;
}
.estado-anulado {
color: red;
font-weight: bold;
text-decoration: line-through;
}
</style>

View File

@@ -1,2 +1,167 @@
<template>
<div class="tabla-planillas-container">
<table class="tabla-planillas">
<thead>
<tr>
<th>ID</th>
<th>Título</th>
<th>Empleado ID</th>
<th>Fecha Desde</th>
<th>Fecha Hasta</th>
<th>Total</th>
<th>Estado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<tr v-if="planillas && planillas.length === 0">
<td :colspan="8" style="text-align: center;">No hay planillas para mostrar.</td>
</tr>
<tr v-for="planilla in planillas" :key="planilla.id">
<td>{{ planilla.id }}</td>
<td>{{ planilla.titulo }}</td>
<td>{{ planilla.empleado_id }}</td>
<td>{{ formatDate(planilla.fecha_desde) }}</td>
<td>{{ formatDate(planilla.fecha_hasta) }}</td>
<td>{{ formatCurrency(planilla.total) }}</td>
<td><span :class="`estado-${planilla.estado?.toLowerCase()}`">{{ planilla.estado }}</span></td>
<td>
<button @click="editPlanilla(planilla.id)" class="action-button edit-button">Editar</button>
<button @click="confirmDeletePlanilla(planilla)" class="action-button delete-button">Eliminar</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<template></template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import { usePlanillasStore } from '../../stores/usePlanillas';
const props = defineProps({
planillas: {
type: Array,
required: true,
default: () => [],
},
});
const emit = defineEmits(['edit']); // Removed 'delete' as it's handled internally
const planillasStore = usePlanillasStore();
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleDateString('es-ES', {
year: 'numeric',
month: '2-digit', // Changed to 2-digit for table compactness
day: '2-digit', // Changed to 2-digit for table compactness
});
};
const formatCurrency = (value) => {
if (value == null) return 'N/A';
return Number(value).toLocaleString('es-PY', {
style: 'currency',
currency: 'PYG',
});
};
const editPlanilla = (id) => {
emit('edit', id);
};
const confirmDeletePlanilla = (planilla) => {
if (confirm(`¿Está seguro de que desea eliminar la planilla "${planilla.titulo}" (ID: ${planilla.id})?`)) {
deletePlanillaInternal(planilla.id);
}
};
const deletePlanillaInternal = async (id) => {
try {
await planillasStore.deletePlanilla(id);
// Optional: Show success notification
// No need to emit 'delete' if the store handles list updates and parent components react to store changes
} catch (error) {
console.error(`Error deleting planilla with id ${id}:`, error);
// Optional: Show error notification
}
};
</script>
<style scoped>
.tabla-planillas-container {
overflow-x: auto; /* Allows table to be scrolled horizontally if needed */
}
.tabla-planillas {
width: 100%;
border-collapse: collapse;
margin-top: 1em;
font-size: 0.9em;
}
.tabla-planillas th,
.tabla-planillas td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.tabla-planillas th {
background-color: #f2f2f2;
font-weight: bold;
}
.tabla-planillas tr:nth-child(even) {
background-color: #f9f9f9;
}
.tabla-planillas tr:hover {
background-color: #f1f1f1;
}
.action-button {
padding: 5px 10px;
margin-right: 5px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.edit-button {
background-color: #007bff;
color: white;
}
.edit-button:hover {
background-color: #0056b3;
}
.delete-button {
background-color: #dc3545;
color: white;
}
.delete-button:hover {
background-color: #c82333;
}
/* Status styling (similar to cardPlanilla) */
.estado-pagado {
color: green;
font-weight: bold;
}
.estado-pendiente {
color: orange;
font-weight: bold;
}
.estado-anulado {
color: red;
font-weight: bold;
/* text-decoration: line-through; */ /* Optional for table view */
}
</style>

View File

@@ -1,2 +1,159 @@
<template>
<div class="tarea-card">
<h4>{{ tarea.titulo }}</h4>
<p><strong>Empleado ID:</strong> {{ tarea.empleado_id }}</p>
<p><strong>Fecha:</strong> {{ formatDate(tarea.fecha) }}</p>
<p><strong>Estado:</strong> <span :class="`estado-${tarea.estado?.toLowerCase().replace(/\s+/g, '-')}`">{{ tarea.estado }}</span></p>
<p><strong>Tipo:</strong> {{ tarea.tipo || 'N/A' }}</p>
<p v-if="tarea.precio != null"><strong>Precio:</strong> {{ formatCurrency(tarea.precio) }}</p>
<p v-if="tarea.observacion"><strong>Observación:</strong> {{ tarea.observacion }}</p>
<div class="actions">
<button @click="editTarea" class="action-button edit-button">Editar</button>
<button @click="confirmDeleteTarea" class="action-button delete-button">Eliminar</button>
</div>
</div>
</template>
<template></template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import { useTareasStore } from '../../stores/useTareas';
const props = defineProps({
tarea: {
type: Object,
required: true,
},
});
const emit = defineEmits(['edit']);
const tareasStore = useTareasStore();
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleDateString('es-ES', { // Spanish locale
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatCurrency = (value) => {
if (value == null) return '';
return Number(value).toLocaleString('es-PY', { // Paraguayan Guarani
style: 'currency',
currency: 'PYG',
});
};
const editTarea = () => {
emit('edit', props.tarea.id);
};
const confirmDeleteTarea = () => {
if (confirm(`¿Está seguro de que desea eliminar la tarea "${props.tarea.titulo}"?`)) {
deleteTareaInternal();
}
};
const deleteTareaInternal = async () => {
try {
await tareasStore.deleteTarea(props.tarea.id);
// Optionally, emit a 'deleted' event: emit('deleted', props.tarea.id);
// Or show a success notification.
} catch (error) {
console.error('Error deleting tarea:', error);
alert('Ocurrió un error al eliminar la tarea. Por favor, intente de nuevo.');
// Potentially show a more user-friendly notification.
}
};
</script>
<style scoped>
.tarea-card {
border: 1px solid #e0e0e0;
padding: 16px;
margin-bottom: 16px;
border-radius: 8px;
background-color: #ffffff;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
transition: box-shadow 0.3s ease-in-out;
}
.tarea-card:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.08);
}
.tarea-card h4 {
margin-top: 0;
margin-bottom: 12px;
color: #333;
font-size: 1.15em; /* Slightly smaller than a typical h3 */
}
.tarea-card p {
margin: 6px 0;
color: #555;
font-size: 0.95em;
line-height: 1.5;
}
.tarea-card p strong {
color: #444;
}
.actions {
margin-top: 16px;
display: flex;
gap: 10px;
}
.action-button {
padding: 8px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.2s ease, opacity 0.2s ease;
}
.edit-button {
background-color: #007bff; /* Primary blue */
color: white;
}
.edit-button:hover {
background-color: #0056b3; /* Darker blue */
}
.delete-button {
background-color: #dc3545; /* Red */
color: white;
}
.delete-button:hover {
background-color: #c82333; /* Darker red */
}
/* Status styling: Added .replace(/\s+/g, '-') for multi-word statuses */
.estado-pendiente {
color: #ff9800; /* Orange */
font-weight: bold;
}
.estado-realizada,
.estado-completada, /* Common synonyms for 'done' */
.estado-hecho {
color: #4caf50; /* Green */
font-weight: bold;
}
.estado-en-progreso { /* Example for 'in progress' */
color: #2196f3; /* Blue */
font-weight: bold;
}
.estado-anulada,
.estado-cancelada {
color: #f44336; /* Red */
font-weight: bold;
/* text-decoration: line-through; */ /* Optional */
}
</style>

View File

@@ -1,2 +1,167 @@
<template>
<div class="tabla-tareas-container">
<table class="tabla-tareas">
<thead>
<tr>
<th>ID</th>
<th>Título</th>
<th>Empleado ID</th>
<th>Fecha</th>
<th>Estado</th>
<th>Tipo</th>
<th>Precio</th>
<th>Planilla ID</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<tr v-if="!tareas || tareas.length === 0">
<td :colspan="9" style="text-align: center;">No hay tareas para mostrar.</td>
</tr>
<tr v-for="tarea in tareas" :key="tarea.id">
<td>{{ tarea.id }}</td>
<td>{{ tarea.titulo }}</td>
<td>{{ tarea.empleado_id }}</td>
<td>{{ formatDate(tarea.fecha) }}</td>
<td><span :class="`estado-${tarea.estado?.toLowerCase().replace(/\s+/g, '-')}`">{{ tarea.estado }}</span></td>
<td>{{ tarea.tipo || 'N/A' }}</td>
<td>{{ tarea.precio != null ? formatCurrency(tarea.precio) : 'N/A' }}</td>
<td>{{ tarea.planilla_id || 'N/A' }}</td>
<td>
<button @click="editTarea(tarea.id)" class="action-button edit-button">Editar</button>
<button @click="confirmDeleteTarea(tarea)" class="action-button delete-button">Eliminar</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<template></template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import { useTareasStore } from '../../stores/useTareas';
const props = defineProps({
tareas: {
type: Array,
required: true,
default: () => [],
},
});
const emit = defineEmits(['edit']);
const tareasStore = useTareasStore();
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
// Assuming dateString might be a full ISO string, ensure it's handled correctly by Date constructor
const date = new Date(dateString);
const day = String(date.getUTCDate()).padStart(2, '0'); // Use getUTCDate for consistency if dates are stored in UTC
const month = String(date.getUTCMonth() + 1).padStart(2, '0'); // Months are 0-indexed
const year = date.getUTCFullYear();
return `${day}/${month}/${year}`;
};
const formatCurrency = (value) => {
if (value == null) return 'N/A';
return Number(value).toLocaleString('es-PY', { // Paraguayan Guarani
style: 'currency',
currency: 'PYG',
});
};
const editTarea = (id) => {
emit('edit', id);
};
const confirmDeleteTarea = (tarea) => {
if (confirm(`¿Está seguro de que desea eliminar la tarea "${tarea.titulo}" (ID: ${tarea.id})?`)) {
deleteTareaInternal(tarea.id);
}
};
const deleteTareaInternal = async (id) => {
try {
await tareasStore.deleteTarea(id);
// Optional: Show success notification or emit 'deleted' event
} catch (error) {
console.error(`Error deleting tarea with id ${id}:`, error);
alert('Ocurrió un error al eliminar la tarea.');
// Optional: Show error notification
}
};
</script>
<style scoped>
.tabla-tareas-container {
overflow-x: auto;
}
.tabla-tareas {
width: 100%;
border-collapse: collapse;
margin-top: 1em;
font-size: 0.9em;
}
.tabla-tareas th,
.tabla-tareas td {
border: 1px solid #ddd;
padding: 10px;
text-align: left;
vertical-align: middle;
}
.tabla-tareas th {
background-color: #f4f6f8;
font-weight: 600;
color: #333;
}
.tabla-tareas tr:nth-child(even) {
background-color: #f9fafb;
}
.tabla-tareas tr:hover {
background-color: #f0f0f0;
}
.action-button {
padding: 6px 10px;
margin-right: 6px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
transition: background-color 0.2s ease, transform 0.1s ease;
}
.action-button:hover {
transform: translateY(-1px);
}
.edit-button {
background-color: #007bff; /* Blue */
color: white;
}
.edit-button:hover {
background-color: #0056b3;
}
.delete-button {
background-color: #dc3545; /* Red */
color: white;
}
.delete-button:hover {
background-color: #c82333;
}
/* Status styling (consistent with cardTarea) */
.estado-pendiente { color: #ff9800; font-weight: bold; } /* Orange */
.estado-realizada,
.estado-completada, /* Synonyms */
.estado-hecho { color: #4caf50; font-weight: bold; } /* Green */
.estado-en-progreso { color: #2196f3; font-weight: bold; } /* Blue */
.estado-anulada,
.estado-cancelada { color: #f44336; font-weight: bold; } /* Red */
</style>

View File

@@ -1,5 +1,88 @@
import { defineStore } from 'pinia'
import { defineStore } from 'pinia';
import apiClient from '../apiClient'; // Assuming apiClient is configured
export const useAsistencias = defineStore('asistencias', {
state: () => ({ asistencias: [] }),
})
// Helper function to get default values for currentAsistencia
const getDefaultCurrentAsistencia = () => ({
id: null,
empleado_id: null,
entrada: null, // Will likely be a datetime string
salida: null, // Will likely be a datetime string
historial: null, // JSON field, can be an object or string
observacion: '',
estado: 'pendiente', // Default from schema (if applicable, or common practice)
// fecha_anulado is not typically part of creation/edit form directly
});
export const useAsistenciasStore = defineStore('asistencias', {
state: () => ({
asistencias: [],
currentAsistencia: getDefaultCurrentAsistencia(),
}),
actions: {
async fetchAsistencias() {
try {
const response = await apiClient.get('/api/asistencias');
this.asistencias = response.data;
} catch (error) {
console.error('Error fetching asistencias:', error);
// Handle error (e.g., show a notification to the user)
}
},
async fetchAsistenciaById(id) {
try {
const response = await apiClient.get(`/api/asistencias/${id}`);
// Assuming API returns dates as ISO strings.
// No special date conversion needed here, Pinia will store as is.
// Components will handle formatting for display or input fields.
this.currentAsistencia = response.data;
} catch (error) {
console.error(`Error fetching asistencia with id ${id}:`, error);
this.currentAsistencia = getDefaultCurrentAsistencia(); // Reset on error
}
},
async createAsistencia(asistenciaData) {
try {
// Ensure date fields are in a format the API expects (e.g., ISO string)
// If asistenciaData.entrada/salida are Date objects, convert them:
// if (asistenciaData.entrada instanceof Date) asistenciaData.entrada = asistenciaData.entrada.toISOString();
// if (asistenciaData.salida instanceof Date) asistenciaData.salida = asistenciaData.salida.toISOString();
await apiClient.post('/api/asistencias', asistenciaData);
await this.fetchAsistencias(); // Refresh the list
} catch (error) {
console.error('Error creating asistencia:', error);
throw error; // Re-throw to allow form to handle it
}
},
async updateAsistencia(id, asistenciaData) {
try {
// Similar date conversion logic as in createAsistencia if needed
// if (asistenciaData.entrada instanceof Date) asistenciaData.entrada = asistenciaData.entrada.toISOString();
// if (asistenciaData.salida instanceof Date) asistenciaData.salida = asistenciaData.salida.toISOString();
await apiClient.put(`/api/asistencias/${id}`, asistenciaData);
await this.fetchAsistencias(); // Refresh the list
this.currentAsistencia = getDefaultCurrentAsistencia(); // Reset currentAsistencia
} catch (error) {
console.error(`Error updating asistencia with id ${id}:`, error);
throw error; // Re-throw to allow form to handle it
}
},
async deleteAsistencia(id) {
try {
await apiClient.delete(`/api/asistencias/${id}`);
await this.fetchAsistencias(); // Refresh the list
} catch (error) {
console.error(`Error deleting asistencia with id ${id}:`, error);
throw error; // Re-throw to allow handling in component
}
},
// Action to clear currentAsistencia, useful when navigating away from a form
clearCurrentAsistencia() {
this.currentAsistencia = getDefaultCurrentAsistencia();
}
},
});

View File

@@ -1,5 +1,81 @@
import { defineStore } from 'pinia'
import { defineStore } from 'pinia';
import apiClient from '../apiClient'; // Assuming apiClient is configured for API calls
export const usePlanillas = defineStore('planillas', {
state: () => ({ planillas: [] }),
})
export const usePlanillasStore = defineStore('planillas', {
state: () => ({
planillas: [],
currentPlanilla: {
id: null,
fecha_desde: null,
fecha_hasta: null,
titulo: '',
total: null,
estado: 'pagado', // Default value from schema
fecha_anulado: null,
empleado_id: null,
// Empleado relation is not included here, will be handled by ID.
},
}),
actions: {
async fetchPlanillas() {
try {
const response = await apiClient.get('/api/planillas');
this.planillas = response.data;
} catch (error) {
console.error('Error fetching planillas:', error);
// Handle error (e.g., show a notification to the user)
}
},
async fetchPlanillaById(id) {
try {
const response = await apiClient.get(`/api/planillas/${id}`);
this.currentPlanilla = response.data;
} catch (error) {
console.error(`Error fetching planilla with id ${id}:`, error);
// Handle error
}
},
async createPlanilla(planillaData) {
try {
await apiClient.post('/api/planillas', planillaData);
await this.fetchPlanillas(); // Refresh the list
} catch (error) {
console.error('Error creating planilla:', error);
// Handle error
}
},
async updatePlanilla(id, planillaData) {
try {
await apiClient.put(`/api/planillas/${id}`, planillaData);
await this.fetchPlanillas(); // Refresh the list
this.currentPlanilla = { // Reset currentPlanilla
id: null,
fecha_desde: null,
fecha_hasta: null,
titulo: '',
total: null,
estado: 'pagado',
fecha_anulado: null,
empleado_id: null,
};
} catch (error) {
console.error(`Error updating planilla with id ${id}:`, error);
// Handle error
}
},
async deletePlanilla(id) {
try {
await apiClient.delete(`/api/planillas/${id}`);
await this.fetchPlanillas(); // Refresh the list
} catch (error)
{
console.error(`Error deleting planilla with id ${id}:`, error);
// Handle error
}
},
},
});

View File

@@ -1,5 +1,80 @@
import { defineStore } from 'pinia'
import { defineStore } from 'pinia';
import apiClient from '../apiClient'; // Assuming apiClient is configured
export const useTareas = defineStore('tareas', {
state: () => ({ tareas: [] }),
})
// Helper function to get default values for currentTarea
const getDefaultCurrentTarea = () => ({
id: null,
empleado_id: null,
planilla_id: null, // Optional
titulo: '',
precio: null, // Optional
estado: 'pendiente', // Default from schema
observacion: '', // Optional
fecha: null, // Should be a date
tipo: '', // Default from schema
// fecha_anulado is not typically part of creation/edit form directly
});
export const useTareasStore = defineStore('tareas', {
state: () => ({
tareas: [],
currentTarea: getDefaultCurrentTarea(),
}),
actions: {
async fetchTareas() {
try {
const response = await apiClient.get('/api/tareas');
this.tareas = response.data;
} catch (error) {
console.error('Error fetching tareas:', error);
// Consider more sophisticated error handling (e.g., user notifications)
}
},
async fetchTareaById(id) {
try {
const response = await apiClient.get(`/api/tareas/${id}`);
this.currentTarea = response.data;
} catch (error) {
console.error(`Error fetching tarea with id ${id}:`, error);
this.currentTarea = getDefaultCurrentTarea(); // Reset on error
}
},
async createTarea(tareaData) {
try {
await apiClient.post('/api/tareas', tareaData);
await this.fetchTareas(); // Refresh the list
} catch (error) {
console.error('Error creating tarea:', error);
throw error; // Re-throw to allow form to handle it
}
},
async updateTarea(id, tareaData) {
try {
await apiClient.put(`/api/tareas/${id}`, tareaData);
await this.fetchTareas(); // Refresh the list
this.currentTarea = getDefaultCurrentTarea(); // Reset currentTarea
} catch (error) {
console.error(`Error updating tarea with id ${id}:`, error);
throw error; // Re-throw to allow form to handle it
}
},
async deleteTarea(id) {
try {
await apiClient.delete(`/api/tareas/${id}`);
await this.fetchTareas(); // Refresh the list
} catch (error) {
console.error(`Error deleting tarea with id ${id}:`, error);
throw error; // Re-throw to allow handling in component
}
},
// Action to clear currentTarea, useful when navigating away from a form
clearCurrentTarea() {
this.currentTarea = getDefaultCurrentTarea();
}
},
});

View File

@@ -1,2 +1,297 @@
<template>
<div class="asistencia-form-container">
<h2>{{ formTitle }}</h2>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="empleado_id">Empleado ID:</label>
<input type="number" id="empleado_id" v-model.number="formData.empleado_id" />
<span v-if="formErrors.empleado_id" class="error-message">{{ formErrors.empleado_id }}</span>
</div>
<div class="form-group">
<label for="entrada">Entrada:</label>
<input type="datetime-local" id="entrada" v-model="formData.entrada" />
<span v-if="formErrors.entrada" class="error-message">{{ formErrors.entrada }}</span>
</div>
<template></template>
<div class="form-group">
<label for="salida">Salida (Opcional):</label>
<input type="datetime-local" id="salida" v-model="formData.salida" />
<span v-if="formErrors.salida" class="error-message">{{ formErrors.salida }}</span>
</div>
<div class="form-group">
<label for="estado">Estado:</label>
<select id="estado" v-model="formData.estado">
<option value="pendiente">Pendiente</option>
<option value="presente">Presente</option>
<option value="ausente">Ausente</option>
<option value="justificada">Justificada</option>
<option value="cancelada">Cancelada</option>
</select>
<span v-if="formErrors.estado" class="error-message">{{ formErrors.estado }}</span>
</div>
<div class="form-group">
<label for="observacion">Observación (Opcional):</label>
<textarea id="observacion" v-model="formData.observacion" rows="3"></textarea>
</div>
<div class="form-actions">
<button type="submit" :disabled="isSubmitting">{{ isSubmitting ? 'Guardando...' : 'Guardar' }}</button>
<button type="button" @click="handleCancel" :disabled="isSubmitting">Cancelar</button>
</div>
</form>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, computed, watch } from 'vue';
import { useAsistenciasStore } from '../../stores/useAsistencias';
import { useRouter, useRoute } from 'vue-router';
const props = defineProps({
id: String,
});
const router = useRouter();
const route = useRoute();
const asistenciasStore = useAsistenciasStore();
const formatDateTimeForInput = (dateString) => {
const date = dateString ? new Date(dateString) : new Date();
const timezoneOffset = date.getTimezoneOffset() * 60000;
const localDate = new Date(date.getTime() - timezoneOffset);
return localDate.toISOString().slice(0, 16);
};
const getDefaultFormData = () => ({
empleado_id: null,
entrada: formatDateTimeForInput(null),
salida: '',
observacion: '',
estado: 'pendiente',
});
const formData = reactive(getDefaultFormData());
const formErrors = reactive({
empleado_id: '',
entrada: '',
salida: '',
estado: '',
});
const isSubmitting = ref(false);
const editingId = ref(route.params.id || props.id || null);
const formTitle = computed(() => (editingId.value ? 'Editar Asistencia' : 'Registrar Nueva Asistencia'));
const loadAsistenciaForEditing = async (id) => {
try {
await asistenciasStore.fetchAsistenciaById(id);
if (asistenciasStore.currentAsistencia && String(asistenciasStore.currentAsistencia.id) === String(id)) {
const current = asistenciasStore.currentAsistencia;
formData.empleado_id = current.empleado_id ? Number(current.empleado_id) : null;
formData.entrada = current.entrada ? formatDateTimeForInput(current.entrada) : '';
formData.salida = current.salida ? formatDateTimeForInput(current.salida) : '';
formData.observacion = current.observacion || '';
formData.estado = current.estado || 'pendiente';
} else {
console.error(`Failed to load asistencia data for ID: ${id} or ID mismatch.`);
router.push({ name: 'AsistenciasIndex' });
}
} catch (error) {
console.error(`Error fetching asistencia ${id} for editing:`, error);
router.push({ name: 'AsistenciasIndex' });
}
};
onMounted(() => {
if (editingId.value) {
loadAsistenciaForEditing(editingId.value);
}
});
watch(() => route.params.id, (newId) => {
editingId.value = newId || null;
if (newId) {
loadAsistenciaForEditing(newId);
} else {
Object.assign(formData, getDefaultFormData());
}
});
onUnmounted(() => {
asistenciasStore.clearCurrentAsistencia();
});
const validateForm = () => {
let isValid = true;
Object.keys(formErrors).forEach(key => formErrors[key] = '');
if (formData.empleado_id == null || isNaN(Number(formData.empleado_id)) || Number(formData.empleado_id) <= 0) {
formErrors.empleado_id = 'El ID de empleado es obligatorio y debe ser un número positivo.';
isValid = false;
}
if (!formData.entrada) {
formErrors.entrada = 'La fecha y hora de entrada son obligatorias.';
isValid = false;
}
if (!formData.estado) {
formErrors.estado = 'El estado es obligatorio.';
isValid = false;
}
if (formData.entrada && formData.salida) {
// Convert to Date objects for comparison
// The value from datetime-local is already in a format that Date constructor can parse
const entradaDate = new Date(formData.entrada);
const salidaDate = new Date(formData.salida);
if (salidaDate < entradaDate) {
formErrors.salida = 'La fecha y hora de salida no pueden ser anteriores a la entrada.';
isValid = false;
}
}
return isValid;
};
const handleSubmit = async () => {
if (!validateForm()) return;
isSubmitting.value = true;
// Ensure datetime strings are converted to full ISO strings if API requires (includes seconds and Z for UTC)
// Otherwise, YYYY-MM-DDTHH:mm (from datetime-local) might be acceptable by some backends.
const toISOStringOrNull = (datetimeLocalString) => {
if (!datetimeLocalString) return null;
// Create Date object. datetime-local is interpreted as local time.
// To send as UTC, new Date(datetimeLocalString).toISOString() works if the API expects UTC.
// If the API expects local time but in full ISO format, more complex handling might be needed
// or send as is and let backend handle it. For now, we send as is from input.
return datetimeLocalString;
};
const payload = {
...formData,
empleado_id: Number(formData.empleado_id),
entrada: toISOStringOrNull(formData.entrada),
salida: toISOStringOrNull(formData.salida),
};
// If your API strictly needs ISO8601 with Z (UTC) and datetime-local gives local time:
// payload.entrada = formData.entrada ? new Date(formData.entrada).toISOString() : null;
// payload.salida = formData.salida ? new Date(formData.salida).toISOString() : null;
try {
if (editingId.value) {
await asistenciasStore.updateAsistencia(editingId.value, payload);
} else {
await asistenciasStore.createAsistencia(payload);
}
router.push({ name: 'AsistenciasIndex' });
} catch (error) {
console.error('Error saving asistencia:', error);
const errorMsg = error.response?.data?.message || error.message || 'Ocurrió un error.';
alert(`Error al guardar la asistencia: ${errorMsg}`);
} finally {
isSubmitting.value = false;
}
};
const handleCancel = () => {
router.go(-1);
};
</script>
<style scoped>
.asistencia-form-container {
max-width: 600px;
margin: 20px auto;
padding: 25px;
background-color: #f9f9f9;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h2 {
text-align: center;
color: #333;
margin-bottom: 25px;
}
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: bold;
color: #555;
}
.form-group input[type="number"],
.form-group input[type="datetime-local"],
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
font-size: 1em;
}
.form-group textarea {
resize: vertical;
}
.error-message {
display: block;
color: #e74c3c;
font-size: 0.9em;
margin-top: 5px;
}
.form-actions {
margin-top: 25px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.form-actions button {
padding: 10px 18px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
font-weight: 500;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
}
.form-actions button[type="submit"] {
background-color: #3498db; /* Blue */
color: white;
}
.form-actions button[type="submit"]:hover {
background-color: #2980b9;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-actions button[type="submit"]:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
.form-actions button[type="button"] {
background-color: #e74c3c; /* Red for cancel */
color: white;
}
.form-actions button[type="button"]:hover {
background-color: #c0392b;
}
.form-actions button[type="button"]:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
</style>

View File

@@ -1,2 +1,143 @@
<template>
<div class="asistencias-index-container">
<header class="page-header">
<h1>Gestión de Asistencias</h1>
<button @click="navigateToCreateForm" class="btn-create">
Registrar Nueva Asistencia
</button>
</header>
<template></template>
<div v-if="isLoading" class="loading-message">
Cargando asistencias...
</div>
<div v-else-if="errorLoading" class="error-message-full">
<p>Ocurrió un error al cargar las asistencias. Por favor, intente de nuevo más tarde.</p>
<p v-if="errorMessage">Detalle: {{ errorMessage }}</p>
</div>
<div v-else>
<tabla-asistencias
:asistencias="asistenciasList"
@edit="handleEditAsistencia"
/>
<div v-if="!asistenciasList || asistenciasList.length === 0 && !isLoading" class="no-data-message">
No hay asistencias registradas. Puede registrar una nueva haciendo clic en el botón de arriba.
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useAsistenciasStore } from '../../stores/useAsistencias';
import { useRouter } from 'vue-router';
import TablaAsistencias from '../../components/asistencias/tablaAsistencias.vue';
const asistenciasStore = useAsistenciasStore();
const router = useRouter();
const isLoading = ref(true);
const errorLoading = ref(false);
const errorMessage = ref('');
const asistenciasList = computed(() => asistenciasStore.asistencias);
onMounted(async () => {
try {
isLoading.value = true;
errorLoading.value = false;
errorMessage.value = '';
await asistenciasStore.fetchAsistencias();
} catch (error) {
console.error("Error fetching asistencias on mount:", error);
errorLoading.value = true;
errorMessage.value = error.message || 'Error desconocido al cargar asistencias.';
} finally {
isLoading.value = false;
}
});
const navigateToCreateForm = () => {
// Assuming route for creating a new asistencia is named 'AsistenciaFormNew'
router.push({ name: 'AsistenciaFormNew' });
};
const handleEditAsistencia = (asistenciaId) => {
// Assuming route for editing is named 'AsistenciaFormEdit'
router.push({ name: 'AsistenciaFormEdit', params: { id: asistenciaId } });
};
</script>
<style scoped>
.asistencias-index-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
font-family: 'Arial', sans-serif;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px solid #dee2e6;
}
.page-header h1 {
color: #212529;
font-size: 1.8em;
font-weight: 600;
}
.btn-create {
background-color: #17a2b8; /* Info Blue */
color: white;
padding: 10px 18px;
border: none;
border-radius: 5px;
font-size: 1em;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease-in-out, box-shadow 0.2s ease;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.btn-create:hover {
background-color: #138496;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.loading-message,
.error-message-full,
.no-data-message {
text-align: center;
padding: 25px;
margin-top: 25px;
border-radius: 8px;
font-size: 1.1em;
}
.loading-message {
color: #495057;
}
.error-message-full {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.error-message-full p {
margin: 5px 0;
}
.no-data-message {
background-color: #f8f9fa;
color: #343a40;
border: 1px solid #e9ecef;
}
</style>

View File

@@ -1,2 +1,305 @@
<template>
<div class="planilla-form-container">
<h2>{{ formTitle }}</h2>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="titulo">Título:</label>
<input type="text" id="titulo" v-model="formData.titulo" />
<span v-if="formErrors.titulo" class="error-message">{{ formErrors.titulo }}</span>
</div>
<template></template>
<div class="form-group">
<label for="empleado_id">Empleado ID:</label>
<input type="number" id="empleado_id" v-model.number="formData.empleado_id" />
<span v-if="formErrors.empleado_id" class="error-message">{{ formErrors.empleado_id }}</span>
</div>
<div class="form-group">
<label for="fecha_desde">Fecha Desde:</label>
<input type="date" id="fecha_desde" v-model="formData.fecha_desde" />
<span v-if="formErrors.fecha_desde" class="error-message">{{ formErrors.fecha_desde }}</span>
</div>
<div class="form-group">
<label for="fecha_hasta">Fecha Hasta:</label>
<input type="date" id="fecha_hasta" v-model="formData.fecha_hasta" />
<span v-if="formErrors.fecha_hasta" class="error-message">{{ formErrors.fecha_hasta }}</span>
</div>
<div class="form-group">
<label for="total">Total:</label>
<input type="number" step="any" id="total" v-model.number="formData.total" />
<span v-if="formErrors.total" class="error-message">{{ formErrors.total }}</span>
</div>
<div class="form-group">
<label for="estado">Estado:</label>
<select id="estado" v-model="formData.estado">
<option value="pendiente">Pendiente</option>
<option value="pagado">Pagado</option>
<option value="anulado">Anulado</option>
</select>
<span v-if="formErrors.estado" class="error-message">{{ formErrors.estado }}</span>
</div>
<div class="form-actions">
<button type="submit" :disabled="isSubmitting">{{ isSubmitting ? 'Guardando...' : 'Guardar' }}</button>
<button type="button" @click="handleCancel" :disabled="isSubmitting">Cancelar</button>
</div>
</form>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue';
import { usePlanillasStore } from '../../stores/usePlanillas';
import { useRouter, useRoute } from 'vue-router';
const props = defineProps({
id: String, // From route params, will be a string
});
const router = useRouter();
const route = useRoute(); // Can also get id from route.params.id
const planillasStore = usePlanillasStore();
const formData = reactive({
titulo: '',
empleado_id: null,
fecha_desde: '',
fecha_hasta: '',
total: null,
estado: 'pendiente', // Default state
});
const formErrors = reactive({
titulo: '',
empleado_id: '',
fecha_desde: '',
fecha_hasta: '',
estado: '',
total: '', // Though not explicitly required, can add validation if needed
});
const isSubmitting = ref(false);
const editingId = ref(null); // To store the actual ID for editing
const formTitle = computed(() => (editingId.value ? 'Editar Planilla' : 'Crear Nueva Planilla'));
// Helper to format date for <input type="date">
const formatDateForInput = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
// Adjust for timezone issues if date is off by one day
const offset = date.getTimezoneOffset();
const correctedDate = new Date(date.getTime() - (offset * 60 * 1000));
return correctedDate.toISOString().split('T')[0];
};
onMounted(() => {
const routeId = route.params.id || props.id;
if (routeId) {
editingId.value = routeId;
planillasStore.fetchPlanillaById(routeId).then(() => {
if (planillasStore.currentPlanilla && String(planillasStore.currentPlanilla.id) === String(editingId.value)) {
populateFormFromStore();
} else {
console.error(`Failed to load planilla data for ID: ${routeId} or mismatch. Current store ID: ${planillasStore.currentPlanilla?.id}`);
// router.push({ name: 'PlanillasIndex' });
}
}).catch(error => {
console.error(`Error fetching planilla ${routeId}:`, error);
// router.push({ name: 'PlanillasIndex' });
});
}
});
// Watch for changes in currentPlanilla if the component might be reused
// or if fetchPlanillaById updates currentPlanilla asynchronously after mount
watch(() => planillasStore.currentPlanilla, (newPlanilla) => {
if (editingId.value && newPlanilla && String(newPlanilla.id) === String(editingId.value)) {
populateFormFromStore();
}
}, { deep: true });
function populateFormFromStore() {
Object.assign(formData, {
...planillasStore.currentPlanilla,
fecha_desde: formatDateForInput(planillasStore.currentPlanilla.fecha_desde),
fecha_hasta: formatDateForInput(planillasStore.currentPlanilla.fecha_hasta),
empleado_id: planillasStore.currentPlanilla.empleado_id ? Number(planillasStore.currentPlanilla.empleado_id) : null,
total: planillasStore.currentPlanilla.total ? parseFloat(planillasStore.currentPlanilla.total) : null,
});
}
const validateForm = () => {
let isValid = true;
for (const key in formErrors) {
formErrors[key] = '';
}
if (!formData.titulo?.trim()) {
formErrors.titulo = 'El título es obligatorio.';
isValid = false;
}
if (formData.empleado_id == null || isNaN(parseInt(formData.empleado_id)) || Number(formData.empleado_id) <= 0) {
formErrors.empleado_id = 'El ID de empleado es obligatorio y debe ser un número positivo.';
isValid = false;
}
if (!formData.fecha_desde) {
formErrors.fecha_desde = 'La fecha desde es obligatoria.';
isValid = false;
}
if (!formData.fecha_hasta) {
formErrors.fecha_hasta = 'La fecha hasta es obligatoria.';
isValid = false;
}
if (formData.fecha_desde && formData.fecha_hasta && new Date(formData.fecha_desde) > new Date(formData.fecha_hasta)) {
formErrors.fecha_hasta = 'La fecha hasta no puede ser anterior a la fecha desde.';
isValid = false;
}
if (!formData.estado) {
formErrors.estado = 'El estado es obligatorio.';
isValid = false;
}
if (formData.total != null && (isNaN(parseFloat(formData.total)) || parseFloat(formData.total) < 0)) {
formErrors.total = 'El total debe ser un número positivo.';
isValid = false;
}
return isValid;
};
const handleSubmit = async () => {
if (!validateForm()) {
return;
}
isSubmitting.value = true;
try {
const payload = {
...formData,
empleado_id: parseInt(formData.empleado_id, 10),
total: formData.total != null ? parseFloat(formData.total) : null,
// Ensure dates are actual Date objects or correctly formatted ISO strings if API requires
// The <input type="date"> provides YYYY-MM-DD. If API needs full DateTime:
// fecha_desde: formData.fecha_desde ? new Date(formData.fecha_desde).toISOString() : null,
// fecha_hasta: formData.fecha_hasta ? new Date(formData.fecha_hasta).toISOString() : null,
};
if (editingId.value) {
await planillasStore.updatePlanilla(editingId.value, payload);
} else {
await planillasStore.createPlanilla(payload);
}
router.push({ name: 'PlanillasIndex' });
} catch (error) {
console.error('Error saving planilla:', error);
const errorMessage = error.response?.data?.message || error.message || 'Ocurrió un error al guardar la planilla.';
alert(`Error: ${errorMessage}`);
// Potentially set a form-level error message:
// formErrors.general = `Error: ${errorMessage}`;
} finally {
isSubmitting.value = false;
}
};
const handleCancel = () => {
router.go(-1);
};
</script>
<style scoped>
.planilla-form-container {
max-width: 600px;
margin: 20px auto;
padding: 20px;
background-color: #f9f9f9;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h2 {
text-align: center;
color: #333;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #555;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group input[type="date"],
.form-group select {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
.form-group select {
appearance: none;
background-color: white;
}
.error-message {
display: block;
color: red;
font-size: 0.9em;
margin-top: 5px;
}
.form-actions {
margin-top: 20px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.form-actions button {
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
}
.form-actions button[type="submit"] {
background-color: #28a745; /* Green */
color: white;
}
.form-actions button[type="submit"]:hover {
background-color: #218838;
}
.form-actions button[type="submit"]:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.form-actions button[type="button"] {
background-color: #6c757d; /* Gray */
color: white;
}
.form-actions button[type="button"]:hover {
background-color: #5a6268;
}
.form-actions button[type="button"]:disabled {
background-color: #ccc;
cursor: not-allowed;
}
</style>

View File

@@ -1,2 +1,152 @@
<template>
<div class="planillas-index-container">
<header class="page-header">
<h1>Gestión de Planillas</h1>
<button @click="navigateToCreateForm" class="btn-create">
Crear Nueva Planilla
</button>
</header>
<template></template>
<div v-if="isLoading" class="loading-message">
Cargando planillas...
</div>
<div v-else-if="errorLoading" class="error-message-full">
<p>Ocurrió un error al cargar las planillas. Por favor, intente de nuevo más tarde.</p>
<p v-if="errorMessage">Detalle: {{ errorMessage }}</p>
</div>
<div v-else>
<tabla-planillas
:planillas="planillasList"
@edit="handleEditPlanilla"
/>
<div v-if="!planillasList || planillasList.length === 0 && !isLoading" class="no-data-message">
No hay planillas registradas. Puede crear una nueva haciendo clic en el botón de arriba.
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { usePlanillasStore } from '../../stores/usePlanillas';
import { useRouter } from 'vue-router';
import TablaPlanillas from '../../components/planillas/tablaPlanillas.vue'; // Corrected path
const planillasStore = usePlanillasStore();
const router = useRouter();
const isLoading = ref(true); // Set to true initially
const errorLoading = ref(false);
const errorMessage = ref('');
// Computed property to get planillas from the store
const planillasList = computed(() => planillasStore.planillas);
// Fetch planillas when the component is mounted
onMounted(async () => {
try {
isLoading.value = true;
errorLoading.value = false;
errorMessage.value = '';
await planillasStore.fetchPlanillas();
} catch (error) {
console.error("Error fetching planillas on mount:", error);
errorLoading.value = true;
errorMessage.value = error.message || 'Error desconocido.';
} finally {
isLoading.value = false;
}
});
// Navigate to the form for creating a new planilla
const navigateToCreateForm = () => {
// Assuming your route for creating a new planilla is named 'PlanillaFormNew'
// This name should match what's defined in your router configuration
router.push({ name: 'PlanillaFormNew' });
};
// Navigate to the form for editing an existing planilla
const handleEditPlanilla = (planillaId) => {
// Assuming your route for editing is named 'PlanillaFormEdit'
// This name should match what's defined in your router configuration
router.push({ name: 'PlanillaFormEdit', params: { id: planillaId } });
};
</script>
<style scoped>
.planillas-index-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
font-family: Arial, sans-serif;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.page-header h1 {
color: #2c3e50; /* Darker, more neutral blue */
font-size: 2.2em;
font-weight: 600;
}
.btn-create {
background-color: #3498db; /* Vibrant blue */
color: white;
padding: 12px 18px;
border: none;
border-radius: 5px;
font-size: 1em;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease-in-out, box-shadow 0.2s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.btn-create:hover {
background-color: #2980b9; /* Darker shade of vibrant blue */
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.loading-message,
.error-message-full,
.no-data-message {
text-align: center;
padding: 25px;
margin-top: 25px;
border-radius: 8px;
font-size: 1.1em;
}
.loading-message {
color: #7f8c8d; /* Gray */
}
.error-message-full {
background-color: #fdedec; /* Lighter red */
color: #e74c3c; /* Strong red */
border: 1px solid #f5b7b1; /* Light red border */
}
.error-message-full p {
margin: 5px 0;
}
.no-data-message {
background-color: #eafaf1; /* Lighter green/blue */
color: #2ecc71; /* Green */
border: 1px solid #a3e4d7; /* Light green/blue border */
}
/* Styling for the imported tablaPlanillas can be managed within its own component,
but you can add overrides or container styles here if needed. */
</style>

View File

@@ -1,2 +1,318 @@
<template>
<div class="tarea-form-container">
<h2>{{ formTitle }}</h2>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="titulo">Título:</label>
<input type="text" id="titulo" v-model="formData.titulo" />
<span v-if="formErrors.titulo" class="error-message">{{ formErrors.titulo }}</span>
</div>
<template></template>
<div class="form-group">
<label for="empleado_id">Empleado ID:</label>
<input type="number" id="empleado_id" v-model.number="formData.empleado_id" />
<span v-if="formErrors.empleado_id" class="error-message">{{ formErrors.empleado_id }}</span>
</div>
<div class="form-group">
<label for="fecha">Fecha:</label>
<input type="date" id="fecha" v-model="formData.fecha" />
<span v-if="formErrors.fecha" class="error-message">{{ formErrors.fecha }}</span>
</div>
<div class="form-group">
<label for="estado">Estado:</label>
<select id="estado" v-model="formData.estado">
<option value="pendiente">Pendiente</option>
<option value="en progreso">En Progreso</option>
<option value="completada">Completada</option>
<option value="cancelada">Cancelada</option>
</select>
<span v-if="formErrors.estado" class="error-message">{{ formErrors.estado }}</span>
</div>
<div class="form-group">
<label for="tipo">Tipo:</label>
<input type="text" id="tipo" v-model="formData.tipo" />
<span v-if="formErrors.tipo" class="error-message">{{ formErrors.tipo }}</span>
</div>
<div class="form-group">
<label for="precio">Precio (Opcional):</label>
<input type="number" step="any" id="precio" v-model.number="formData.precio" />
<span v-if="formErrors.precio" class="error-message">{{ formErrors.precio }}</span>
</div>
<div class="form-group">
<label for="planilla_id">Planilla ID (Opcional):</label>
<input type="number" id="planilla_id" v-model.number="formData.planilla_id" />
<span v-if="formErrors.planilla_id" class="error-message">{{ formErrors.planilla_id }}</span>
</div>
<div class="form-group">
<label for="observacion">Observación (Opcional):</label>
<textarea id="observacion" v-model="formData.observacion" rows="3"></textarea>
</div>
<div class="form-actions">
<button type="submit" :disabled="isSubmitting">{{ isSubmitting ? 'Guardando...' : 'Guardar' }}</button>
<button type="button" @click="handleCancel" :disabled="isSubmitting">Cancelar</button>
</div>
</form>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, computed, watch } from 'vue';
import { useTareasStore } from '../../stores/useTareas';
import { useRouter, useRoute } from 'vue-router';
const props = defineProps({
id: String,
});
const router = useRouter();
const route = useRoute();
const tareasStore = useTareasStore();
const formatDateForInput = (dateString) => {
const date = dateString ? new Date(dateString) : new Date();
// Apply timezone offset correction to ensure the date displayed is the intended "local" date
const userTimezoneOffset = date.getTimezoneOffset() * 60000;
const correctedDate = new Date(date.getTime() - userTimezoneOffset);
return correctedDate.toISOString().split('T')[0];
};
const getDefaultFormData = () => ({
titulo: '',
empleado_id: null,
planilla_id: null,
precio: null,
estado: 'pendiente',
observacion: '',
fecha: formatDateForInput(null),
tipo: '',
});
const formData = reactive(getDefaultFormData());
const formErrors = reactive({
titulo: '',
empleado_id: '',
fecha: '',
estado: '',
tipo: '',
precio: '',
planilla_id: '',
});
const isSubmitting = ref(false);
const editingId = ref(route.params.id || props.id || null);
const formTitle = computed(() => (editingId.value ? 'Editar Tarea' : 'Crear Nueva Tarea'));
const loadTareaForEditing = async (id) => {
try {
await tareasStore.fetchTareaById(id);
if (tareasStore.currentTarea && String(tareasStore.currentTarea.id) === String(id)) {
Object.assign(formData, {
...tareasStore.currentTarea,
fecha: formatDateForInput(tareasStore.currentTarea.fecha),
empleado_id: tareasStore.currentTarea.empleado_id ? Number(tareasStore.currentTarea.empleado_id) : null,
planilla_id: tareasStore.currentTarea.planilla_id ? Number(tareasStore.currentTarea.planilla_id) : null,
precio: tareasStore.currentTarea.precio ? parseFloat(tareasStore.currentTarea.precio) : null,
});
} else {
console.error(`Failed to load tarea data for ID: ${id} or ID mismatch.`);
router.push({ name: 'TareasIndex' });
}
} catch (error) {
console.error(`Error fetching tarea ${id} for editing:`, error);
router.push({ name: 'TareasIndex' });
}
};
onMounted(() => {
if (editingId.value) {
loadTareaForEditing(editingId.value);
}
// else, new form uses default data, already set
});
watch(() => route.params.id, (newId) => {
editingId.value = newId || null; // Update editingId when route changes
if (newId) {
loadTareaForEditing(newId);
} else {
// Navigated to create form from an edit form or similar scenario
Object.assign(formData, getDefaultFormData());
}
});
onUnmounted(() => {
tareasStore.clearCurrentTarea();
});
const validateForm = () => {
let isValid = true;
Object.keys(formErrors).forEach(key => formErrors[key] = '');
if (!formData.titulo?.trim()) {
formErrors.titulo = 'El título es obligatorio.';
isValid = false;
}
if (formData.empleado_id == null || isNaN(Number(formData.empleado_id)) || Number(formData.empleado_id) <= 0) {
formErrors.empleado_id = 'El ID de empleado es obligatorio y debe ser un número positivo.';
isValid = false;
}
if (!formData.fecha) {
formErrors.fecha = 'La fecha es obligatoria.';
isValid = false;
}
if (!formData.estado) {
formErrors.estado = 'El estado es obligatorio.';
isValid = false;
}
if (!formData.tipo?.trim()) {
formErrors.tipo = 'El tipo es obligatorio.';
isValid = false;
}
if (formData.precio != null && (isNaN(parseFloat(formData.precio)) || parseFloat(formData.precio) < 0)) {
formErrors.precio = 'El precio, si se ingresa, debe ser un número positivo.';
isValid = false;
}
if (formData.planilla_id != null && (formData.planilla_id !== '' && (isNaN(Number(formData.planilla_id)) || Number(formData.planilla_id) <= 0))) {
// Allow empty string for planilla_id, but if a value is present, it must be a positive number
formErrors.planilla_id = 'El ID de planilla, si se ingresa, debe ser un número positivo.';
isValid = false;
}
return isValid;
};
const handleSubmit = async () => {
if (!validateForm()) return;
isSubmitting.value = true;
const payload = {
...formData,
empleado_id: Number(formData.empleado_id),
// Send null if planilla_id is empty or not a number, otherwise send the number
planilla_id: (formData.planilla_id && !isNaN(Number(formData.planilla_id))) ? Number(formData.planilla_id) : null,
precio: (formData.precio != null && formData.precio !== '') ? parseFloat(formData.precio) : null,
// API expects date as YYYY-MM-DD string or full ISO. Input type="date" provides YYYY-MM-DD.
// If API needs full DateTime, convert: new Date(formData.fecha + 'T00:00:00Z').toISOString()
};
try {
if (editingId.value) {
await tareasStore.updateTarea(editingId.value, payload);
} else {
await tareasStore.createTarea(payload);
}
router.push({ name: 'TareasIndex' });
} catch (error) {
console.error('Error saving tarea:', error);
const errorMsg = error.response?.data?.message || error.message || 'Ocurrió un error.';
alert(`Error al guardar la tarea: ${errorMsg}`);
} finally {
isSubmitting.value = false;
}
};
const handleCancel = () => {
router.go(-1);
};
</script>
<style scoped>
.tarea-form-container {
max-width: 650px;
margin: 20px auto;
padding: 25px;
background-color: #f9f9f9;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h2 {
text-align: center;
color: #333;
margin-bottom: 25px;
}
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: bold;
color: #555;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group input[type="date"],
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
font-size: 1em;
}
.form-group textarea {
resize: vertical;
}
.error-message {
display: block;
color: #e74c3c;
font-size: 0.9em;
margin-top: 5px;
}
.form-actions {
margin-top: 25px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.form-actions button {
padding: 10px 18px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
font-weight: 500;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
}
.form-actions button[type="submit"] {
background-color: #2ecc71;
color: white;
}
.form-actions button[type="submit"]:hover {
background-color: #27ae60;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-actions button[type="submit"]:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
.form-actions button[type="button"] {
background-color: #95a5a6;
color: white;
}
.form-actions button[type="button"]:hover {
background-color: #7f8c8d;
}
.form-actions button[type="button"]:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
</style>

View File

@@ -1,2 +1,143 @@
<template>
<div class="tareas-index-container">
<header class="page-header">
<h1>Gestión de Tareas</h1>
<button @click="navigateToCreateForm" class="btn-create">
Crear Nueva Tarea
</button>
</header>
<template></template>
<div v-if="isLoading" class="loading-message">
Cargando tareas...
</div>
<div v-else-if="errorLoading" class="error-message-full">
<p>Ocurrió un error al cargar las tareas. Por favor, intente de nuevo más tarde.</p>
<p v-if="errorMessage">Detalle: {{ errorMessage }}</p>
</div>
<div v-else>
<tabla-tareas
:tareas="tareasList"
@edit="handleEditTarea"
/>
<div v-if="!tareasList || tareasList.length === 0 && !isLoading" class="no-data-message">
No hay tareas registradas. Puede crear una nueva haciendo clic en el botón de arriba.
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useTareasStore } from '../../stores/useTareas';
import { useRouter } from 'vue-router';
import TablaTareas from '../../components/tareas/tablaTareas.vue';
const tareasStore = useTareasStore();
const router = useRouter();
const isLoading = ref(true);
const errorLoading = ref(false);
const errorMessage = ref('');
const tareasList = computed(() => tareasStore.tareas);
onMounted(async () => {
try {
isLoading.value = true;
errorLoading.value = false;
errorMessage.value = '';
await tareasStore.fetchTareas();
} catch (error) {
console.error("Error fetching tareas on mount:", error);
errorLoading.value = true;
errorMessage.value = error.message || 'Error desconocido al cargar tareas.';
} finally {
isLoading.value = false;
}
});
const navigateToCreateForm = () => {
// Assuming route for creating a new tarea is named 'TareaFormNew'
router.push({ name: 'TareaFormNew' });
};
const handleEditTarea = (tareaId) => {
// Assuming route for editing is named 'TareaFormEdit'
router.push({ name: 'TareaFormEdit', params: { id: tareaId } });
};
</script>
<style scoped>
.tareas-index-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px solid #e0e0e0;
}
.page-header h1 {
color: #333;
font-size: 2em;
font-weight: 600;
}
.btn-create {
background-color: #5cb85c;
color: white;
padding: 12px 20px;
border: none;
border-radius: 5px;
font-size: 1em;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease-in-out, box-shadow 0.2s ease;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.btn-create:hover {
background-color: #4cae4c;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.loading-message,
.error-message-full,
.no-data-message {
text-align: center;
padding: 25px;
margin-top: 25px;
border-radius: 8px;
font-size: 1.1em;
}
.loading-message {
color: #555;
}
.error-message-full {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.error-message-full p {
margin: 5px 0;
}
.no-data-message {
background-color: #e9ecef;
color: #495057;
border: 1px solid #ced4da;
}
</style>

View File

@@ -1,5 +1,7 @@
import cron from 'node-cron';
import { syncEmpleadosToExternalDB } from './sync-empleados.js';
// Example cron job, runs every 5 seconds for demonstration/testing.
cron.schedule('*/5 * * * * *', () => {
const now = new Date();
if (!isNaN(now)) {
@@ -9,3 +11,12 @@ cron.schedule('*/5 * * * * *', () => {
console.log('[cron] Fecha inválida');
}
});
// // Schedules the daily employee data synchronization to run at midnight.
// cron.schedule('0 0 * * *', () => {
// console.log('[CronWorker] Running daily employee synchronization task...');
// syncEmpleadosToExternalDB().catch(err => {
// console.error('[CronWorker] Error during scheduled employee synchronization:', err);
// });
// });
// console.log('[CronWorker] Daily employee synchronization task scheduled for 00:00.');

169
worker/sync-empleados.js Normal file
View File

@@ -0,0 +1,169 @@
// This script is responsible for synchronizing employee data from the local
// Prisma database to an external database. It is designed to be run as a scheduled task.
// --- External Database Configuration (PLACEHOLDERS - User MUST Configure via environment variables) ---
// IMPORTANT: These variables define the connection to your external database.
// You MUST configure these, ideally through environment variables, for the script to work.
// The script provides a template for different database types, but you'll need to:
// 1. Install the appropriate database client library (e.g., `npm install pg` or `npm install mysql2`).
// 2. Implement the actual database connection and query logic in the designated section.
const EXTERNAL_DB_TYPE = process.env.EXTERNAL_DB_TYPE || 'your_db_type_here'; // e.g., 'postgres', 'mysql'. User must set this.
const EXTERNAL_DB_HOST = process.env.EXTERNAL_DB_HOST || 'YOUR_EXTERNAL_DB_HOST'; // User must set this.
const EXTERNAL_DB_PORT = process.env.EXTERNAL_DB_PORT || 5432; // Adjust default port if needed. User must set this.
const EXTERNAL_DB_USER = process.env.EXTERNAL_DB_USER || 'YOUR_EXTERNAL_DB_USER'; // User must set this.
const EXTERNAL_DB_PASSWORD = process.env.EXTERNAL_DB_PASSWORD || 'YOUR_EXTERNAL_DB_PASSWORD'; // User must set this.
const EXTERNAL_DB_NAME = process.env.EXTERNAL_DB_NAME || 'YOUR_EXTERNAL_DB_NAME'; // User must set this.
const EXTERNAL_DB_EMPLEADOS_TABLE = process.env.EXTERNAL_DB_EMPLEADOS_TABLE || 'empleados_externos'; // User must set this.
// --- End of External Database Configuration ---
import { PrismaClient } from '../api/prisma/generated/client';
const prisma = new PrismaClient();
async function syncEmpleadosToExternalDB() {
console.log('[SyncEmpleados] Starting synchronization process...');
// Core logic for synchronization, wrapped in try/catch/finally to ensure
// resources like the Prisma client are properly managed (e.g., disconnected).
try {
// Fetch all 'Cliente' records from the local Prisma database that are marked as 'empleado'.
const localEmpleados = await prisma.cliente.findMany({
where: { empleado: true }, // Filters for clients who are also employees
select: {
id: true, // Local ID, might be useful for logging/tracing
name: true,
cedula: true,
ubicacion: true,
telefono: true,
// avatar_url: true, // Add other relevant fields
// idciat: true, // Add other relevant fields
},
});
if (!localEmpleados.length) {
console.log('[SyncEmpleados] No employees found in local database. Nothing to sync.');
return;
}
console.log(`[SyncEmpleados] Found ${localEmpleados.length} employees to sync.`);
// --- External DB Connection Logic (User to implement based on EXTERNAL_DB_TYPE) ---
// User must implement the actual database connection logic here based on EXTERNAL_DB_TYPE
// and install the required database client library (e.g., 'pg' for PostgreSQL, 'mysql2' for MySQL).
// The following are conceptual examples.
// Example for PostgreSQL using 'pg' library (user would need to install it: npm install pg)
/*
if (EXTERNAL_DB_TYPE === 'postgres') {
// const { Client } = require('pg'); // User would uncomment and install
// const externalDbClient = new Client({
// host: EXTERNAL_DB_HOST,
// port: EXTERNAL_DB_PORT,
// user: EXTERNAL_DB_USER,
// password: EXTERNAL_DB_PASSWORD,
// database: EXTERNAL_DB_NAME,
// });
// await externalDbClient.connect();
// console.log('[SyncEmpleados] Connected to external PostgreSQL database.');
// ... (sync logic using externalDbClient) ...
// await externalDbClient.end();
// console.log('[SyncEmpleados] Disconnected from external PostgreSQL database.');
} else if (EXTERNAL_DB_TYPE === 'mysql') {
// const mysql = require('mysql2/promise'); // User would uncomment and install
// const connection = await mysql.createConnection({
// host: EXTERNAL_DB_HOST,
// user: EXTERNAL_DB_USER,
// password: EXTERNAL_DB_PASSWORD,
// database: EXTERNAL_DB_NAME,
// port: EXTERNAL_DB_PORT,
// });
// console.log('[SyncEmpleados] Connected to external MySQL database.');
// ... (sync logic using connection) ...
// await connection.end();
// console.log('[SyncEmpleados] Disconnected from external MySQL database.');
} else {
console.error(`[SyncEmpleados] Unsupported EXTERNAL_DB_TYPE: ${EXTERNAL_DB_TYPE}. User needs to implement connection logic.`);
// For now, we will just log the data that would be synced
}
*/
// --- End of External DB Connection Logic ---
console.log('[SyncEmpleados] --- Data to be Synced (Conceptual) ---');
for (const emp of localEmpleados) {
console.log(`[SyncEmpleados] Processing employee: ID=${emp.id}, Cedula=${emp.cedula}, Name=${emp.name}`);
// Conceptual Upsert Logic: The following section outlines where you would implement the upsert (update or insert) operation.
// You'll need to use your chosen external database client to first check if an employee
// with a matching unique identifier (e.g., emp.cedula) exists in the ${EXTERNAL_DB_EMPLEADOS_TABLE}.
// If it exists, execute an UPDATE statement. If not, execute an INSERT statement.
// Ensure you map fields from 'emp' (local employee) to the corresponding columns in your external table.
// Example of data to be inserted/updated:
const externalData = {
// Assuming external table has these columns. User must map accordingly.
// Ensure data types are compatible between local and external databases.
cedula: emp.cedula, // Primary key for matching in the external table (assumed)
nombre: emp.name,
telefono: emp.telefono,
ubicacion: emp.ubicacion,
// local_id: emp.id, // Optional: store local ID for reference
// ... map other fields ...
};
console.log(`[SyncEmpleados] Data for external table: ${JSON.stringify(externalData)}`);
}
console.log('[SyncEmpleados] --- End of Data to be Synced ---');
} catch (error) {
// Catch any errors that occur during the synchronization process.
console.error('[SyncEmpleados] Error during synchronization:', error);
} finally {
// Ensure the Prisma client is always disconnected, even if an error occurs.
await prisma.$disconnect();
console.log('[SyncEmpleados] Prisma client disconnected.');
}
console.log('[SyncEmpleados] Synchronization process finished.');
}
export { syncEmpleadosToExternalDB };
// --- Testing Considerations ---
// 1. **Configuration is Key:** This script requires the `EXTERNAL_DB_...` variables to be correctly
// configured with your actual external database credentials and details before any meaningful
// end-to-end testing can be performed.
//
// 2. **Manual Testing:**
// * Once configured, you can test the script by running it directly: `node worker/sync-empleados.js`
// * Observe the console logs for any errors or successful completion messages.
// * Verify that data from your local 'Cliente' table (where empleado=true) appears correctly
// in your designated external database table (`EXTERNAL_DB_EMPLEADOS_TABLE`).
// * Check that subsequent runs correctly update existing records and insert new ones.
// * The cron job in `worker/cron-worker.js` is scheduled to run this script daily at midnight.
// You can monitor logs after this time to see its execution.
//
// 3. **Automated Testing (Recommendations for future development):**
// * **Unit Tests:**
// - Mock the Prisma client (`../api/prisma/generated/client`) to return predefined employee data
// and spy on its methods.
// - Mock the external database client (e.g., `pg`, `mysql2`) to simulate connection,
// query execution (select, insert, update), and disconnections. This allows testing
// the sync logic without actual database dependencies.
// * **Integration Tests:**
// - If possible, set up a dedicated test instance of your external database.
// - Write tests that populate the local Prisma test database with sample employee data,
// run the sync script, and then query the external test database to assert that
// the data was synchronized correctly. This provides more comprehensive testing
// but requires more setup.
//
// 4. **Logging:** Pay close attention to the console output. The script includes logging for various
// stages, which will be crucial for diagnosing issues during testing and operation.
// --- End of Testing Considerations ---
// Optional: For direct execution of this script, e.g., `node worker/sync-empleados.js`
if (require.main === module) {
console.log('[SyncEmpleados] Running synchronization script directly.');
syncEmpleadosToExternalDB()
.then(() => console.log('[SyncEmpleados] Direct execution completed.'))
.catch(err => console.error('[SyncEmpleados] Direct execution failed:', err));
}