Compare commits

...

45 Commits

Author SHA1 Message Date
javier 480dfc657f feat(projects): usuarios/empresas del proyecto como tablas Rappasoft + permiso assign companies
- Permiso 'assign companies' propio (antes empresas reutilizaba 'assign users');
  concedido a los roles que ya tenían 'assign users'.
- ProjectUsersTable y ProjectCompaniesTable (Rappasoft): búsqueda, filtro por rol,
  cambio de rol en línea y quitar; gateadas por assign users / assign companies.
- ProjectUsers/ProjectCompanies quedan como contenedor (form de asignación) que
  embebe la tabla y refresca el desplegable vía eventos.
- Unificadas confirmaciones (wire:confirm) y notificaciones (dispatch notify).

Tests: ProjectAssignmentsTest (4). Suite 69 passing (solo 2 pre-existentes sqlite).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:22:59 +02:00
javier c378ab5884 feat(phases): modal crear/editar fase + tabla Rappasoft
- PhaseList pasa a contenedor: botón "Agregar fase" + modal crear/editar con todos
  los parámetros (nombre, descripción, orden, color, progreso, fechas previstas y
  reales) y validación. Antes "Agregar fase" creaba directamente 'Nueva fase'.
- PhaseTable (Rappasoft): orden, nombre+descripción, barra de progreso, fechas,
  color y acciones (editar abre el modal vía evento, actualizar progreso, eliminar);
  búsqueda y ordenación. Gateado por 'manage phases' + acceso al proyecto.

Tests: PhaseManagementTest (4). Suite 65 passing (solo 2 pre-existentes sqlite).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:56:05 +02:00
javier 3d0f4d5cad feat(issues): tipo/categoría de incidencia (defecto/seguridad/calidad/documentación/otro)
- Issue::TYPES + typeLabels() (ES) + accessors type_label/type_color; columna type
  (string, default 'other') + fillable.
- IssueForm: select "Tipo de incidencia" con validación/carga/guardado.
- IssueTable: columna Tipo (badge) + SelectFilter por tipo.
- IssueDetail: badge de tipo en la cabecera.
- Sync offline: issue.create/update aceptan type; bundle (mapIssue) lo incluye.

Tests: IssuesEnhancementsTest (create muestra el campo vía HTTP, edición persiste) +
MobileApiTest (create con type). Suite 61 passing (solo 2 pre-existentes sqlite).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:30:54 +02:00
javier 19e1f57983 feat(users): idioma por defecto con banderas SVG + conmutador coherente
- Campo "Idioma por defecto" al crear/editar usuario (columna locale ya existente),
  como desplegable Alpine con banderas SVG reales (no emoji, que en Windows se ven
  como "ES"/"GB") servidas localmente: public/images/flags/{es,gb}.svg.
- User: locale añadido a fillable. UserForm: propiedad/validación/guardado de locale.
- LanguageSwitcher de la cabecera usa las mismas banderas SVG.
- Regla CSS [x-cloak] en el layout para evitar parpadeo de desplegables Alpine.

Tests: UserLocaleTest (2) — crear/editar persisten el idioma.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:10:27 +02:00
javier 8c774d075d feat(issues): notificaciones, plantillas de checklist, alertas de vencimiento y reporte desde el mapa
- Notificaciones (DB): asignación de incidencia (IssueAssigned), asignación de tarea
  (IssueTaskAssigned), comentario (IssueCommented) y cambio de estado
  (IssueStatusChanged) a reporter+asignado excluyendo al actor.
- Plantillas de checklist: tabla issue_checklist_templates + modelo, gestor CRUD
  (IssueChecklistManager, ruta projects.issues.checklists) y "Aplicar plantilla" en
  el detalle (alta masiva de tareas).
- Alertas de vencimiento: columna overdue_notified_at + scope overdue, comando
  issues:notify-overdue (programado a diario) que avisa al asignado una sola vez;
  badge "vencidas" en la tabla y resaltado por tarea en el detalle.
- Reporte desde el mapa: botón "Incidencia" en el panel del feature seleccionado →
  formulario con feature pre-vinculado (IssueForm lee ?feature=).

Tests: IssuesEnhancementsTest (7). Suite 57 passing (solo 2 pre-existentes sqlite).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:51:41 +02:00
javier 3f240e5277 feat(issues): incidencias enriquecidas (tareas/comentarios/fotos/verificación) + tabla Rappasoft + logo
Web:
- IssueTask + IssueComment (modelos, migraciones, soft-deletes, campos de sync).
  Issue gana tasks()/comments() y accessor de % de avance derivado de tareas.
- IssueDetail (página): checklist con asignado/fecha límite/progreso, hilo de
  comentarios con foto por comentario, galería de fotos de la incidencia y flujo
  de verificación open→in_review→resolved/closed (+reabrir) con notas.
- Creación/edición en páginas propias (IssueForm), sin modal; al guardar redirige
  al detalle. Rutas projects.issues.create/edit/show.
- Listado con tabla Rappasoft (IssueTable): filtros por estado/prioridad, búsqueda,
  barra de progreso y acciones por fila gateadas por permisos; IssueManager queda
  como contenedor (cabecera + stats) que embebe la tabla.
- Seguridad: pertenencia al proyecto + permisos por acción (view/create/edit/delete
  issues, upload/delete media) en todos los componentes.

API móvil (offline):
- /sync: issue_task.create/update y issue_comment.create (idempotente, LWW).
- /media: parent_entity issue_task / issue_comment.
- bundle + tombstones incluyen issue_tasks / issue_comments.
- openapi.yaml + MOBILE_SYNC_PROTOCOL.md actualizados.

Tests: MobileApiTest 23 passing (+5); IssuesTablePageTest (3) smoke de la tabla.

Branding: logo RTE International — MAI Group (public/images/logo-rte.png) en login
y navegación; application-logo pasa de SVG por defecto a <img>.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:12:39 +02:00
javier 14758136b6 feat(api): mobile API Milestones 5+6 — media upload, sync_logs idempotency, OpenAPI
Milestone 5 (media):
- POST /api/v1/media — multipart upload, attaches to feature/issue/project/
  phase/layer, idempotent by uuid, authz member + 'upload media'. Added
  uuid+client_updated_at to media.
- Bundle now includes a 'media' array (URLs) for the project's project/feature/
  issue attachments (delta-aware).

Milestone 6 (hardening + docs):
- sync_logs table/model: every applied op is logged; /sync short-circuits on a
  repeated op uuid -> 'duplicate' (true idempotency for updates too, not just
  creates).
- Rate limiting on login (10/min), sync (60/min), media (120/min).
- docs/openapi.yaml: OpenAPI 3 contract for the mobile team.

Tests: 18 passing (added media upload idempotency + sync_logs idempotency).
The mobile API (Milestones 1-6) is now feature-complete on the webapp side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:23:50 +02:00
javier 9d2b63c8f4 feat(api): mobile API Milestone 4 — full PUSH (inspections/issues/features + conflicts)
SyncController now handles the full mutation vocabulary:
- inspection.create (idempotent by uuid; project/layer derived from feature;
  authz member + 'create inspections'; status defaults to completed).
- issue.create (idempotent; authz member + 'create issues').
- issue.update (by server id; authz member + 'edit issues'; sets resolved_at
  when resolved/closed; last-write-wins conflict).
- feature.update (by server id; authz member + 'update progress'; recomputes
  phase progress; last-write-wins conflict).
- Conflict detection: client_updated_at vs server updated_at → returns
  'conflict' with the current server value.
Added uuid + client_updated_at to features/inspections/issues (guarded migration)
and their fillables. Tests: 16 passing (added inspection/issue/feature + conflict).

Note: 2 PRE-EXISTING test failures remain (not from this work, sqlite-only):
ExampleTest expects '/'=200 (app redirects), and the dashboard route uses MySQL
FIELD() which sqlite lacks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:09:34 +02:00
javier b5deb1c53a feat(api): mobile API Milestone 3 — full PULL (delta, tombstones, templates)
ProjectApiController bundle now supports incremental sync:
- ?since=<ISO8601> returns only records changed after that time (phases, layers,
  features, inspections, issues, templates), each filtered by its own updated_at.
- 'deleted' tombstones (soft-deleted ids since 'since') for phases/layers/
  features/inspections/issues so the device can purge locally.
- Bundle now also includes inspections, issues and inspection templates
  (with version + content hash for incremental template download).
- New GET /api/v1/templates (accessible projects, ?since= delta).
Tests: 12 passing (added delta, tombstones, templates cases). Note: the 'since'
query param must be URL-encoded by clients (ISO8601 '+' offset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:45:18 +02:00
javier 17a824f925 feat(api): mobile API Milestone 1+2 — Sanctum auth + offline sync vertical slice
Milestone 1 (auth foundation):
- Installed laravel/sanctum; HasApiTokens on User; published config + migration.
- routes/api.php with /api/v1; Sanctum 'ability' middleware alias registered.
- AuthController: POST login (long-lived revocable device token w/ ability
  mobile-sync + devices table), GET me, POST logout. New Device model/table.

Milestone 2 (vertical slice, offline-first):
- progress_updates: +uuid (client-generated) +client_updated_at.
- ProjectApiController: GET projects (accessibleBy), GET projects/{id}/bundle
  (project/phases/layers/features, membership-authorized).
- SyncController: POST sync — batch ops, idempotent by uuid, per-op result
  (applied/duplicate/error), server-set user_id, authz by permission+membership.
  Currently handles progress_update.create.

Tests: tests/Feature/Api/MobileApiTest (9 passing) — auth, accessible projects,
bundle authz, sync apply+idempotency, permission enforcement.

Also fixed a latent schema bug: projects.reference (and external_reference_1)
existed in the live DB but had no migration — added a guarded migration so fresh
installs match production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:05:20 +02:00
javier ba363e7e18 docs: mobile offline-first sync protocol (Sanctum API tokens)
Approved plan/protocol for connecting a mobile app to the webapp: offline-first
with device outbox, PULL (bundle/delta/versioned templates/tombstones), PUSH
(/api/v1/sync idempotent by client uuid), media via multipart, conflict policy,
schema additions, security, and phased webapp deliverables. Auth decided:
Laravel Sanctum API tokens. No implementation yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:36:35 +02:00
javier 13f36e8ec0 feat(authz): per-route permission gating for /admin (granular admin roles)
Finishes Phase 2: the /admin route group no longer requires 'manage all'
globally. Each route is gated by its specific permission so a non-super-admin
role can be granted partial admin access:
- /admin/users (+show) -> can:view users; create -> can:create users;
  edit -> can:edit users
- /admin/roles, roles/*, permissions -> can:manage roles
- Aligned the role screens' mount checks (RoleForm/RoleView/RolePermissionManager)
  from 'manage all' to 'manage roles'.
- Nav 'Administrator' link now shows on can('view users').
Admins keep full access via Gate::before (manage all). Closure routes
(users/roles lists) are now protected at the route level.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:15:58 +02:00
javier 8025fa6d05 refactor(authz): Phase 2 — replace hasRole('Admin') with permission checks
Permissions now actually govern access instead of the hard-coded Admin role:
- Super-admin bypass (see all projects / full access) -> can('manage all')
  in Project::scopeAccessibleBy, ProjectMap, ProjectDashboard, PhaseGantt,
  LayerManager, ProjectReportController.
- Redundant '|| hasRole(Admin)' fallbacks dropped (Gate::before already lets
  manage-all through can()): LayerManager (upload/delete layers), MediaManager
  (upload), ProjectMap (update progress), ProjectUsers/ProjectCompanies
  (assign users).
- Admin-only screens now gated by the matching permission: AdminUsers/UserView
  -> can('view users'), UserForm -> can('create users')|can('edit users'),
  CompanyView -> can('view companies').
- MediaManager delete: can('delete media') OR owner.
- Kept UserForm's domain guard (can't remove your own Admin role).

Note: the /admin route group still has middleware can:manage all, so admin
screens stay super-admin-only until that group is relaxed per-route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:10:23 +02:00
javier efccb67635 feat(user-view): add Details (Ficha) tab as default with basic info + access validity
New 'Ficha' tab (first, default) on the user view: basic info card
(name/username/email/phone/address/member since) plus the 'Validez de acceso'
card and the Empresa card, moved here from the Permissions tab. The Permissions
tab now focuses on roles + the direct-permissions form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:02:05 +02:00
javier 0120c4bfb8 feat(roles/users): add-user form on role view + per-user direct permissions form
1. Role view (Details tab): a small form to add users to the role (select of
   users not yet in the role + Add) and a per-row remove button. Uses
   assignRole/removeRole.
2. User view (Permissions tab): the same grouped, collapsible permissions form
   with switches — operating on the user's DIRECT permissions
   (givePermissionTo/revokePermissionTo). Permissions inherited from a role show
   as checked+disabled with a 'from role' tag; per-group All/None too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:51:59 +02:00
javier 7f20399337 feat(roles): collapsible permission groups with check/uncheck-all + single-column switches
Permissions tab in the role view:
1. Each section is now a collapsible card (Alpine, chevron rotates).
2. Section header has 'All' / 'None' buttons (setGroup grants/revokes every
   permission of that group for the role; Admin keeps 'manage all').
3. Permissions render in a single column: name+description on the left, control
   on the right.
4. Controls are DaisyUI toggle switches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:44:58 +02:00
javier 433c15a183 feat(permissions): full permission catalogue grouped by section
- Migration: add 'group' and 'description' columns to the permissions table.
- PermissionCatalogSeeder (idempotent updateOrCreate): full catalogue across 11
  sections — Proyectos, Fases y progreso, Capas y elementos, Inspecciones,
  Incidencias, Empresas, Usuarios, Roles, Informes, Archivos, General. Sets
  group + description on existing and creates the new ones; does NOT touch role
  assignments. Registered in DatabaseSeeder.
- RoleView: group permission toggles by the 'group' column in a defined section
  order and show each permission's description.
DB updated locally (migrate + seed run).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:32:16 +02:00
javier 5587026446 feat(roles): Rappasoft list, slim create form, and 2-tab role view
1. Roles list now uses a Rappasoft table (RoleTable): search/sort, per-row
   view/edit/delete, and built-in bulk selection + 'Delete selected'. The
   /admin/roles page is a plain view embedding <livewire:role-table />.
   RoleForm create/edit now only has Name + Description (permissions removed).
2. New RoleView page (/admin/roles/{role}) with two tabs:
   - 'Details': header with role name + Back button; description with Edit/Delete
     buttons; table of users holding the role (avatar+name | last name | status).
   - 'Permissions': all permissions grouped by section (by resource), each with a
     toggle switch to grant/revoke for this role (Admin keeps 'manage all').
Removed the old RoleManager component/view (superseded).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:21:16 +02:00
javier 5092896a1e refactor(roles): role create/edit as a full page instead of a modal
Per feedback, 'New role' (and Edit) now open a dedicated page instead of a
modal:
- New RoleForm full-page component + view at /admin/roles/create and
  /admin/roles/{role}/edit (name, description, permission checkboxes; saves
  and redirects back to the list).
- RoleManager trimmed: the create/edit modal and its logic removed; 'New role'
  and the per-row/view-modal Edit are now links to the new pages.
- Kept the read-only View modal, single + bulk delete, and protections.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:05:01 +02:00
javier 938e704a67 feat(roles): role CRUD screen (list + form name/description + view/edit/delete + bulk)
Per request:
- Migration: add nullable 'description' to the roles table.
- RoleManager Livewire component + view at /admin/roles:
  * Roles list table with per-row checkboxes for bulk selection (+ select-all)
    and a 'Delete selected' bulk action (protected roles skipped).
  * 'New role' opens a modal form with just Name + Description (and permission
    checkboxes to assign).
  * Per-row View / Edit / Delete buttons (View modal shows description,
    counts and assigned permissions).
- Admin role stays protected (no rename/delete/lose 'manage all').
- /admin/users links to the new Roles screen; the phase-1 permission matrix
  stays available via a 'Matrix view' link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:57:59 +02:00
javier 828e70fbe2 feat(permissions): admin role/permission matrix + Gate::before super-admin
Phase 1 (additive, doesn't touch existing checks):
- Gate::before grants everything to holders of 'manage all' (the Admin role),
  robustly (returns true/null, never false; swallows missing-permission).
- New RolePermissionManager Livewire component + view at /admin/permissions:
  editable Roles x Permissions matrix (toggle saves instantly), create/delete
  roles, create/delete permissions. Admin role and 'manage all' are protected.
- Link to the screen from /admin/users header.
Roles are editable from the UI as chosen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:39:28 +02:00
javier da0c8bd134 fix(auth): register Spatie role/permission middleware + add missing #[Layout] (fixes post-login crash)
Login authenticated fine but the landing page crashed (so it looked like
'login doesn't work'):
- bootstrap/app.php didn't register Spatie's middleware aliases -> any route
  with role:/permission: threw 'Target class [role] does not exist'.
  Registered role / permission / role_or_permission.
- config/livewire.php absent -> default layout is the non-existent
  components.layouts.app. ProjectList, PhaseProgress and ReportsDashboard
  lacked #[Layout('layouts.app')] -> MissingLayoutException. Added it (the
  other 10 routed components already had it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:12:20 +02:00
javier 316e0ede39 fix(routes): remove dead /logout route + dead Auth controller imports
web.php referenced App\Http\Controllers\Auth\AuthenticatedSessionController
(and imported 8 other Auth\* controllers) that don't exist — this is a
Breeze+Volt app where auth is handled by Volt pages (routes/auth.php) and
logout by the Volt navigation action (App\Livewire\Actions\Logout).

The broken /logout route made 'php artisan route:list' throw
ReflectionException. Removed the dead route (nothing uses route('logout');
the nav uses wire:click=logout) and the unused Auth controller imports.
Login/register/reset already worked via Volt; logout works via the Volt action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:38:28 +02:00
javier 564b433a62 style: manual UI tweaks (users/companies headers, wider layout, user-view, projects index)
User's manual changes: header slots with New-user/New-company actions, wider
max-w-7xl containers on /admin/users and /companies, plus tweaks to
user-view and projects index views. All views compile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:33:02 +02:00
javier 7df6d208d9 feat(issues): build the rich Issues screen (recover yesterday's draft)
Rebuilt IssueManager to match the 403-line draft view that had no companion
component:
- Modal create/edit form (title, description, priority, status, assignee,
  resolution notes shown when resolved/closed)
- Stats bar (open/in_review/resolved/closed/total) and a styled table
- New methods: openForm/closeForm, resolve, close (+ existing save/delete),
  projectUsers for the assignee dropdown, resolved_at kept in sync with status
- render() now points to livewire.issues.issue-manager; deleted the old
  89-line stub livewire/issue-manager.blade.php
The Issue model already had everything (resolution_notes, resolved_at,
priority_color/status_color accessors, reporter/feature/assignee relations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:16:14 +02:00
javier 860c502f32 chore: remove obsolete duplicate views/components (superseded code)
Deleted (all superseded, recoverable in git history):
- resources/views/projects/edit.blade.php + ProjectController@edit()
- resources/views/projects/create.blade.php + ProjectController@create()
  (projects.create/edit routes point to the Livewire ProjectForm; these
   controller methods were excluded from the resource and never invoked)
- app/Livewire/ProjectEditTabs.php + project-edit-tabs.blade.php
  (old tabbed editor, functionality recovered inside ProjectForm)
- app/Livewire/LayerUpload.php + layer-upload.blade.php (superseded by LayerManager)

Kept resources/views/livewire/issues/issue-manager.blade.php as a reference
for the future rich Issues screen (its companion component was never built).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:11:23 +02:00
javier 8101f22413 refactor: migrate users/companies pages to Rappasoft tables; remove unused welcome view
Point 2 (migrate to Rappasoft tables):
- /admin/users now renders <livewire:user-table /> (+ a New user button)
  instead of the custom admin-users component
- /companies now renders <livewire:company-table /> (+ New company button)
  instead of the hand-rolled card list

Point 3 (delete): removed resources/views/welcome.blade.php (unused — '/'
redirects to dashboard).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:09:15 +02:00
javier fe57388f05 feat(project-form): wire the rich data form (labels-left) + edit tabs
The edit/create project page used a stripped-down inline form. Rewired it
to the existing-but-orphaned pieces:
- Project Data uses the rich partial project-data-form (labels-left/field-right
  layout, sections Identification/Location/Planning, address search + Leaflet
  map with draggable marker + reverse/forward geocoding, country dropdown)
- When editing, tabs added for Phases / Users / Companies (nested Livewire
  components phase-list / project-users / project-companies)
- ProjectForm now provides $countryList (the partial's country dropdown needs it)
- Added the map JS the partial was missing: inits #project-location-map, search
  box, and calls $this->setLocation(lat,lng,address,country) so the wire:model
  fields update

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:42:42 +02:00
javier 75c07aa0d4 fix(project-form): use $project instead of undefined $projectId in blade
project-form.blade.php referenced $projectId, but the ProjectForm component
exposes $project (the model, null when creating). Caused 'Undefined variable
$projectId' on /projects/{id}/edit. Switched both usages to $project.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:20:37 +02:00
javier 558b1732aa feat(project-map): clearer editor tabs + per-phase and per-layer visibility
1. Editor tabs restyled as spaced DaisyUI buttons (btn-primary when active,
   btn-ghost otherwise) — fixes cramped labels and missing active indicator.
2. Layer visibility now works at two levels:
   - Phase toggle calls togglePhase() and shows/hides ALL its layers
     (checked only when every layer of the phase is active)
   - Each layer has its own independent toggle calling toggleLayer()
   Map JS regrouped to build one Leaflet group per LAYER (keyed by layer id)
   instead of per phase, so activeLayers (layer ids) drives visibility
   correctly per layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:14:17 +02:00
javier 19fef5aa25 fix(project-map): raise inspection modal z-index above map panel
The DaisyUI .modal default z-index (999) was below the phases/layers panel
(z-1000) and its reopen button (z-1001), so they showed on top of the modal.
Set the modal to z-[2000].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:02:27 +02:00
javier 238310180f feat(project-map): edit tab redesign, table cleanup, inspection viewer, base layers
1.1 Edit tab: in fullscreen the content splits into 2 columns (Alpine :class)
1.2 Full-width feature title with progress as a number badge on the left;
    removed the progress slider and the 'Save progress' button; Responsible
    now auto-saves on blur (wire:blur)
2.  Features table: zebra/pinned-rows styling, progress badge; removed the
    Responsible and Template columns
3.  Inspections table: same styling; wired the eye button to viewInspection()
    and added the inspection viewer modal (uses existing component state)
6a. Phases/layers collapse button moved into the panel title; a small floating
    button reopens it when collapsed (saves space)
6b. Base-layer switcher on the map: Streets / OpenStreetMap / Satellite (Esri)

Issues tab (point 5) intentionally left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:42:18 +02:00
javier 0fca7387e0 fix(project-map): remove literal @if from HTML comment (Blade ParseError)
The tab-content wrapper comment contained the text '@if', which Blade
compiles even inside HTML comments, causing a ParseError on page load.
Reworded the comment to avoid the directive token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:10:12 +02:00
javier ffd377cd39 fix(project-map): tab content hidden by DaisyUI .tab-content (display:none)
The tab panel wrapper used class 'tab-content', which DaisyUI hides by
default (display:none) unless paired with a checked radio .tab sibling.
Since visibility is driven by Livewire @if($activeTab===...), the class
only kept the content permanently hidden. Replaced with a neutral wrapper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 11:57:12 +02:00
javier 24976e28da fix(project-map): null-safe feature/inspection relations in tab tables
Features and Inspections tab tables read $feature->layer->name and
$inspection->feature->name (and template/user) without null guards. When a
referenced feature/layer was soft-deleted, the relation is null and rendering
threw 'Attempt to read property name on null', returning HTTP 500 on every
Livewire update — which also prevented the tabs from switching (the update
that changes activeTab crashed during re-render).

Made all chained relation reads null-safe with ?-> and '—' fallbacks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 11:52:56 +02:00
javier de68638d7c feat(project-map): rework right panel per feedback
1. Hide the project navigation bar (kept in DOM via 'hidden', not deleted)
2. Move the tabs into the panel header where the 'Map' title was
3. (tabs setActiveTab logic already correct — recompiled)
4. Make the phases/layers panel collapsible via an Alpine toggle button
5. Replace all emoji icons with blade-heroicons (<x-heroicon-o-*>)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 11:44:48 +02:00
javier 3fd4d62df1 feat(project-map): re-integrate Issues tab + project nav on 7d854ff base
Recovers the project-map progress from f8a1310 (project navigation bar +
Issues tab + embedded IssueManager) but applied on top of 7d854ff's
COMPLETE, working component (449 lines: setActiveTab, openIssuesCount,
inspection editor, filters, togglePhase/toggleLayer, IDOR checks).

f8a1310 had added this UI to the blade but simultaneously gutted the
component (down to 347 lines, removing setActiveTab) which broke the tabs.
This commit keeps the good component and adds only the blade UI, so the
tabs, inspection editor and Issues tab all work together.

Verified: all blade templates compile, routes (gantt/report/issues/
dashboard) exist, IssueManager::mount(Project) matches the passed param.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 11:25:01 +02:00
javier 25f61cdb7d fix: close inline @if in company-management blade (bare 'endif' -> '@endif')
Pre-existing bug in 7d854ff: the company type CSS-class @if block at
line 258 was closed with a bare 'endif' (no @), causing a ParseError on
GET /companies (unexpected 'endforeach').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 11:09:18 +02:00
javier 6e66f707d5 restore: roll back to 7d854ff (stable pre-security state)
Full restore of the 7d854ff snapshot (2026-06-16 18:05, before the security
review). Forward commit, no history rewrite — f8a1310 and all later commits
remain recoverable in history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:56:25 +02:00
javier 941dbd5997 restore: bring back f8a1310 (security review) state
Restores all files to the f8a1310 security-review snapshot as requested,
plus the 2 boot-critical fixes from a24c8a2 (config/session.php env()
instead of app()->environment(), and removal of the duplicate $activeTab
in ProjectMap.php) so the application actually boots.

Forward commit, no history rewrite. The 7d854ff state remains in history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:36:44 +02:00
javier c44958ac16 revert: roll back to 7d854ff (pre-security-review state)
Restores all 27 files changed by the security commit (f8a1310) and later
work back to their 7d854ff state (2026-06-16 18:05), as requested. The
security rewrite regressed map functionality (tabs, inspection editor,
collapsing layers panel) without adding protections the 7d854ff version
did not already have (XSS escaping + IDOR checks were already present).

Done as a forward commit (no history rewrite / force-push) so f8a1310,
a24c8a2 and the merge remain in history and are fully recoverable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:23:29 +02:00
javier ee3086c34b Merge branch 'main' of https://homehud.duckdns.org/javier/construprogress 2026-06-17 09:39:27 +02:00
javier a24c8a2c2e fix: restore Rappasoft tables + fix boot errors from security commit
- Restore UserTable/CompanyTable/ProjectTable usage in users, companies and projects-list pages (security commit had replaced them with plain HTML/DaisyUI tables, losing sorting/search/pagination/format)
- Add missing User->company() belongsTo relationship (UserTable eager loads it; column + migration existed but relation was undefined)
- Add #[Layout] attribute to CompanyManagement/ProjectList/PhaseProgress full-page Livewire components
- Fix config/session.php: use env() instead of app()->environment() which fails during LoadConfiguration (env binding not yet registered)
- Remove duplicate activeTab property in ProjectMap (fatal PHP error)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:32:36 +02:00
javier f8a1310c0f security: fix 27 vulnerabilities + UI integration (Issues tab, project nav, validation)
Security fixes (27 vulnerabilities across 20 files):
CRITICAL:
- MediaManager: whitelist mediable types prevents RCE via class instantiation
- MediaManager/OfflineSyncController: IDOR fixes, remove Auth::id()??1 fallback
- ClientProjects: verify project ownership on all mutations (IDOR)
- CompanyManagement: Admin role check on mount() and mutations (auth bypass)
- ProjectMap: scope feature/template lookups to current project (IDOR x5)
- PhaseList/TemplateManager/LayerManager: scope mutations to owned resources (IDOR)
- ProjectEditTabs: Gate::authorize on mount() and updateProject()
- routes/web.php: reports routes moved inside can:manage all middleware (auth bypass)

MEDIUM:
- layer-manager: escapeHtml() on Leaflet popup interpolations (XSS)
- MediaManager: server-side MIME validation + 50MB limit
- ProjectList/ProjectUsers/ProjectCompanies/PhaseProgress: auth checks added
- AdminUsers/ReportsDashboard/ExportController: role/permission checks added

LOW:
- config/session.php: secure cookie tied to production env
- OfflineSyncController: sanitize storage path (path traversal)

UI integration:
- project-map: Issues tab (4th) with open-count badge
- project-map: project navigation bar (Dashboard/Map/Gantt/Report/Issues)
- project-dashboard: action buttons for Map/Gantt/Report/Issues
- project-form: validation error summary + per-field @error spans
- template-manager: validation error display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 18:25:36 +02:00
javier 7d854ffb0a feat: i18n, language switcher fix, DataTable improvements, blade translations
- Translation system: lang/es/ PHP files (auth, validation, pagination, passwords)
- Rappasoft vendor translations published (lang/vendor/livewire-tables/es/)
- JSON files synced to 391 keys (EN + ES, full parity)
- APP_LOCALE changed to 'es', users.locale column default changed to 'es'
- Language switcher fixed: JS event + window.location.reload() avoids /livewire/update redirect
- SetLocale middleware fallback uses config('app.locale') instead of hardcoded 'en'
- setSortingPillsEnabled(false) on ProjectTable, CompanyTable, UserTable
- Translated 17 blade views: project-map, template-manager, layer-manager,
  company-management, phase-list, media-manager, reports-dashboard,
  client-projects, layer-upload, project-form, project-map-editor-tab,
  admin/users, projects/media, projects/templates, layouts/client
- Navigation 'Empresas' link uses __('Companies')
- Fixed typo key 'Fases and layers' -> 'Phases and layers'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 18:05:53 +02:00
174 changed files with 14634 additions and 2725 deletions
+1
View File
@@ -22,3 +22,4 @@
Homestead.json
Homestead.yaml
Thumbs.db
.claude/worktrees/
@@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use App\Models\IssueTask;
use App\Notifications\IssueTaskOverdueNotification;
use Illuminate\Console\Command;
class NotifyOverdueIssueTasks extends Command
{
protected $signature = 'issues:notify-overdue';
protected $description = 'Notify assignees of issue tasks that are overdue (one notification per task)';
public function handle(): int
{
$tasks = IssueTask::overdue()
->whereNull('overdue_notified_at')
->whereNotNull('assigned_to')
->with(['assignee', 'issue'])
->get();
$sent = 0;
foreach ($tasks as $task) {
if ($task->assignee) {
$task->assignee->notify(new IssueTaskOverdueNotification($task));
$sent++;
}
$task->forceFill(['overdue_notified_at' => now()])->save();
}
$this->info("Tareas vencidas notificadas: {$sent}");
return self::SUCCESS;
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Device;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
/**
* Issue a long-lived, revocable device token (Sanctum).
*/
public function login(Request $request)
{
$data = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'device_name' => ['required', 'string', 'max:255'],
'app_version' => ['nullable', 'string', 'max:50'],
]);
$user = User::where('email', $data['email'])->first();
if (! $user || ! Hash::check($data['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => [__('auth.failed')],
]);
}
// One token per device name: revoke the previous one for this device.
$user->tokens()->where('name', $data['device_name'])->delete();
$token = $user->createToken($data['device_name'], ['mobile-sync']);
Device::updateOrCreate(
['user_id' => $user->id, 'name' => $data['device_name']],
[
'token_id' => $token->accessToken->id,
'app_version' => $data['app_version'] ?? null,
'last_seen_at' => now(),
]
);
return response()->json([
'token' => $token->plainTextToken,
'user' => $this->userPayload($user),
]);
}
public function me(Request $request)
{
return response()->json(['user' => $this->userPayload($request->user())]);
}
public function logout(Request $request)
{
$token = $request->user()->currentAccessToken();
// Clean up the device record bound to this token.
Device::where('token_id', $token->id)->delete();
$token->delete();
return response()->json(['message' => 'Logged out']);
}
private function userPayload(User $user): array
{
return [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'roles' => $user->getRoleNames(),
'permissions' => $user->getAllPermissions()->pluck('name')->values(),
];
}
}
@@ -0,0 +1,112 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Feature;
use App\Models\Issue;
use App\Models\IssueComment;
use App\Models\IssueTask;
use App\Models\Layer;
use App\Models\Media;
use App\Models\Phase;
use App\Models\Project;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Support\Str;
class MediaController extends Controller
{
private array $map = [
'feature' => Feature::class,
'issue' => Issue::class,
'issue_task' => IssueTask::class,
'issue_comment' => IssueComment::class,
'project' => Project::class,
'phase' => Phase::class,
'layer' => Layer::class,
];
/** Upload a file (multipart) and attach it to a parent record. Idempotent by uuid. */
public function upload(Request $request)
{
$data = $request->validate([
'uuid' => ['required', 'uuid'],
'parent_entity' => ['required', Rule::in(array_keys($this->map))],
'parent_id' => ['required', 'integer'],
'file' => ['required', 'file', 'max:20480'], // 20 MB
'category' => ['nullable', 'in:image,document,other'],
'description' => ['nullable', 'string'],
]);
// Idempotency: same uuid already uploaded → return it.
if ($existing = Media::where('uuid', $data['uuid'])->first()) {
return response()->json(['status' => 'duplicate', 'media' => $this->payload($existing)]);
}
$parent = $this->map[$data['parent_entity']]::find($data['parent_id']);
if (! $parent) {
return response()->json(['status' => 'error', 'error' => 'parent not found'], 422);
}
$user = $request->user();
$project = $this->projectOf($data['parent_entity'], $parent);
abort_unless($this->canAccess($user, $project) && $user->can('upload media'), 403);
$file = $request->file('file');
$path = $file->store("media/{$data['parent_entity']}/{$parent->id}", 'public');
$mime = $file->getClientMimeType();
$media = $parent->media()->create([
'uuid' => $data['uuid'],
'name' => $file->getClientOriginalName(),
'file_path' => $path,
'file_type' => $mime,
'file_extension' => $file->getClientOriginalExtension(),
'file_size' => $file->getSize(),
'category' => $data['category'] ?? (Str::startsWith($mime, 'image/') ? 'image' : 'document'),
'description' => $data['description'] ?? null,
'uploaded_by' => $user->id,
'client_updated_at' => $request->input('client_updated_at'),
]);
return response()->json(['status' => 'applied', 'media' => $this->payload($media)]);
}
private function projectOf(string $entity, $parent): ?Project
{
return match ($entity) {
'project' => $parent,
'phase' => $parent->project,
'layer' => $parent->phase?->project,
'feature' => $parent->layer?->phase?->project,
'issue' => $parent->project,
'issue_task' => $parent->issue?->project,
'issue_comment' => $parent->issue?->project,
default => null,
};
}
private function canAccess(User $user, ?Project $project): bool
{
if (! $project) {
return false;
}
return $user->can('manage all')
|| $project->users()->where('user_id', $user->id)->exists();
}
private function payload(Media $m): array
{
return [
'id' => $m->id,
'uuid' => $m->uuid,
'url' => $m->url,
'name' => $m->name,
'file_type' => $m->file_type,
'category' => $m->category,
'updated_at' => $m->updated_at?->toIso8601String(),
];
}
}
@@ -0,0 +1,231 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Feature;
use App\Models\Inspection;
use App\Models\InspectionTemplate;
use App\Models\Issue;
use App\Models\IssueComment;
use App\Models\IssueTask;
use App\Models\Layer;
use App\Models\Media;
use App\Models\Phase;
use App\Models\Project;
use Carbon\Carbon;
use Illuminate\Http\Request;
class ProjectApiController extends Controller
{
/** Projects the authenticated user can access. */
public function index(Request $request)
{
$projects = Project::accessibleBy($request->user())
->orderBy('name')
->get(['id', 'reference', 'name', 'address', 'status', 'lat', 'lng', 'updated_at']);
return response()->json(['projects' => $projects]);
}
/**
* Offline bundle for one project. Full snapshot, or a delta when `?since=` is
* given (only records changed after that timestamp + tombstones for deletions).
*/
public function bundle(Request $request, Project $project)
{
$this->authorizeProject($request, $project);
$since = $request->query('since');
$since = $since ? Carbon::parse($since) : null;
$changed = fn ($query) => $since ? $query->where('updated_at', '>', $since) : $query;
$allPhaseIds = Phase::withTrashed()->where('project_id', $project->id)->pluck('id');
$allLayerIds = Layer::withTrashed()->whereIn('phase_id', $allPhaseIds)->pluck('id');
$phases = $changed(Phase::where('project_id', $project->id))->orderBy('order')->get();
$layers = $changed(Layer::whereIn('phase_id', $allPhaseIds))->get();
$features = $changed(Feature::whereIn('layer_id', $allLayerIds))->get();
$inspections = $changed(Inspection::where('project_id', $project->id))->get();
$issues = $changed(Issue::where('project_id', $project->id))->get();
$templates = $changed(InspectionTemplate::where('project_id', $project->id))->get();
$allIssueIds = Issue::withTrashed()->where('project_id', $project->id)->pluck('id');
$issueTasks = $changed(IssueTask::whereIn('issue_id', $allIssueIds))->get();
$issueComments = $changed(IssueComment::whereIn('issue_id', $allIssueIds))->get();
$featureIds = Feature::whereIn('layer_id', $allLayerIds)->pluck('id');
$issueIds = Issue::where('project_id', $project->id)->pluck('id');
$taskIds = IssueTask::whereIn('issue_id', $allIssueIds)->pluck('id');
$commentIds = IssueComment::whereIn('issue_id', $allIssueIds)->pluck('id');
$media = $changed(Media::where(function ($q) use ($project, $featureIds, $issueIds, $taskIds, $commentIds) {
$q->where(fn ($w) => $w->where('mediable_type', Project::class)->where('mediable_id', $project->id))
->orWhere(fn ($w) => $w->where('mediable_type', Feature::class)->whereIn('mediable_id', $featureIds))
->orWhere(fn ($w) => $w->where('mediable_type', Issue::class)->whereIn('mediable_id', $issueIds))
->orWhere(fn ($w) => $w->where('mediable_type', IssueTask::class)->whereIn('mediable_id', $taskIds))
->orWhere(fn ($w) => $w->where('mediable_type', IssueComment::class)->whereIn('mediable_id', $commentIds));
}))->get();
return response()->json([
'server_time' => now()->toIso8601String(),
'project' => $this->mapProject($project),
'phases' => $phases->map(fn ($p) => $this->mapPhase($p))->values(),
'layers' => $layers->map(fn ($l) => $this->mapLayer($l))->values(),
'features' => $features->map(fn ($f) => $this->mapFeature($f))->values(),
'inspections' => $inspections->map(fn ($i) => $this->mapInspection($i))->values(),
'issues' => $issues->map(fn ($i) => $this->mapIssue($i))->values(),
'issue_tasks' => $issueTasks->map(fn ($t) => $this->mapIssueTask($t))->values(),
'issue_comments' => $issueComments->map(fn ($c) => $this->mapIssueComment($c))->values(),
'templates' => $templates->map(fn ($t) => $this->mapTemplate($t))->values(),
'media' => $media->map(fn ($m) => $this->mapMedia($m))->values(),
'deleted' => $since ? $this->tombstones($since, $project, $allPhaseIds, $allLayerIds, $allIssueIds) : (object) [],
]);
}
/** Inspection templates for the projects the user can access (with version/hash). */
public function templates(Request $request)
{
$since = $request->query('since');
$since = $since ? Carbon::parse($since) : null;
$projectIds = Project::accessibleBy($request->user())->pluck('id');
$query = InspectionTemplate::whereIn('project_id', $projectIds);
if ($since) {
$query->where('updated_at', '>', $since);
}
return response()->json([
'templates' => $query->get()->map(fn ($t) => $this->mapTemplate($t))->values(),
]);
}
// ── Helpers ────────────────────────────────────────────────────────────────
private function authorizeProject(Request $request, Project $project): void
{
$user = $request->user();
abort_unless(
$user->can('manage all') || $project->users()->where('user_id', $user->id)->exists(),
403
);
}
private function tombstones(Carbon $since, Project $project, $allPhaseIds, $allLayerIds, $allIssueIds): array
{
return [
'phases' => Phase::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(),
'layers' => Layer::onlyTrashed()->whereIn('phase_id', $allPhaseIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
'features' => Feature::onlyTrashed()->whereIn('layer_id', $allLayerIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
'inspections' => Inspection::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(),
'issues' => Issue::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(),
'issue_tasks' => IssueTask::onlyTrashed()->whereIn('issue_id', $allIssueIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
'issue_comments' => IssueComment::onlyTrashed()->whereIn('issue_id', $allIssueIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
];
}
private function mapProject(Project $p): array
{
return [
'id' => $p->id, 'reference' => $p->reference, 'name' => $p->name,
'address' => $p->address, 'lat' => $p->lat, 'lng' => $p->lng,
'status' => $p->status, 'updated_at' => $p->updated_at?->toIso8601String(),
];
}
private function mapPhase(Phase $p): array
{
return [
'id' => $p->id, 'name' => $p->name, 'order' => $p->order, 'color' => $p->color,
'progress_percent' => $p->progress_percent, 'updated_at' => $p->updated_at?->toIso8601String(),
];
}
private function mapLayer(Layer $l): array
{
return [
'id' => $l->id, 'phase_id' => $l->phase_id, 'name' => $l->name,
'color' => $l->color, 'updated_at' => $l->updated_at?->toIso8601String(),
];
}
private function mapFeature(Feature $f): array
{
return [
'id' => $f->id, 'layer_id' => $f->layer_id, 'name' => $f->name,
'geometry' => $f->geometry, 'status' => $f->status, 'progress' => $f->progress,
'responsible' => $f->responsible, 'template_id' => $f->template_id,
'updated_at' => $f->updated_at?->toIso8601String(),
];
}
private function mapInspection(Inspection $i): array
{
return [
'id' => $i->id, 'feature_id' => $i->feature_id, 'layer_id' => $i->layer_id,
'template_id' => $i->template_id, 'user_id' => $i->user_id, 'data' => $i->data,
'status' => $i->status, 'result' => $i->result, 'notes' => $i->notes,
'created_at' => $i->created_at?->toIso8601String(), 'updated_at' => $i->updated_at?->toIso8601String(),
];
}
private function mapIssue(Issue $i): array
{
return [
'id' => $i->id, 'feature_id' => $i->feature_id, 'title' => $i->title,
'description' => $i->description, 'status' => $i->status, 'priority' => $i->priority,
'type' => $i->type,
'reported_by' => $i->reported_by, 'assigned_to' => $i->assigned_to,
'resolved_at' => $i->resolved_at?->toIso8601String(), 'updated_at' => $i->updated_at?->toIso8601String(),
];
}
private function mapIssueTask(IssueTask $t): array
{
return [
'id' => $t->id, 'issue_id' => $t->issue_id, 'title' => $t->title,
'is_done' => $t->is_done, 'done_at' => $t->done_at?->toIso8601String(), 'done_by' => $t->done_by,
'assigned_to' => $t->assigned_to, 'due_date' => $t->due_date?->toDateString(),
'order' => $t->order, 'updated_at' => $t->updated_at?->toIso8601String(),
];
}
private function mapIssueComment(IssueComment $c): array
{
return [
'id' => $c->id, 'issue_id' => $c->issue_id, 'user_id' => $c->user_id, 'body' => $c->body,
'created_at' => $c->created_at?->toIso8601String(), 'updated_at' => $c->updated_at?->toIso8601String(),
];
}
private function mapTemplate(InspectionTemplate $t): array
{
return [
'id' => $t->id, 'project_id' => $t->project_id, 'phase_id' => $t->phase_id,
'name' => $t->name, 'description' => $t->description, 'fields' => $t->fields,
'version' => $t->updated_at?->timestamp,
'hash' => md5(json_encode($t->fields) . $t->name),
'updated_at' => $t->updated_at?->toIso8601String(),
];
}
private function mapMedia(Media $m): array
{
$entity = [
Project::class => 'project',
Feature::class => 'feature',
Issue::class => 'issue',
IssueTask::class => 'issue_task',
IssueComment::class => 'issue_comment',
][$m->mediable_type] ?? class_basename($m->mediable_type);
return [
'id' => $m->id, 'uuid' => $m->uuid,
'parent_entity' => $entity, 'parent_id' => $m->mediable_id,
'url' => $m->url, 'name' => $m->name, 'file_type' => $m->file_type,
'category' => $m->category, 'updated_at' => $m->updated_at?->toIso8601String(),
];
}
}
@@ -0,0 +1,449 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Feature;
use App\Models\Inspection;
use App\Models\Issue;
use App\Models\IssueComment;
use App\Models\IssueTask;
use App\Models\Phase;
use App\Models\ProgressUpdate;
use App\Models\Project;
use App\Models\SyncLog;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class SyncController extends Controller
{
/**
* Push a batch of offline mutations. Returns a per-operation result
* (applied | duplicate | conflict | error). Never aborts the whole batch.
*
* Identity:
* - create ops the new record stores the client-generated `uuid` (idempotent).
* - update ops target identified by `data.id` (server id); last-write-wins.
*/
public function sync(Request $request)
{
$data = $request->validate([
'operations' => ['required', 'array'],
'operations.*.entity' => ['required', 'string'],
'operations.*.op' => ['required', 'string'],
'operations.*.uuid' => ['required', 'uuid'],
'operations.*.data' => ['required', 'array'],
'operations.*.client_updated_at' => ['nullable', 'date'],
]);
$user = $request->user();
$results = [];
foreach ($data['operations'] as $op) {
$results[] = $this->handle($user, $op);
}
return response()->json(['results' => $results]);
}
private function handle(User $user, array $op): array
{
$uuid = $op['uuid'];
// Op-level idempotency: if this operation was already applied, replay its result.
$prior = SyncLog::where('op_uuid', $uuid)
->where('entity', $op['entity'])->where('op', $op['op'])->first();
if ($prior) {
return ['uuid' => $uuid, 'status' => 'duplicate', 'server_id' => $prior->server_id];
}
try {
$result = match ($op['entity'] . '.' . $op['op']) {
'progress_update.create' => $this->progressUpdateCreate($user, $uuid, $op),
'inspection.create' => $this->inspectionCreate($user, $uuid, $op),
'issue.create' => $this->issueCreate($user, $uuid, $op),
'issue.update' => $this->issueUpdate($user, $uuid, $op),
'issue_task.create' => $this->issueTaskCreate($user, $uuid, $op),
'issue_task.update' => $this->issueTaskUpdate($user, $uuid, $op),
'issue_comment.create' => $this->issueCommentCreate($user, $uuid, $op),
'feature.update' => $this->featureUpdate($user, $uuid, $op),
default => $this->error($uuid, 'unsupported entity/op: ' . $op['entity'] . '.' . $op['op']),
};
} catch (\Throwable $e) {
$result = $this->error($uuid, $e->getMessage());
}
// Record only terminal successes so conflicts/errors can be safely retried.
if ($result['status'] === 'applied') {
SyncLog::create([
'user_id' => $user->id,
'op_uuid' => $uuid,
'entity' => $op['entity'],
'op' => $op['op'],
'status' => 'applied',
'server_id' => $result['server_id'] ?? null,
]);
}
return $result;
}
// ── progress_update.create ───────────────────────────────────────────────────
private function progressUpdateCreate(User $user, string $uuid, array $op): array
{
if ($existing = ProgressUpdate::where('uuid', $uuid)->first()) {
return $this->duplicate($uuid, $existing->id);
}
$v = Validator::make($op['data'], [
'phase_id' => ['required', 'integer', 'exists:phases,id'],
'progress' => ['required', 'integer', 'min:0', 'max:100'],
'comment' => ['nullable', 'string'],
'location' => ['nullable', 'array'],
]);
if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first());
}
$d = $v->validated();
$phase = Phase::with('project')->findOrFail($d['phase_id']);
if (! $this->canAccess($user, $phase->project) || ! $user->can('update progress')) {
return $this->error($uuid, 'forbidden');
}
$pu = ProgressUpdate::create([
'uuid' => $uuid,
'phase_id' => $phase->id,
'user_id' => $user->id,
'progress_percent' => $d['progress'],
'comment' => $d['comment'] ?? null,
'location' => $d['location'] ?? null,
'client_updated_at' => $op['client_updated_at'] ?? null,
]);
$phase->progress_percent = $d['progress'];
$phase->save();
return $this->applied($uuid, $pu->id);
}
// ── inspection.create ──────────────────────────────────────────────────────────
private function inspectionCreate(User $user, string $uuid, array $op): array
{
if ($existing = Inspection::where('uuid', $uuid)->first()) {
return $this->duplicate($uuid, $existing->id);
}
$v = Validator::make($op['data'], [
'feature_id' => ['required', 'integer', 'exists:features,id'],
'template_id' => ['nullable', 'integer', 'exists:inspection_templates,id'],
'data' => ['nullable', 'array'],
'status' => ['nullable', 'string'],
'result' => ['nullable', 'string'],
'notes' => ['nullable', 'string'],
]);
if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first());
}
$d = $v->validated();
$feature = Feature::with('layer.phase.project')->findOrFail($d['feature_id']);
$project = $feature->layer?->phase?->project;
if (! $this->canAccess($user, $project) || ! $user->can('create inspections')) {
return $this->error($uuid, 'forbidden');
}
$inspection = Inspection::create([
'uuid' => $uuid,
'project_id' => $project->id,
'layer_id' => $feature->layer_id,
'feature_id' => $feature->id,
'template_id' => $d['template_id'] ?? null,
'user_id' => $user->id,
'data' => $d['data'] ?? [],
'status' => $d['status'] ?? 'completed',
'result' => $d['result'] ?? null,
'notes' => $d['notes'] ?? null,
'client_updated_at' => $op['client_updated_at'] ?? null,
]);
return $this->applied($uuid, $inspection->id);
}
// ── issue.create / issue.update ──────────────────────────────────────────────────
private function issueCreate(User $user, string $uuid, array $op): array
{
if ($existing = Issue::where('uuid', $uuid)->first()) {
return $this->duplicate($uuid, $existing->id);
}
$v = Validator::make($op['data'], [
'project_id' => ['required', 'integer', 'exists:projects,id'],
'feature_id' => ['nullable', 'integer', 'exists:features,id'],
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'priority' => ['nullable', 'in:' . implode(',', Issue::PRIORITIES)],
'status' => ['nullable', 'in:' . implode(',', Issue::STATUSES)],
'type' => ['nullable', 'in:' . implode(',', Issue::TYPES)],
]);
if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first());
}
$d = $v->validated();
$project = Project::find($d['project_id']);
if (! $this->canAccess($user, $project) || ! $user->can('create issues')) {
return $this->error($uuid, 'forbidden');
}
$issue = Issue::create([
'uuid' => $uuid,
'project_id' => $project->id,
'feature_id' => $d['feature_id'] ?? null,
'title' => $d['title'],
'description' => $d['description'] ?? null,
'priority' => $d['priority'] ?? 'medium',
'status' => $d['status'] ?? 'open',
'type' => $d['type'] ?? 'other',
'reported_by' => $user->id,
'client_updated_at' => $op['client_updated_at'] ?? null,
]);
return $this->applied($uuid, $issue->id);
}
private function issueUpdate(User $user, string $uuid, array $op): array
{
$v = Validator::make($op['data'], [
'id' => ['required', 'integer', 'exists:issues,id'],
'title' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'priority' => ['nullable', 'in:' . implode(',', Issue::PRIORITIES)],
'status' => ['nullable', 'in:' . implode(',', Issue::STATUSES)],
'type' => ['nullable', 'in:' . implode(',', Issue::TYPES)],
'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
'resolution_notes' => ['nullable', 'string'],
]);
if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first());
}
$d = $v->validated();
$issue = Issue::with('project')->findOrFail($d['id']);
if (! $this->canAccess($user, $issue->project) || ! $user->can('edit issues')) {
return $this->error($uuid, 'forbidden');
}
if ($conflict = $this->conflict($uuid, $issue, $op)) {
return $conflict;
}
$issue->fill(collect($d)->except('id')->toArray());
if (in_array($issue->status, ['resolved', 'closed'], true) && ! $issue->resolved_at) {
$issue->resolved_at = now();
}
$issue->client_updated_at = $op['client_updated_at'] ?? null;
$issue->save();
return $this->applied($uuid, $issue->id);
}
// ── issue_task.create / issue_task.update ──────────────────────────────────────
private function issueTaskCreate(User $user, string $uuid, array $op): array
{
if ($existing = IssueTask::where('uuid', $uuid)->first()) {
return $this->duplicate($uuid, $existing->id);
}
$v = Validator::make($op['data'], [
'issue_id' => ['required', 'integer', 'exists:issues,id'],
'title' => ['required', 'string', 'max:255'],
'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
'due_date' => ['nullable', 'date'],
'is_done' => ['nullable', 'boolean'],
]);
if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first());
}
$d = $v->validated();
$issue = Issue::with('project')->findOrFail($d['issue_id']);
if (! $this->canAccess($user, $issue->project) || ! $user->can('edit issues')) {
return $this->error($uuid, 'forbidden');
}
$done = $d['is_done'] ?? false;
$task = IssueTask::create([
'uuid' => $uuid,
'issue_id' => $issue->id,
'title' => $d['title'],
'assigned_to' => $d['assigned_to'] ?? null,
'due_date' => $d['due_date'] ?? null,
'is_done' => $done,
'done_at' => $done ? now() : null,
'done_by' => $done ? $user->id : null,
'order' => ((int) $issue->tasks()->max('order')) + 1,
'client_updated_at' => $op['client_updated_at'] ?? null,
]);
return $this->applied($uuid, $task->id);
}
private function issueTaskUpdate(User $user, string $uuid, array $op): array
{
$v = Validator::make($op['data'], [
'id' => ['required', 'integer', 'exists:issue_tasks,id'],
'title' => ['nullable', 'string', 'max:255'],
'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
'due_date' => ['nullable', 'date'],
'is_done' => ['nullable', 'boolean'],
]);
if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first());
}
$d = $v->validated();
$task = IssueTask::with('issue.project')->findOrFail($d['id']);
if (! $this->canAccess($user, $task->issue?->project) || ! $user->can('edit issues')) {
return $this->error($uuid, 'forbidden');
}
if ($conflict = $this->conflict($uuid, $task, $op)) {
return $conflict;
}
if (array_key_exists('is_done', $d)) {
$task->is_done = (bool) $d['is_done'];
$task->done_at = $d['is_done'] ? ($task->done_at ?? now()) : null;
$task->done_by = $d['is_done'] ? ($task->done_by ?? $user->id) : null;
}
$task->fill(collect($d)->only('title', 'assigned_to', 'due_date')->toArray());
$task->client_updated_at = $op['client_updated_at'] ?? null;
$task->save();
return $this->applied($uuid, $task->id);
}
// ── issue_comment.create ────────────────────────────────────────────────────────
private function issueCommentCreate(User $user, string $uuid, array $op): array
{
if ($existing = IssueComment::where('uuid', $uuid)->first()) {
return $this->duplicate($uuid, $existing->id);
}
$v = Validator::make($op['data'], [
'issue_id' => ['required', 'integer', 'exists:issues,id'],
'body' => ['required', 'string', 'max:5000'],
]);
if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first());
}
$d = $v->validated();
$issue = Issue::with('project')->findOrFail($d['issue_id']);
if (! $this->canAccess($user, $issue->project) || ! $user->can('view issues')) {
return $this->error($uuid, 'forbidden');
}
$comment = IssueComment::create([
'uuid' => $uuid,
'issue_id' => $issue->id,
'user_id' => $user->id,
'body' => $d['body'],
'client_updated_at' => $op['client_updated_at'] ?? null,
]);
return $this->applied($uuid, $comment->id);
}
// ── feature.update ────────────────────────────────────────────────────────────
private function featureUpdate(User $user, string $uuid, array $op): array
{
$v = Validator::make($op['data'], [
'id' => ['required', 'integer', 'exists:features,id'],
'status' => ['nullable', 'string'],
'progress' => ['nullable', 'integer', 'min:0', 'max:100'],
'responsible' => ['nullable', 'string'],
]);
if ($v->fails()) {
return $this->error($uuid, 'validation: ' . $v->errors()->first());
}
$d = $v->validated();
$feature = Feature::with('layer.phase.project')->findOrFail($d['id']);
$project = $feature->layer?->phase?->project;
if (! $this->canAccess($user, $project) || ! $user->can('update progress')) {
return $this->error($uuid, 'forbidden');
}
if ($conflict = $this->conflict($uuid, $feature, $op)) {
return $conflict;
}
$feature->fill(collect($d)->except('id')->toArray());
$feature->client_updated_at = $op['client_updated_at'] ?? null;
$feature->save();
// Mirror web behaviour: recompute the phase progress from its features.
if ($phase = $feature->layer?->phase) {
$phase->progress_percent = (int) round($phase->features()->avg('progress') ?: 0);
$phase->save();
}
return $this->applied($uuid, $feature->id);
}
// ── Helpers ──────────────────────────────────────────────────────────────────────
private function canAccess(User $user, ?Project $project): bool
{
if (! $project) {
return false;
}
return $user->can('manage all')
|| $project->users()->where('user_id', $user->id)->exists();
}
/**
* Last-write-wins conflict detection: if the server row was updated after the
* client's edit, reject with the current server value.
*/
private function conflict(string $uuid, $model, array $op): ?array
{
if (empty($op['client_updated_at'])) {
return null;
}
$clientAt = Carbon::parse($op['client_updated_at']);
if ($model->updated_at && $model->updated_at->gt($clientAt)) {
return [
'uuid' => $uuid,
'status' => 'conflict',
'server' => $model->fresh()->toArray(),
];
}
return null;
}
private function applied(string $uuid, int $id): array
{
return ['uuid' => $uuid, 'status' => 'applied', 'server_id' => $id];
}
private function duplicate(string $uuid, int $id): array
{
return ['uuid' => $uuid, 'status' => 'duplicate', 'server_id' => $id];
}
private function error(string $uuid, string $message): array
{
return ['uuid' => $uuid, 'status' => 'error', 'error' => $message];
}
}
@@ -19,15 +19,6 @@ class ProjectController extends Controller
return view('projects.index');
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
Gate::authorize('create projects');
return view('projects.create');
}
/**
* Store a newly created resource in storage.
*/
@@ -58,15 +49,6 @@ class ProjectController extends Controller
return redirect()->route('projects.map', $project);
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Project $project) // <--- ROUTE MODEL BINDING
{
Gate::authorize('edit projects', $project);
return view('projects.edit', compact('project'));
}
/**
* Update the specified resource in storage.
*/
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers;
use App\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ProjectReportController extends Controller
{
public function show(Project $project)
{
$user = Auth::user();
if (!$user->can('manage all') && !$project->users()->where('user_id', $user->id)->exists()) {
abort(403);
}
$phases = $project->phases()
->with(['layers.features.inspections', 'layers.features.issues'])
->orderBy('order')
->get();
$stats = [
'total_features' => $phases->flatMap(fn($p) => $p->layers)->flatMap(fn($l) => $l->features)->count(),
'completed_features' => $phases->flatMap(fn($p) => $p->layers)->flatMap(fn($l) => $l->features)->where('status', 'completed')->count(),
'total_inspections' => \App\Models\Inspection::where('project_id', $project->id)->count(),
'open_issues' => \App\Models\Issue::where('project_id', $project->id)->where('status', 'open')->count(),
'avg_progress' => round($phases->avg('progress_percent') ?? 0),
];
$pdf_data = compact('project', 'phases', 'stats');
// Use Blade to render HTML, then return as "print" view
// (barryvdh/laravel-dompdf is not installed, so we render a printable HTML page)
return view('reports.project-report', $pdf_data);
}
}
+2 -2
View File
@@ -41,9 +41,9 @@ class SetLocale
}
}
// 4. Default to English
// 4. Default to app locale
if (!$locale) {
$locale = 'en';
$locale = config('app.locale', 'es');
}
App::setLocale($locale);
+18 -24
View File
@@ -9,44 +9,38 @@ use Illuminate\Support\Facades\Auth;
class AdminUsers extends Component
{
public $users;
public string $search = '';
public $roles;
public function mount()
public function mount(): void
{
if (!Auth::user()->hasRole('Admin')) {
abort(403);
}
$this->roles = Role::all();
$this->loadUsers();
abort_unless(Auth::user()->can('view users'), 403);
$this->roles = Role::orderBy('name')->get();
}
public function loadUsers()
public function getUsersProperty()
{
$this->users = User::with('roles')->orderBy('name')->get();
return User::with('roles')
->when($this->search, fn($q) =>
$q->where(fn($q2) => $q2
->where('name', 'like', '%' . $this->search . '%')
->orWhere('email', 'like', '%' . $this->search . '%')))
->orderBy('name')
->get();
}
public function updateRole($userId, $roleName)
public function deleteUser(int $userId): void
{
$user = Auth::user();
if (!$user->hasRole('Admin')) {
session()->flash('error', 'Solo administradores.');
if ($userId === Auth::id()) {
$this->dispatch('notify', 'No puedes eliminarte a ti mismo.');
return;
}
$targetUser = User::findOrFail($userId);
if ($targetUser->id === $user->id && $targetUser->hasRole('Admin')) {
session()->flash('error', 'No puedes cambiarte el rol a ti mismo.');
return;
}
$targetUser->syncRoles([$roleName]);
$this->loadUsers();
$this->dispatch('notify', 'Rol actualizado.');
User::findOrFail($userId)->delete();
$this->dispatch('notify', 'Usuario eliminado.');
}
public function render()
{
return view('livewire.admin-users');
}
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\Attributes\Layout;
use App\Models\Company;
use Illuminate\Support\Facades\Storage;
#[Layout('layouts.app')]
class CompanyForm extends Component
{
use WithFileUploads;
public ?Company $company = null;
// Form fields
public string $name = '';
public string $apodo = '';
public string $tax_id = '';
public string $estado = 'activo';
public string $type = 'other';
public string $address = '';
public string $phone = '';
public string $email = '';
public string $website = '';
public string $notes = '';
public $logo = null;
public function mount(?Company $company = null): void
{
if ($company && $company->exists) {
$this->company = $company;
$this->name = $company->name;
$this->apodo = $company->apodo ?? '';
$this->tax_id = $company->tax_id ?? '';
$this->estado = $company->estado ?? 'activo';
$this->type = $company->type ?? 'other';
$this->address = $company->address ?? '';
$this->phone = $company->phone ?? '';
$this->email = $company->email ?? '';
$this->website = $company->website ?? '';
$this->notes = $company->notes ?? '';
}
}
protected function rules(): array
{
$id = $this->company?->id ?? 'NULL';
return [
'name' => 'required|string|max:255',
'apodo' => 'nullable|string|max:100',
'tax_id' => "nullable|string|max:50|unique:companies,tax_id,{$id}",
'estado' => 'required|in:activo,inactivo,suspendido',
'type' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other',
'address' => 'nullable|string',
'phone' => 'nullable|string|max:30',
'email' => 'nullable|email|max:255',
'website' => 'nullable|url|max:255',
'notes' => 'nullable|string',
'logo' => 'nullable|image|max:2048',
];
}
public function save(): void
{
$this->validate();
$data = [
'name' => $this->name,
'apodo' => $this->apodo ?: null,
'tax_id' => $this->tax_id ?: null,
'estado' => $this->estado,
'type' => $this->type,
'address' => $this->address ?: null,
'phone' => $this->phone ?: null,
'email' => $this->email ?: null,
'website' => $this->website ?: null,
'notes' => $this->notes ?: null,
];
if ($this->logo) {
// Delete old logo when replacing
if ($this->company?->logo_path) {
Storage::disk('public')->delete($this->company->logo_path);
}
$data['logo_path'] = $this->logo->store('company-logos', 'public');
}
if ($this->company && $this->company->exists) {
$this->company->update($data);
session()->flash('notify', 'Empresa actualizada correctamente.');
} else {
Company::create($data);
session()->flash('notify', 'Empresa creada correctamente.');
}
$this->redirect(route('companies.manage'), navigate: true);
}
public function render()
{
return view('livewire.company-form');
}
}
+41 -210
View File
@@ -3,234 +3,65 @@
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\Attributes\Layout;
use App\Models\Company;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Response;
#[Layout('layouts.app')]
class CompanyManagement extends Component
{
use WithFileUploads;
// Form state
public $name = '';
public $tax_id = '';
public $address = '';
public $email = '';
public $website = '';
public $type = 'other';
public $notes = '';
public $apodo = '';
public $estado = 'activo';
public $logo = null;
// UI state
public $showCreateForm = false;
public $showEditForm = false;
public $editingCompanyId = null;
public $search = '';
// Filter state
public $filterType = '';
public $filterEstado = '';
// Validation rules
protected $rules = [
'name' => 'required|string|max:255',
'apodo' => 'nullable|string|max:100',
'tax_id' => 'nullable|string|max:50|unique:companies,tax_id,',
'estado' => 'required|in:activo,inactivo,suspendido',
'address' => 'nullable|string',
'phone' => 'nullable|string|max:20',
'email' => 'nullable|email|max:255',
'website' => 'nullable|url|max:255',
'type' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other',
'notes' => 'nullable|string',
'logo' => 'nullable|image|max:2048', // 2MB max
];
public function mount()
{
$this->resetForm();
}
public function resetForm()
{
$this->name = '';
$this->tax_id = '';
$this->address = '';
$this->phone = '';
$this->email = '';
$this->website = '';
$this->type = 'other';
$this->notes = '';
$this->apodo = '';
$this->estado = 'activo';
$this->logo = null;
$this->editingCompanyId = null;
$this->showCreateForm = false;
$this->showEditForm = false;
$this->resetErrorBag();
$this->resetValidation();
}
public function resetFilters()
{
$this->search = '';
$this->filterType = '';
$this->filterEstado = '';
}
public function toggleCreateForm()
{
$this->showCreateForm = !$this->showCreateForm;
if ($this->showCreateForm) {
$this->showEditForm = false;
$this->resetForm();
}
}
public function editCompany(Company $company)
{
$this->editingCompanyId = $company->id;
$this->name = $company->name;
$this->tax_id = $company->tax_id;
$this->address = $company->address;
$this->phone = $company->phone;
$this->email = $company->email;
$this->website = $company->website;
$this->type = $company->type;
$this->notes = $company->notes;
$this->apodo = $company->apodo;
$this->estado = $company->estado;
// Note: logo is not populated for security reasons
$this->showEditForm = true;
$this->showCreateForm = false;
}
public function updateCompany()
{
$this->validate();
$company = Company::findOrFail($this->editingCompanyId);
$data = [
'name' => $this->name,
'tax_id' => $this->tax_id,
'address' => $this->address,
'phone' => $this->phone,
'email' => $this->email,
'website' => $this->website,
'type' => $this->type,
'notes' => $this->notes,
];
if ($this->logo) {
$logoPath = $this->logo->store('company-logos', 'public');
$data['logo_path'] = $logoPath;
}
$company->update($data);
session()->flash('message', 'Empresa actualizada correctamente.');
$this->resetForm();
}
public function createCompany()
{
$this->validate();
$data = [
'name' => $this->name,
'tax_id' => $this->tax_id,
'address' => $this->address,
'phone' => $this->phone,
'email' => $this->email,
'website' => $this->website,
'type' => $this->type,
'notes' => $this->notes,
];
if ($this->logo) {
$logoPath = $this->logo->store('company-logos', 'public');
$data['logo_path'] = $logoPath;
}
Company::create($data);
session()->flash('message', 'Empresa creada correctamente.');
$this->resetForm();
}
public function deleteCompany(Company $company)
{
$company->delete(); // Soft delete
session()->flash('message', 'Empresa eliminada correctamente.');
}
public string $search = '';
public string $filterType = '';
public string $filterEstado = '';
public function getCompaniesProperty()
{
return Company::when($this->search, function ($query) {
$query->where('name', 'like', '%' . $this->search . '%')
->orWhere('apodo', 'like', '%' . $this->search . '%')
->orWhere('tax_id', 'like', '%' . $this->search . '%');
})
->when($this->filterType, function ($query) {
$query->where('type', $this->filterType);
})
->when($this->filterEstado, function ($query) {
$query->where('estado', $this->filterEstado);
})
->withCount('projects') // Eager load project count
->orderBy('name')
->get();
return Company::when($this->search, function ($q) {
$s = '%' . $this->search . '%';
$q->where(fn($q2) => $q2
->where('name', 'like', $s)
->orWhere('apodo', 'like', $s)
->orWhere('tax_id', 'like', $s));
})
->when($this->filterType, fn($q) => $q->where('type', $this->filterType))
->when($this->filterEstado, fn($q) => $q->where('estado', $this->filterEstado))
->withCount('projects')
->orderBy('name')
->get();
}
public function deleteCompany(Company $company): void
{
if ($company->logo_path) {
Storage::disk('public')->delete($company->logo_path);
}
$company->delete();
$this->dispatch('notify', 'Empresa eliminada.');
}
public function exportCsv()
{
$companies = $this->getCompaniesProperty();
// Create CSV content
$headers = [
"Content-type: text/csv",
"Content-Disposition: attachment; filename=empresas.csv",
"Pragma: no-cache",
"Cache-Control: must-revalidate, post-check=0, pre-check=0",
"Expires: 0"
];
$callback = function() use ($companies) {
return response()->streamDownload(function () use ($companies) {
$handle = fopen('php://output', 'w');
// Add BOM for UTF-8 in Excel
fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF));
// Header row
fputcsv($handle, ['Nombre', 'Apodo', 'NIF/Tax ID', 'Tipo', 'Estado', 'Dirección', 'Teléfono', 'Email', 'Website', 'Proyectos Asociados', 'Fecha Creación']);
foreach ($companies as $company) {
fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($handle, ['Nombre', 'Apodo', 'NIF/Tax ID', 'Tipo', 'Estado', 'Dirección', 'Teléfono', 'Email', 'Website', 'Proyectos', 'Creación']);
foreach ($companies as $c) {
fputcsv($handle, [
$company->name,
$company->apodo ?? '',
$company->tax_id ?? '',
$company->type,
$company->estado,
$company->address ?? '',
$company->phone ?? '',
$company->email ?? '',
$company->website ?? '',
$company->projects_count ?? 0,
$company->created_at ? $company->created_at->format('d/m/Y H:i') : ''
$c->name, $c->apodo ?? '', $c->tax_id ?? '',
$c->type, $c->estado, $c->address ?? '',
$c->phone ?? '', $c->email ?? '', $c->website ?? '',
$c->projects_count ?? 0,
$c->created_at?->format('d/m/Y'),
]);
}
fclose($handle);
};
return response()->stream($callback, 200, $headers);
}, 'empresas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
public function render()
{
return view('livewire.company-management');
}
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
namespace App\Livewire;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use App\Models\Company;
class CompanyTable extends DataTableComponent
{
protected $model = Company::class;
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects([
'companies.id as id',
'companies.apodo as apodo',
'companies.tax_id as tax_id',
'companies.phone as phone',
'companies.email as email',
'companies.logo_path as logo_path',
'companies.created_at as created_at',
]);
}
public function builder(): Builder
{
return Company::withCount('projects');
}
public function columns(): array
{
return [
Column::make('Empresa', 'name')
->sortable()
->searchable()
->format(function ($value, $row) {
$logoHtml = '';
if ($row->logo_path && Storage::disk('public')->exists($row->logo_path)) {
$url = Storage::disk('public')->url($row->logo_path);
$logoHtml = '<img src="'.e($url).'" class="w-9 h-9 rounded object-contain border border-base-300 shrink-0" />';
} else {
$logoHtml = '<div class="w-9 h-9 rounded bg-base-200 flex items-center justify-center shrink-0 text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-2 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
</div>';
}
$html = '<div class="flex items-center gap-3">'.$logoHtml.'<div>';
$html .= '<p class="font-semibold text-sm leading-tight">'.e($value).'</p>';
if ($row->apodo) $html .= '<p class="text-xs text-gray-500">'.e($row->apodo).'</p>';
if ($row->tax_id) $html .= '<p class="text-xs text-gray-400">NIF: '.e($row->tax_id).'</p>';
$html .= '</div></div>';
return $html;
})
->html(),
Column::make('Tipo', 'type')
->sortable()
->format(function ($value) {
$map = [
'owner' => ['badge-success', 'Promotor'],
'constructor' => ['badge-primary', 'Constructor'],
'subcontractor' => ['badge-secondary', 'Subcontratista'],
'consultant' => ['badge-info', 'Consultor'],
'supplier' => ['badge-warning', 'Proveedor'],
];
[$cls, $label] = $map[$value] ?? ['badge-ghost', 'Otro'];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make('Contacto', 'phone')
->format(function ($value, $row) {
$html = '';
if ($row->phone) {
$html .= '<div class="flex items-center gap-1 text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 opacity-50 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg>
'.e($row->phone).'</div>';
}
if ($row->email) {
$html .= '<div class="flex items-center gap-1 text-xs text-gray-500 max-w-[180px] truncate">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 opacity-50 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
'.e($row->email).'</div>';
}
return $html ?: '<span class="text-gray-300">—</span>';
})
->html(),
Column::make('Estado', 'estado')
->sortable()
->format(function ($value) {
$map = [
'activo' => ['badge-success', 'Activo'],
'inactivo' => ['badge-ghost', 'Inactivo'],
'suspendido' => ['badge-error', 'Suspendido'],
];
[$cls, $label] = $map[$value ?? 'activo'] ?? ['badge-ghost', ucfirst($value ?? 'activo')];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make('Proyectos')
->label(fn ($row) =>
'<span class="badge badge-outline badge-sm">'.(int)($row->projects_count ?? 0).'</span>'
)
->html(),
Column::make('Acciones')
->label(function ($row) {
$ver = route('companies.show', $row->id);
$editar = route('companies.edit', $row->id);
$name = addslashes($row->name);
$html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>';
$html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>';
$html .= '<button wire:click="deleteCompany('.$row->id.')"
wire:confirm="¿Eliminar \''.$name.'\'? Esta acción no se puede deshacer."
class="btn btn-xs btn-outline btn-error" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>';
$html .= '</div>';
return $html;
})
->html(),
];
}
public function filters(): array
{
return [
SelectFilter::make('Tipo', 'type')
->options([
'' => 'Tipo: todos',
'owner' => 'Promotor',
'constructor' => 'Constructor',
'subcontractor' => 'Subcontratista',
'consultant' => 'Consultor',
'supplier' => 'Proveedor',
'other' => 'Otro',
])
->filter(fn (Builder $query, string $value) => $query->where('type', $value)),
SelectFilter::make('Estado', 'estado')
->options([
'' => 'Estado: todos',
'activo' => 'Activo',
'inactivo' => 'Inactivo',
'suspendido' => 'Suspendido',
])
->filter(fn (Builder $query, string $value) => $query->where('estado', $value)),
];
}
public function deleteCompany(int $id): void
{
$company = Company::findOrFail($id);
if ($company->logo_path) {
Storage::disk('public')->delete($company->logo_path);
}
$company->delete();
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Company;
use App\Models\Project;
use App\Models\User;
use App\Models\Issue;
use Illuminate\Support\Facades\Auth;
#[Layout('layouts.app')]
class CompanyView extends Component
{
public Company $company;
public string $activeTab = 'summary';
// Projects tab
public ?int $addProjectId = null;
public string $addProjectRole = '';
public $availableProjects;
// People tab
public ?int $assignUserId = null;
public $assignableUsers;
// Notes tab
public string $notes = '';
public bool $editingNotes = false;
// Stats (computed once in mount, refreshed on mutations)
public int $usersCount = 0;
public int $projectsCount = 0;
public float $avgProgress = 0.0;
public int $openIssues = 0;
public function mount(Company $company): void
{
abort_unless(Auth::user()->can('view companies'), 403);
$this->company = $company->load(['users.roles', 'projects.phases']);
$this->notes = $company->notes ?? '';
$this->loadAvailableProjects();
$this->loadAssignableUsers();
$this->computeStats();
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function loadAvailableProjects(): void
{
$assignedIds = $this->company->projects->pluck('id');
$this->availableProjects = Project::whereNotIn('id', $assignedIds)
->orderBy('name')->get();
}
private function loadAssignableUsers(): void
{
$this->assignableUsers = User::where(function ($q) {
$q->where('company_id', '!=', $this->company->id)
->orWhereNull('company_id');
})->orderBy('name')->get();
}
private function computeStats(): void
{
$this->usersCount = $this->company->users->count();
$this->projectsCount = $this->company->projects->count();
$this->avgProgress = round(
$this->company->projects->flatMap(fn($p) => $p->phases)->avg('progress_percent') ?? 0
);
$userIds = $this->company->users->pluck('id');
$this->openIssues = $userIds->isNotEmpty()
? Issue::whereIn('reported_by', $userIds)->where('status', 'open')->count()
: 0;
}
// ── Tabs ─────────────────────────────────────────────────────────────────
public function setTab(string $tab): void
{
$this->activeTab = $tab;
}
// ── Projects ──────────────────────────────────────────────────────────────
public function assignProject(): void
{
$this->validate([
'addProjectId' => 'required|exists:projects,id',
'addProjectRole' => 'required|string|max:150',
], [], ['addProjectId' => 'proyecto', 'addProjectRole' => 'rol en proyecto']);
$this->company->projects()->attach($this->addProjectId, [
'role_in_project' => $this->addProjectRole,
]);
$this->company->load('projects.phases');
$this->addProjectId = null;
$this->addProjectRole = '';
$this->loadAvailableProjects();
$this->computeStats();
$this->dispatch('notify', 'Proyecto asignado correctamente.');
}
public function removeProject(int $projectId): void
{
$this->company->projects()->detach($projectId);
$this->company->load('projects.phases');
$this->loadAvailableProjects();
$this->computeStats();
$this->dispatch('notify', 'Proyecto desasignado.');
}
// ── People ────────────────────────────────────────────────────────────────
public function assignUser(): void
{
$this->validate([
'assignUserId' => 'required|exists:users,id',
], [], ['assignUserId' => 'usuario']);
User::find($this->assignUserId)?->update(['company_id' => $this->company->id]);
$this->company->load('users.roles');
$this->assignUserId = null;
$this->loadAssignableUsers();
$this->computeStats();
$this->dispatch('notify', 'Usuario vinculado a la empresa.');
}
public function removeUser(int $userId): void
{
User::find($userId)?->update(['company_id' => null]);
$this->company->load('users.roles');
$this->loadAssignableUsers();
$this->computeStats();
$this->dispatch('notify', 'Usuario desvinculado de la empresa.');
}
// ── Notes ─────────────────────────────────────────────────────────────────
public function saveNotes(): void
{
$this->validate(['notes' => 'nullable|string']);
$this->company->update(['notes' => $this->notes ?: null]);
$this->editingNotes = false;
$this->dispatch('notify', 'Notas guardadas.');
}
public function render()
{
return view('livewire.company-view');
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace App\Livewire;
use App\Models\IssueChecklistTemplate;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class IssueChecklistManager extends Component
{
public Project $project;
public $templates = [];
public bool $showForm = false;
public $editingId = null;
public string $name = '';
public array $items = [''];
public function mount(Project $project)
{
$this->project = $project;
abort_unless($this->canAccessProject() && Auth::user()->can('edit issues'), 403);
$this->loadTemplates();
}
private function canAccessProject(): bool
{
$user = Auth::user();
return $user->can('manage all')
|| $this->project->users()->where('user_id', $user->id)->exists();
}
public function loadTemplates(): void
{
$this->templates = IssueChecklistTemplate::where('project_id', $this->project->id)
->orderBy('name')->get();
}
public function newTemplate(): void
{
$this->reset(['editingId', 'name']);
$this->items = [''];
$this->resetErrorBag();
$this->showForm = true;
}
public function edit($id): void
{
$t = IssueChecklistTemplate::where('project_id', $this->project->id)->findOrFail($id);
$this->editingId = $t->id;
$this->name = $t->name;
$this->items = array_values($t->items ?: ['']) ?: [''];
$this->resetErrorBag();
$this->showForm = true;
}
public function addItemLine(): void
{
$this->items[] = '';
}
public function removeItemLine(int $i): void
{
unset($this->items[$i]);
$this->items = array_values($this->items);
if (empty($this->items)) {
$this->items = [''];
}
}
public function save(): void
{
$this->validate([
'name' => 'required|string|max:255',
'items' => 'required|array',
'items.*' => 'nullable|string|max:255',
]);
$items = array_values(array_filter(array_map('trim', $this->items), fn ($v) => $v !== ''));
if (empty($items)) {
$this->addError('items', 'Añade al menos una tarea.');
return;
}
IssueChecklistTemplate::updateOrCreate(
['id' => $this->editingId, 'project_id' => $this->project->id],
['name' => $this->name, 'items' => $items, 'project_id' => $this->project->id],
);
$this->showForm = false;
$this->loadTemplates();
$this->dispatch('notify', 'Plantilla guardada');
}
public function delete($id): void
{
IssueChecklistTemplate::where('project_id', $this->project->id)->findOrFail($id)->delete();
$this->loadTemplates();
$this->dispatch('notify', 'Plantilla eliminada');
}
public function render()
{
return view('livewire.issues.issue-checklist-manager');
}
}
+295
View File
@@ -0,0 +1,295 @@
<?php
namespace App\Livewire;
use App\Models\Issue;
use App\Models\IssueChecklistTemplate;
use App\Models\IssueComment;
use App\Models\IssueTask;
use App\Models\Project;
use App\Notifications\IssueCommentedNotification;
use App\Notifications\IssueStatusChangedNotification;
use App\Notifications\IssueTaskAssignedNotification;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithFileUploads;
#[Layout('layouts.app')]
class IssueDetail extends Component
{
use WithFileUploads;
public Project $project;
public Issue $issue;
// New task form
public string $newTaskTitle = '';
public $newTaskAssignee = '';
public $newTaskDue = '';
// Checklist templates
public $checklistTemplates = [];
public $applyTemplateId = '';
// New comment form
public string $newComment = '';
public $commentPhoto = null; // single optional photo on a comment
// Issue-level photos
public $issuePhotos = []; // multiple
// Verification / resolution
public string $resolutionNotes = '';
public $projectUsers = [];
public function mount(Project $project, Issue $issue)
{
abort_unless($issue->project_id === $project->id, 404);
$this->project = $project;
$this->issue = $issue;
abort_unless($this->canAccessProject() && Auth::user()->can('view issues'), 403);
$this->resolutionNotes = $issue->resolution_notes ?? '';
$this->projectUsers = $project->users()->orderBy('name')->get();
$this->checklistTemplates = IssueChecklistTemplate::where('project_id', $project->id)->orderBy('name')->get();
$this->refreshIssue();
}
private function canAccessProject(): bool
{
$user = Auth::user();
return $user->can('manage all')
|| $this->project->users()->where('user_id', $user->id)->exists();
}
private function canEdit(): bool
{
return Auth::user()->can('edit issues');
}
/** Notify the issue's stakeholders (reporter + assignee), excluding the current actor. */
private function notifyStakeholders($notification): void
{
$this->issue->loadMissing(['reporter', 'assignee']);
collect([$this->issue->reporter, $this->issue->assignee])
->filter()
->unique('id')
->reject(fn ($u) => $u->id === Auth::id())
->each(fn ($u) => $u->notify($notification));
}
public function refreshIssue(): void
{
$this->issue->load([
'reporter', 'assignee', 'feature',
'tasks.assignee', 'tasks.completer',
'comments.user', 'comments.media',
'media.uploader',
]);
}
// ── Checklist ────────────────────────────────────────────────────────────────
public function addTask(): void
{
abort_unless($this->canEdit(), 403);
$this->validate([
'newTaskTitle' => 'required|string|max:255',
'newTaskAssignee' => 'nullable|exists:users,id',
'newTaskDue' => 'nullable|date',
]);
$task = $this->issue->tasks()->create([
'title' => $this->newTaskTitle,
'assigned_to' => $this->newTaskAssignee ?: null,
'due_date' => $this->newTaskDue ?: null,
'order' => ((int) $this->issue->tasks()->max('order')) + 1,
'uuid' => (string) \Illuminate\Support\Str::uuid(),
]);
// Notify the assignee (unless they assigned it to themselves).
if ($task->assigned_to && $task->assigned_to !== Auth::id()) {
$task->loadMissing('issue');
$task->assignee?->notify(new IssueTaskAssignedNotification($task));
}
$this->reset(['newTaskTitle', 'newTaskAssignee', 'newTaskDue']);
$this->refreshIssue();
$this->dispatch('notify', 'Tarea añadida');
}
public function applyTemplate(): void
{
abort_unless($this->canEdit(), 403);
$this->validate(['applyTemplateId' => 'required|exists:issue_checklist_templates,id']);
$template = IssueChecklistTemplate::where('project_id', $this->project->id)
->findOrFail($this->applyTemplateId);
$order = (int) $this->issue->tasks()->max('order');
foreach ($template->items ?: [] as $title) {
$this->issue->tasks()->create([
'title' => $title,
'order' => ++$order,
'uuid' => (string) \Illuminate\Support\Str::uuid(),
]);
}
$this->applyTemplateId = '';
$this->refreshIssue();
$this->dispatch('notify', 'Plantilla aplicada');
}
public function toggleTask($taskId): void
{
abort_unless($this->canEdit(), 403);
$task = $this->issue->tasks()->findOrFail($taskId);
$done = ! $task->is_done;
$task->update([
'is_done' => $done,
'done_at' => $done ? now() : null,
'done_by' => $done ? Auth::id() : null,
]);
$this->refreshIssue();
}
public function deleteTask($taskId): void
{
abort_unless($this->canEdit(), 403);
$this->issue->tasks()->findOrFail($taskId)->delete();
$this->refreshIssue();
$this->dispatch('notify', 'Tarea eliminada');
}
// ── Comments + photos ──────────────────────────────────────────────────────────
public function addComment(): void
{
// Anyone who can view the issue (and is a member) may comment / report progress.
$this->validate([
'newComment' => 'nullable|string|max:5000',
'commentPhoto' => 'nullable|image|max:20480', // 20 MB
]);
if (trim($this->newComment) === '' && ! $this->commentPhoto) {
throw ValidationException::withMessages([
'newComment' => 'Escribe un comentario o adjunta una foto.',
]);
}
$comment = $this->issue->comments()->create([
'user_id' => Auth::id(),
'body' => trim($this->newComment) ?: '(foto)',
'uuid' => (string) \Illuminate\Support\Str::uuid(),
]);
if ($this->commentPhoto) {
abort_unless(Auth::user()->can('upload media'), 403);
$this->storeUpload($this->commentPhoto, $comment, 'comment');
}
$comment->setRelation('issue', $this->issue)->setRelation('user', Auth::user());
$this->notifyStakeholders(new IssueCommentedNotification($comment));
$this->reset(['newComment', 'commentPhoto']);
$this->refreshIssue();
$this->dispatch('notify', 'Comentario añadido');
}
public function uploadIssuePhotos(): void
{
abort_unless(Auth::user()->can('upload media'), 403);
$this->validate(['issuePhotos.*' => 'required|image|max:20480']);
foreach ($this->issuePhotos as $photo) {
$this->storeUpload($photo, $this->issue, 'issue');
}
$this->reset('issuePhotos');
$this->refreshIssue();
$this->dispatch('notify', 'Fotos subidas');
}
public function deleteMedia($mediaId): void
{
$media = \App\Models\Media::findOrFail($mediaId);
$user = Auth::user();
abort_unless($user->can('delete media') || $media->uploaded_by === $user->id, 403);
$media->delete();
$this->refreshIssue();
$this->dispatch('notify', 'Foto eliminada');
}
/** Store an uploaded file on the public disk and attach it to a parent model. */
private function storeUpload($file, $parent, string $entity): void
{
$mime = $file->getMimeType();
$path = $file->store("uploads/issues/{$this->issue->id}/{$entity}", 'public');
$parent->media()->create([
'name' => $file->getClientOriginalName(),
'file_path' => $path,
'file_type' => $mime,
'file_extension' => $file->getClientOriginalExtension(),
'file_size' => $file->getSize(),
'category' => str_starts_with($mime, 'image/') ? 'image' : 'document',
'uploaded_by' => Auth::id(),
'uuid' => (string) \Illuminate\Support\Str::uuid(),
]);
}
// ── Status workflow (verification) ───────────────────────────────────────────────
public function sendToReview(): void
{
abort_unless($this->canEdit(), 403);
$this->issue->update(['status' => 'in_review']);
$this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'in_review'));
$this->refreshIssue();
$this->dispatch('notify', 'Incidencia enviada a revisión');
}
public function verifyResolve(): void
{
abort_unless($this->canEdit(), 403);
$this->issue->update([
'status' => 'resolved',
'resolved_at' => $this->issue->resolved_at ?? now(),
'resolution_notes' => $this->resolutionNotes ?: null,
]);
$this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'resolved'));
$this->refreshIssue();
$this->dispatch('notify', 'Incidencia validada y resuelta');
}
public function closeIssue(): void
{
abort_unless($this->canEdit(), 403);
$this->issue->update([
'status' => 'closed',
'resolved_at' => $this->issue->resolved_at ?? now(),
]);
$this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'closed'));
$this->refreshIssue();
$this->dispatch('notify', 'Incidencia cerrada');
}
public function reopen(): void
{
abort_unless($this->canEdit(), 403);
$this->issue->update(['status' => 'open', 'resolved_at' => null]);
$this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'open'));
$this->refreshIssue();
$this->dispatch('notify', 'Incidencia reabierta');
}
public function render()
{
return view('livewire.issues.issue-detail', [
'canEdit' => $this->canEdit(),
]);
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace App\Livewire;
use App\Models\Issue;
use App\Models\Project;
use App\Notifications\IssueAssignedNotification;
use App\Notifications\IssueReportedNotification;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class IssueForm extends Component
{
public Project $project;
public ?Issue $issue = null; // null = create, set = edit
public $projectUsers = [];
// Form fields
public $title = '';
public $description = '';
public $status = 'open';
public $priority = 'medium';
public $type = 'defect';
public $assignedTo = '';
public $resolutionNotes = '';
// Optional context (e.g. when reporting from a map feature)
public $featureId = null;
public $inspectionId = null;
public $featureName = null; // shown when the issue is pre-linked to a map element
public function mount(Project $project, ?Issue $issue = null)
{
$this->project = $project;
if ($issue) {
abort_unless($issue->project_id === $project->id, 404);
}
abort_unless($this->canAccessProject() && Auth::user()->can($issue ? 'edit issues' : 'create issues'), 403);
$this->projectUsers = $project->users()->orderBy('name')->get();
if ($issue) {
$this->issue = $issue;
$this->title = $issue->title;
$this->description = $issue->description ?? '';
$this->status = $issue->status;
$this->priority = $issue->priority;
$this->type = $issue->type ?? 'defect';
$this->assignedTo = $issue->assigned_to ?? '';
$this->resolutionNotes = $issue->resolution_notes ?? '';
$this->featureId = $issue->feature_id;
$this->inspectionId = $issue->inspection_id;
$this->featureName = $issue->feature?->name;
} elseif ($featureId = request()->integer('feature')) {
// Pre-link to a map element when reporting from the project map.
$feature = \App\Models\Feature::with('layer.phase')->find($featureId);
if ($feature && $feature->layer?->phase?->project_id === $project->id) {
$this->featureId = $feature->id;
$this->featureName = $feature->name;
}
}
}
private function canAccessProject(): bool
{
$user = Auth::user();
return $user->can('manage all')
|| $this->project->users()->where('user_id', $user->id)->exists();
}
protected function rules(): array
{
return [
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'status' => 'required|in:' . implode(',', Issue::STATUSES),
'priority' => 'required|in:' . implode(',', Issue::PRIORITIES),
'type' => 'required|in:' . implode(',', Issue::TYPES),
'assignedTo' => 'nullable|exists:users,id',
'resolutionNotes' => 'nullable|string',
];
}
public function save()
{
abort_unless(Auth::user()->can($this->issue ? 'edit issues' : 'create issues'), 403);
$this->validate();
$payload = [
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'type' => $this->type,
'feature_id' => $this->featureId,
'inspection_id' => $this->inspectionId,
'assigned_to' => $this->assignedTo ?: null,
'resolution_notes' => $this->resolutionNotes ?: null,
];
// Keep resolved_at in sync with the status
$payload['resolved_at'] = in_array($this->status, ['resolved', 'closed']) ? now() : null;
if ($this->issue) {
$previousAssignee = $this->issue->assigned_to;
// Don't overwrite an existing resolved date
if ($this->issue->resolved_at && in_array($this->status, ['resolved', 'closed'])) {
unset($payload['resolved_at']);
}
$this->issue->update($payload);
$issue = $this->issue;
// Notify a newly assigned user (when it changed and isn't the current actor).
if ($issue->assigned_to && $issue->assigned_to !== $previousAssignee && $issue->assigned_to !== Auth::id()) {
$issue->assignee?->notify(new IssueAssignedNotification($issue));
}
} else {
$issue = Issue::create(array_merge($payload, [
'project_id' => $this->project->id,
'reported_by' => Auth::id(),
]));
$issue->load(['feature', 'assignee']);
$creator = $this->project->creator;
if ($creator && $creator->id !== Auth::id()) {
$creator->notify(new IssueReportedNotification($issue));
}
if ($issue->assignee && $issue->assignee->id !== Auth::id()) {
$issue->assignee->notify(new IssueReportedNotification($issue));
}
}
session()->flash('message', $this->issue ? 'Incidencia actualizada' : 'Incidencia creada');
return $this->redirectRoute('projects.issues.show', ['project' => $this->project, 'issue' => $issue], navigate: true);
}
public function render()
{
return view('livewire.issues.issue-form');
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Illuminate\Support\Facades\Auth;
use App\Models\Project;
use App\Models\Issue;
#[Layout('layouts.app')]
class IssueManager extends Component
{
public Project $project;
public function mount(Project $project)
{
$this->project = $project;
abort_unless($this->canAccessProject() && Auth::user()->can('view issues'), 403);
}
/** The current user must be a project member (or super-admin) to touch issues. */
private function canAccessProject(): bool
{
$user = Auth::user();
return $user->can('manage all')
|| $this->project->users()->where('user_id', $user->id)->exists();
}
/** Re-render the stats bar after the embedded table changes an issue. */
#[On('issuesChanged')]
public function refreshStats(): void
{
// No state to mutate — the listener simply triggers a re-render so the
// stat counters recompute from the database in render().
}
public function render()
{
$counts = Issue::where('project_id', $this->project->id)
->selectRaw('status, count(*) as c')
->groupBy('status')
->pluck('c', 'status');
return view('livewire.issues.issue-manager', [
'countOpen' => (int) ($counts['open'] ?? 0),
'countInReview' => (int) ($counts['in_review'] ?? 0),
'countResolved' => (int) ($counts['resolved'] ?? 0),
'countClosed' => (int) ($counts['closed'] ?? 0),
'countTotal' => (int) $counts->sum(),
]);
}
}
+233
View File
@@ -0,0 +1,233 @@
<?php
namespace App\Livewire;
use App\Models\Issue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
class IssueTable extends DataTableComponent
{
protected $model = Issue::class;
public int $projectId;
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('created_at', 'desc')
->setSortingPillsEnabled(false)
->setAdditionalSelects(['issues.id as id', 'issues.created_at as created_at']);
}
public function builder(): Builder
{
// Defence in depth: only members (or super-admin) may list a project's issues.
$user = Auth::user();
abort_unless(
$user->can('view issues') && (
$user->can('manage all')
|| \App\Models\Project::whereKey($this->projectId)->whereHas('users', fn ($q) => $q->where('user_id', $user->id))->exists()
),
403
);
return Issue::where('issues.project_id', $this->projectId)
->with(['feature', 'reporter', 'assignee'])
->withCount([
'comments',
'media',
'tasks',
'tasks as tasks_done_count' => fn (Builder $q) => $q->where('is_done', true),
'tasks as overdue_tasks_count' => fn (Builder $q) => $q->where('is_done', false)
->whereNotNull('due_date')
->whereDate('due_date', '<', now()->toDateString()),
]);
}
public function columns(): array
{
return [
Column::make('Prioridad', 'priority')
->sortable()
->format(function ($value, $row) {
$label = ['low' => 'Bajo', 'medium' => 'Medio', 'high' => 'Alto', 'critical' => 'Crítico'][$value] ?? ucfirst($value);
$textColor = in_array($value, ['critical', 'high']) ? '#fff' : '#1f2937';
return '<span class="badge badge-sm font-semibold" style="background-color:'.$row->priority_color.';color:'.$textColor.';border-color:transparent;">'.$label.'</span>';
})
->html(),
Column::make('Título', 'title')
->sortable()
->searchable()
->format(function ($value, $row) {
$url = route('projects.issues.show', [$this->projectId, $row->id]);
$html = '<a href="'.$url.'" wire:navigate class="font-medium text-sm link link-hover">'.e($value).'</a>';
if ($row->description) {
$html .= '<div class="text-xs text-base-content/50 truncate max-w-xs">'.e(Str::limit($row->description, 60)).'</div>';
}
$meta = [];
if ($row->reporter) $meta[] = 'Reportado por '.e($row->reporter->name);
if ($row->comments_count) $meta[] = '💬 '.$row->comments_count;
if ($row->media_count) $meta[] = '📷 '.$row->media_count;
if ($meta) {
$html .= '<div class="text-xs text-base-content/40 mt-0.5">'.implode(' · ', $meta).'</div>';
}
if ($row->tasks_count) {
$pct = (int) round($row->tasks_done_count / $row->tasks_count * 100);
$html .= '<div class="flex items-center gap-2 mt-1 max-w-xs">
<progress class="progress progress-success w-24 h-1.5" value="'.$pct.'" max="100"></progress>
<span class="text-xs text-base-content/50">'.$row->tasks_done_count.'/'.$row->tasks_count.' tareas</span>
</div>';
}
if ($row->overdue_tasks_count) {
$html .= '<div class="mt-1"><span class="badge badge-error badge-sm gap-1">⏰ '.$row->overdue_tasks_count.' vencida'.($row->overdue_tasks_count > 1 ? 's' : '').'</span></div>';
}
return $html;
})
->html(),
Column::make('Tipo', 'type')
->sortable()
->format(fn ($value, $row) =>
'<span class="badge badge-sm" style="background-color:'.$row->type_color.';color:#fff;border-color:transparent;">'.e($row->type_label).'</span>')
->html(),
Column::make('Feature')
->label(fn ($row) => $row->feature
? '<span class="badge badge-outline badge-sm">'.e($row->feature->name).'</span>'
: '<span class="text-base-content/30 text-xs">—</span>')
->html(),
Column::make('Estado', 'status')
->sortable()
->format(function ($value, $row) {
$label = ['open' => 'Abierto', 'in_review' => 'En revisión', 'resolved' => 'Resuelto', 'closed' => 'Cerrado'][$value] ?? ucfirst($value);
return '<span class="badge badge-sm" style="background-color:'.$row->status_color.';color:#fff;border-color:transparent;">'.$label.'</span>';
})
->html(),
Column::make('Asignado a')
->label(fn ($row) => $row->assignee
? '<span class="text-sm">'.e($row->assignee->name).'</span>'
: '<span class="text-base-content/30 text-xs">Sin asignar</span>')
->html(),
Column::make('Fecha', 'created_at')
->sortable()
->format(function ($value, $row) {
$html = $row->created_at->format('d/m/Y');
if ($row->resolved_at) {
$html .= '<div class="text-success text-xs">Res. '.$row->resolved_at->format('d/m/Y').'</div>';
}
return $html;
})
->html(),
Column::make('Acciones')
->label(function ($row) {
$user = Auth::user();
$detail = route('projects.issues.show', [$this->projectId, $row->id]);
$edit = route('projects.issues.edit', [$this->projectId, $row->id]);
$html = '<div class="flex items-center justify-end gap-1 flex-wrap">';
$html .= '<a href="'.$detail.'" wire:navigate class="btn btn-xs btn-ghost" title="Abrir detalle">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>';
if ($user->can('edit issues')) {
$html .= '<a href="'.$edit.'" wire:navigate class="btn btn-xs btn-ghost" title="Editar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>';
if (in_array($row->status, ['open', 'in_review'])) {
$html .= '<button wire:click="resolve('.$row->id.')" class="btn btn-xs btn-success" title="Marcar como resuelto">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</button>';
}
if ($row->status !== 'closed') {
$html .= '<button wire:click="close('.$row->id.')" class="btn btn-xs btn-neutral" title="Cerrar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>';
}
}
if ($user->can('delete issues')) {
$html .= '<button wire:click="deleteIssue('.$row->id.')" wire:confirm="¿Eliminar esta incidencia? Esta acción no se puede deshacer."
class="btn btn-xs btn-error btn-outline" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>';
}
$html .= '</div>';
return $html;
})
->html(),
];
}
public function filters(): array
{
return [
SelectFilter::make('Estado', 'status')
->options([
'' => 'Estado: todos',
'open' => 'Abierto',
'in_review' => 'En revisión',
'resolved' => 'Resuelto',
'closed' => 'Cerrado',
])
->filter(fn (Builder $query, string $value) => $query->where('issues.status', $value)),
SelectFilter::make('Prioridad', 'priority')
->options([
'' => 'Prioridad: todas',
'critical' => 'Crítica',
'high' => 'Alta',
'medium' => 'Media',
'low' => 'Baja',
])
->filter(fn (Builder $query, string $value) => $query->where('issues.priority', $value)),
SelectFilter::make('Tipo', 'type')
->options(['' => 'Tipo: todos'] + \App\Models\Issue::typeLabels())
->filter(fn (Builder $query, string $value) => $query->where('issues.type', $value)),
];
}
// ── Row actions ────────────────────────────────────────────────────────────────
private function findIssue(int $id): Issue
{
return Issue::where('project_id', $this->projectId)->findOrFail($id);
}
public function resolve(int $id): void
{
abort_unless(Auth::user()->can('edit issues'), 403);
$issue = $this->findIssue($id);
$issue->update(['status' => 'resolved', 'resolved_at' => $issue->resolved_at ?? now()]);
$this->dispatch('issuesChanged');
$this->dispatch('notify', 'Incidencia marcada como resuelta');
}
public function close(int $id): void
{
abort_unless(Auth::user()->can('edit issues'), 403);
$issue = $this->findIssue($id);
$issue->update(['status' => 'closed', 'resolved_at' => $issue->resolved_at ?? now()]);
$this->dispatch('issuesChanged');
$this->dispatch('notify', 'Incidencia cerrada');
}
public function deleteIssue(int $id): void
{
abort_unless(Auth::user()->can('delete issues'), 403);
$this->findIssue($id)->delete();
$this->dispatch('issuesChanged');
$this->dispatch('notify', 'Incidencia eliminada');
}
}
+7 -6
View File
@@ -9,20 +9,19 @@ use Illuminate\Support\Facades\Session;
class LanguageSwitcher extends Component
{
public $currentLocale;
public string $currentLocale;
public function mount()
public function mount(): void
{
$this->currentLocale = App::getLocale();
}
public function switchLanguage($locale)
public function switchLanguage(string $locale): void
{
if (!in_array($locale, ['en', 'es'])) {
return;
}
App::setLocale($locale);
Session::put('locale', $locale);
if (Auth::check()) {
@@ -31,8 +30,10 @@ class LanguageSwitcher extends Component
$user->save();
}
$this->currentLocale = $locale;
$this->dispatch('localeChanged', $locale);
// Dispatch a browser event — JavaScript reloads the page.
// PHP-side redirects break because $this->redirect() runs inside
// /livewire/update (the AJAX endpoint), not on the real page URL.
$this->dispatch('locale-changed');
}
public function render()
+247 -159
View File
@@ -8,9 +8,11 @@ use Livewire\Attributes\Layout;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Layer;
use App\Services\SpatialFileConverter;
use App\Models\Feature;
use App\Models\InspectionTemplate;
use App\Services\SpatialFileConverter;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
#[Layout('layouts.app')]
@@ -19,97 +21,109 @@ class LayerManager extends Component
use WithFileUploads;
public Project $project;
public Phase $phase;
public Phase $phase;
public $layers;
public $selectedLayer = null;
public $visibleLayers = []; // IDs de capas visibles
public $visibleLayers = [];
public $uploadFile = null;
public $layerName = '';
public $layerColor = '#3b82f6';
public $manualGeojson = null;
public $drawingMode = false;
public $uploadFile = null;
public $layerName = '';
public $layerColor = '#3b82f6';
protected $rules = [
'uploadFile' => 'required|file|mimes:geojson,kmz,kml,shp,dwg,zip|max:51200',
'layerName' => 'required|string|max:255',
'layerColor' => 'nullable|string|size:7',
];
// Batch assign
public $templates = [];
public $batchTemplateId = null;
public $batchStatus = '';
public function mount(Project $project, Phase $phase)
{
$this->project = $project;
$this->phase = $phase;
$this->loadLayers();
if ($this->phase->project_id !== $this->project->id) {
abort(404);
$this->phase = $phase;
if ($this->phase->project_id !== $this->project->id) abort(404);
$user = Auth::user();
if (!$user->can('manage all') && !$project->users()->where('user_id', $user->id)->exists()) {
abort(403);
}
// Por defecto todas visibles
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
$this->loadLayers();
$this->visibleLayers = $this->layers->pluck('id')->toArray();
$this->emitInitialLayersData();
}
// ── Data loaders ──────────────────────────────────────────────────────────
public function loadLayers()
{
$this->layers = Layer::with('features')->where('phase_id', $this->phase->id)->latest()->get();
$this->visibleLayers = array_intersect($this->visibleLayers, $this->layers->pluck('id')->toArray());
$this->layers = Layer::withCount('features')
->withAvg('features', 'progress')
->where('phase_id', $this->phase->id)
->latest()
->get();
$this->visibleLayers = array_values(
array_intersect($this->visibleLayers, $this->layers->pluck('id')->toArray())
);
}
private function buildLayerPayload(Layer $layer): array
{
$color = $layer->color ?: '#3b82f6';
$features = ($layer->relationLoaded('features') ? $layer->features : $layer->features()->get())
->map(fn($f) => [
'type' => 'Feature',
'id' => $f->id,
'geometry' => $f->geometry,
'properties' => [
'name' => $f->name ?? 'Elemento',
'progress' => $f->progress,
'status' => $f->status ?? 'planned',
'responsible' => $f->responsible,
'template_id' => $f->template_id,
],
])->values()->toArray();
return [
'id' => $layer->id,
'color' => $color,
'geojson' => [
'type' => 'FeatureCollection',
'features' => $features,
'style' => ['color' => $color],
],
];
}
private function emitInitialLayersData()
{
$layersData = $this->layers->map(function($layer) {
// Usar el color guardado en BD o el color del formulario
$color = $layer->color ?: ($this->layerColor ?: '#3b82f6');
// Construir FeatureCollection a partir de los features de esta capa
$features = $layer->features->map(function($feature) {
return [
'type' => 'Feature',
'id' => $feature->id,
'geometry' => $feature->geometry,
'properties' => [
'name' => $feature->name,
'progress' => $feature->progress,
'responsible' => $feature->responsible,
'template_id' => $feature->template_id,
]
];
})->values()->toArray();
$geojson = [
'type' => 'FeatureCollection',
'features' => $features,
'style' => ['color' => $color]
];
return [
'id' => $layer->id,
'geojson' => $geojson,
'color' => $color,
];
});
$this->layers->loadMissing('features');
$this->dispatch('initialLayersData', [
'layers' => $layersData,
'visibleLayers' => $this->visibleLayers,
'layers' => $this->layers->map(fn($l) => $this->buildLayerPayload($l)),
'visibleLayers' => $this->visibleLayers,
'selectedLayerId' => $this->selectedLayer?->id,
]);
}
// ── Visibility ────────────────────────────────────────────────────────────
public function toggleLayerVisibility($layerId)
{
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
session()->flash('info', 'No puedes ocultar la capa que estás editando.');
$this->dispatch('notify', 'No puedes ocultar la capa que estás editando');
return;
}
if (in_array($layerId, $this->visibleLayers)) {
$this->visibleLayers = array_diff($this->visibleLayers, [$layerId]);
$this->visibleLayers = array_values(array_diff($this->visibleLayers, [$layerId]));
} else {
$this->visibleLayers[] = $layerId;
}
$this->dispatch('visibilityChanged', $this->visibleLayers);
}
// ── Select ────────────────────────────────────────────────────────────────
public function selectLayer($layerId)
{
$this->selectedLayer = Layer::with('features')->find($layerId);
@@ -120,185 +134,259 @@ class LayerManager extends Component
$this->dispatch('visibilityChanged', $this->visibleLayers);
}
// Construir el GeoJSON desde los features de la capa seleccionada
$features = $this->selectedLayer->features->map(function($feature) {
return [
'type' => 'Feature',
'id' => $feature->id,
'geometry' => $feature->geometry,
'properties' => [
'name' => $feature->name,
'progress' => $feature->progress,
'responsible' => $feature->responsible,
'template_id' => $feature->template_id,
]
];
})->values()->toArray();
$color = $this->selectedLayer->color ?: ($this->layerColor ?: '#3b82f6');
$geojson = [
'type' => 'FeatureCollection',
'features' => $features,
'style' => ['color' => $color]
];
$payload = $this->buildLayerPayload($this->selectedLayer);
$this->dispatch('layerSelectedForEdit', [
'layerId' => $layerId,
'geojson' => $geojson,
'color' => $color,
'geojson' => $payload['geojson'],
'color' => $payload['color'],
]);
session()->flash('info', 'Editando capa: ' . $this->selectedLayer->name);
$this->dispatch('notify', 'Editando: ' . $this->selectedLayer->name);
}
// ── Import file ───────────────────────────────────────────────────────────
public function importFile()
{
$user = Auth::user();
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
session()->flash('error', 'Sin permisos.');
if (!$user->can('upload layers')) {
$this->dispatch('notify', 'Sin permisos para subir capas');
return;
}
// Validar campos obligatorios y tamaño máximo
$this->validate([
'uploadFile' => 'required|file|max:51200',
'layerName' => 'required|string|max:255',
'layerName' => 'required|string|max:255',
'layerColor' => 'nullable|string|size:7',
]);
$extension = strtolower($this->uploadFile->getClientOriginalExtension());
$mime = $this->uploadFile->getMimeType();
$allowedExtensions = ['geojson', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
$allowedMimes = [
'application/vnd.google-earth.kml+xml',
'application/vnd.google-earth.kmz',
'application/zip',
'application/x-zip-compressed',
'application/x-shapefile',
'image/vnd.dwg',
'application/acad',
'application/geo+json',
'text/xml', // ✅ Aceptar KML con text/xml
'application/xml', // ✅ Alternativa
];
if (!in_array($extension, $allowedExtensions) && !in_array($mime, $allowedMimes)) {
session()->flash('error', 'Tipo de archivo no permitido. Extensiones válidas: ' . implode(', ', $allowedExtensions));
$ext = strtolower($this->uploadFile->getClientOriginalExtension());
$allowed = ['geojson', 'json', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
if (!in_array($ext, $allowed)) {
$this->dispatch('notify', 'Extensión no permitida. Válidas: ' . implode(', ', $allowed));
return;
}
$projectDir = "uploads/projects/{$this->project->id}/layers";
$originalPath = $this->uploadFile->store($projectDir, 'public');
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
if (!$geojson) {
session()->flash('error', 'Conversión fallida. Asegúrate de que el archivo sea válido (KML, GeoJSON, etc.).');
$this->dispatch('notify', 'No se pudo convertir el archivo. Comprueba que sea GeoJSON, KML o Shapefile válido.');
return;
}
$layerColor = $this->layerColor ?: '#3b82f6';
$geojson['style'] = ['color' => $layerColor];
$layerName = $this->layerName;
$layer = Layer::create([
'project_id' => $this->project->id,
'phase_id' => $this->phase->id,
'name' => $this->layerName,
'color' => $layerColor,
'original_file' => $originalPath,
'uploaded_by' => $user->id,
]);
try {
DB::transaction(function () use ($geojson, $layerColor, $layerName, $user) {
$path = $this->uploadFile->store(
"uploads/projects/{$this->project->id}/layers", 'public'
);
// Crear features a partir del GeoJSON
if (isset($geojson['features'])) {
foreach ($geojson['features'] as $featureData) {
Feature::create([
'layer_id' => $layer->id,
'name' => $featureData['properties']['name'] ?? null,
'geometry' => $featureData['geometry'],
'properties' => $featureData['properties'] ?? [],
'template_id' => $featureData['properties']['template_id'] ?? null,
'progress' => $featureData['properties']['progress'] ?? 0,
'responsible' => $featureData['properties']['responsible'] ?? null,
$layer = Layer::create([
'project_id' => $this->project->id,
'phase_id' => $this->phase->id,
'name' => $layerName,
'color' => $layerColor,
'original_file' => $path,
'uploaded_by' => $user->id,
]);
}
$idx = 0;
foreach ($geojson['features'] ?? [] as $fd) {
$idx++;
$name = trim($fd['properties']['name'] ?? '');
if ($name === '') $name = $layerName . ' — Elemento ' . $idx;
Feature::create([
'layer_id' => $layer->id,
'name' => $name,
'geometry' => $fd['geometry'],
'properties' => $fd['properties'] ?? [],
'template_id' => $fd['properties']['template_id'] ?? null,
'progress' => $fd['properties']['progress'] ?? 0,
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
? $fd['properties']['status']
: 'planned',
'responsible' => $fd['properties']['responsible'] ?? null,
]);
}
$this->visibleLayers[] = $layer->id;
});
} catch (\Throwable $e) {
$this->dispatch('notify', 'Error al importar: ' . $e->getMessage());
return;
}
$this->loadLayers();
$this->visibleLayers[] = $layer->id;
$this->reset(['uploadFile', 'layerName']);
$this->emitInitialLayersData();
session()->flash('message', 'Capa importada correctamente.');
$this->dispatch('notify', 'Capa importada correctamente');
}
// ── Create empty layer ────────────────────────────────────────────────────
public function createEmptyLayer()
{
$user = Auth::user();
if (!$user->can('upload layers')) {
$this->dispatch('notify', 'Sin permisos para crear capas');
return;
}
$layer = Layer::create([
'project_id' => $this->project->id,
'phase_id' => $this->phase->id,
'name' => $this->layerName ?: 'Nueva capa',
'color' => $this->layerColor ?: '#3b82f6',
'project_id' => $this->project->id,
'phase_id' => $this->phase->id,
'name' => $this->layerName ?: 'Nueva capa',
'color' => $this->layerColor ?: '#3b82f6',
'original_file' => null,
'uploaded_by' => $user->id,
'uploaded_by' => $user->id,
]);
$this->loadLayers();
$this->visibleLayers[] = $layer->id;
$this->selectLayer($layer->id);
$this->emitInitialLayersData();
session()->flash('message', 'Capa vacía creada. Usa el editor para añadir elementos.');
$this->dispatch('notify', 'Capa vacía creada. Dibuja elementos en el mapa.');
}
// ── Save drawn GeoJSON ────────────────────────────────────────────────────
public function saveManualGeojson($geojsonString)
{
if (!$this->selectedLayer) {
session()->flash('error', 'No hay capa seleccionada.');
$this->dispatch('notify', 'No hay capa seleccionada');
return;
}
$geojson = json_decode($geojsonString, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($geojson['features'])) {
session()->flash('error', 'GeoJSON inválido.');
$this->dispatch('notify', 'GeoJSON inválido');
return;
}
// Eliminar todos los features existentes de esta capa
$this->selectedLayer->features()->delete();
$layerId = $this->selectedLayer->id;
$layerName = $this->selectedLayer->name;
// Crear nuevos features a partir del GeoJSON
foreach ($geojson['features'] as $featureData) {
Feature::create([
'layer_id' => $this->selectedLayer->id,
'name' => $featureData['properties']['name'] ?? null,
'geometry' => $featureData['geometry'],
'properties' => $featureData['properties'] ?? [],
'template_id' => $featureData['properties']['template_id'] ?? null,
'progress' => $featureData['properties']['progress'] ?? 0,
'responsible' => $featureData['properties']['responsible'] ?? null,
]);
try {
DB::transaction(function () use ($geojson, $layerId, $layerName) {
// forceDelete: reemplazamos completamente los elementos de la capa
Feature::where('layer_id', $layerId)->forceDelete();
$idx = 0;
foreach ($geojson['features'] as $fd) {
$idx++;
$name = trim($fd['properties']['name'] ?? '');
if ($name === '') $name = $layerName . ' — Elemento ' . $idx;
Feature::create([
'layer_id' => $layerId,
'name' => $name,
'geometry' => $fd['geometry'],
'properties' => $fd['properties'] ?? [],
'template_id' => $fd['properties']['template_id'] ?? null,
'progress' => $fd['properties']['progress'] ?? 0,
'status' => in_array($fd['properties']['status'] ?? '', Feature::STATUSES)
? $fd['properties']['status']
: 'planned',
'responsible' => $fd['properties']['responsible'] ?? null,
]);
}
});
} catch (\Throwable $e) {
$this->dispatch('notify', 'Error al guardar: ' . $e->getMessage());
return;
}
$this->loadLayers();
$this->selectLayer($this->selectedLayer->id);
$this->emitInitialLayersData();
session()->flash('message', 'Capa guardada con ' . count($geojson['features']) . ' elementos.');
$this->dispatch('notify', count($geojson['features']) . ' elementos guardados');
}
// ── Delete layer ──────────────────────────────────────────────────────────
public function deleteLayer($layerId)
{
$user = Auth::user();
if (!$user->can('delete layers') && !$user->hasRole('Admin')) abort(403);
$layer = Layer::find($layerId);
if (!$user->can('delete layers')) abort(403);
// Verify it belongs to this phase (prevents cross-project deletion)
$layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first();
if (!$layer) return;
if ($layer->original_file) Storage::disk('public')->delete($layer->original_file);
$layer->features()->delete(); // opcional, si no usas cascade
$layer->features()->delete();
$layer->delete();
$this->loadLayers();
if ($this->selectedLayer && $this->selectedLayer->id == $layerId) {
$this->selectedLayer = null;
$this->dispatch('layerSelectedForEdit', null);
}
$this->emitInitialLayersData();
session()->flash('message', 'Capa eliminada.');
$this->dispatch('notify', 'Capa eliminada');
}
// ── Export GeoJSON ────────────────────────────────────────────────────────
public function exportLayer($layerId)
{
$layer = Layer::with('features')
->where('id', $layerId)
->where('phase_id', $this->phase->id)
->first();
if (!$layer) return;
$fc = [
'type' => 'FeatureCollection',
'name' => $layer->name,
'features' => $layer->features->map(fn($f) => [
'type' => 'Feature',
'geometry' => $f->geometry,
'properties' => array_merge($f->properties ?? [], [
'name' => $f->name,
'progress' => $f->progress,
'status' => $f->status,
'responsible' => $f->responsible,
'template_id' => $f->template_id,
]),
])->values()->toArray(),
];
$filename = preg_replace('/[^a-z0-9_\-]/i', '_', $layer->name) . '.geojson';
return response()->streamDownload(function () use ($fc) {
echo json_encode($fc, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}, $filename, ['Content-Type' => 'application/geo+json']);
}
// ── Batch assign template / status ────────────────────────────────────────
public function batchAssign($layerId)
{
$layer = Layer::where('id', $layerId)->where('phase_id', $this->phase->id)->first();
if (!$layer) return;
$data = [];
if ($this->batchStatus && in_array($this->batchStatus, Feature::STATUSES)) {
$data['status'] = $this->batchStatus;
}
if ($this->batchTemplateId) {
$data['template_id'] = (int) $this->batchTemplateId;
}
if (empty($data)) {
$this->dispatch('notify', 'Selecciona un estado o template para asignar');
return;
}
$count = $layer->features()->update($data);
$this->loadLayers();
$this->emitInitialLayersData();
$this->dispatch('notify', "$count elemento(s) actualizados");
}
// ── Cancel editing ────────────────────────────────────────────────────────
public function cancelEditing()
{
$this->selectedLayer = null;
@@ -309,4 +397,4 @@ class LayerManager extends Component
{
return view('livewire.layers.layer-manager');
}
}
}
-124
View File
@@ -1,124 +0,0 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithFileUploads;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Layer;
use App\Models\Feature;
use App\Services\SpatialFileConverter;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
class LayerUpload extends Component
{
use WithFileUploads;
public $projectId;
public $phaseId;
public $uploadFile = null;
public $layerName = '';
public $layerColor = '#3b82f6';
protected $rules = [
'uploadFile' => 'required|file|max:51200',
'layerName' => 'required|string|max:255',
'layerColor' => 'nullable|string|size:7',
];
public function mount($projectId = null, $phaseId = null)
{
$this->projectId = $projectId;
$this->phaseId = $phaseId;
}
public function upload()
{
$user = Auth::user();
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
session()->flash('error', 'Sin permisos.');
return;
}
$this->validate();
if (!$this->projectId || !$this->phaseId) {
session()->flash('error', 'Faltan datos del proyecto/fase.');
return;
}
$project = Project::findOrFail($this->projectId);
$phase = Phase::findOrFail($this->phaseId);
$extension = strtolower($this->uploadFile->getClientOriginalExtension());
$mime = $this->uploadFile->getMimeType();
$allowedExtensions = ['geojson', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
$allowedMimes = [
'application/vnd.google-earth.kml+xml',
'application/vnd.google-earth.kmz',
'application/zip',
'application/x-zip-compressed',
'application/x-shapefile',
'image/vnd.dwg',
'application/acad',
'application/geo+json',
'text/xml',
'application/xml',
];
if (!in_array($extension, $allowedExtensions) && !in_array($mime, $allowedMimes)) {
session()->flash('error', 'Tipo de archivo no permitido.');
return;
}
$projectDir = "uploads/projects/{$project->id}/layers";
$originalPath = $this->uploadFile->store($projectDir, 'public');
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
if (!$geojson) {
session()->flash('error', 'Conversión fallida.');
return;
}
$layerColor = $this->layerColor ?: '#3b82f6';
$geojson['style'] = ['color' => $layerColor];
$layer = Layer::create([
'project_id' => $project->id,
'phase_id' => $phase->id,
'name' => $this->layerName,
'color' => $layerColor,
'original_file' => $originalPath,
'uploaded_by' => $user->id,
]);
if (isset($geojson['features'])) {
foreach ($geojson['features'] as $featureData) {
Feature::create([
'layer_id' => $layer->id,
'name' => $featureData['properties']['name'] ?? null,
'geometry' => $featureData['geometry'],
'properties' => $featureData['properties'] ?? [],
'template_id' => $featureData['properties']['template_id'] ?? null,
'progress' => $featureData['properties']['progress'] ?? 0,
'responsible' => $featureData['properties']['responsible'] ?? null,
]);
}
}
$this->reset(['uploadFile', 'layerName']);
session()->flash('message', "Capa '{$layer->name}' importada correctamente con " . count($geojson['features'] ?? []) . ' elementos.');
$this->dispatch('layerUploaded', projectId: $project->id);
}
public function render()
{
$projects = Project::accessibleBy(Auth::user())->get();
$phases = $this->projectId ? Phase::where('project_id', $this->projectId)->orderBy('order')->get() : collect();
return view('livewire.layer-upload', compact('projects', 'phases'));
}
}
+2 -2
View File
@@ -65,7 +65,7 @@ class MediaManager extends Component
public function upload()
{
$user = Auth::user();
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
if (!$user->can('upload layers')) {
session()->flash('error', 'Sin permisos.');
return;
}
@@ -130,7 +130,7 @@ class MediaManager extends Component
$media = Media::findOrFail($mediaId);
$user = Auth::user();
if (!$user->hasRole('Admin') && $media->uploaded_by !== $user->id) {
if (!$user->can('delete media') && $media->uploaded_by !== $user->id) {
session()->flash('error', 'No puedes borrar archivos de otro usuario.');
return;
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Illuminate\Support\Facades\Auth;
class NotificationBell extends Component
{
public $notifications = [];
public $unreadCount = 0;
public $showDropdown = false;
public function mount()
{
$this->loadNotifications();
}
public function loadNotifications()
{
$user = Auth::user();
$this->notifications = $user->notifications()->latest()->take(10)->get()->toArray();
$this->unreadCount = $user->unreadNotifications()->count();
}
public function markAsRead($id)
{
Auth::user()->notifications()->where('id', $id)->update(['read_at' => now()]);
$this->loadNotifications();
}
public function markAllAsRead()
{
Auth::user()->unreadNotifications->markAsRead();
$this->loadNotifications();
}
public function render()
{
return view('livewire.notification-bell');
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
#[Layout('layouts.app')]
class PhaseGantt extends Component
{
public Project $project;
public $ganttData = [];
public function mount(Project $project)
{
$user = Auth::user();
if (!$user->can('manage all') && !$project->users()->where('user_id', $user->id)->exists()) {
abort(403);
}
$this->project = $project;
$this->loadGanttData();
}
public function loadGanttData()
{
$phases = $this->project->phases()->with(['layers.features'])->orderBy('order')->get();
$projectStart = $this->project->start_date ?? now()->startOfMonth();
$projectEnd = $this->project->end_date_estimated ?? now()->addMonths(6);
$this->ganttData = $phases->map(function($phase) use ($projectStart, $projectEnd) {
$planned_start = $phase->planned_start ?? $projectStart;
$planned_end = $phase->planned_end ?? $projectEnd;
$actual_start = $phase->actual_start;
$actual_end = $phase->actual_end;
$totalDays = max(1, $projectStart->diffInDays($projectEnd));
$pStartOffset = max(0, $projectStart->diffInDays($planned_start));
$pDuration = max(1, $planned_start->diffInDays($planned_end));
$pStartPct = round(($pStartOffset / $totalDays) * 100, 2);
$pWidthPct = round(($pDuration / $totalDays) * 100, 2);
$aStartPct = null; $aWidthPct = null;
if ($actual_start) {
$aStart = max(0, $projectStart->diffInDays($actual_start));
$aEnd = $actual_end ?? now();
$aDuration = max(1, $actual_start->diffInDays($aEnd));
$aStartPct = round(($aStart / $totalDays) * 100, 2);
$aWidthPct = round(($aDuration / $totalDays) * 100, 2);
}
$isDelayed = $phase->planned_end && $phase->planned_end->isPast() && $phase->progress_percent < 100;
return [
'id' => $phase->id,
'name' => $phase->name,
'color' => $phase->color ?? '#3b82f6',
'progress' => $phase->progress_percent,
'planned_start' => $planned_start->format('d/m/Y'),
'planned_end' => $planned_end->format('d/m/Y'),
'actual_start' => $actual_start?->format('d/m/Y'),
'actual_end' => $actual_end?->format('d/m/Y'),
'p_start_pct' => $pStartPct,
'p_width_pct' => min($pWidthPct, 100 - $pStartPct),
'a_start_pct' => $aStartPct,
'a_width_pct' => $aWidthPct ? min($aWidthPct, 100 - $aStartPct) : null,
'is_delayed' => $isDelayed,
'features_count' => $phase->layers->sum(fn($l) => $l->features->count()),
];
})->toArray();
}
public function updatePhaseDates($phaseId, $plannedStart, $plannedEnd, $actualStart = null, $actualEnd = null)
{
$phase = $this->project->phases()->findOrFail($phaseId);
$phase->update([
'planned_start' => $plannedStart ?: null,
'planned_end' => $plannedEnd ?: null,
'actual_start' => $actualStart ?: null,
'actual_end' => $actualEnd ?: null,
]);
$this->loadGanttData();
$this->dispatch('notify', 'Fechas actualizadas');
}
public function render()
{
return view('livewire.projects.phase-gantt', [
'project' => $this->project,
'phases' => $this->project->phases()->orderBy('order')->get(),
]);
}
}
+116 -15
View File
@@ -2,40 +2,141 @@
namespace App\Livewire;
use Livewire\Component;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Component;
class PhaseList extends Component
{
public Project $project;
public $phases;
// Modal state
public bool $showForm = false;
public $editingId = null;
// Form fields
public string $name = '';
public string $description = '';
public string $color = '#3b82f6';
public int $order = 1;
public int $progressPercent = 0;
public string $plannedStart = '';
public string $plannedEnd = '';
public string $actualStart = '';
public string $actualEnd = '';
public function mount(Project $project)
{
$this->project = $project;
$this->phases = $project->phases;
}
public function addPhase()
protected function rules(): array
{
$this->project->phases()->create([
'name' => 'Nueva fase',
'order' => $this->phases->count() + 1,
'color' => '#'.substr(md5(rand()), 0, 6)
return [
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'color' => 'required|string|max:7',
'order' => 'required|integer|min:0',
'progressPercent' => 'required|integer|min:0|max:100',
'plannedStart' => 'nullable|date',
'plannedEnd' => 'nullable|date|after_or_equal:plannedStart',
'actualStart' => 'nullable|date',
'actualEnd' => 'nullable|date|after_or_equal:actualStart',
];
}
protected $validationAttributes = [
'name' => 'nombre',
'color' => 'color',
'order' => 'orden',
'progressPercent' => 'progreso',
'plannedStart' => 'inicio previsto',
'plannedEnd' => 'fin previsto',
'actualStart' => 'inicio real',
'actualEnd' => 'fin real',
];
public function openForm($phaseId = null): void
{
abort_unless(Auth::user()->can('manage phases'), 403);
$this->resetForm();
if ($phaseId) {
$phase = $this->project->phases()->findOrFail($phaseId);
$this->editingId = $phase->id;
$this->name = $phase->name;
$this->description = $phase->description ?? '';
$this->color = $phase->color ?? '#3b82f6';
$this->order = (int) $phase->order;
$this->progressPercent = (int) $phase->progress_percent;
$this->plannedStart = $phase->planned_start?->format('Y-m-d') ?? '';
$this->plannedEnd = $phase->planned_end?->format('Y-m-d') ?? '';
$this->actualStart = $phase->actual_start?->format('Y-m-d') ?? '';
$this->actualEnd = $phase->actual_end?->format('Y-m-d') ?? '';
} else {
$this->order = (int) $this->project->phases()->max('order') + 1;
$this->color = '#' . substr(md5((string) rand()), 0, 6);
}
$this->showForm = true;
}
/** Opened from the table's edit button. */
#[On('phase-edit')]
public function editPhase($id): void
{
$this->openForm($id);
}
public function closeForm(): void
{
$this->showForm = false;
$this->resetForm();
}
private function resetForm(): void
{
$this->reset([
'editingId', 'name', 'description', 'plannedStart', 'plannedEnd', 'actualStart', 'actualEnd',
]);
$this->phases = $this->project->phases()->get();
session()->flash('message', 'Fase agregada');
$this->color = '#3b82f6';
$this->order = 1;
$this->progressPercent = 0;
$this->resetErrorBag();
}
public function deletePhase($phaseId)
public function save(): void
{
Phase::find($phaseId)->delete();
$this->phases = $this->project->phases()->get();
abort_unless(Auth::user()->can('manage phases'), 403);
$this->validate();
$data = [
'name' => $this->name,
'description' => $this->description ?: null,
'color' => $this->color,
'order' => $this->order,
'progress_percent' => $this->progressPercent,
'planned_start' => $this->plannedStart ?: null,
'planned_end' => $this->plannedEnd ?: null,
'actual_start' => $this->actualStart ?: null,
'actual_end' => $this->actualEnd ?: null,
];
if ($this->editingId) {
$this->project->phases()->findOrFail($this->editingId)->update($data);
} else {
$this->project->phases()->create($data);
}
$this->closeForm();
$this->dispatch('phases-changed');
$this->dispatch('notify', 'Fase guardada correctamente');
}
public function render()
{
return view('livewire.phase-list');
}
}
}
+2
View File
@@ -3,8 +3,10 @@
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Phase;
#[Layout('layouts.app')]
class PhaseProgress extends Component
{
public Phase $phase;
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace App\Livewire;
use App\Models\Phase;
use App\Models\Project;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
class PhaseTable extends DataTableComponent
{
protected $model = Phase::class;
public int $projectId;
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('order', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects(['phases.id as id', 'phases.order as order']);
}
/** Re-render when the parent (PhaseList) creates/edits a phase. */
#[On('phases-changed')]
public function refreshRows(): void
{
// no-op: triggers a re-render so the builder re-runs with fresh data.
}
public function builder(): Builder
{
$user = Auth::user();
abort_unless(
$user->can('manage all') || $user->can('edit projects') ||
Project::whereKey($this->projectId)->whereHas('users', fn ($q) => $q->where('user_id', $user->id))->exists(),
403
);
return Phase::where('phases.project_id', $this->projectId);
}
public function columns(): array
{
return [
Column::make('Orden', 'order')->sortable(),
Column::make('Nombre', 'name')
->sortable()
->searchable()
->format(function ($value, $row) {
$html = '<span class="font-medium">'.e($value).'</span>';
if ($row->description) {
$html .= '<div class="text-xs text-base-content/50 truncate max-w-xs">'.e(\Illuminate\Support\Str::limit($row->description, 60)).'</div>';
}
return $html;
})
->html(),
Column::make('Progreso', 'progress_percent')
->sortable()
->format(fn ($value) =>
'<div class="flex items-center gap-2 min-w-[110px]">
<progress class="progress progress-primary w-24 h-2" value="'.(int) $value.'" max="100"></progress>
<span class="text-xs text-base-content/60 w-8 text-right">'.(int) $value.'%</span>
</div>')
->html(),
Column::make('Fechas')
->label(function ($row) {
$ps = $row->planned_start?->format('d/m/Y');
$pe = $row->planned_end?->format('d/m/Y');
if (! $ps && ! $pe) {
return '<span class="text-base-content/30 text-xs">—</span>';
}
return '<span class="text-xs">'.($ps ?: '?').' → '.($pe ?: '?').'</span>';
})
->html(),
Column::make('Color', 'color')
->format(fn ($value) =>
'<div class="w-6 h-6 rounded border border-base-300" style="background:'.e($value).'" title="'.e($value).'"></div>')
->html(),
Column::make('Acciones')
->label(function ($row) {
$user = Auth::user();
$progress = route('phases.progress', $row->id);
$html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$progress.'" class="btn btn-xs btn-outline btn-info" title="Actualizar progreso" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
</a>';
if ($user->can('manage phases')) {
$html .= '<button wire:click="$dispatch(\'phase-edit\', { id: '.$row->id.' })" class="btn btn-xs btn-ghost" title="Editar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>';
$html .= '<button wire:click="deletePhase('.$row->id.')" wire:confirm="¿Eliminar la fase \''.e($row->name).'\'? Esta acción no se puede deshacer."
class="btn btn-xs btn-error btn-outline" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>';
}
$html .= '</div>';
return $html;
})
->html(),
];
}
public function deletePhase(int $id): void
{
abort_unless(Auth::user()->can('manage phases'), 403);
Phase::where('project_id', $this->projectId)->findOrFail($id)->delete();
$this->dispatch('phases-changed');
$this->dispatch('notify', 'Fase eliminada');
}
}
+24 -42
View File
@@ -2,15 +2,15 @@
namespace App\Livewire;
use Livewire\Component;
use App\Models\Project;
use App\Models\Company;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Component;
class ProjectCompanies extends Component
{
public Project $project;
public $assignedCompanies = [];
public $allCompanies = [];
public $selectedCompanyId = '';
public $selectedRole = 'other';
@@ -18,64 +18,46 @@ class ProjectCompanies extends Component
public function mount(Project $project)
{
$this->project = $project;
$this->loadCompanies();
$this->loadAvailable();
}
public function loadCompanies()
/** Companies not yet assigned to the project (for the dropdown). */
public function loadAvailable(): void
{
$this->assignedCompanies = $this->project->companies()->withPivot('role_in_project')->get();
$assignedIds = $this->assignedCompanies->pluck('id')->toArray();
$assignedIds = $this->project->companies()->pluck('companies.id')->toArray();
$this->allCompanies = Company::whereNotIn('id', $assignedIds)->orderBy('name')->get();
}
/** Reload the dropdown when the embedded table changes assignments. */
#[On('project-companies-changed')]
public function onCompaniesChanged(): void
{
$this->loadAvailable();
}
public function assignCompany()
{
$user = Auth::user();
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
session()->flash('error', 'No tienes permisos para asignar compañías.');
return;
}
abort_unless(Auth::user()->can('assign companies'), 403);
$this->validate([
'selectedCompanyId' => 'required|exists:companies,id',
'selectedRole' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other',
'selectedRole' => 'required|in:' . implode(',', array_keys(ProjectCompaniesTable::ROLES)),
]);
$this->project->companies()->attach($this->selectedCompanyId, [
'role_in_project' => $this->selectedRole
'role_in_project' => $this->selectedRole,
]);
$this->reset(['selectedCompanyId', 'selectedRole']);
$this->loadCompanies();
$this->dispatch('notify', 'Compañía asignada al proyecto.');
}
public function removeCompany($companyId)
{
$user = Auth::user();
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
session()->flash('error', 'Sin permisos.');
return;
}
$this->project->companies()->detach($companyId);
$this->loadCompanies();
$this->dispatch('notify', 'Compañía eliminada del proyecto.');
}
public function changeRole($companyId, $role)
{
if (!in_array($role, ['owner', 'constructor', 'subcontractor', 'consultant', 'supplier', 'other'])) return;
$this->project->companies()->updateExistingPivot($companyId, [
'role_in_project' => $role
]);
$this->loadCompanies();
$this->dispatch('notify', 'Rol actualizado.');
$this->loadAvailable();
$this->dispatch('project-companies-changed');
$this->dispatch('notify', 'Empresa asignada al proyecto.');
}
public function render()
{
return view('livewire.project-companies');
return view('livewire.project-companies', [
'roles' => ProjectCompaniesTable::ROLES,
]);
}
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace App\Livewire;
use App\Models\Company;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
class ProjectCompaniesTable extends DataTableComponent
{
protected $model = Company::class;
public int $projectId;
/** role_in_project => label */
public const ROLES = [
'owner' => 'Promotor',
'constructor' => 'Constructor',
'subcontractor' => 'Subcontratista',
'consultant' => 'Consultor',
'supplier' => 'Proveedor',
'other' => 'Otro',
];
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('companies.name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects(['companies.id as id', 'company_project.role_in_project as role_in_project']);
}
#[On('project-companies-changed')]
public function refreshRows(): void
{
// no-op: triggers re-render so the builder re-runs.
}
public function builder(): Builder
{
return Company::query()
->join('company_project', 'company_project.company_id', '=', 'companies.id')
->where('company_project.project_id', $this->projectId);
}
public function columns(): array
{
return [
Column::make('Empresa', 'name')
->sortable()
->searchable()
->format(function ($value, $row) {
$initial = strtoupper(mb_substr($value ?? '?', 0, 1));
$html = '<div class="flex items-center gap-2">
<span class="w-7 h-7 rounded-full bg-primary text-primary-content flex items-center justify-center text-xs font-bold shrink-0">'.$initial.'</span>
<div><span class="font-medium">'.e($value).'</span>';
if ($row->tax_id) {
$html .= '<div class="text-xs text-base-content/50">'.e($row->tax_id).'</div>';
}
$html .= '</div></div>';
return $html;
})
->html(),
Column::make('Rol', 'role_in_project')
->label(function ($row) {
$current = $row->role_in_project;
if (! Auth::user()->can('assign companies')) {
return '<span class="badge badge-sm">'.(self::ROLES[$current] ?? ucfirst((string) $current)).'</span>';
}
$opts = '';
foreach (self::ROLES as $val => $label) {
$opts .= '<option value="'.$val.'"'.($current === $val ? ' selected' : '').'>'.$label.'</option>';
}
return '<select wire:change="changeRole('.$row->id.', $event.target.value)" class="select select-bordered select-xs">'.$opts.'</select>';
})
->html(),
Column::make('Acciones')
->label(function ($row) {
if (! Auth::user()->can('assign companies')) {
return '';
}
return '<div class="flex justify-end">
<button wire:click="removeCompany('.$row->id.')" wire:confirm="¿Quitar a '.e($row->name).' del proyecto?"
class="btn btn-xs btn-error btn-outline" title="Quitar del proyecto">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>';
})
->html(),
];
}
public function filters(): array
{
return [
SelectFilter::make('Rol', 'role')
->options(['' => 'Rol: todos'] + self::ROLES)
->filter(fn (Builder $query, string $value) => $query->where('company_project.role_in_project', $value)),
];
}
public function changeRole($companyId, $role): void
{
abort_unless(Auth::user()->can('assign companies'), 403);
if (! array_key_exists($role, self::ROLES)) {
return;
}
\App\Models\Project::findOrFail($this->projectId)
->companies()->updateExistingPivot($companyId, ['role_in_project' => $role]);
$this->dispatch('project-companies-changed');
$this->dispatch('notify', 'Rol actualizado.');
}
public function removeCompany($companyId): void
{
abort_unless(Auth::user()->can('assign companies'), 403);
\App\Models\Project::findOrFail($this->projectId)->companies()->detach($companyId);
$this->dispatch('project-companies-changed');
$this->dispatch('notify', 'Empresa eliminada del proyecto.');
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Feature;
use App\Models\Inspection;
use App\Models\Issue;
use Illuminate\Support\Facades\Auth;
#[Layout('layouts.app')]
class ProjectDashboard extends Component
{
public Project $project;
// Computed stats (cached as properties after mount)
public array $stats = [];
public $phases;
public $recentInspections;
public $recentIssues;
public $teamMembers;
public $companies;
public function mount(Project $project): void
{
$this->project = $project;
$this->checkAccess();
$this->loadData();
}
private function checkAccess(): void
{
$user = Auth::user();
if ($user->can('manage all')) return;
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403);
}
private function loadData(): void
{
$pid = $this->project->id;
$this->phases = Phase::where('project_id', $pid)
->withCount('layers')
->with(['layers' => fn($q) => $q->withCount('features')])
->orderBy('order')
->get();
$totalFeatures = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $pid))->count();
$completedFeatures = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $pid))
->where('status', 'completed')->count();
$verifiedFeatures = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $pid))
->where('status', 'verified')->count();
$openIssues = Issue::where('project_id', $pid)->where('status', 'open')->count();
$closedIssues = Issue::where('project_id', $pid)->where('status', 'closed')->count();
$criticalIssues = Issue::where('project_id', $pid)->where('status', 'open')->where('priority', 'critical')->count();
$totalInspections = Inspection::where('project_id', $pid)->count();
$passedInspections = Inspection::where('project_id', $pid)->where('result', 'pass')->count();
$failedInspections = Inspection::where('project_id', $pid)->where('result', 'fail')->count();
$globalProgress = $this->phases->avg('progress_percent') ?? 0;
$delayedPhases = $this->phases->filter(fn($p) =>
$p->planned_end && $p->planned_end < now() && $p->progress_percent < 100
)->count();
$this->stats = [
'global_progress' => round($globalProgress),
'total_phases' => $this->phases->count(),
'delayed_phases' => $delayedPhases,
'total_features' => $totalFeatures,
'completed_features' => $completedFeatures,
'verified_features' => $verifiedFeatures,
'open_issues' => $openIssues,
'closed_issues' => $closedIssues,
'critical_issues' => $criticalIssues,
'total_inspections' => $totalInspections,
'passed_inspections' => $passedInspections,
'failed_inspections' => $failedInspections,
];
$this->recentInspections = Inspection::where('project_id', $pid)
->with(['feature', 'template', 'user'])
->latest()->take(6)->get();
$this->recentIssues = Issue::where('project_id', $pid)
->with(['feature', 'reporter'])
->where('status', '!=', 'closed')
->orderByRaw("FIELD(priority,'critical','high','medium','low')")
->take(6)->get();
$this->teamMembers = $this->project->users()->with('roles')->get();
$this->companies = $this->project->companies()->get();
}
public function render()
{
return view('livewire.projects.project-dashboard', [
'project' => $this->project,
]);
}
}
-42
View File
@@ -1,42 +0,0 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use App\Models\Project;
class ProjectEditTabs extends Component
{
public Project $project;
public string $activeTab = 'project-data';
public function mount(Project $project)
{
$this->project = $project;
}
public function setActiveTab($tab)
{
$this->activeTab = $tab;
}
public function tabChanged($tab, $projectId)
{
if ($projectId == $this->project->id) {
$this->activeTab = $tab;
}
}
public function updateProject()
{
$this->project->save();
session()->flash('message', __('Project updated successfully.'));
$this->dispatch('project-updated');
}
public function render()
{
return view('livewire.project-edit-tabs');
}
}
+109 -53
View File
@@ -3,83 +3,139 @@
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
#[Layout('layouts.app')]
class ProjectForm extends Component
{
public $projectId = null;
public $name = '';
public $address = '';
public $lat = null;
public $lng = null;
public $country = '';
public $start_date = '';
public $end_date_estimated = '';
public $status = 'planning';
public ?Project $project = null;
protected $rules = [
'name' => 'required|string|max:255',
'address' => 'required|string',
'lat' => 'nullable|numeric',
'lng' => 'nullable|numeric',
'start_date' => 'required|date',
'end_date_estimated' => 'nullable|date',
'status' => 'required|in:planning,in_progress,paused,completed',
];
// Identification
public string $name = '';
public string $reference = '';
public string $status = 'planning';
public function mount($projectId = null)
// Location
public string $address = '';
public string $country = '';
public string $lat = '';
public string $lng = '';
// Planning
public string $startDate = '';
public string $endDateEstimated = '';
public function mount(?Project $project = null): void
{
if ($projectId) {
$this->projectId = $projectId;
$project = Project::findOrFail($projectId);
$this->name = $project->name;
$this->address = $project->address;
$this->lat = $project->lat;
$this->lng = $project->lng;
$this->start_date = $project->start_date->format('Y-m-d');
$this->end_date_estimated = $project->end_date_estimated?->format('Y-m-d');
$this->status = $project->status;
// country? we don't have stored, maybe we can leave blank or compute from lat/lng? We'll leave blank for now.
if ($project && $project->exists) {
Gate::authorize('edit projects', $project);
$this->project = $project;
$this->name = $project->name;
$this->reference = $project->reference ?? '';
$this->status = $project->status;
$this->address = $project->address;
$this->country = $project->country ?? '';
$this->lat = (string) ($project->lat ?? '');
$this->lng = (string) ($project->lng ?? '');
$this->startDate = $project->start_date->format('Y-m-d');
$this->endDateEstimated = $project->end_date_estimated?->format('Y-m-d') ?? '';
} else {
Gate::authorize('create projects');
$this->startDate = today()->format('Y-m-d');
}
}
public function setCoordinates($lat, $lng)
// Called from JS after map click / marker drag + reverse geocode
public function setLocation(string $lat, string $lng, string $address = '', string $country = ''): void
{
$this->lat = $lat;
$this->lng = $lng;
// Optionally, we could trigger reverse geocoding here via JS and update address and country.
// But we'll do that entirely in JavaScript for better UX.
// We'll emit an event to JS to fetch address.
$this->dispatch('coordinatesUpdated', lat: $lat, lng: $lng);
if ($address) $this->address = $address;
if ($country) $this->country = strtolower($country);
}
public function save()
protected function rules(): array
{
return [
'name' => 'required|string|max:255',
'reference' => 'nullable|string|max:100',
'status' => 'required|in:planning,in_progress,paused,completed',
'address' => 'required|string',
'country' => 'nullable|string|size:2',
'lat' => 'nullable|numeric|between:-90,90',
'lng' => 'nullable|numeric|between:-180,180',
'startDate' => 'required|date',
'endDateEstimated' => 'nullable|date|after_or_equal:startDate',
];
}
protected $validationAttributes = [
'name' => 'nombre',
'reference' => 'referencia',
'status' => 'estado',
'address' => 'dirección',
'country' => 'país',
'lat' => 'latitud',
'lng' => 'longitud',
'startDate' => 'fecha de inicio',
'endDateEstimated' => 'fecha de fin estimada',
];
public function save(): void
{
$this->validate();
if ($this->projectId) {
$project = Project::findOrFail($this->projectId);
$data = [
'name' => $this->name,
'reference' => $this->reference ?: null,
'status' => $this->status,
'address' => $this->address,
'country' => $this->country ?: null,
'lat' => $this->lat ?: null,
'lng' => $this->lng ?: null,
'start_date' => $this->startDate,
'end_date_estimated' => $this->endDateEstimated ?: null,
];
if ($this->project && $this->project->exists) {
$this->project->update($data);
session()->flash('notify', 'Proyecto actualizado correctamente.');
} else {
$project = new Project();
$project->created_by = auth()->id();
$project = Project::create(array_merge($data, ['created_by' => Auth::id()]));
$project->users()->attach(Auth::id(), ['role_in_project' => 'supervisor']);
session()->flash('notify', 'Proyecto creado correctamente.');
}
$project->name = $this->name;
$project->address = $this->address;
$project->lat = $this->lat;
$project->lng = $this->lng;
$project->start_date = $this->start_date;
$project->end_date_estimated = $this->end_date_estimated;
$project->status = $this->status;
$project->save();
session()->flash('message', 'Project saved successfully.');
return redirect()->route('projects.index');
$this->redirect(route('projects.index'), navigate: true);
}
public function render()
{
return view('livewire.projects.project-form');
return view('livewire.projects.project-form', [
'countryList' => $this->countryList(),
]);
}
/**
* ISO alpha-2 (lowercase, matches flagcdn) => display name.
*/
private function countryList(): array
{
return [
'es' => 'España', 'pt' => 'Portugal', 'fr' => 'Francia', 'it' => 'Italia',
'de' => 'Alemania', 'gb' => 'Reino Unido', 'ie' => 'Irlanda', 'nl' => 'Países Bajos',
'be' => 'Bélgica', 'ch' => 'Suiza', 'at' => 'Austria', 'lu' => 'Luxemburgo',
'se' => 'Suecia', 'no' => 'Noruega', 'dk' => 'Dinamarca', 'fi' => 'Finlandia',
'pl' => 'Polonia', 'cz' => 'Chequia', 'gr' => 'Grecia', 'ro' => 'Rumanía',
'us' => 'Estados Unidos', 'ca' => 'Canadá', 'mx' => 'México', 'gt' => 'Guatemala',
'cr' => 'Costa Rica', 'pa' => 'Panamá', 'co' => 'Colombia', 've' => 'Venezuela',
'ec' => 'Ecuador', 'pe' => 'Perú', 'bo' => 'Bolivia', 'cl' => 'Chile',
'ar' => 'Argentina', 'uy' => 'Uruguay', 'py' => 'Paraguay', 'br' => 'Brasil',
'do' => 'República Dominicana', 'ma' => 'Marruecos', 'gq' => 'Guinea Ecuatorial',
'ao' => 'Angola', 'cv' => 'Cabo Verde', 'us' => 'Estados Unidos',
];
}
}
+2
View File
@@ -4,9 +4,11 @@ namespace App\Livewire;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\Attributes\Layout;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
#[Layout('layouts.app')]
class ProjectList extends Component
{
use WithPagination;
+238 -123
View File
@@ -10,27 +10,28 @@ use App\Models\Layer;
use App\Models\Feature;
use App\Models\Inspection;
use App\Models\InspectionTemplate;
use App\Models\Issue;
class ProjectMap extends Component
{
public Project $project;
public $phases;
public $activeLayers = [];
public $activeLayers = []; // Now stores Layer IDs (not Phase IDs)
public $showLayerModal = false;
// Editor properties
public $selectedFeature = null; // será instancia de Feature
public $selectedFeature = null;
public $selectedPhaseId = null;
public $editProgress = 0;
public $editComment = '';
public $editResponsible = '';
public $editPhotos = [];
public $formFullscreen = false;
// Tab management
public $activeTab = 'edit'; // edit, features, inspections
public $allFeatures = [];
public $allInspections = [];
// Tab management
public $activeTab = 'edit';
public $allFeatures;
public $allInspections;
// Templates e inspecciones
public $templates = [];
@@ -42,19 +43,61 @@ class ProjectMap extends Component
public $showFeatureImages = false;
public $featureImageMarkers = [];
// Tab management
public $activeTab = 'edit'; // edit or list
// Filters
public $filterStatus = '';
public $filterResponsible = '';
public $filterProgressMin = 0;
public $filterProgressMax = 100;
public $showFilters = false;
// Inspection workflow
public $inspectionResult = '';
public $inspectionNotes = '';
// Issues
public $openIssuesCount = 0;
// Inspection viewer
public $viewingInspection = null;
public function mount(Project $project)
{
$this->project = $project;
// Cargar fases con sus capas y los features de esas capas (para mostrarlos en el mapa)
$this->phases = $project->phases()->with(['layers' => function ($q) {
$q->withCount('features');
}, 'layers.features'])->get();
// Por defecto mostrar todas las capas activas (todas las fases que tengan alguna capa con features)
$this->activeLayers = $this->phases->pluck('id')->toArray();
$this->authorizeProjectAccess();
$this->phases = $project->phases()->with([
'layers' => fn($q) => $q->withCount('features'),
'layers.features',
'layers.features.images',
])->get();
// Initialize activeLayers with ALL layer IDs (not phase IDs)
$this->activeLayers = $this->phases
->flatMap(fn($p) => $p->layers->pluck('id'))
->map(fn($id) => (int) $id)
->toArray();
$this->loadTemplates();
$this->allFeatures = Feature::whereHas('layer.phase', function($q) use ($project) {
$q->where('project_id', $project->id);
})->with(['layer.phase', 'template'])->get();
$this->allInspections = Inspection::where('project_id', $project->id)
->with(['feature.layer.phase', 'template', 'user'])
->orderBy('created_at', 'desc')
->get();
$this->openIssuesCount = Issue::where('project_id', $project->id)
->where('status', 'open')
->count();
}
private function authorizeProjectAccess(): void
{
$user = Auth::user();
if ($user->can('manage all')) return;
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403);
}
public function loadTemplates()
@@ -62,90 +105,129 @@ class ProjectMap extends Component
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
}
public function toggleLayer($phaseId)
// ─── Layer / Phase visibility ────────────────────────────────────────────────
public function toggleLayer($layerId)
{
if (in_array($phaseId, $this->activeLayers)) {
$this->activeLayers = array_diff($this->activeLayers, [$phaseId]);
$layerId = (int) $layerId;
if (in_array($layerId, $this->activeLayers)) {
$this->activeLayers = array_values(array_diff($this->activeLayers, [$layerId]));
} else {
$this->activeLayers[] = $phaseId;
$this->activeLayers[] = $layerId;
}
$this->dispatch('layersUpdated', $this->activeLayers);
}
public function openLayerModal()
public function togglePhase($phaseId)
{
$this->showLayerModal = true;
$phase = $this->phases->find($phaseId);
if (!$phase) return;
$layerIds = $phase->layers->pluck('id')->map(fn($id) => (int) $id)->toArray();
$allActive = !empty($layerIds) && collect($layerIds)->every(fn($id) => in_array($id, $this->activeLayers));
if ($allActive) {
$this->activeLayers = array_values(array_diff($this->activeLayers, $layerIds));
} else {
$this->activeLayers = array_values(array_unique(array_merge($this->activeLayers, $layerIds)));
}
$this->dispatch('layersUpdated', $this->activeLayers);
}
public function closeLayerModal()
public function openLayerModal() { $this->showLayerModal = true; }
public function closeLayerModal() { $this->showLayerModal = false; }
// ─── Filters ────────────────────────────────────────────────────────────────
public function updatedFilterStatus() { $this->applyFilters(); }
public function updatedFilterResponsible() { $this->applyFilters(); }
public function updatedFilterProgressMin() { $this->applyFilters(); }
public function updatedFilterProgressMax() { $this->applyFilters(); }
public function applyFilters()
{
$this->showLayerModal = false;
$filtered = $this->allFeatures->filter(function($f) {
if ($this->filterStatus && $f->status !== $this->filterStatus) return false;
if ($this->filterResponsible && !str_contains(strtolower($f->responsible ?? ''), strtolower($this->filterResponsible))) return false;
if ($f->progress < $this->filterProgressMin || $f->progress > $this->filterProgressMax) return false;
return true;
});
$this->dispatch('filtersChanged', $filtered->pluck('id')->values()->toArray());
}
public function clearFilters()
{
$this->filterStatus = '';
$this->filterResponsible = '';
$this->filterProgressMin = 0;
$this->filterProgressMax = 100;
$this->dispatch('filtersChanged', $this->allFeatures->pluck('id')->values()->toArray());
}
// ─── Feature status ─────────────────────────────────────────────────────────
public function editFeatureStatus($status)
{
if (!$this->selectedFeature) return;
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
$feature->status = $status;
if ($status === 'completed') $feature->progress = 100;
if ($status === 'planned') $feature->progress = 0;
$feature->save();
$this->selectedFeature = $feature;
$this->editProgress = $feature->progress;
$this->allFeatures = $this->allFeatures->map(fn($f) => $f->id === $feature->id ? $feature : $f);
$this->dispatch('featureStatusChanged', $feature->id, $feature->status, $feature->status_color);
$this->dispatch('notify', 'Estado actualizado');
}
/**
* Actualizar el progreso de un Feature y recalcular el progreso de la fase.
*/
public function updateProgress($featureId, $newProgress, $comment = null)
{
$feature = Feature::findOrFail($featureId);
$feature = Feature::with('layer.phase')->findOrFail($featureId);
$user = Auth::user();
if (!$user->can('update progress') && !$user->hasRole('Admin')) {
if (!$user->can('update progress')) {
$this->dispatch('notify', 'Sin permisos');
return;
}
$oldProgress = $feature->progress;
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
$feature->progress = min(100, max(0, $newProgress));
$feature->save();
// Recalcular el progreso de la fase (promedio de todos sus features)
$phase = Phase::find($feature->layer->phase_id);
$phase = $feature->layer->phase;
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
$phase->save();
// Registrar la actualización en progress_updates
$phase->progressUpdates()->create([
'user_id' => $user->id,
'user_id' => $user->id,
'progress_percent' => $phase->progress_percent,
'comment' => $comment,
'comment' => $comment,
]);
$this->dispatch('progressUpdated', $featureId, $feature->progress);
$this->dispatch('notify', 'Progreso actualizado');
// Si el feature seleccionado es el mismo, actualizar la propiedad local
if ($this->selectedFeature && $this->selectedFeature->id == $featureId) {
$this->selectedFeature->progress = $feature->progress;
$this->editProgress = $feature->progress;
}
}
/**
* Seleccionar un Feature al hacer clic en el mapa.
*/
public function selectFeature($featureId)
{
$this->selectedFeature = null;
$feature = Feature::with('template')->find($featureId);
$feature = Feature::with(['template', 'layer.phase'])->find($featureId);
if (!$feature) return;
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
$this->selectedFeature = $feature;
$this->selectedPhaseId = $feature->layer->phase_id;
$this->editProgress = $feature->progress;
$this->editResponsible = $feature->responsible ?? '';
$this->editPhotos = $feature->properties['photos'] ?? [];
$this->selectedFeature = $feature;
$this->selectedPhaseId = $feature->layer->phase_id;
$this->editProgress = $feature->progress;
$this->editResponsible = $feature->responsible ?? '';
$this->editPhotos = $feature->properties['photos'] ?? [];
$this->selectedTemplateId = $feature->template_id;
$this->activeTab = 'edit';
$this->loadInspectionHistory();
$this->resetInspectionForm();
$this->dispatch('featureSelected', $featureId);
$this->dispatch('featureSelected', $featureId, $feature->name);
}
/**
* Cargar el historial de inspecciones del feature seleccionado.
*/
public function loadInspectionHistory()
{
if (!$this->selectedFeature) {
@@ -158,12 +240,11 @@ class ProjectMap extends Component
->get();
}
/**
* Reiniciar el formulario de inspección según el template seleccionado.
*/
public function resetInspectionForm()
{
$this->inspectionFormData = [];
$this->inspectionResult = '';
$this->inspectionNotes = '';
if ($this->selectedTemplateId) {
$template = InspectionTemplate::find($this->selectedTemplateId);
if ($template) {
@@ -174,19 +255,16 @@ class ProjectMap extends Component
}
}
/**
* Guardar una nueva inspección.
*/
public function saveInspection()
{
if (!$this->selectedFeature || !$this->selectedTemplateId) {
$this->dispatch('notify', 'Selecciona un elemento y un template.');
return;
}
$feature = Feature::with('layer.phase')->find($this->selectedFeature->id);
if (!$feature || $feature->layer->phase->project_id !== $this->project->id) abort(403);
$this->validate([
'selectedTemplateId' => 'required|exists:inspection_templates,id',
]);
$this->validate(['selectedTemplateId' => 'required|exists:inspection_templates,id']);
$template = InspectionTemplate::find($this->selectedTemplateId);
foreach ($template->fields as $field) {
@@ -197,70 +275,117 @@ class ProjectMap extends Component
}
$inspection = Inspection::create([
'project_id' => $this->project->id,
'layer_id' => $this->selectedFeature->layer_id,
'feature_id' => $this->selectedFeature->id,
'template_id' => $this->selectedTemplateId,
'user_id' => auth()->id(),
'data' => $this->inspectionFormData,
'project_id' => $this->project->id,
'layer_id' => $this->selectedFeature->layer_id,
'feature_id' => $this->selectedFeature->id,
'template_id' => $this->selectedTemplateId,
'user_id' => auth()->id(),
'inspector_user_id' => auth()->id(),
'status' => 'completed',
'completed_at' => now(),
'result' => $this->inspectionResult ?: null,
'notes' => $this->inspectionNotes ?: null,
'data' => $this->inspectionFormData,
]);
// Si el template tiene un campo llamado 'progress', actualizar el progreso del feature
if (isset($this->inspectionFormData['progress'])) {
$this->updateProgress($this->selectedFeature->id, (int)$this->inspectionFormData['progress'], 'Inspección registrada');
if ($this->inspectionResult === 'fail') {
Issue::create([
'project_id' => $this->project->id,
'feature_id' => $this->selectedFeature->id,
'inspection_id' => $inspection->id,
'title' => 'Fallo en inspección: ' . ($template->name ?? 'Sin nombre'),
'description' => $this->inspectionNotes,
'priority' => 'high',
'status' => 'open',
'reported_by' => auth()->id(),
]);
$this->openIssuesCount = Issue::where('project_id', $this->project->id)
->where('status', 'open')->count();
$this->dispatch('notify', 'Inspección fallida — Issue creado automáticamente');
} else {
if (isset($this->inspectionFormData['progress'])) {
$this->updateProgress($this->selectedFeature->id, (int)$this->inspectionFormData['progress'], 'Inspección registrada');
}
$this->dispatch('notify', 'Inspección guardada correctamente');
}
// Reload global list
$this->allInspections = Inspection::where('project_id', $this->project->id)
->with(['feature.layer.phase', 'template', 'user'])
->orderBy('created_at', 'desc')
->get();
$this->loadInspectionHistory();
$this->resetInspectionForm();
$this->dispatch('notify', 'Inspección guardada correctamente');
}
/**
* Asignar un template al feature seleccionado.
*/
public function assignTemplateToFeature($templateId)
{
if (!$this->selectedFeature) return;
$this->selectedFeature->template_id = $templateId;
$this->selectedFeature->save();
$template = InspectionTemplate::where('id', $templateId)
->where('project_id', $this->project->id)->first();
if (!$template) abort(403);
$feature = Feature::findOrFail($this->selectedFeature->id);
$feature->template_id = $templateId;
$feature->save();
$this->selectedFeature = $feature;
$this->selectedTemplateId = $templateId;
$this->resetInspectionForm();
$this->dispatch('notify', 'Template asignado al elemento');
}
/**
* Guardar progreso y responsable del feature seleccionado.
*/
public function saveFeatureProgress()
{
if (!$this->selectedFeature) return;
$this->selectedFeature->progress = min(100, max(0, (int)$this->editProgress));
$this->selectedFeature->responsible = $this->editResponsible;
$this->selectedFeature->save();
// Recalcular progreso de la fase
$phase = Phase::find($this->selectedFeature->layer->phase_id);
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
$feature->progress = min(100, max(0, (int)$this->editProgress));
$feature->responsible = $this->editResponsible;
$feature->save();
$this->selectedFeature = $feature;
$phase = $feature->layer->phase;
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
$phase->save();
$this->dispatch('progressUpdated', $phase->id, $phase->progress_percent);
$this->dispatch('notify', 'Progreso guardado');
}
/**
* Cuando cambia el template seleccionado, reiniciar el formulario.
*/
public function onTemplateChange()
{
$this->resetInspectionForm();
}
/**
* Toggle mostrar imágenes en el mapa.
*/
// ─── Inspection viewer ───────────────────────────────────────────────────────
public function viewInspection($id)
{
$ins = Inspection::where('project_id', $this->project->id)
->with(['feature.layer.phase', 'template', 'user'])
->find($id);
if (!$ins) return;
$this->viewingInspection = [
'id' => $ins->id,
'feature_name' => $ins->feature?->name ?? '—',
'layer_name' => $ins->feature?->layer?->name ?? '—',
'phase_name' => $ins->feature?->layer?->phase?->name ?? '—',
'template_name' => $ins->template?->name ?? '—',
'user_name' => $ins->user?->name ?? '—',
'date' => $ins->created_at->format('d/m/Y H:i'),
'status' => $ins->status,
'result' => $ins->result,
'notes' => $ins->notes,
'data' => $ins->data ?? [],
'fields' => $ins->template?->fields ?? [],
];
}
public function closeViewInspection()
{
$this->viewingInspection = null;
}
// ─── Feature images ──────────────────────────────────────────────────────────
public function toggleFeatureImages()
{
$this->showFeatureImages = !$this->showFeatureImages;
@@ -268,44 +393,31 @@ class ProjectMap extends Component
$this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers);
}
/**
* Cargar marcadores de imágenes para el mapa.
*/
public function loadFeatureImageMarkers()
{
if (!$this->showFeatureImages) {
$this->featureImageMarkers = [];
return;
}
if (!$this->showFeatureImages) { $this->featureImageMarkers = []; return; }
$markers = [];
foreach ($this->phases as $phase) {
foreach ($phase->layers as $layer) {
foreach ($layer->features as $feature) {
$image = $feature->images()->first();
$image = $feature->images->first();
if ($image) {
$geo = $feature->geometry;
$geo = $feature->geometry;
$coords = null;
if ($geo && isset($geo['coordinates'])) {
if ($geo['type'] === 'Point') {
$coords = [
'lat' => $geo['coordinates'][1],
'lng' => $geo['coordinates'][0],
];
$coords = ['lat' => $geo['coordinates'][1], 'lng' => $geo['coordinates'][0]];
} elseif (in_array($geo['type'], ['Polygon', 'LineString'])) {
$coords = [
'lat' => $geo['coordinates'][0][1] ?? null,
'lng' => $geo['coordinates'][0][0] ?? null,
];
$coords = ['lat' => $geo['coordinates'][0][1] ?? null, 'lng' => $geo['coordinates'][0][0] ?? null];
}
}
if ($coords && $coords['lat'] && $coords['lng']) {
$markers[] = [
'feature_id' => $feature->id,
'name' => $feature->name,
'lat' => $coords['lat'],
'lng' => $coords['lng'],
'image_url' => $image->url,
'name' => $feature->name,
'lat' => $coords['lat'],
'lng' => $coords['lng'],
'image_url' => $image->url,
'image_name' => $image->name,
];
}
@@ -319,16 +431,19 @@ class ProjectMap extends Component
public function toggleFullscreen()
{
$this->formFullscreen = !$this->formFullscreen;
if (!$this->formFullscreen) {
$this->dispatch('mapResize');
}
if (!$this->formFullscreen) $this->dispatch('mapResize');
}
public function setActiveTab($tab)
{
$this->activeTab = $tab;
}
public function render()
{
return view('livewire.projects.project-map', [
'project' => $this->project,
'phases' => $this->phases,
'phases' => $this->phases,
]);
}
}
}
+77 -62
View File
@@ -4,9 +4,8 @@ namespace App\Livewire;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Columns\{BooleanColumn, ButtonGroupColumn, LinkColumn, ImageColumn};
use Rappasoft\LaravelLivewireTables\Views\Filters\{DateFilter, MultiSelectFilter, SelectFilter};
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use App\Models\Project;
class ProjectTable extends DataTableComponent
@@ -17,86 +16,102 @@ class ProjectTable extends DataTableComponent
{
$this->setPrimaryKey('id')
->setDefaultSort('created_at', 'desc')
->setTableAttributes(['class' => 'table-auto w-full']);
->setSortingPillsEnabled(false)
->setAdditionalSelects(['projects.id as id', 'projects.created_at as created_at']);
}
$this->setThAttributes(function(Column $column) {
return ['class' => 'px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'];
});
$this->setTdAttributes(function(Column $column) {
return ['class' => 'px-4 py-2 whitespace-nowrap text-sm text-gray-900'];
});
public function builder(): Builder
{
return Project::accessibleBy(Auth::user())
->with('phases');
}
public function columns(): array
{
return [
Column::make(__('ID'), 'id')
Column::make('Referencia', 'reference')
->sortable()
->searchable()
->format(function ($value, $row) {
$url = route('projects.dashboard', $row->id);
return $value
? '<a href="'.$url.'" class="font-mono text-xs text-primary hover:underline" wire:navigate>'.e($value).'</a>'
: '<span class="text-gray-300">—</span>';
})
->html(),
Column::make(__('Name'), 'name')
->sortable()
->searchable(),
Column::make(__('Project Name'), 'name')
->sortable()
->searchable(),
Column::make(__('Address'), 'address')
->sortable()
->searchable(),
->searchable()
->format(fn ($value) => $value
? '<span class="truncate block max-w-xs" title="'.e($value).'">'.e($value).'</span>'
: '<span class="text-gray-400">—</span>')
->html(),
Column::make(__('Status'), 'status')
->sortable(),
->sortable()
->format(function ($value) {
$map = [
'planning' => ['badge-ghost', 'Planificación'],
'in_progress' => ['badge-primary', 'En progreso'],
'paused' => ['badge-warning', 'Pausado'],
'completed' => ['badge-success', 'Completado'],
];
[$cls, $label] = $map[$value] ?? ['badge-ghost', ucfirst($value)];
return '<span class="badge '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make(__('Progress'))
->label(function ($row) {
$avg = $row->phases->avg('progress_percent') ?? 0;
$pct = round($avg);
return '
<div class="flex items-center gap-2 min-w-[100px]">
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div class="bg-primary h-2 rounded-full" style="width:'.$pct.'%"></div>
</div>
<span class="text-xs text-gray-500 w-8 text-right">'.$pct.'%</span>
</div>';
})
->html(),
Column::make(__('Start Date'), 'start_date')
->sortable()
->format(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : ''),
->format(fn ($value) => $value ? $value->format('d/m/Y') : ''),
Column::make(__('Estimated End Date'), 'end_date_estimated')
Column::make(__('Est. End'), 'end_date_estimated')
->sortable()
->format(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : ''),
->format(fn ($value) => $value ? $value->format('d/m/Y') : ''),
Column::make(__('Actions'))
->label(function ($row) {
$confirm = __('Are you sure you want to delete this project?');
return '
<div class="flex space-x-2">
<a href="'.route('projects.edit', $row->id).'" class="btn btn-sm">'.__('Edit').'</a>
<form action="'.route('projects.destroy', $row->id).'" method="POST" onsubmit="return confirm(\''.$confirm.'\');">
'.csrf_field().'
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="btn btn-sm">'.__('Delete').'</button>
</form>
</div>';
})
->html(),
->label(function ($row) {
$dashboard = route('projects.dashboard', $row->id);
$map = route('projects.map', $row->id);
$edit = route('projects.edit', $row->id);
ButtonGroupColumn::make(__('Actions'))
->attributes(function($row) {
return [
'class' => 'space-x-2',
];
})
->buttons([
LinkColumn::make('Edit')
->title(fn($row) => __('Edit'))
->location(fn($row) => route('projects.edit', $row->id))
->attributes(function($row) {
return [
'target' => '_blank',
'class' => 'text-blue-500 hover:underline',
];
}),
$canEdit = Auth::user()->can('edit projects');
LinkColumn::make('View') // make() has no effect in this case but needs to be set anyway
->title(fn($row) => __('View'))
->location(fn($row) => route('projects.map', $row->id))
->attributes(function($row) {
return [
'class' => 'text-blue-500 hover:underline',
];
}),
]),
$html = '<div class="flex items-center gap-1">';
$html .= '<a href="'.$dashboard.'" class="btn btn-xs btn-outline" title="Dashboard" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
</a>';
$html .= '<a href="'.$map.'" class="btn btn-xs btn-outline" title="Mapa" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
</a>';
if ($canEdit) {
$html .= '<a href="'.$edit.'" class="btn btn-xs btn-warning" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>';
}
$html .= '</div>';
return $html;
})
->html(),
];
}
@@ -104,4 +119,4 @@ class ProjectTable extends DataTableComponent
{
return [];
}
}
}
+22 -40
View File
@@ -2,15 +2,15 @@
namespace App\Livewire;
use Livewire\Component;
use App\Models\Project;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Component;
class ProjectUsers extends Component
{
public Project $project;
public $assignedUsers = [];
public $allUsers = [];
public $selectedUserId = '';
public $selectedRole = 'viewer';
@@ -18,64 +18,46 @@ class ProjectUsers extends Component
public function mount(Project $project)
{
$this->project = $project;
$this->loadUsers();
$this->loadAvailable();
}
public function loadUsers()
/** Users not yet assigned to the project (for the dropdown). */
public function loadAvailable(): void
{
$this->assignedUsers = $this->project->users()->withPivot('role_in_project')->get();
$assignedIds = $this->assignedUsers->pluck('id')->toArray();
$assignedIds = $this->project->users()->pluck('users.id')->toArray();
$this->allUsers = User::whereNotIn('id', $assignedIds)->orderBy('name')->get();
}
/** Reload the dropdown when the embedded table changes assignments. */
#[On('project-users-changed')]
public function onUsersChanged(): void
{
$this->loadAvailable();
}
public function assignUser()
{
$user = Auth::user();
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
session()->flash('error', 'No tienes permisos para asignar usuarios.');
return;
}
abort_unless(Auth::user()->can('assign users'), 403);
$this->validate([
'selectedUserId' => 'required|exists:users,id',
'selectedRole' => 'required|in:supervisor,consultant,client,viewer',
'selectedRole' => 'required|in:' . implode(',', array_keys(ProjectUsersTable::ROLES)),
]);
$this->project->users()->attach($this->selectedUserId, [
'role_in_project' => $this->selectedRole
'role_in_project' => $this->selectedRole,
]);
$this->reset(['selectedUserId', 'selectedRole']);
$this->loadUsers();
$this->loadAvailable();
$this->dispatch('project-users-changed');
$this->dispatch('notify', 'Usuario asignado al proyecto.');
}
public function removeUser($userId)
{
$user = Auth::user();
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
session()->flash('error', 'Sin permisos.');
return;
}
$this->project->users()->detach($userId);
$this->loadUsers();
$this->dispatch('notify', 'Usuario eliminado del proyecto.');
}
public function changeRole($userId, $role)
{
if (!in_array($role, ['supervisor', 'consultant', 'client', 'viewer'])) return;
$this->project->users()->updateExistingPivot($userId, [
'role_in_project' => $role
]);
$this->loadUsers();
$this->dispatch('notify', 'Rol actualizado.');
}
public function render()
{
return view('livewire.project-users');
return view('livewire.project-users', [
'roles' => ProjectUsersTable::ROLES,
]);
}
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
namespace App\Livewire;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
class ProjectUsersTable extends DataTableComponent
{
protected $model = User::class;
public int $projectId;
/** role_in_project => label */
public const ROLES = [
'supervisor' => 'Supervisor',
'consultant' => 'Consultor',
'client' => 'Cliente',
'viewer' => 'Observador',
];
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('users.name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects(['users.id as id', 'project_user.role_in_project as role_in_project']);
}
#[On('project-users-changed')]
public function refreshRows(): void
{
// no-op: triggers re-render so the builder re-runs.
}
public function builder(): Builder
{
return User::query()
->join('project_user', 'project_user.user_id', '=', 'users.id')
->where('project_user.project_id', $this->projectId);
}
public function columns(): array
{
return [
Column::make('Nombre', 'name')
->sortable()
->searchable()
->format(function ($value, $row) {
$initial = strtoupper(mb_substr($value ?? '?', 0, 1));
return '<div class="flex items-center gap-2">
<span class="w-7 h-7 rounded-full bg-primary text-primary-content flex items-center justify-center text-xs font-bold shrink-0">'.$initial.'</span>
<span class="font-medium">'.e($value).'</span>
</div>';
})
->html(),
Column::make('Email', 'email')
->sortable()
->searchable(),
Column::make('Rol', 'role_in_project')
->label(function ($row) {
$current = $row->role_in_project;
if (! Auth::user()->can('assign users')) {
return '<span class="badge badge-sm">'.(self::ROLES[$current] ?? ucfirst((string) $current)).'</span>';
}
$opts = '';
foreach (self::ROLES as $val => $label) {
$opts .= '<option value="'.$val.'"'.($current === $val ? ' selected' : '').'>'.$label.'</option>';
}
return '<select wire:change="changeRole('.$row->id.', $event.target.value)" class="select select-bordered select-xs">'.$opts.'</select>';
})
->html(),
Column::make('Acciones')
->label(function ($row) {
if (! Auth::user()->can('assign users')) {
return '';
}
return '<div class="flex justify-end">
<button wire:click="removeUser('.$row->id.')" wire:confirm="¿Quitar a '.e($row->name).' del proyecto?"
class="btn btn-xs btn-error btn-outline" title="Quitar del proyecto">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>';
})
->html(),
];
}
public function filters(): array
{
return [
SelectFilter::make('Rol', 'role')
->options(['' => 'Rol: todos'] + self::ROLES)
->filter(fn (Builder $query, string $value) => $query->where('project_user.role_in_project', $value)),
];
}
public function changeRole($userId, $role): void
{
abort_unless(Auth::user()->can('assign users'), 403);
if (! array_key_exists($role, self::ROLES)) {
return;
}
\App\Models\Project::findOrFail($this->projectId)
->users()->updateExistingPivot($userId, ['role_in_project' => $role]);
$this->dispatch('project-users-changed');
$this->dispatch('notify', 'Rol actualizado.');
}
public function removeUser($userId): void
{
abort_unless(Auth::user()->can('assign users'), 403);
\App\Models\Project::findOrFail($this->projectId)->users()->detach($userId);
$this->dispatch('project-users-changed');
$this->dispatch('notify', 'Usuario eliminado del proyecto.');
}
}
@@ -3,11 +3,13 @@
namespace App\Livewire\Reports;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Inspection;
use Carbon\Carbon;
#[Layout('layouts.app')]
class ReportsDashboard extends Component
{
public $dateRange = 'month'; // week, month, quarter, year
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Illuminate\Support\Facades\Auth;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
#[Layout('layouts.app')]
class RoleForm extends Component
{
public ?Role $role = null;
public string $name = '';
public string $description = '';
private const PROTECTED_ROLES = ['Admin'];
private const CORE_PERMISSION = 'manage all';
public function mount(?Role $role = null): void
{
abort_unless(Auth::user()?->can('manage roles'), 403);
if ($role && $role->exists) {
$this->role = $role;
$this->name = $role->name;
$this->description = $role->description ?? '';
}
}
public function save()
{
$this->validate([
'name' => 'required|string|max:50|unique:roles,name' . ($this->role ? ',' . $this->role->id : ''),
'description' => 'nullable|string|max:255',
], [], ['name' => 'nombre', 'description' => 'descripción']);
if ($this->role) {
// Protected roles can't be renamed
if (! in_array($this->role->name, self::PROTECTED_ROLES, true)) {
$this->role->name = $this->name;
}
$this->role->description = $this->description ?: null;
$this->role->save();
} else {
Role::create([
'name' => $this->name,
'description' => $this->description ?: null,
]);
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
session()->flash('message', 'Rol guardado correctamente.');
return $this->redirect(route('admin.roles'), navigate: true);
}
public function render()
{
return view('livewire.roles.role-form', [
'isProtected' => $this->role && in_array($this->role->name, self::PROTECTED_ROLES, true),
]);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Illuminate\Support\Facades\Auth;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\PermissionRegistrar;
#[Layout('layouts.app')]
class RolePermissionManager extends Component
{
public string $newRole = '';
public string $newPermission = '';
/** Roles that must not be deleted or stripped of core powers. */
private const PROTECTED_ROLES = ['Admin'];
private const CORE_PERMISSION = 'manage all';
public function mount(): void
{
abort_unless(Auth::user()?->can('manage roles'), 403);
}
private function flushCache(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
public function togglePermission(int $roleId, string $permissionName): void
{
$role = Role::findOrFail($roleId);
if ($role->hasPermissionTo($permissionName)) {
// Admin must always keep the core permission
if ($role->name === 'Admin' && $permissionName === self::CORE_PERMISSION) {
$this->dispatch('notify', "El rol Admin no puede perder '" . self::CORE_PERMISSION . "'.");
return;
}
$role->revokePermissionTo($permissionName);
} else {
$role->givePermissionTo($permissionName);
}
$this->flushCache();
$this->dispatch('notify', 'Permisos actualizados');
}
public function addRole(): void
{
$this->validate([
'newRole' => 'required|string|max:50|unique:roles,name',
], [], ['newRole' => 'nombre de rol']);
Role::create(['name' => trim($this->newRole)]);
$this->newRole = '';
$this->flushCache();
$this->dispatch('notify', 'Rol creado');
}
public function deleteRole(int $roleId): void
{
$role = Role::findOrFail($roleId);
if (in_array($role->name, self::PROTECTED_ROLES, true)) {
$this->dispatch('notify', "El rol '{$role->name}' está protegido y no se puede borrar.");
return;
}
$role->delete();
$this->flushCache();
$this->dispatch('notify', 'Rol eliminado');
}
public function addPermission(): void
{
$this->validate([
'newPermission' => 'required|string|max:50|unique:permissions,name',
], [], ['newPermission' => 'nombre de permiso']);
Permission::create(['name' => trim($this->newPermission)]);
$this->newPermission = '';
$this->flushCache();
$this->dispatch('notify', 'Permiso creado');
}
public function deletePermission(int $permissionId): void
{
$permission = Permission::findOrFail($permissionId);
if ($permission->name === self::CORE_PERMISSION) {
$this->dispatch('notify', "El permiso '" . self::CORE_PERMISSION . "' está protegido y no se puede borrar.");
return;
}
$permission->delete();
$this->flushCache();
$this->dispatch('notify', 'Permiso eliminado');
}
public function render()
{
return view('livewire.role-permission-manager', [
'roles' => Role::with('permissions')->orderBy('name')->get(),
'permissions' => Permission::orderBy('name')->get(),
]);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Livewire;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Illuminate\Database\Eloquent\Builder;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class RoleTable extends DataTableComponent
{
protected $model = Role::class;
private const PROTECTED_ROLES = ['Admin'];
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false);
}
public function builder(): Builder
{
return Role::withCount(['permissions', 'users']);
}
public function columns(): array
{
return [
Column::make(__('Name'), 'name')
->sortable()
->searchable()
->format(fn ($value, $row) =>
'<a href="'.route('admin.roles.show', $row->id).'" class="font-semibold text-primary hover:underline" wire:navigate>'.e($value).'</a>'
. (in_array($row->name, self::PROTECTED_ROLES, true) ? ' <span class="badge badge-ghost badge-xs">protegido</span>' : '')
)
->html(),
Column::make(__('Description'), 'description')
->sortable()
->searchable()
->format(fn ($value) => $value
? '<span class="text-sm text-gray-500">'.e($value).'</span>'
: '<span class="text-gray-300">—</span>')
->html(),
Column::make(__('Permissions'))
->label(fn ($row) => '<span class="badge badge-outline badge-sm">'.(int) $row->permissions_count.'</span>')
->html(),
Column::make(__('Users'))
->label(fn ($row) => '<span class="badge badge-ghost badge-sm">'.(int) $row->users_count.'</span>')
->html(),
Column::make(__('Actions'))
->label(function ($row) {
$show = route('admin.roles.show', $row->id);
$edit = route('admin.roles.edit', $row->id);
$eye = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>';
$pencil = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>';
$trash = '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>';
$html = '<div class="flex items-center gap-1">';
$html .= '<a href="'.$show.'" class="btn btn-xs btn-ghost" title="Ver" wire:navigate>'.$eye.'</a>';
$html .= '<a href="'.$edit.'" class="btn btn-xs btn-ghost text-info" title="Editar" wire:navigate>'.$pencil.'</a>';
if (! in_array($row->name, self::PROTECTED_ROLES, true)) {
$html .= '<button wire:click="deleteRole('.$row->id.')" wire:confirm="¿Eliminar el rol \''.e($row->name).'\'?" class="btn btn-xs btn-ghost text-error" title="Eliminar">'.$trash.'</button>';
}
$html .= '</div>';
return $html;
})
->html(),
];
}
public function bulkActions(): array
{
return ['bulkDelete' => __('Delete selected')];
}
public function bulkDelete(): void
{
$roles = Role::whereIn('id', $this->selected)->get();
foreach ($roles as $role) {
if (in_array($role->name, self::PROTECTED_ROLES, true)) continue;
$role->delete();
}
$this->clearSelected();
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->dispatch('notify', __('Roles deleted'));
}
public function deleteRole(int $id): void
{
$role = Role::findOrFail($id);
if (in_array($role->name, self::PROTECTED_ROLES, true)) {
return;
}
$role->delete();
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->dispatch('notify', __('Role deleted'));
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use App\Models\User;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\PermissionRegistrar;
#[Layout('layouts.app')]
class RoleView extends Component
{
public Role $role;
public string $tab = 'ficha'; // ficha | permisos
public $newUserId = '';
private const PROTECTED_ROLES = ['Admin'];
private const CORE_PERMISSION = 'manage all';
public function mount(Role $role): void
{
abort_unless(Auth::user()?->can('manage roles'), 403);
$this->role = $role;
}
public function setTab(string $tab): void
{
$this->tab = in_array($tab, ['ficha', 'permisos'], true) ? $tab : 'ficha';
}
public function togglePermission(string $permissionName): void
{
// Admin must always keep the core permission
if ($this->role->name === 'Admin'
&& $permissionName === self::CORE_PERMISSION
&& $this->role->hasPermissionTo($permissionName)) {
$this->dispatch('notify', "El rol Admin no puede perder '" . self::CORE_PERMISSION . "'.");
return;
}
if ($this->role->hasPermissionTo($permissionName)) {
$this->role->revokePermissionTo($permissionName);
} else {
$this->role->givePermissionTo($permissionName);
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->role->load('permissions');
$this->dispatch('notify', 'Permisos actualizados');
}
public function addUser(): void
{
$this->validate(['newUserId' => 'required|exists:users,id'], [], ['newUserId' => 'usuario']);
User::findOrFail($this->newUserId)->assignRole($this->role->name);
$this->newUserId = '';
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->dispatch('notify', 'Usuario añadido al rol');
}
public function removeUser(int $userId): void
{
User::findOrFail($userId)->removeRole($this->role->name);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->dispatch('notify', 'Usuario quitado del rol');
}
public function setGroup(string $group, bool $enabled): void
{
$names = Permission::where('group', $group)->pluck('name');
foreach ($names as $name) {
// Admin must always keep the core permission
if (! $enabled && $this->role->name === 'Admin' && $name === self::CORE_PERMISSION) {
continue;
}
$enabled ? $this->role->givePermissionTo($name) : $this->role->revokePermissionTo($name);
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->role->load('permissions');
$this->dispatch('notify', $enabled ? 'Permisos del grupo activados' : 'Permisos del grupo desactivados');
}
public function delete()
{
if (in_array($this->role->name, self::PROTECTED_ROLES, true)) {
$this->dispatch('notify', "El rol '{$this->role->name}' está protegido y no se puede borrar.");
return;
}
$this->role->delete();
app(PermissionRegistrar::class)->forgetCachedPermissions();
session()->flash('message', 'Rol eliminado.');
return $this->redirect(route('admin.roles'), navigate: true);
}
/** Section title for a permission name (groups by the resource / last word). */
private function sectionFor(string $name): string
{
if ($name === self::CORE_PERMISSION) {
return 'General';
}
$resource = Str::afterLast($name, ' ');
return Str::headline($resource ?: 'General');
}
public function render()
{
$users = $this->role->users()
->orderBy('first_name')
->orderBy('name')
->get();
$order = [
'Proyectos', 'Fases y progreso', 'Capas y elementos', 'Inspecciones',
'Incidencias', 'Empresas', 'Usuarios', 'Roles', 'Informes', 'Archivos', 'General',
];
$grouped = Permission::orderBy('name')->get()
->groupBy(fn ($perm) => $perm->group ?: $this->sectionFor($perm->name))
->sortBy(function ($perms, $section) use ($order) {
$i = array_search($section, $order, true);
return $i === false ? 999 : $i;
});
$availableUsers = User::whereDoesntHave('roles', fn ($q) => $q->where('roles.id', $this->role->id))
->orderBy('first_name')->orderBy('name')->get();
return view('livewire.roles.role-view', [
'users' => $users,
'availableUsers' => $availableUsers,
'grouped' => $grouped,
'rolePerms' => $this->role->permissions->pluck('name')->toArray(),
'isProtected' => in_array($this->role->name, self::PROTECTED_ROLES, true),
]);
}
}
+336 -44
View File
@@ -3,35 +3,55 @@
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithFileUploads;
use App\Models\InspectionTemplate;
use App\Models\Project;
use App\Models\Phase;
use Illuminate\Support\Facades\Auth;
use PhpOffice\PhpSpreadsheet\IOFactory;
class TemplateManager extends Component
{
use WithFileUploads;
public $project;
public $templates;
public $phases;
// ── Formulario principal ───────────────────────────────────────────────
public $editingTemplate = null;
public $showForm = false; // Controla si mostrar el formulario
public $showForm = false;
public $form = [
'name' => '',
'name' => '',
'description' => '',
'phase_id' => null,
'fields' => [],
];
public $fieldTypes = [
'text' => 'Texto corto',
'textarea' => 'Texto largo',
'integer' => 'Número entero',
'decimal' => 'Número decimal',
'percentage' => 'Porcentaje (0-100)',
'boolean' => 'Sí/No (checkbox)',
'date' => 'Fecha',
'select' => 'Lista desplegable',
'phase_id' => null,
'fields' => [],
];
protected $listeners = ['showTemplateForm' => 'newTemplate'];
// ── Importar desde CSV/Excel ───────────────────────────────────────────
public $showImportFileModal = false;
public $importFile = null;
public $importPreviewFields = [];
public $importTemplateName = '';
public $importError = '';
// ── Importar desde otro proyecto ──────────────────────────────────────
public $showImportProjectModal = false;
public $availableProjects = [];
public $importProjectId = null;
public $importableTemplates = [];
public $selectedImportTemplateIds = [];
public $fieldTypes = [
'text' => 'Texto corto',
'textarea' => 'Texto largo',
'integer' => 'Número entero',
'decimal' => 'Número decimal',
'percentage' => 'Porcentaje (0-100)',
'boolean' => 'Sí/No (checkbox)',
'date' => 'Fecha',
'select' => 'Lista desplegable',
];
public function mount(Project $project)
{
@@ -47,20 +67,28 @@ class TemplateManager extends Component
public function loadTemplates()
{
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
$this->templates = InspectionTemplate::where('project_id', $this->project->id)
->with('phase')
->get();
}
// ── Formulario manual ─────────────────────────────────────────────────
public function newTemplate()
{
$this->resetForm();
$this->editingTemplate = null;
$this->showForm = true;
}
public function editTemplate($id)
{
$template = InspectionTemplate::find($id);
$this->form = $template->only(['name', 'description', 'phase_id', 'fields']);
$template = InspectionTemplate::findOrFail($id);
$this->form = [
'name' => $template->name,
'description' => $template->description ?? '',
'phase_id' => $template->phase_id,
'fields' => $template->fields ?? [],
];
$this->editingTemplate = $id;
$this->showForm = true;
}
@@ -74,10 +102,10 @@ class TemplateManager extends Component
public function resetForm()
{
$this->form = [
'name' => '',
'name' => '',
'description' => '',
'phase_id' => null,
'fields' => [],
'phase_id' => null,
'fields' => [],
];
$this->editingTemplate = null;
}
@@ -85,14 +113,14 @@ class TemplateManager extends Component
public function addField()
{
$this->form['fields'][] = [
'name' => '',
'label' => '',
'type' => 'text',
'options' => [],
'name' => '',
'label' => '',
'type' => 'text',
'options' => '',
'required' => false,
'min' => null,
'max' => null,
'step' => null,
'min' => null,
'max' => null,
'step' => null,
];
}
@@ -105,24 +133,25 @@ class TemplateManager extends Component
public function saveTemplate()
{
$this->validate([
'form.name' => 'required|string|max:255',
'form.name' => 'required|string|max:255',
'form.phase_id' => 'nullable|exists:phases,id',
'form.fields' => 'array',
'form.fields' => 'array',
]);
$data = [
'name' => $this->form['name'],
'description' => $this->form['description'],
'project_id' => $this->project->id,
'phase_id' => $this->form['phase_id'] ?: null,
'fields' => array_values($this->form['fields']),
];
if ($this->editingTemplate) {
$template = InspectionTemplate::find($this->editingTemplate);
$template->update($this->form);
session()->flash('message', 'Template actualizado');
InspectionTemplate::findOrFail($this->editingTemplate)->update($data);
$this->dispatch('notify', 'Template actualizado correctamente');
} else {
InspectionTemplate::create([
'name' => $this->form['name'],
'description' => $this->form['description'],
'project_id' => $this->project->id,
'phase_id' => $this->form['phase_id'],
'fields' => $this->form['fields'],
]);
session()->flash('message', 'Template creado');
InspectionTemplate::create($data);
$this->dispatch('notify', 'Template creado correctamente');
}
$this->cancelForm();
@@ -131,9 +160,272 @@ class TemplateManager extends Component
public function deleteTemplate($id)
{
InspectionTemplate::find($id)->delete();
InspectionTemplate::findOrFail($id)->delete();
$this->loadTemplates();
session()->flash('message', 'Template eliminado');
$this->dispatch('notify', 'Template eliminado');
}
// ── Exportar template a CSV ────────────────────────────────────────────
public function exportTemplate($id)
{
$template = InspectionTemplate::findOrFail($id);
$rows = [];
$rows[] = ['name', 'label', 'type', 'required', 'options', 'min', 'max', 'step'];
foreach ($template->fields as $field) {
$rows[] = [
$field['name'] ?? '',
$field['label'] ?? '',
$field['type'] ?? 'text',
($field['required'] ?? false) ? '1' : '0',
$field['options'] ?? '',
$field['min'] ?? '',
$field['max'] ?? '',
$field['step'] ?? '',
];
}
$filename = preg_replace('/[^a-z0-9_\-]/i', '_', $template->name) . '.csv';
return response()->streamDownload(function () use ($rows) {
$out = fopen('php://output', 'w');
// BOM para Excel con UTF-8
fwrite($out, "\xEF\xBB\xBF");
foreach ($rows as $row) {
fputcsv($out, $row);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
public function downloadExampleCsv()
{
$rows = [
['name', 'label', 'type', 'required', 'options', 'min', 'max', 'step'],
['altura_viga', 'Altura de viga (mm)', 'decimal', '1', '', '0', '2000', '1'],
['estado_armado', 'Estado del armado', 'select', '1', 'Conforme,No conforme,Obs.', '', '', ''],
['fotos_tomadas', '¿Fotos tomadas?', 'boolean', '1', '', '', '', ''],
['observaciones', 'Observaciones generales','textarea', '0', '', '', '', ''],
['fecha_visita', 'Fecha de visita', 'date', '0', '', '', '', ''],
['avance_pct', 'Avance medido (%)', 'percentage', '0', '', '0', '100', '1'],
];
return response()->streamDownload(function () use ($rows) {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
foreach ($rows as $row) {
fputcsv($out, $row);
}
fclose($out);
}, 'ejemplo-template.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
// ── Importar desde CSV / Excel ─────────────────────────────────────────
public function openImportFileModal()
{
$this->importFile = null;
$this->importPreviewFields = [];
$this->importTemplateName = '';
$this->importError = '';
$this->showImportFileModal = true;
}
public function parseImportFile()
{
$this->importError = '';
$this->validate([
'importFile' => 'required|file|mimes:csv,txt,xlsx,xls|max:5120',
'importTemplateName' => 'required|string|max:255',
], [
'importFile.required' => 'Selecciona un archivo.',
'importFile.mimes' => 'Solo se aceptan archivos CSV o Excel (xlsx/xls).',
'importTemplateName.required' => 'Escribe un nombre para el template.',
]);
try {
$rows = $this->readFileRows();
} catch (\Throwable $e) {
$this->importError = 'No se pudo leer el archivo: ' . $e->getMessage();
return;
}
$fields = $this->parseRows($rows);
if (empty($fields)) {
$this->importError = 'No se encontraron filas de datos válidas. Revisa que el archivo tenga el formato correcto.';
return;
}
$this->importPreviewFields = $fields;
$this->dispatch('notify', count($fields) . ' campos detectados. Revisa la vista previa.');
}
public function confirmImportFile()
{
if (empty($this->importPreviewFields) || empty($this->importTemplateName)) return;
InspectionTemplate::create([
'name' => $this->importTemplateName,
'description' => 'Importado desde archivo',
'project_id' => $this->project->id,
'phase_id' => null,
'fields' => array_values($this->importPreviewFields),
]);
$this->showImportFileModal = false;
$this->importPreviewFields = [];
$this->importTemplateName = '';
$this->importFile = null;
$this->loadTemplates();
$this->dispatch('notify', 'Template importado correctamente desde archivo');
}
private function readFileRows(): array
{
$ext = strtolower($this->importFile->getClientOriginalExtension());
$path = $this->importFile->getRealPath();
if ($ext === 'xlsx' || $ext === 'xls') {
$spreadsheet = IOFactory::load($path);
$sheet = $spreadsheet->getActiveSheet();
$rows = $sheet->toArray(null, true, true, false);
array_shift($rows); // quitar cabecera
return array_filter($rows, fn($r) => !empty($r[0]));
}
// CSV / TXT
$rows = [];
$handle = fopen($path, 'r');
// Detectar y descartar BOM UTF-8
$bom = fread($handle, 3);
if ($bom !== "\xEF\xBB\xBF") rewind($handle);
fgetcsv($handle); // cabecera
while (($row = fgetcsv($handle)) !== false) {
if (!empty($row[0])) $rows[] = $row;
}
fclose($handle);
return $rows;
}
private function parseRows(array $rows): array
{
$fields = [];
foreach ($rows as $row) {
$row = array_values((array) $row);
$rawName = trim($row[0] ?? '');
if ($rawName === '') continue;
$fields[] = [
'name' => $this->slugify($rawName),
'label' => trim($row[1] ?? $rawName),
'type' => $this->normalizeType($row[2] ?? 'text'),
'required' => in_array(strtolower(trim($row[3] ?? '0')), ['1', 'si', 'sí', 'yes', 'true']),
'options' => trim($row[4] ?? ''),
'min' => $row[5] !== '' && $row[5] !== null ? $row[5] : null,
'max' => $row[6] !== '' && $row[6] !== null ? $row[6] : null,
'step' => $row[7] !== '' && $row[7] !== null ? $row[7] : null,
];
}
return $fields;
}
private function slugify(string $str): string
{
$str = mb_strtolower(trim($str));
$str = preg_replace('/\s+/', '_', $str);
$str = preg_replace('/[^a-z0-9_]/i', '', $str);
return trim($str, '_') ?: 'campo';
}
private function normalizeType(string $type): string
{
$map = [
'texto' => 'text', 'text' => 'text', 'string' => 'text', 'corto' => 'text',
'textarea' => 'textarea', 'largo' => 'textarea', 'parrafo' => 'textarea',
'integer' => 'integer', 'entero' => 'integer', 'int' => 'integer', 'numero' => 'integer',
'decimal' => 'decimal', 'float' => 'decimal', 'number' => 'decimal', 'numerico' => 'decimal',
'percentage' => 'percentage', 'porcentaje' => 'percentage', 'pct' => 'percentage', '%' => 'percentage',
'boolean' => 'boolean', 'bool' => 'boolean', 'checkbox' => 'boolean', 'sino' => 'boolean', 'si/no' => 'boolean',
'date' => 'date', 'fecha' => 'date',
'select' => 'select', 'lista' => 'select', 'dropdown' => 'select', 'opciones' => 'select',
];
return $map[strtolower(trim($type))] ?? 'text';
}
// ── Importar desde otro proyecto ──────────────────────────────────────
public function openImportProjectModal()
{
$user = Auth::user();
$this->availableProjects = Project::accessibleBy($user)
->where('id', '!=', $this->project->id)
->orderBy('name')
->get();
$this->importProjectId = null;
$this->importableTemplates = [];
$this->selectedImportTemplateIds = [];
$this->showImportProjectModal = true;
}
public function updatedImportProjectId()
{
$this->selectedImportTemplateIds = [];
if (!$this->importProjectId) {
$this->importableTemplates = [];
return;
}
// Solo mostrar templates de proyectos accesibles
$user = Auth::user();
$allowed = Project::accessibleBy($user)->pluck('id');
if (!$allowed->contains($this->importProjectId)) {
$this->importableTemplates = [];
return;
}
$this->importableTemplates = InspectionTemplate::where('project_id', $this->importProjectId)->get();
}
public function importFromProject()
{
if (empty($this->selectedImportTemplateIds)) {
$this->dispatch('notify', 'Selecciona al menos un template.');
return;
}
// Verificar que los templates pertenecen a un proyecto accesible
$user = Auth::user();
$allowed = Project::accessibleBy($user)->pluck('id');
$imported = 0;
foreach ($this->selectedImportTemplateIds as $templateId) {
$source = InspectionTemplate::find($templateId);
if (!$source || !$allowed->contains($source->project_id)) continue;
// Evitar duplicados por nombre
$name = $source->name;
if (InspectionTemplate::where('project_id', $this->project->id)->where('name', $name)->exists()) {
$name .= ' (copia)';
}
InspectionTemplate::create([
'name' => $name,
'description' => $source->description,
'project_id' => $this->project->id,
'phase_id' => null,
'fields' => $source->fields,
]);
$imported++;
}
$this->showImportProjectModal = false;
$this->importProjectId = null;
$this->importableTemplates = [];
$this->selectedImportTemplateIds = [];
$this->loadTemplates();
$this->dispatch('notify', "$imported template(s) importado(s) desde otro proyecto");
}
public function render()
+178
View File
@@ -0,0 +1,178 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\User;
use App\Models\Company;
use Spatie\Permission\Models\Role;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
#[Layout('layouts.app')]
class UserForm extends Component
{
public ?User $user = null;
// Información personal
public string $title = '';
public string $lastName = '';
public string $firstName = '';
// Validación
public string $userStatus = 'active';
public string $validFrom = '';
public string $validUntil = '';
public string $formPassword = '';
// Contacto
public ?int $companyId = null;
public string $address = '';
public string $phone = '';
public string $email = '';
// Permisos
public string $formRole = '';
// Preferencias
public string $locale = 'es';
// Notas
public string $notes = '';
// Catálogos
public $roles;
public $companies;
/** Idiomas disponibles (código => nombre + archivo de bandera). */
public array $languages = [
'es' => ['name' => 'Español', 'flag' => 'es.svg'],
'en' => ['name' => 'English', 'flag' => 'gb.svg'],
];
public function mount(?User $user = null): void
{
abort_unless(Auth::user()->can('create users') || Auth::user()->can('edit users'), 403);
$this->roles = Role::orderBy('name')->get();
$this->companies = Company::where('estado', 'activo')->orderBy('name')->get();
$this->formRole = $this->roles->first()?->name ?? '';
if ($user && $user->exists) {
$this->user = $user;
$this->title = $user->title ?? '';
$this->lastName = $user->last_name ?? '';
$this->firstName = $user->first_name ?? '';
$this->userStatus = $user->status ?? 'active';
$this->validFrom = $user->valid_from?->format('Y-m-d') ?? '';
$this->validUntil = $user->valid_until?->format('Y-m-d') ?? '';
$this->companyId = $user->company_id;
$this->address = $user->address ?? '';
$this->phone = $user->phone ?? '';
$this->email = $user->email;
$this->notes = $user->notes ?? '';
$this->formRole = $user->roles->first()?->name ?? $this->formRole;
$this->locale = $user->locale ?? $this->locale;
}
}
protected function rules(): array
{
$id = $this->user?->id ?? 'NULL';
$rules = [
'lastName' => 'required|string|max:100',
'firstName' => 'required|string|max:100',
'title' => 'nullable|string|max:20',
'userStatus' => 'required|in:active,inactive,suspended',
'validFrom' => 'nullable|date',
'validUntil' => 'nullable|date|after_or_equal:validFrom',
'companyId' => 'required|exists:companies,id',
'address' => 'nullable|string',
'phone' => 'nullable|string|max:30',
'email' => "required|email|max:255|unique:users,email,{$id}",
'formRole' => 'required|exists:roles,name',
'locale' => 'required|in:' . implode(',', array_keys($this->languages)),
];
if (!$this->user) {
$rules['formPassword'] = ['required', Password::min(8)->letters()->mixedCase()->numbers()];
} elseif ($this->formPassword !== '') {
$rules['formPassword'] = [Password::min(8)->letters()->mixedCase()->numbers()];
}
return $rules;
}
protected $validationAttributes = [
'lastName' => 'apellidos',
'firstName' => 'nombre',
'userStatus' => 'estado',
'validFrom' => 'fecha de inicio',
'validUntil' => 'fecha de fin',
'companyId' => 'empresa',
'formPassword'=> 'contraseña',
'formRole' => 'rol',
'locale' => 'idioma',
];
public function copyCompanyAddress(): void
{
if (!$this->companyId) return;
$company = Company::find($this->companyId);
if ($company?->address) {
$this->address = $company->address;
}
}
public function save(): void
{
$this->validate();
if ($this->user && $this->user->id === Auth::id()
&& $this->user->hasRole('Admin') && $this->formRole !== 'Admin') {
$this->addError('formRole', 'No puedes quitarte el rol Admin a ti mismo.');
return;
}
$fullName = trim($this->firstName . ' ' . $this->lastName);
$data = [
'name' => $fullName,
'title' => $this->title ?: null,
'first_name' => $this->firstName,
'last_name' => $this->lastName,
'status' => $this->userStatus,
'valid_from' => $this->validFrom ?: null,
'valid_until'=> $this->validUntil ?: null,
'company_id' => $this->companyId,
'address' => $this->address ?: null,
'phone' => $this->phone ?: null,
'email' => $this->email,
'notes' => $this->notes ?: null,
'locale' => $this->locale,
];
if ($this->formPassword !== '') {
$data['password'] = Hash::make($this->formPassword);
}
if ($this->user && $this->user->exists) {
$this->user->update($data);
$this->user->syncRoles([$this->formRole]);
session()->flash('notify', 'Usuario actualizado correctamente.');
} else {
$user = User::create($data);
$user->assignRole($this->formRole);
session()->flash('notify', 'Usuario creado correctamente.');
}
$this->redirect(route('admin.users'), navigate: true);
}
public function render()
{
return view('livewire.user-form');
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
namespace App\Livewire;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Spatie\Permission\Models\Role;
use App\Models\User;
class UserTable extends DataTableComponent
{
protected $model = User::class;
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects([
'users.id as id',
'users.email as email',
'users.email_verified_at as email_verified_at',
'users.status as status',
'users.phone as phone',
'users.company_id as company_id',
'users.created_at as created_at',
]);
}
public function builder(): Builder
{
return User::with(['roles', 'company']);
}
public function columns(): array
{
return [
Column::make('Usuario', 'name')
->sortable()
->searchable()
->format(function ($value, $row) {
$initial = strtoupper(mb_substr($value, 0, 1));
$html = '<div class="flex items-center gap-3">';
$html .= '<div class="avatar placeholder shrink-0">
<div class="bg-neutral text-neutral-content rounded-full w-8">
<span class="text-xs font-semibold">'.$initial.'</span>
</div>
</div>';
$html .= '<div>';
$html .= '<p class="font-semibold text-sm leading-tight">'.e($value).'</p>';
$html .= '<p class="text-xs text-gray-500">'.e($row->email).'</p>';
$html .= '</div></div>';
return $html;
})
->html(),
Column::make('Empresa')
->label(fn ($row) =>
$row->company
? '<span class="text-sm">'.e($row->company->name).'</span>'
: '<span class="text-gray-300 text-sm">—</span>'
)
->html(),
Column::make('Rol')
->label(function ($row) {
if ($row->roles->isEmpty()) {
return '<span class="badge badge-sm badge-ghost">Sin rol</span>';
}
return $row->roles->map(fn ($role) =>
'<span class="badge badge-sm '.($role->name === 'Admin' ? 'badge-error' : 'badge-primary').'">'.e($role->name).'</span>'
)->implode(' ');
})
->html(),
Column::make('Estado', 'status')
->sortable()
->format(function ($value) {
$map = [
'active' => ['badge-success', 'Activo'],
'inactive' => ['badge-ghost', 'Inactivo'],
'suspended' => ['badge-error', 'Suspendido'],
];
[$cls, $label] = $map[$value ?? 'active'] ?? ['badge-ghost', ucfirst($value ?? '')];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make('Verificado', 'email_verified_at')
->sortable()
->format(fn ($value) =>
$value
? '<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
)
->html(),
Column::make('Acciones')
->label(function ($row) {
$ver = route('admin.users.show', $row->id);
$editar = route('admin.users.edit', $row->id);
$name = addslashes($row->name);
$isSelf = $row->id === Auth::id();
$html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>';
$html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>';
if (! $isSelf) {
$html .= '<button wire:click="deleteUser('.$row->id.')"
wire:confirm="¿Eliminar a \''.$name.'\'? Se perderán todos sus datos."
class="btn btn-xs btn-outline btn-error" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>';
}
$html .= '</div>';
return $html;
})
->html(),
];
}
public function filters(): array
{
$roleOptions = [''] + Role::orderBy('name')->pluck('name', 'name')->prepend('Rol: todos', '')->toArray();
return [
SelectFilter::make('Rol')
->options($roleOptions)
->filter(fn (Builder $query, string $value) =>
$query->whereHas('roles', fn ($q) => $q->where('name', $value))
),
SelectFilter::make('Estado', 'status')
->options([
'' => 'Estado: todos',
'active' => 'Activo',
'inactive' => 'Inactivo',
'suspended' => 'Suspendido',
])
->filter(fn (Builder $query, string $value) => $query->where('status', $value)),
];
}
public function deleteUser(int $id): void
{
if ($id === Auth::id()) return;
User::findOrFail($id)->delete();
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\User;
use App\Models\Project;
use App\Models\Inspection;
use App\Models\Issue;
use Illuminate\Support\Facades\Auth;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\PermissionRegistrar;
#[Layout('layouts.app')]
class UserView extends Component
{
public User $user;
public string $activeTab = 'ficha';
// Projects tab
public ?int $addProjectId = null;
public string $addProjectRole = '';
public $availableProjects;
// Notes tab
public string $notes = '';
public bool $editingNotes = false;
// Recent activity (loaded once)
public $recentInspections;
public $recentIssues;
public function mount(User $user): void
{
abort_unless(Auth::user()->can('view users'), 403);
$this->user = $user->load(['roles', 'company', 'projects.phases']);
$this->notes = $user->notes ?? '';
$this->loadAvailableProjects();
$this->loadActivity();
}
private function loadAvailableProjects(): void
{
$assignedIds = $this->user->projects->pluck('id');
$this->availableProjects = Project::whereNotIn('id', $assignedIds)
->orderBy('name')->get();
}
private function loadActivity(): void
{
$this->recentInspections = Inspection::where('user_id', $this->user->id)
->with(['feature.layer.phase.project', 'template'])
->latest()->take(8)->get();
$this->recentIssues = Issue::where('reported_by', $this->user->id)
->with(['feature', 'project'])
->latest()->take(8)->get();
}
// ── Tabs ─────────────────────────────────────────────────────────────────
public function setTab(string $tab): void
{
$this->activeTab = $tab;
}
// ── Projects ──────────────────────────────────────────────────────────────
public function assignProject(): void
{
$this->validate([
'addProjectId' => 'required|exists:projects,id',
'addProjectRole' => 'nullable|string|max:100',
], [], ['addProjectId' => 'proyecto', 'addProjectRole' => 'rol en proyecto']);
$this->user->projects()->attach($this->addProjectId, [
'role_in_project' => $this->addProjectRole ?: null,
]);
$this->user->load('projects.phases');
$this->addProjectId = null;
$this->addProjectRole = '';
$this->loadAvailableProjects();
$this->dispatch('notify', 'Proyecto asignado.');
}
public function removeProject(int $projectId): void
{
$this->user->projects()->detach($projectId);
$this->user->load('projects.phases');
$this->loadAvailableProjects();
$this->dispatch('notify', 'Proyecto desasignado.');
}
// ── Permissions (direct, per user) ─────────────────────────────────────────
public function togglePermission(string $name): void
{
if ($this->user->hasDirectPermission($name)) {
$this->user->revokePermissionTo($name);
} else {
$this->user->givePermissionTo($name);
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->user->load('roles', 'permissions');
$this->dispatch('notify', 'Permisos del usuario actualizados');
}
public function setUserGroup(string $group, bool $enabled): void
{
foreach (Permission::where('group', $group)->pluck('name') as $name) {
if ($enabled) {
if (! $this->user->hasPermissionTo($name)) {
$this->user->givePermissionTo($name);
}
} elseif ($this->user->hasDirectPermission($name)) {
$this->user->revokePermissionTo($name);
}
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->user->load('roles', 'permissions');
$this->dispatch('notify', $enabled ? 'Permisos del grupo concedidos' : 'Permisos directos del grupo quitados');
}
// ── Notes ─────────────────────────────────────────────────────────────────
public function saveNotes(): void
{
$this->validate(['notes' => 'nullable|string']);
$this->user->update(['notes' => $this->notes ?: null]);
$this->editingNotes = false;
$this->dispatch('notify', 'Notas guardadas.');
}
public function render()
{
$order = [
'Proyectos', 'Fases y progreso', 'Capas y elementos', 'Inspecciones',
'Incidencias', 'Empresas', 'Usuarios', 'Roles', 'Informes', 'Archivos', 'General',
];
$grouped = Permission::orderBy('name')->get()
->groupBy(fn ($perm) => $perm->group ?: 'General')
->sortBy(function ($perms, $section) use ($order) {
$i = array_search($section, $order, true);
return $i === false ? 999 : $i;
});
return view('livewire.user-view', [
'grouped' => $grouped,
'directPerms' => $this->user->getDirectPermissions()->pluck('name')->toArray(),
'rolePerms' => $this->user->getPermissionsViaRoles()->pluck('name')->toArray(),
]);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
class ActivityLog extends Model
{
public $timestamps = false;
protected $fillable = ['action', 'model_type', 'model_id', 'user_id', 'changes', 'created_at'];
protected $casts = [
'changes' => 'array',
'created_at' => 'datetime',
];
public static function record(string $action, Model $model, array $changes = []): void
{
static::create([
'action' => $action,
'model_type' => class_basename($model),
'model_id' => $model->getKey(),
'user_id' => Auth::id(),
'changes' => empty($changes) ? null : $changes,
'created_at' => now(),
]);
}
public function user()
{
return $this->belongsTo(User::class);
}
}
+6
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -26,6 +27,11 @@ class Company extends Model
protected $dates = ['deleted_at'];
// Relationships
public function users()
{
return $this->hasMany(User::class);
}
public function projects()
{
return $this->belongsToMany(Project::class, 'company_project')
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Device extends Model
{
protected $fillable = [
'user_id', 'name', 'token_id', 'app_version', 'last_seen_at',
];
protected $casts = [
'last_seen_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(User::class);
}
}
+33 -3
View File
@@ -3,15 +3,23 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\LogsActivity;
class Feature extends Model
{
use SoftDeletes, LogsActivity;
const STATUSES = ['planned', 'started', 'in_progress', 'completed', 'verified'];
protected $fillable = [
'layer_id', 'name', 'geometry', 'properties', 'template_id', 'progress', 'responsible'
'layer_id', 'name', 'geometry', 'properties', 'template_id',
'progress', 'status', 'responsible', 'responsible_user_id',
'uuid', 'client_updated_at',
];
protected $casts = [
'geometry' => 'array',
'geometry' => 'array',
'properties' => 'array',
];
@@ -30,6 +38,16 @@ class Feature extends Model
return $this->hasMany(Inspection::class, 'feature_id');
}
public function issues()
{
return $this->hasMany(Issue::class);
}
public function responsibleUser()
{
return $this->belongsTo(User::class, 'responsible_user_id');
}
public function media()
{
return $this->morphMany(Media::class, 'mediable');
@@ -39,4 +57,16 @@ class Feature extends Model
{
return $this->morphMany(Media::class, 'mediable')->where('category', 'image');
}
}
public function getStatusColorAttribute(): string
{
return match($this->status) {
'planned' => '#6b7280',
'started' => '#3b82f6',
'in_progress' => '#f59e0b',
'completed' => '#10b981',
'verified' => '#8b5cf6',
default => '#6b7280',
};
}
}
+30 -2
View File
@@ -3,12 +3,26 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\LogsActivity;
class Inspection extends Model
{
protected $fillable = ['project_id', 'layer_id', 'feature_id', 'template_id', 'user_id', 'data'];
use SoftDeletes, LogsActivity;
protected $casts = ['data' => 'array'];
const STATUSES = ['pending', 'in_progress', 'completed', 'approved', 'rejected'];
const RESULTS = ['pass', 'fail', 'conditional'];
protected $fillable = [
'project_id', 'layer_id', 'feature_id', 'template_id', 'user_id',
'data', 'status', 'inspector_user_id', 'completed_at', 'result', 'notes',
'uuid', 'client_updated_at',
];
protected $casts = [
'data' => 'array',
'completed_at' => 'datetime',
];
public function project()
{
@@ -30,8 +44,22 @@ class Inspection extends Model
return $this->belongsTo(User::class);
}
public function inspector()
{
return $this->belongsTo(User::class, 'inspector_user_id');
}
public function feature()
{
return $this->belongsTo(Feature::class, 'feature_id');
}
public function issues()
{
return $this->hasMany(Issue::class);
}
public function scopePending($q) { return $q->where('status', 'pending'); }
public function scopeCompleted($q) { return $q->where('status', 'completed'); }
public function scopeRejected($q) { return $q->where('status', 'rejected'); }
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\LogsActivity;
class Issue extends Model
{
use SoftDeletes, LogsActivity;
const STATUSES = ['open', 'in_review', 'resolved', 'closed'];
const PRIORITIES = ['low', 'medium', 'high', 'critical'];
const TYPES = ['defect', 'safety', 'quality', 'documentation', 'other'];
protected $fillable = [
'project_id', 'feature_id', 'inspection_id',
'title', 'description', 'status', 'priority', 'type',
'reported_by', 'assigned_to', 'resolved_at', 'resolution_notes',
'uuid', 'client_updated_at',
];
protected $casts = ['resolved_at' => 'datetime'];
public function project() { return $this->belongsTo(Project::class); }
public function feature() { return $this->belongsTo(Feature::class); }
public function inspection() { return $this->belongsTo(Inspection::class); }
public function reporter() { return $this->belongsTo(User::class, 'reported_by'); }
public function assignee() { return $this->belongsTo(User::class, 'assigned_to'); }
public function media() { return $this->morphMany(Media::class, 'mediable'); }
public function tasks() { return $this->hasMany(IssueTask::class)->orderBy('order')->orderBy('id'); }
public function comments() { return $this->hasMany(IssueComment::class)->orderBy('created_at'); }
public function scopeOpen($q) { return $q->where('status', 'open'); }
public function scopeCritical($q) { return $q->where('priority', 'critical'); }
/** Resolution progress derived from the checklist: done tasks / total. */
public function getProgressAttribute(): int
{
$total = $this->tasks->count();
if ($total === 0) {
return in_array($this->status, ['resolved', 'closed'], true) ? 100 : 0;
}
return (int) round($this->tasks->where('is_done', true)->count() / $total * 100);
}
/** True when there is at least one task and all of them are done. */
public function getTasksCompleteAttribute(): bool
{
return $this->tasks->count() > 0 && $this->tasks->every(fn ($t) => $t->is_done);
}
public function getPriorityColorAttribute(): string
{
return match($this->priority) {
'low' => '#6b7280',
'medium' => '#f59e0b',
'high' => '#ef4444',
'critical' => '#7c3aed',
default => '#6b7280',
};
}
public function getStatusColorAttribute(): string
{
return match($this->status) {
'open' => '#ef4444',
'in_review' => '#f59e0b',
'resolved' => '#10b981',
'closed' => '#6b7280',
default => '#6b7280',
};
}
/** Human label (Spanish) for each issue type. */
public static function typeLabels(): array
{
return [
'defect' => 'Defecto',
'safety' => 'Seguridad',
'quality' => 'Calidad',
'documentation' => 'Documentación',
'other' => 'Otro',
];
}
public function getTypeLabelAttribute(): string
{
return self::typeLabels()[$this->type] ?? ucfirst((string) $this->type);
}
public function getTypeColorAttribute(): string
{
return match($this->type) {
'defect' => '#ef4444',
'safety' => '#f97316',
'quality' => '#0ea5e9',
'documentation' => '#8b5cf6',
default => '#6b7280',
};
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class IssueChecklistTemplate extends Model
{
use SoftDeletes;
protected $fillable = ['project_id', 'name', 'items'];
protected $casts = ['items' => 'array'];
public function project()
{
return $this->belongsTo(Project::class);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class IssueComment extends Model
{
use SoftDeletes;
protected $fillable = [
'issue_id', 'user_id', 'body',
'uuid', 'client_updated_at',
];
public function issue() { return $this->belongsTo(Issue::class); }
public function user() { return $this->belongsTo(User::class); }
public function media() { return $this->morphMany(Media::class, 'mediable'); }
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class IssueTask extends Model
{
use SoftDeletes;
protected $fillable = [
'issue_id', 'title', 'is_done', 'done_at', 'done_by',
'assigned_to', 'due_date', 'order', 'overdue_notified_at',
'uuid', 'client_updated_at',
];
protected $casts = [
'is_done' => 'boolean',
'done_at' => 'datetime',
'due_date' => 'date',
'overdue_notified_at' => 'datetime',
];
/** Tasks that are past their due date and not yet completed. */
public function scopeOverdue($q)
{
return $q->where('is_done', false)
->whereNotNull('due_date')
->whereDate('due_date', '<', now()->toDateString());
}
public function issue() { return $this->belongsTo(Issue::class); }
public function assignee() { return $this->belongsTo(User::class, 'assigned_to'); }
public function completer() { return $this->belongsTo(User::class, 'done_by'); }
public function media() { return $this->morphMany(Media::class, 'mediable'); }
/** Overdue = has a due date in the past and not yet done. */
public function getIsOverdueAttribute(): bool
{
return ! $this->is_done && $this->due_date && $this->due_date->isPast();
}
}
+8
View File
@@ -3,10 +3,13 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Layer extends Model
{
use SoftDeletes;
protected $fillable = [
'project_id', 'phase_id', 'name', 'color', 'geojson_data', 'original_file', 'uploaded_by'
];
@@ -34,6 +37,11 @@ class Layer extends Model
return $this->hasMany(Feature::class);
}
public function issues()
{
return $this->hasMany(Issue::class);
}
public function media()
{
return $this->morphMany(Media::class, 'mediable');
+1
View File
@@ -13,6 +13,7 @@ class Media extends Model
'mediable_type', 'mediable_id',
'name', 'file_path', 'file_type', 'file_extension', 'file_size',
'category', 'description', 'metadata', 'uploaded_by',
'uuid', 'client_updated_at',
];
protected $casts = [
+23 -38
View File
@@ -1,51 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Phase extends Model
{
use SoftDeletes;
protected $fillable = [
'project_id', 'name', 'description', 'order', 'color', 'progress_percent'
'project_id', 'name', 'description', 'order', 'color', 'progress_percent',
'planned_start', 'planned_end', 'actual_start', 'actual_end'
];
public function project()
{
return $this->belongsTo(Project::class);
}
protected $casts = [
'planned_start' => 'date',
'planned_end' => 'date',
'actual_start' => 'date',
'actual_end' => 'date',
];
public function layers()
{
return $this->hasMany(Layer::class);
}
public function project() { return $this->belongsTo(Project::class); }
public function layers() { return $this->hasMany(Layer::class); }
public function progressUpdates() { return $this->hasMany(ProgressUpdate::class); }
public function currentLayer() { return $this->hasOne(Layer::class)->latestOfMany(); }
public function features() { return $this->hasManyThrough(Feature::class, Layer::class); }
public function media() { return $this->morphMany(Media::class, 'mediable'); }
public function images() { return $this->morphMany(Media::class, 'mediable')->where('category', 'image'); }
public function progressUpdates()
public function getDeviationDaysAttribute(): ?int
{
return $this->hasMany(ProgressUpdate::class);
if (!$this->planned_end) return null;
$end = $this->actual_end ?? now();
return $this->planned_end->diffInDays($end, false);
}
// Get latest active layer (most recent upload)
public function currentLayer()
{
return $this->hasOne(Layer::class)->latestOfMany();
}
/**
* Get all features across all layers of this phase.
*/
public function features()
{
return $this->hasManyThrough(Feature::class, Layer::class);
}
public function media()
{
return $this->morphMany(Media::class, 'mediable');
}
public function images()
{
return $this->morphMany(Media::class, 'mediable')->where('category', 'image');
}
}
}
+2 -1
View File
@@ -7,11 +7,12 @@ use Illuminate\Database\Eloquent\Model;
class ProgressUpdate extends Model
{
protected $fillable = [
'phase_id', 'user_id', 'progress_percent', 'comment', 'location'
'uuid', 'phase_id', 'user_id', 'progress_percent', 'comment', 'location', 'client_updated_at'
];
protected $casts = [
'location' => 'array', // Store as [lat, lng]
'client_updated_at' => 'datetime',
];
public function phase()
+5 -4
View File
@@ -4,14 +4,15 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Project extends Model
{
use HasFactory;
use HasFactory;
use HasFactory, SoftDeletes;
protected $fillable = [
'name', 'address', 'lat', 'lng', 'start_date', 'end_date_estimated', 'status', 'created_by'
'name', 'reference', 'address', 'country', 'lat', 'lng',
'start_date', 'end_date_estimated', 'status', 'created_by',
];
protected $casts = [
@@ -65,7 +66,7 @@ class Project extends Model
// Scope to filter accessible projects for non-admin users
public function scopeAccessibleBy($query, User $user)
{
if ($user->hasRole('Admin')) {
if ($user->can('manage all')) {
return $query;
}
return $query->whereHas('users', function ($q) use ($user) {
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SyncLog extends Model
{
protected $fillable = [
'user_id', 'op_uuid', 'entity', 'op', 'status', 'server_id', 'error',
];
}
+16 -6
View File
@@ -7,12 +7,13 @@ use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, HasRoles;
use HasFactory, Notifiable, HasRoles, HasApiTokens;
/**
* The attributes that are mass assignable.
@@ -20,9 +21,11 @@ class User extends Authenticatable
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
'name', 'title', 'first_name', 'last_name',
'email', 'password',
'status', 'valid_from', 'valid_until',
'company_id', 'phone', 'address', 'notes',
'locale',
];
/**
@@ -44,9 +47,16 @@ class User extends Authenticatable
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'password' => 'hashed',
'valid_from' => 'date',
'valid_until' => 'date',
];
}
public function company()
{
return $this->belongsTo(\App\Models\Company::class);
}
// Many-to-many with projects
public function projects()
{
@@ -0,0 +1,31 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use App\Models\Feature;
class FeatureCompletedNotification extends Notification
{
use Queueable;
public function __construct(public Feature $feature) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
return [
'type' => 'feature_completed',
'feature_id' => $this->feature->id,
'project_id' => $this->feature->layer?->phase?->project_id,
'feature_name' => $this->feature->name,
'progress' => 100,
'message' => "Elemento '{$this->feature->name}' marcado como completado",
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
use App\Models\Inspection;
class InspectionCompletedNotification extends Notification
{
use Queueable;
public function __construct(public Inspection $inspection) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
return [
'type' => 'inspection_completed',
'inspection_id' => $this->inspection->id,
'project_id' => $this->inspection->project_id,
'feature_name' => $this->inspection->feature?->name ?? '—',
'template_name' => $this->inspection->template?->name ?? '—',
'result' => $this->inspection->result,
'message' => "Inspección completada en '{$this->inspection->feature?->name}': " . ($this->inspection->result ?? 'sin resultado'),
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Notifications;
use App\Models\Issue;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class IssueAssignedNotification extends Notification
{
use Queueable;
public function __construct(public Issue $issue) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
return [
'type' => 'issue_assigned',
'issue_id' => $this->issue->id,
'project_id' => $this->issue->project_id,
'priority' => $this->issue->priority,
'message' => "Se te ha asignado la incidencia '{$this->issue->title}'",
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Notifications;
use App\Models\IssueComment;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class IssueCommentedNotification extends Notification
{
use Queueable;
public function __construct(public IssueComment $comment) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
$issue = $this->comment->issue;
return [
'type' => 'issue_commented',
'issue_id' => $this->comment->issue_id,
'project_id' => $issue?->project_id,
'author' => $this->comment->user?->name,
'message' => "{$this->comment->user?->name} comentó en '{$issue?->title}': " . Str::limit($this->comment->body, 60),
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use App\Models\Issue;
class IssueReportedNotification extends Notification
{
use Queueable;
public function __construct(public Issue $issue) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
return [
'type' => 'issue_reported',
'issue_id' => $this->issue->id,
'project_id' => $this->issue->project_id,
'feature_name' => $this->issue->feature?->name ?? '—',
'priority' => $this->issue->priority,
'message' => "Nuevo issue '{$this->issue->title}' (prioridad: {$this->issue->priority})",
];
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Notifications;
use App\Models\Issue;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class IssueStatusChangedNotification extends Notification
{
use Queueable;
public function __construct(public Issue $issue, public string $status) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
$label = [
'open' => 'reabierta',
'in_review' => 'enviada a revisión',
'resolved' => 'resuelta',
'closed' => 'cerrada',
][$this->status] ?? $this->status;
return [
'type' => 'issue_status_changed',
'issue_id' => $this->issue->id,
'project_id' => $this->issue->project_id,
'status' => $this->status,
'message' => "La incidencia '{$this->issue->title}' ha sido {$label}",
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Notifications;
use App\Models\IssueTask;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class IssueTaskAssignedNotification extends Notification
{
use Queueable;
public function __construct(public IssueTask $task) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
return [
'type' => 'issue_task_assigned',
'issue_id' => $this->task->issue_id,
'task_id' => $this->task->id,
'project_id' => $this->task->issue?->project_id,
'due_date' => $this->task->due_date?->toDateString(),
'message' => "Se te ha asignado la tarea '{$this->task->title}'",
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Notifications;
use App\Models\IssueTask;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class IssueTaskOverdueNotification extends Notification
{
use Queueable;
public function __construct(public IssueTask $task) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
return [
'type' => 'issue_task_overdue',
'issue_id' => $this->task->issue_id,
'task_id' => $this->task->id,
'project_id' => $this->task->issue?->project_id,
'due_date' => $this->task->due_date?->toDateString(),
'message' => "Tarea vencida: '{$this->task->title}' (venció el {$this->task->due_date?->format('d/m/Y')})",
];
}
}
+11 -1
View File
@@ -3,6 +3,7 @@
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Gate;
class AppServiceProvider extends ServiceProvider
{
@@ -19,6 +20,15 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
// Super-admin bypass: anyone with the "manage all" permission
// (the Admin role has it) passes every authorization check.
// Return true to allow, or null to let normal checks run — never false.
Gate::before(function ($user, $ability) {
try {
return $user->hasPermissionTo('manage all') ? true : null;
} catch (\Throwable $e) {
return null;
}
});
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Traits;
use Illuminate\Support\Facades\Auth;
use App\Models\ActivityLog;
trait LogsActivity
{
public static function bootLogsActivity(): void
{
static::created(function ($model) {
ActivityLog::record('created', $model);
});
static::updated(function ($model) {
ActivityLog::record('updated', $model, $model->getDirty());
});
static::deleted(function ($model) {
ActivityLog::record('deleted', $model);
});
}
}
+10
View File
@@ -7,11 +7,21 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->appendToGroup('web', \App\Http\Middleware\SetLocale::class);
// Spatie permission + Sanctum ability middleware aliases
$middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
+1
View File
@@ -10,6 +10,7 @@
"blade-ui-kit/blade-heroicons": "^2.7",
"gasparesganga/php-shapefile": "^3.4",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1",
"league/geotools": "^1.3",
"livewire/livewire": "^3.6.4",
Generated
+67 -4
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "1b74f08906a514a8a24b6e299587d9f3",
"content-hash": "45553317b713050f78b4233c204790f9",
"packages": [
{
"name": "blade-ui-kit/blade-heroicons",
@@ -1753,6 +1753,69 @@
},
"time": "2026-04-20T16:07:33+00:00"
},
{
"name": "laravel/sanctum",
"version": "v4.3.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
"reference": "2a9bccc18e9907808e0018dd15fa643937886b1e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e",
"reference": "2a9bccc18e9907808e0018dd15fa643937886b1e",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/contracts": "^11.0|^12.0|^13.0",
"illuminate/database": "^11.0|^12.0|^13.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"php": "^8.2",
"symfony/console": "^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sanctum\\SanctumServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sanctum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
"keywords": [
"auth",
"laravel",
"sanctum"
],
"support": {
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
"time": "2026-04-30T11:46:25+00:00"
},
{
"name": "laravel/serializable-closure",
"version": "v2.0.12",
@@ -10429,12 +10492,12 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.2"
},
"platform-dev": [],
"plugin-api-version": "2.6.0"
"platform-dev": {},
"plugin-api-version": "2.9.0"
}
+87
View File
@@ -0,0 +1,87 @@
<?php
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => AuthenticateSession::class,
'encrypt_cookies' => EncryptCookies::class,
'validate_csrf_token' => ValidateCsrfToken::class,
],
];
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('features', function (Blueprint $table) {
$table->enum('status', ['planned', 'started', 'in_progress', 'completed', 'verified'])
->default('planned')
->after('progress');
$table->foreignId('responsible_user_id')
->nullable()
->constrained('users')
->nullOnDelete()
->after('responsible');
});
}
public function down(): void
{
Schema::table('features', function (Blueprint $table) {
$table->dropForeign(['responsible_user_id']);
$table->dropColumn(['status', 'responsible_user_id']);
});
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('inspections', function (Blueprint $table) {
$table->enum('status', ['pending', 'in_progress', 'completed', 'approved', 'rejected'])
->default('pending')
->after('data');
$table->foreignId('inspector_user_id')
->nullable()
->constrained('users')
->nullOnDelete()
->after('status');
$table->timestamp('completed_at')
->nullable()
->after('inspector_user_id');
$table->enum('result', ['pass', 'fail', 'conditional'])
->nullable()
->after('completed_at');
$table->text('notes')
->nullable()
->after('result');
});
}
public function down(): void
{
Schema::table('inspections', function (Blueprint $table) {
$table->dropForeign(['inspector_user_id']);
$table->dropColumn(['status', 'inspector_user_id', 'completed_at', 'result', 'notes']);
});
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('phases', function (Blueprint $table) {
$table->date('planned_start')->nullable()->after('progress_percent');
$table->date('planned_end')->nullable()->after('planned_start');
$table->date('actual_start')->nullable()->after('planned_end');
$table->date('actual_end')->nullable()->after('actual_start');
});
}
public function down(): void
{
Schema::table('phases', function (Blueprint $table) {
$table->dropColumn(['planned_start', 'planned_end', 'actual_start', 'actual_end']);
});
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$tables = ['projects', 'phases', 'layers', 'features', 'inspections'];
foreach ($tables as $table) {
if (!Schema::hasColumn($table, 'deleted_at')) {
Schema::table($table, function (Blueprint $t) {
$t->softDeletes();
});
}
}
}
public function down(): void
{
$tables = ['projects', 'phases', 'layers', 'features', 'inspections'];
foreach ($tables as $table) {
if (Schema::hasColumn($table, 'deleted_at')) {
Schema::table($table, function (Blueprint $t) {
$t->dropSoftDeletes();
});
}
}
}
};
@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('issues', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')
->constrained('projects')
->cascadeOnDelete();
$table->foreignId('feature_id')
->nullable()
->constrained('features')
->nullOnDelete();
$table->foreignId('inspection_id')
->nullable()
->constrained('inspections')
->nullOnDelete();
$table->string('title');
$table->text('description')->nullable();
$table->enum('status', ['open', 'in_review', 'resolved', 'closed'])
->default('open');
$table->enum('priority', ['low', 'medium', 'high', 'critical'])
->default('medium');
$table->foreignId('reported_by')
->constrained('users');
$table->foreignId('assigned_to')
->nullable()
->constrained('users')
->nullOnDelete();
$table->timestamp('resolved_at')->nullable();
$table->text('resolution_notes')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('issues');
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('notifications');
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('activity_logs', function (Blueprint $table) {
$table->id();
$table->string('action');
$table->string('model_type');
$table->unsignedBigInteger('model_id');
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->json('changes')->nullable();
$table->timestamps();
$table->index(['model_type', 'model_id']);
});
}
public function down(): void
{
Schema::dropIfExists('activity_logs');
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('title', 20)->nullable()->after('id');
$table->string('first_name')->nullable()->after('title');
$table->string('last_name')->nullable()->after('first_name');
$table->string('status', 20)->default('active')->after('name');
$table->date('valid_from')->nullable()->after('status');
$table->date('valid_until')->nullable()->after('valid_from');
$table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete()->after('valid_until');
$table->string('phone', 30)->nullable()->after('company_id');
$table->text('address')->nullable()->after('phone');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropForeign(['company_id']);
$table->dropColumn([
'title', 'first_name', 'last_name', 'status',
'valid_from', 'valid_until', 'company_id', 'phone', 'address',
]);
});
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->text('notes')->nullable()->after('address');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('notes');
});
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->char('country', 2)->nullable()->after('address');
});
}
public function down(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->dropColumn('country');
});
}
};
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('locale', 5)->default('es')->change();
});
// Reset all users still on the old default so they load in Spanish.
// Users that explicitly chose 'en' keep their preference.
DB::table('users')->where('locale', 'en')->update(['locale' => 'es']);
}
public function down(): void
{
DB::table('users')->where('locale', 'es')->update(['locale' => 'en']);
Schema::table('users', function (Blueprint $table) {
$table->string('locale', 5)->default('en')->change();
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$table = config('permission.table_names.roles', 'roles');
Schema::table($table, function (Blueprint $table) {
if (! Schema::hasColumn($table->getTable(), 'description')) {
$table->string('description')->nullable()->after('name');
}
});
}
public function down(): void
{
$table = config('permission.table_names.roles', 'roles');
Schema::table($table, function (Blueprint $table) {
if (Schema::hasColumn($table->getTable(), 'description')) {
$table->dropColumn('description');
}
});
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$table = config('permission.table_names.permissions', 'permissions');
Schema::table($table, function (Blueprint $table) {
if (! Schema::hasColumn($table->getTable(), 'group')) {
$table->string('group')->nullable()->after('name');
}
if (! Schema::hasColumn($table->getTable(), 'description')) {
$table->string('description')->nullable()->after('group');
}
});
}
public function down(): void
{
$table = config('permission.table_names.permissions', 'permissions');
Schema::table($table, function (Blueprint $table) {
foreach (['group', 'description'] as $col) {
if (Schema::hasColumn($table->getTable(), $col)) {
$table->dropColumn($col);
}
}
});
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('devices', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name'); // device_name del login
$table->unsignedBigInteger('token_id')->nullable(); // id del personal_access_token actual
$table->string('app_version')->nullable();
$table->timestamp('last_seen_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'name']);
});
}
public function down(): void
{
Schema::dropIfExists('devices');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('progress_updates', function (Blueprint $table) {
if (! Schema::hasColumn('progress_updates', 'uuid')) {
$table->uuid('uuid')->nullable()->unique()->after('id');
}
if (! Schema::hasColumn('progress_updates', 'client_updated_at')) {
$table->timestamp('client_updated_at')->nullable()->after('location');
}
});
}
public function down(): void
{
Schema::table('progress_updates', function (Blueprint $table) {
foreach (['uuid', 'client_updated_at'] as $col) {
if (Schema::hasColumn('progress_updates', $col)) {
$table->dropColumn($col);
}
}
});
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* `reference` (and `external_reference_1`) exist in the live DB but were never
* created by a migration (the "add_reference_and_country" migration only added
* `country`). This guarded migration reconciles the schema: on the live DB the
* columns already exist and are skipped; on a fresh install they get created.
*/
public function up(): void
{
Schema::table('projects', function (Blueprint $table) {
if (! Schema::hasColumn('projects', 'reference')) {
$table->string('reference')->nullable()->after('id');
}
if (! Schema::hasColumn('projects', 'external_reference_1')) {
$table->string('external_reference_1')->nullable()->after('reference');
}
});
}
public function down(): void
{
Schema::table('projects', function (Blueprint $table) {
foreach (['reference', 'external_reference_1'] as $col) {
if (Schema::hasColumn('projects', $col)) {
$table->dropColumn($col);
}
}
});
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private array $tables = ['features', 'inspections', 'issues'];
public function up(): void
{
foreach ($this->tables as $name) {
Schema::table($name, function (Blueprint $table) use ($name) {
if (! Schema::hasColumn($name, 'uuid')) {
$table->uuid('uuid')->nullable()->unique()->after('id');
}
if (! Schema::hasColumn($name, 'client_updated_at')) {
$table->timestamp('client_updated_at')->nullable();
}
});
}
}
public function down(): void
{
foreach ($this->tables as $name) {
Schema::table($name, function (Blueprint $table) use ($name) {
foreach (['uuid', 'client_updated_at'] as $col) {
if (Schema::hasColumn($name, $col)) {
$table->dropColumn($col);
}
}
});
}
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('media', function (Blueprint $table) {
if (! Schema::hasColumn('media', 'uuid')) {
$table->uuid('uuid')->nullable()->unique()->after('id');
}
if (! Schema::hasColumn('media', 'client_updated_at')) {
$table->timestamp('client_updated_at')->nullable();
}
});
}
public function down(): void
{
Schema::table('media', function (Blueprint $table) {
foreach (['uuid', 'client_updated_at'] as $col) {
if (Schema::hasColumn('media', $col)) {
$table->dropColumn($col);
}
}
});
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('sync_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->uuid('op_uuid')->index(); // idempotency key of the operation
$table->string('entity');
$table->string('op');
$table->string('status'); // applied | duplicate | conflict | error
$table->unsignedBigInteger('server_id')->nullable();
$table->text('error')->nullable();
$table->timestamps();
// One processed result per (entity, op, op_uuid).
$table->unique(['entity', 'op', 'op_uuid']);
});
}
public function down(): void
{
Schema::dropIfExists('sync_logs');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('issue_tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('issue_id')->constrained('issues')->cascadeOnDelete();
$table->string('title');
$table->boolean('is_done')->default(false);
$table->timestamp('done_at')->nullable();
$table->foreignId('done_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
$table->date('due_date')->nullable();
$table->integer('order')->default(0);
// Offline sync
$table->uuid('uuid')->nullable()->unique();
$table->timestamp('client_updated_at')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('issue_tasks');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('issue_comments', function (Blueprint $table) {
$table->id();
$table->foreignId('issue_id')->constrained('issues')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users');
$table->text('body');
// Offline sync
$table->uuid('uuid')->nullable()->unique();
$table->timestamp('client_updated_at')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('issue_comments');
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('issue_checklist_templates', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained('projects')->cascadeOnDelete();
$table->string('name');
$table->json('items')->nullable(); // array of task titles
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('issue_checklist_templates');
}
};

Some files were not shown because too many files have changed in this diff Show More