Compare commits

48 Commits

Author SHA1 Message Date
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
javier c832d4f3da feat: optimize project-map Livewire component with eager loading, XSS prevention, URL validation, and performance improvements 2026-05-28 21:46:25 +02:00
javier 2711dcf2f2 Fix Livewire component structural error and fix JavaScript syntax error in popup content (unexpected token ')') 2026-05-28 16:34:02 +02:00
javier 052e1397df Fix: Corrected structural error in project-map Livewire component (multiple root elements). Moved closing </div> after @push('scripts') to ensure single root element. 2026-05-28 13:07:14 +02:00
javier 02e99329eb Add tabs to project map: Edit, Features, Inspections. Features and Inspections tabs show all items. 2026-05-27 22:40:45 +02:00
javier cf3d32a6fa Add interactive map to project form for setting coordinates and updating address/country 2026-05-27 20:28:44 +02:00
javier 52f586f815 Fix: selectFeature and window.openViewer JS syntax in project-map.blade.php 2026-05-27 19:48:29 +02:00
javier 0f1aa2c38e feat: Update ProjectTable with ID column, improved actions buttons, and modern column configuration 2026-05-27 13:38:23 +02:00
javier 2da0eb817e feat: Add tabs to project map right column with element selector, inspection history and media viewer 2026-05-27 11:56:44 +02:00
javier 971420ebaa feat: Add language switcher to client portal header for desktop view 2026-05-27 10:12:57 +02:00
javier 0f720567c3 feat: Register background sync for offline actions when queued or stored 2026-05-27 09:29:44 +02:00
javier 0bf2d82ee1 Implement company management with logo, nickname, status fields; add filters by type and estado; CSV export functionality 2026-05-27 01:33:27 +02:00
javier 4ab7935c17 feat: Add change orders system with client approval/rejection and integrate with client portal 2026-05-25 19:08:06 +02:00
javier 07ffce437f feat: Add offline media capture capability and enhance offline sync system with comprehensive action type support 2026-05-25 18:41:54 +02:00
javier d4d5097fe2 feat: Enhance offline sync system with support for multiple action types (progress_update, inspection, feature_create, media_upload) and improved error handling 2026-05-25 17:59:03 +02:00
javier c556a4910b feat: Add Excel export functionality for reports (projects, phases, inspections) using maatwebsite/excel 2026-05-25 17:21:25 +02:00
javier fd166edbc6 feat: Enhance PWA with advanced service worker (network-first strategy), background sync, and push notifications 2026-05-25 16:35:55 +02:00
javier 8ca8dfbccc feat: Add client portal with project selection, progress overview, gallery, and change order approval 2026-05-25 15:57:06 +02:00
javier 4f5569a156 feat: Add reports dashboard with Chart.js analytics and PWA improvements (Avante) 2026-05-25 14:38:49 +02:00
129 changed files with 12367 additions and 2007 deletions
+1
View File
@@ -22,3 +22,4 @@
Homestead.json
Homestead.yaml
Thumbs.db
.claude/worktrees/
+1 -1
View File
@@ -1,4 +1,4 @@
# ConstruProgress
# Avante
Sistema de gestión de proyectos de construcción con mapas interactivos, control de progreso, inspecciones y soporte offline.
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Exports;
use App\Models\Inspection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
class InspectionsExport implements FromCollection, WithHeadings
{
public function collection()
{
return Inspection::select([
'id',
'project_id',
'feature_id',
'template_id',
'status',
'notes',
'created_at',
'updated_at'
])->get();
}
public function headings(): array
{
return [
'ID',
'ID Proyecto',
'ID Característica',
'ID Plantilla',
'Estado',
'Notas',
'Creado el',
'Actualizado el'
];
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Exports;
use App\Models\Phase;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
class PhasesExport implements FromCollection, WithHeadings
{
public function collection()
{
return Phase::select([
'id',
'project_id',
'name',
'progress_percent',
'start_date',
'end_date',
'created_at',
'updated_at'
])->get();
}
public function headings(): array
{
return [
'ID',
'ID Proyecto',
'Nombre',
'Progreso (%)',
'Fecha de inicio',
'Fecha de fin',
'Creado el',
'Actualizado el'
];
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Exports;
use App\Models\Project;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
class ProjectsExport implements FromCollection, WithHeadings
{
public function collection()
{
return Project::select([
'id',
'name',
'description',
'start_date',
'end_date',
'status',
'created_at',
'updated_at'
])->get();
}
public function headings(): array
{
return [
'ID',
'Nombre',
'Descripción',
'Fecha de inicio',
'Fecha de fin',
'Estado',
'Creado el',
'Actualizado el'
];
}
}
+75 -16
View File
@@ -4,15 +4,19 @@ namespace App\Http\Controllers;
use App\Models\PendingSync;
use App\Models\Phase;
use App\Models\Inspection;
use App\Models\Feature;
use App\Models\Media;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
class OfflineSyncController extends Controller
{
public function storePending(Request $request)
{
$payload = $request->validate([
'action' => 'required|in:progress_update,task_complete',
'action' => 'required|in:progress_update,inspection,feature_create,media_upload,task_complete',
'payload' => 'required|array',
]);
$pending = PendingSync::create([
@@ -27,23 +31,78 @@ class OfflineSyncController extends Controller
{
$user = Auth::user();
$pendings = PendingSync::where('user_id', $user->id)->whereNull('synced_at')->get();
$results = [];
foreach ($pendings as $pending) {
if ($pending->action === 'progress_update') {
$phase = Phase::find($pending->payload['phase_id']);
if ($phase) {
$phase->progress_percent = $pending->payload['progress'];
$phase->save();
$phase->progressUpdates()->create([
'user_id' => $user->id,
'progress_percent' => $pending->payload['progress'],
'comment' => $pending->payload['comment'] ?? '',
'location' => $pending->payload['location'] ?? null,
]);
$result = ['id' => $pending->id, 'action' => $pending->action, 'success' => false, 'error' => null];
try {
if ($pending->action === 'progress_update') {
$phase = Phase::find($pending->payload['phase_id']);
if ($phase) {
$phase->progress_percent = $pending->payload['progress'];
$phase->save();
$phase->progressUpdates()->create([
'user_id' => $user->id,
'progress_percent' => $pending->payload['progress'],
'comment' => $pending->payload['comment'] ?? '',
'location' => $pending->payload['location'] ?? null,
]);
}
$result['success'] = true;
} elseif ($pending->action === 'inspection') {
$inspection = Inspection::create($pending->payload);
$result['success'] = true;
$result['data'] = ['inspection_id' => $inspection->id];
} elseif ($pending->action === 'feature_create') {
$feature = Feature::create($pending->payload);
$result['success'] = true;
$result['data'] = ['feature_id' => $feature->id];
} elseif ($pending->action === 'media_upload') {
// Assuming payload has: 'file' (base64), 'path', 'model_type', 'model_id'
// We'll decode the base64 and store the file
if (isset($pending->payload['file'], $pending->payload['path'])) {
$decoded = base64_decode($pending->payload['file']);
if ($decoded !== false) {
$path = Storage::put($pending->payload['path'], $decoded);
// Attach to model if model_type and model_id are provided
if (isset($pending->payload['model_type'], $pending->payload['model_id'])) {
$model = new $pending->payload['model_type'];
$model = $model->find($pending->payload['model_id']);
if ($model) {
$model->media()->create([
'name' => $pending->payload['name'] ?? 'unnamed',
'path' => $path,
'mime_type' => $pending->payload['mime_type'] ?? 'application/octet-stream',
'disk' => 'public',
]);
}
}
$result['success'] = true;
$result['data'] = ['path' => $path];
} else {
$result['error'] = 'Failed to decode base64 file';
}
} else {
$result['error'] = 'Missing file or path in payload';
}
} elseif ($pending->action === 'task_complete') {
// Example: mark a task as complete (you can adjust as needed)
// For now, just log and mark as success
\Log::info('Task completed offline', $pending->payload);
$result['success'] = true;
} else {
$result['error'] = 'Unknown action type';
}
} catch (\Exception $e) {
$result['error'] = $e->getMessage();
}
$pending->synced_at = now();
$pending->save();
if ($result['success']) {
$pending->synced_at = now();
$pending->save();
}
$results[] = $result;
}
return response()->json(['synced' => count($pendings)]);
return response()->json(['synced' => $results]);
}
}
}
@@ -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->hasRole('Admin') && !$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);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Reports;
use App\Http\Controllers\Controller;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Inspection;
use Maatwebsite\Excel\Facades\Excel;
use App\Exports\ProjectsExport;
use App\Exports\PhasesExport;
use App\Exports\InspectionsExport;
use Illuminate\Http\Request;
class ExportController extends Controller
{
public function exportProjects(Request $request)
{
return Excel::download(new ProjectsExport, 'projects.xlsx');
}
public function exportPhases(Request $request)
{
return Excel::download(new PhasesExport, 'phases.xlsx');
}
public function exportInspections(Request $request)
{
return Excel::download(new InspectionsExport, 'inspections.xlsx');
}
}
+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();
if (!Auth::user()->hasRole('Admin')) abort(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');
}
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace App\Livewire\Client;
use Livewire\Component;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Inspection;
use App\Models\Feature;
use App\Models\ChangeOrder;
use Carbon\Carbon;
class ClientProjects extends Component
{
public $projects = [];
public $selectedProject = null;
public $projectDetails = [];
public $galleryImages = [];
public $changeOrders = [];
public function mount()
{
$this->loadProjects();
}
public function loadProjects()
{
// Get projects where the user has the 'client' role
$user = auth()->user();
$this->projects = $user->projects()
->wherePivot('role_in_project', 'client')
->with(['phases' => function($query) {
$query->select('id', 'project_id', 'name', 'progress_percent');
}])
->get()
->toArray();
}
public function selectProject($projectId)
{
$this->selectedProject = $projectId;
$this->loadProjectDetails();
}
public function loadProjectDetails()
{
if (!$this->selectedProject) {
return;
}
$project = Project::with([
'phases.features',
'inspections.template',
'changeOrders' // Load change orders for this project
])->find($this->selectedProject);
if (!$project) {
return;
}
$this->projectDetails = [
'id' => $project->id,
'name' => $project->name,
'description' => $project->description,
'start_date' => $project->start_date,
'end_date' => $project->end_date,
'status' => $project->status,
'progress' => $project->phases->avg('progress_percent') ?? 0,
];
// Get recent images (we can fetch from media table if needed, but for now we'll keep simulated or link to real)
// For simplicity, we'll try to get some media images for the project
$mediaImages = $project->media()
->where('category', 'image')
->latest()
->take(3)
->get()
->map(function($media) {
return [
'url' => $media->url,
'title' => $media->name,
'date' => $media->created_at->format('d/m/Y')
];
})
->toArray();
// If we don't have 3 images, we can fallback to placeholders or just use what we have
if (count($mediaImages) > 0) {
$this->galleryImages = $mediaImages;
} else {
// Fallback to placeholders
$this->galleryImages = [
[
'url' => 'https://via.placeholder.com/400x300?text=Avance+1',
'title' => 'Avance inicial',
'date' => now()->subDays(30)->format('d/m/Y')
],
[
'url' => 'https://via.placeholder.com/400x300?text=Avance+2',
'title' => 'Estructura levantada',
'date' => now()->subDays(15)->format('d/m/Y')
],
[
'url' => 'https://via.placeholder.com/400x300?text=Avance+3',
'title' => 'Instalaciones',
'date' => now()->subDays(5)->format('d/m/Y')
]
];
}
// Get change orders for this project
$this->changeOrders = $project->changeOrders
->orderBy('requested_at', 'desc')
->get()
->map(function($order) {
return [
'id' => $order->id,
'title' => $order->title,
'description' => $order->description,
'status' => $order->status,
'requested_at' => $order->requested_at->format('d/m/Y'),
'amount' => $order->amount
];
})
->toArray();
}
public function approveChangeOrder($orderId)
{
// Update the change order in the database
$changeOrder = ChangeOrder::find($orderId);
if ($changeOrder) {
// Check that the change order belongs to the selected project (security)
if ($changeOrder->project_id == $this->selectedProject) {
$changeOrder->status = 'approved';
$changeOrder->responded_at = now()->toDateString();
$changeOrder->responded_by = auth()->id();
$changeOrder->save();
// Refresh the change orders list
$this->loadProjectDetails();
// Notify any listeners (optional)
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'approved']);
}
}
}
public function rejectChangeOrder($orderId)
{
// Update the change order in the database
$changeOrder = ChangeOrder::find($orderId);
if ($changeOrder) {
// Check that the change order belongs to the selected project (security)
if ($changeOrder->project_id == $this->selectedProject) {
$changeOrder->status = 'rejected';
$changeOrder->responded_at = now()->toDateString();
$changeOrder->responded_by = auth()->id();
$changeOrder->save();
// Refresh the change orders list
$this->loadProjectDetails();
// Notify any listeners (optional)
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'rejected']);
}
}
}
public function render()
{
return view('livewire.client.client-projects');
}
}
+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');
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Company;
use Illuminate\Support\Facades\Storage;
#[Layout('layouts.app')]
class CompanyManagement extends Component
{
public string $search = '';
public string $filterType = '';
public string $filterEstado = '';
public function getCompaniesProperty()
{
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();
return response()->streamDownload(function () use ($companies) {
$handle = fopen('php://output', 'w');
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, [
$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);
}, '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
{
if (!Auth::user()->hasRole('Admin')) abort(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');
}
}
+191
View File
@@ -0,0 +1,191 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Illuminate\Support\Facades\Auth;
use App\Models\Project;
use App\Models\Issue;
use App\Notifications\IssueReportedNotification;
#[Layout('layouts.app')]
class IssueManager extends Component
{
public Project $project;
public $issues = [];
public $projectUsers = [];
// Form / modal state
public $showForm = false;
public $editingIssue = null; // issue id when editing, null when creating
// Form fields
public $title = '';
public $description = '';
public $status = 'open';
public $priority = 'medium';
public $assignedTo = '';
public $resolutionNotes = '';
// Optional context (e.g. when reporting from a map feature)
public $featureId = null;
public $inspectionId = null;
public function mount(Project $project)
{
$this->project = $project;
$this->loadProjectUsers();
$this->loadIssues();
}
public function loadIssues()
{
$this->issues = Issue::where('project_id', $this->project->id)
->with(['feature', 'reporter', 'assignee'])
->orderBy('created_at', 'desc')
->get();
}
public function loadProjectUsers()
{
$this->projectUsers = $this->project->users()->orderBy('name')->get();
}
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),
'assignedTo' => 'nullable|exists:users,id',
'resolutionNotes' => 'nullable|string',
];
}
public function openForm($issueId = null)
{
$this->resetForm();
if ($issueId) {
$issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId);
$this->editingIssue = $issue->id;
$this->title = $issue->title;
$this->description = $issue->description ?? '';
$this->status = $issue->status;
$this->priority = $issue->priority;
$this->assignedTo = $issue->assigned_to ?? '';
$this->resolutionNotes = $issue->resolution_notes ?? '';
$this->featureId = $issue->feature_id;
$this->inspectionId = $issue->inspection_id;
}
$this->showForm = true;
}
public function closeForm()
{
$this->showForm = false;
$this->resetForm();
}
private function resetForm(): void
{
$this->reset([
'title', 'description', 'assignedTo', 'resolutionNotes',
'featureId', 'inspectionId', 'editingIssue',
]);
$this->status = 'open';
$this->priority = 'medium';
$this->resetErrorBag();
}
public function save()
{
$this->validate();
$payload = [
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'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
if (in_array($this->status, ['resolved', 'closed'])) {
$payload['resolved_at'] = now();
} else {
$payload['resolved_at'] = null;
}
if ($this->editingIssue) {
$issue = Issue::where('project_id', $this->project->id)->findOrFail($this->editingIssue);
// Don't overwrite an existing resolved date if it was already resolved
if ($issue->resolved_at && in_array($this->status, ['resolved', 'closed'])) {
unset($payload['resolved_at']);
}
$issue->update($payload);
} else {
$issue = Issue::create(array_merge($payload, [
'project_id' => $this->project->id,
'reported_by' => Auth::id(),
]));
if ($issue->wasRecentlyCreated) {
$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));
}
}
}
$this->closeForm();
$this->loadIssues();
$this->dispatch('notify', 'Issue guardado correctamente');
}
public function resolve($issueId)
{
$issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId);
$issue->update([
'status' => 'resolved',
'resolved_at' => $issue->resolved_at ?? now(),
]);
$this->loadIssues();
$this->dispatch('notify', 'Issue marcado como resuelto');
}
public function close($issueId)
{
$issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId);
$issue->update([
'status' => 'closed',
'resolved_at' => $issue->resolved_at ?? now(),
]);
$this->loadIssues();
$this->dispatch('notify', 'Issue cerrado');
}
public function delete($issueId)
{
$issue = Issue::where('project_id', $this->project->id)->findOrFail($issueId);
$issue->delete();
$this->loadIssues();
$this->dispatch('notify', 'Issue eliminado');
}
public function render()
{
return view('livewire.issues.issue-manager');
}
}
+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()
+245 -157
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->hasRole('Admin') && !$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.');
$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') && !$user->hasRole('Admin')) {
$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);
// 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'));
}
}
+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->hasRole('Admin') && !$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(),
]);
}
}
+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;
+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->hasRole('Admin')) 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');
}
}
+129 -1
View File
@@ -3,11 +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 ?Project $project = null;
// Identification
public string $name = '';
public string $reference = '';
public string $status = 'planning';
// 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 ($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');
}
}
// 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;
if ($address) $this->address = $address;
if ($country) $this->country = strtolower($country);
}
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();
$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 = Project::create(array_merge($data, ['created_by' => Auth::id()]));
$project->users()->attach(Auth::id(), ['role_in_project' => 'supervisor']);
session()->flash('notify', 'Proyecto creado correctamente.');
}
$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;
+239 -116
View File
@@ -10,16 +10,17 @@ 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 = '';
@@ -27,6 +28,11 @@ class ProjectMap extends Component
public $editPhotos = [];
public $formFullscreen = false;
// Tab management
public $activeTab = 'edit';
public $allFeatures;
public $allInspections;
// Templates e inspecciones
public $templates = [];
public $selectedTemplateId = null;
@@ -37,16 +43,61 @@ class ProjectMap extends Component
public $showFeatureImages = false;
public $featureImageMarkers = [];
// 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->hasRole('Admin')) return;
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403);
}
public function loadTemplates()
@@ -54,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')) {
$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) {
@@ -150,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) {
@@ -166,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) {
@@ -189,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;
@@ -260,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,
];
}
@@ -311,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,
]);
}
}
}
+89 -33
View File
@@ -4,6 +4,8 @@ namespace App\Livewire;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use App\Models\Project;
class ProjectTable extends DataTableComponent
@@ -14,53 +16,107 @@ class ProjectTable extends DataTableComponent
{
$this->setPrimaryKey('id')
->setDefaultSort('created_at', 'desc')
->setTableAttributes(['class' => 'table-auto w-full'])
->setThAttributes(['class' => 'px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'])
->setTdAttributes(['class' => 'px-4 py-2 whitespace-nowrap text-sm text-gray-900']);
->setSortingPillsEnabled(false)
->setAdditionalSelects(['projects.id as id', 'projects.created_at as created_at']);
}
public function builder(): Builder
{
return Project::accessibleBy(Auth::user())
->with('phases');
}
public function columns(): array
{
return [
Column::make(__('Project Name'), 'name')
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(__('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()
->filterable([
'planning' => __('Planning'),
'in_progress' => __('In progress'),
'paused' => __('Paused'),
'completed' => __('Completed'),
])
->label(fn ($value, $row, $column) =>
match ($value) {
'planning' => '<span class="badge badge-primary">'.__('Planning').'</span>',
'in_progress' => '<span class="badge badge-success">'.__('In progress').'</span>',
'paused' => '<span class="badge badge-warning">'.__('Paused').'</span>',
'completed' => '<span class="badge badge-secondary">'.__('Completed').'</span>',
default => $value
}
),
->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') : ''),
Column::make(__('Estimated End Date'), 'end_date_estimated')
->format(fn ($value) => $value ? $value->format('d/m/Y') : ''),
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(fn ($row) => '<div class="flex space-x-2">
<a href="'.route('projects.edit', $row->id).'" class="btn btn-sm btn-primary">'.__('Edit').'</a>
<form action="'.route('projects.destroy', $row->id).'" method="POST" onsubmit="return confirm(__('Are you sure you want to delete this project?'));">
'.csrf_field().'
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="btn btn-sm btn-error">'.__('Delete').'</button>
</form>
</div>')
->htmlAttribute(['class' => 'text-right']),
->label(function ($row) {
$dashboard = route('projects.dashboard', $row->id);
$map = route('projects.map', $row->id);
$edit = route('projects.edit', $row->id);
$canEdit = Auth::user()->can('edit projects');
$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(),
];
}
}
public function filters(): array
{
return [];
}
}
-111
View File
@@ -1,111 +0,0 @@
<?php
namespace App\Livewire;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use App\Models\Project;
class ProjectTable extends DataTableComponent
{
protected $model = Project::class;
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('created_at', 'desc')
->setTableAttributes(['class' => 'table-auto w-full'])
->setThAttributes(['class' => 'px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'])
->setTdAttributes(['class' => 'px-4 py-2 whitespace-nowrap text-sm text-gray-900']);
$this->addColumn('name', __('Project Name'))
->setSortable()
->setSearchable();
$this->addColumn('address', __('Address'))
->setSortable()
->setSearchable();
$this->addColumn('status', __('Status'))
->setSortable()
->setFilterable([
'planning' => __('Planning'),
'in_progress' => __('In progress'),
'paused' => __('Paused'),
'completed' => __('Completed'),
])
->setLabel(fn ($value, $row, $column, $component) =>
match ($value) {
'planning' => '<span class="badge badge-primary">'.__('Planning').'</span>',
'in_progress' => '<span class="badge badge-success">'.__('In progress').'</span>',
'paused' => '<span class="badge badge-warning">'.__('Paused').'</span>',
'completed' => '<span class="badge badge-secondary">'.__('Completed').'</span>',
default => $value
}
);
$this->addColumn('start_date', __('Start Date'))
->setSortable()
->setFormat(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : '');
$this->addColumn('end_date_estimated', __('Estimated End Date'))
->setSortable()
->setFormat(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : '');
$this->addColumn('actions', __('Actions'))
->setLabel(fn ($row) => '<div class="flex space-x-2">
<a href="'.route('projects.edit', $row->id).'" class="btn btn-sm btn-primary">'.__('Edit').'</a>
<form action="'.route('projects.destroy', $row->id).'" method="POST" onsubmit="return confirm(''.__('Are you sure you want to delete this project?').'');">
'.csrf_field().'
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="btn btn-sm btn-error">'.__('Delete').'</button>
</form>
</div>')
->setHtmlAttribute(['class' => 'text-right']);
}
public function columns(): array
{
return [
Column::make(__('Project Name'), 'name')
->sortable()
->searchable(),
Column::make(__('Address'), 'address')
->sortable()
->searchable(),
Column::make(__('Status'), 'status')
->sortable()
->filterable([
'planning' => __('Planning'),
'in_progress' => __('In progress'),
'paused' => __('Paused'),
'completed' => __('Completed'),
])
->label(fn ($value, $row, $column) =>
match ($value) {
'planning' => '<span class="badge badge-primary">'.__('Planning').'</span>',
'in_progress' => '<span class="badge badge-success">'.__('In progress').'</span>',
'paused' => '<span class="badge badge-warning">'.__('Paused').'</span>',
'completed' => '<span class="badge badge-secondary">'.__('Completed').'</span>',
default => $value
}
),
Column::make(__('Start Date'), 'start_date')
->sortable()
->format(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : ''),
Column::make(__('Estimated End Date'), 'end_date_estimated')
->sortable()
->format(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : ''),
Column::make(__('Actions'))
->label(fn ($row) => '<div class="flex space-x-2">
<a href="'.route('projects.edit', $row->id).'" class="btn btn-sm btn-primary">'.__('Edit').'</a>
<form action="'.route('projects.destroy', $row->id).'" method="POST" onsubmit="return confirm(''.__('Are you sure you want to delete this project?').'');">
'.csrf_field().'
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="btn btn-sm btn-error">'.__('Delete').'</button>
</form>
</div>')
->htmlAttribute(['class' => 'text-right']),
];
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
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
public $chartData = [];
public function mount()
{
$this->loadChartData();
}
public function loadChartData()
{
// Project progress over time (last 6 months)
$projects = Project::with(['phases' => function($query) {
$query->select('project_id', 'progress_percent', 'updated_at');
}])->get();
// Simulate monthly progress data (since we don't have historical stored)
// In a real app, we'd have a progress_history table or similar
$months = [];
$current = Carbon::now();
for ($i = 5; $i >= 0; $i--) {
$month = $current->copy()->subMonths($i);
$months[] = $month->format('M Y');
}
$projectProgress = [];
foreach ($projects as $project) {
$progressData = [];
foreach ($months as $month) {
// For demo, we'll use current progress with some variation
$avgProgress = $project->phases->avg('progress_percent') ?? 0;
// Add some random variation for demo purposes
$variation = rand(-10, 10);
$progress = max(0, min(100, $avgProgress + $variation));
$progressData[] = round($progress);
}
$projectProgress[] = [
'name' => $project->name,
'data' => $progressData
];
}
// Inspections by type (last 6 months)
$inspections = Inspection::with(['template', 'feature'])
->whereDate('created_at', '>=', Carbon::now()->subMonths(6))
->get();
$inspectionTypes = $inspections->groupBy(function($inspection) {
return $inspection->template ? $inspection->template->name : 'Sin plantilla';
})->map(function($group) {
return $group->count();
});
// Projects by status
$projectsByStatus = Project::selectRaw('status, count(*) as count')
->groupBy('status')
->pluck('count', 'status')
->toArray();
// Average phase progress by project
$projectPhaseProgress = Project::with(['phases'])
->get()
->map(function($project) {
return [
'name' => $project->name,
'progress' => $project->phases->avg('progress_percent') ?? 0
];
});
$this->chartData = [
'months' => $months,
'projectProgress' => $projectProgress,
'inspectionTypes' => [
'labels' => $inspectionTypes->keys()->toArray(),
'data' => $inspectionTypes->values()->toArray()
],
'projectsByStatus' => [
'labels' => array_map(function($status) {
return ucfirst(str_replace('_', ' ', $status));
}, array_keys($projectsByStatus)),
'data' => array_values($projectsByStatus)
],
'projectPhaseProgress' => $projectPhaseProgress
];
}
public function render()
{
return view('livewire.reports.reports-dashboard');
}
}
+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(self::CORE_PERMISSION), 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(self::CORE_PERMISSION), 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'));
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
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
private const PROTECTED_ROLES = ['Admin'];
private const CORE_PERMISSION = 'manage all';
public function mount(Role $role): void
{
abort_unless(Auth::user()?->can(self::CORE_PERMISSION), 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 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;
});
return view('livewire.roles.role-view', [
'users' => $users,
'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()
+165
View File
@@ -0,0 +1,165 @@
<?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 = '';
// Notas
public string $notes = '';
// Catálogos
public $roles;
public $companies;
public function mount(?User $user = null): void
{
if (!Auth::user()->hasRole('Admin')) abort(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;
}
}
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',
];
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',
];
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,
];
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();
}
}
+110
View File
@@ -0,0 +1,110 @@
<?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;
#[Layout('layouts.app')]
class UserView extends Component
{
public User $user;
public string $activeTab = 'permissions';
// 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
{
if (!Auth::user()->hasRole('Admin')) abort(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.');
}
// ── 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()
{
return view('livewire.user-view');
}
}
+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);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ChangeOrder extends Model
{
protected $fillable = [
'project_id',
'title',
'description',
'amount',
'status',
'requested_at',
'responded_at',
'responded_by',
];
protected $casts = [
'requested_at' => 'date',
'responded_at' => 'date',
'amount' => 'decimal:2',
];
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function responder(): BelongsTo
{
return $this->belongsTo(User::class, 'responded_by');
}
}
+9
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -10,6 +11,9 @@ class Company extends Model
use HasFactory;
protected $fillable = [
'apodo',
'estado',
'logo_path',
'name',
'tax_id',
'address',
@@ -23,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')
+32 -3
View File
@@ -3,15 +3,22 @@
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',
];
protected $casts = [
'geometry' => 'array',
'geometry' => 'array',
'properties' => 'array',
];
@@ -30,6 +37,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 +56,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',
};
}
}
+29 -2
View File
@@ -3,12 +3,25 @@
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',
];
protected $casts = [
'data' => 'array',
'completed_at' => 'datetime',
];
public function project()
{
@@ -30,8 +43,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'); }
}
+55
View File
@@ -0,0 +1,55 @@
<?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'];
protected $fillable = [
'project_id', 'feature_id', 'inspection_id',
'title', 'description', 'status', 'priority',
'reported_by', 'assigned_to', 'resolved_at', 'resolution_notes'
];
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 scopeOpen($q) { return $q->where('status', 'open'); }
public function scopeCritical($q) { return $q->where('priority', 'critical'); }
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',
};
}
}
+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');
+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');
}
}
}
+11 -4
View File
@@ -4,20 +4,27 @@ 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, 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 = [
'start_date' => 'date',
'end_date_estimated' => 'date',
"start_date" => "date",
"end_date_estimated" => "date",
];
public function changeOrders()
{
return $this->hasMany(ChangeOrder::class);
}
// Relationships
public function phases()
{
+12 -4
View File
@@ -20,9 +20,10 @@ 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',
];
/**
@@ -44,9 +45,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,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})",
];
}
}
+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);
});
}
}
+7
View File
@@ -12,6 +12,13 @@ return Application::configure(basePath: dirname(__DIR__))
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->appendToGroup('web', \App\Http\Middleware\SetLocale::class);
// Spatie permission middleware aliases
$middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
+1
View File
@@ -14,6 +14,7 @@
"league/geotools": "^1.3",
"livewire/livewire": "^3.6.4",
"livewire/volt": "^1.7.0",
"maatwebsite/excel": "*",
"mansoor/blade-lets-icons": "^1.0",
"phayes/geophp": "^1.2",
"rappasoft/laravel-livewire-tables": "^3.7",
Generated
+592 -1
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": "afa93484318041be0823eb4914a47317",
"content-hash": "1b74f08906a514a8a24b6e299587d9f3",
"packages": [
{
"name": "blade-ui-kit/blade-heroicons",
@@ -285,6 +285,162 @@
],
"time": "2024-02-09T16:56:22+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "composer/semver",
"version": "3.4.4",
"source": {
"type": "git",
"url": "https://github.com/composer/semver.git",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"shasum": ""
},
"require": {
"php": "^5.3.2 || ^7.0 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.11",
"symfony/phpunit-bridge": "^3 || ^7"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Semver\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nils Adermann",
"email": "naderman@naderman.de",
"homepage": "http://www.naderman.de"
},
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
},
{
"name": "Rob Bast",
"email": "rob.bast@gmail.com",
"homepage": "http://robbast.nl"
}
],
"description": "Semver library that offers utilities, version constraint parsing and validation.",
"keywords": [
"semantic",
"semver",
"validation",
"versioning"
],
"support": {
"irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/semver/issues",
"source": "https://github.com/composer/semver/tree/3.4.4"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2025-08-20T19:15:30+00:00"
},
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@@ -658,6 +814,67 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "ezyang/htmlpurifier",
"version": "v4.19.0",
"source": {
"type": "git",
"url": "https://github.com/ezyang/htmlpurifier.git",
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf",
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf",
"shasum": ""
},
"require": {
"php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"require-dev": {
"cerdic/css-tidy": "^1.7 || ^2.0",
"simpletest/simpletest": "dev-master"
},
"suggest": {
"cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.",
"ext-bcmath": "Used for unit conversion and imagecrash protection",
"ext-iconv": "Converts text to and from non-UTF-8 encodings",
"ext-tidy": "Used for pretty-printing HTML"
},
"type": "library",
"autoload": {
"files": [
"library/HTMLPurifier.composer.php"
],
"psr-0": {
"HTMLPurifier": "library/"
},
"exclude-from-classmap": [
"/library/HTMLPurifier/Language/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "Edward Z. Yang",
"email": "admin@htmlpurifier.org",
"homepage": "http://ezyang.com"
}
],
"description": "Standards compliant HTML filter written in PHP",
"homepage": "http://htmlpurifier.org/",
"keywords": [
"html"
],
"support": {
"issues": "https://github.com/ezyang/htmlpurifier/issues",
"source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0"
},
"time": "2025-10-17T16:34:55+00:00"
},
{
"name": "fruitcake/php-cors",
"version": "v1.4.0",
@@ -2447,6 +2664,165 @@
},
"time": "2026-03-18T14:16:30+00:00"
},
{
"name": "maatwebsite/excel",
"version": "3.1.69",
"source": {
"type": "git",
"url": "https://github.com/SpartnerNL/Laravel-Excel.git",
"reference": "ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760",
"reference": "ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760",
"shasum": ""
},
"require": {
"composer/semver": "^3.3",
"ext-json": "*",
"illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0||^13.0",
"php": "^7.0||^8.0",
"phpoffice/phpspreadsheet": "^1.30.4",
"psr/simple-cache": "^1.0||^2.0||^3.0"
},
"require-dev": {
"laravel/scout": "^7.0||^8.0||^9.0||^10.0||^11.0",
"orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0||^11.0",
"predis/predis": "^1.1"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Excel": "Maatwebsite\\Excel\\Facades\\Excel"
},
"providers": [
"Maatwebsite\\Excel\\ExcelServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Maatwebsite\\Excel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Patrick Brouwers",
"email": "patrick@spartner.nl"
}
],
"description": "Supercharged Excel exports and imports in Laravel",
"keywords": [
"PHPExcel",
"batch",
"csv",
"excel",
"export",
"import",
"laravel",
"php",
"phpspreadsheet"
],
"support": {
"issues": "https://github.com/SpartnerNL/Laravel-Excel/issues",
"source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.69"
},
"funding": [
{
"url": "https://laravel-excel.com/commercial-support",
"type": "custom"
},
{
"url": "https://github.com/patrickbrouwers",
"type": "github"
}
],
"time": "2026-04-30T20:03:58+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.2",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2026-04-11T18:38:28+00:00"
},
{
"name": "mansoor/blade-lets-icons",
"version": "v1.0.2",
@@ -2511,6 +2887,113 @@
],
"time": "2025-05-22T05:35:38+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@@ -3141,6 +3624,114 @@
},
"time": "2024-10-02T11:20:13+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
"version": "1.30.4",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "02970383cc12e7bf0bc0707ea6e2e8ed23a7aec9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/02970383cc12e7bf0bc0707ea6e2e8ed23a7aec9",
"reference": "02970383cc12e7bf0bc0707ea6e2e8ed23a7aec9",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"ezyang/htmlpurifier": "^4.15",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": ">=7.4.0 <8.5.0",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"doctrine/instantiator": "^1.5",
"dompdf/dompdf": "^1.0 || ^2.0 || ^3.0",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.3",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1",
"phpstan/phpstan-phpunit": "^1.0",
"phpunit/phpunit": "^8.5 || ^9.0",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.4"
},
"time": "2026-04-19T06:00:39+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.5",
@@ -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
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('change_orders', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained()->onDelete('cascade');
$table->string('title');
$table->text('description');
$table->decimal('amount', 10, 2)->default(0.00);
$table->enum('status', ['pending', 'approved', 'rejected'])->default('pending');
$table->date('requested_at');
$table->date('responded_at')->nullable();
$table->foreignId('responded_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('change_orders');
}
};
@@ -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
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->string('logo_path')->nullable()->after('notes');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->dropColumn('logo_path');
});
}
};
@@ -0,0 +1,29 @@
<?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('companies', function (Blueprint $table) {
$table->string('apodo')->nullable()->after('name');
$table->enum('estado', ['activo', 'inactivo', 'suspendido'])->default('activo')->after('apodo');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->dropColumn(['apodo', 'estado']);
});
}
};
@@ -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);
}
}
});
}
};
+1
View File
@@ -25,6 +25,7 @@ class DatabaseSeeder extends Seeder
$this->call([
RolesAndPermissionsSeeder::class,
PermissionCatalogSeeder::class,
ProjectExampleSeeder::class,
]);
}
@@ -0,0 +1,92 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\PermissionRegistrar;
class PermissionCatalogSeeder extends Seeder
{
/**
* Full permission catalogue, grouped by section.
* Idempotent: updates group/description on existing permissions and
* creates the missing ones. Does NOT change role assignments.
*/
public function run(): void
{
$guard = config('auth.defaults.guard', 'web');
$catalog = [
'Proyectos' => [
'view projects' => 'Ver listado y fichas de proyectos',
'create projects' => 'Crear proyectos',
'edit projects' => 'Editar datos del proyecto',
'delete projects' => 'Eliminar proyectos',
'export projects' => 'Exportar proyectos (Excel/PDF)',
],
'Fases y progreso' => [
'view phases' => 'Ver fases del proyecto',
'manage phases' => 'Crear, editar, ordenar y eliminar fases',
'update progress' => 'Actualizar el porcentaje de progreso',
],
'Capas y elementos' => [
'view layers' => 'Ver capas y elementos en el mapa',
'upload layers' => 'Subir/importar capas',
'edit layers' => 'Editar capas y elementos',
'delete layers' => 'Eliminar capas/elementos',
],
'Inspecciones' => [
'view inspections' => 'Ver inspecciones e historial',
'create inspections' => 'Registrar inspecciones',
'delete inspections' => 'Eliminar inspecciones',
'manage templates' => 'Gestionar plantillas de inspección',
],
'Incidencias' => [
'view issues' => 'Ver incidencias',
'create issues' => 'Crear incidencias',
'edit issues' => 'Editar, resolver y cerrar incidencias',
'delete issues' => 'Eliminar incidencias',
],
'Empresas' => [
'view companies' => 'Ver empresas',
'create companies' => 'Crear empresas',
'edit companies' => 'Editar empresas',
'delete companies' => 'Eliminar empresas',
],
'Usuarios' => [
'view users' => 'Ver usuarios',
'create users' => 'Crear usuarios',
'edit users' => 'Editar usuarios',
'delete users' => 'Eliminar usuarios',
'assign users' => 'Asignar usuarios/roles a proyectos',
],
'Roles' => [
'manage roles' => 'Crear/editar/borrar roles y asignar permisos',
],
'Informes' => [
'view reports' => 'Ver panel de informes',
'export reports' => 'Exportar informes',
],
'Archivos' => [
'view media' => 'Ver archivos/galería',
'upload media' => 'Subir archivos',
'delete media' => 'Eliminar archivos',
],
'General' => [
'manage all' => 'Súper-admin: acceso total al sistema',
],
];
foreach ($catalog as $group => $permissions) {
foreach ($permissions as $name => $description) {
Permission::updateOrCreate(
['name' => $name, 'guard_name' => $guard],
['group' => $group, 'description' => $description]
);
}
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
}
+252 -2
View File
@@ -128,7 +128,7 @@
"Longitude": "Longitude",
"Register inspection": "Register inspection",
"Files of element": "Files of element",
"Fases and layers": "Phases and layers",
"Phases and layers": "Phases and layers",
"Elements": "Elements",
"optional": "optional",
"each": "each",
@@ -145,5 +145,255 @@
"Viewer": "Viewer",
"Remove": "Remove",
"No users assigned yet": "No users assigned yet",
"Select": "Select"
"Select": "Select",
"Log Out": "Log Out",
"Company": "Company",
"Companies": "Companies",
"Company Management": "Company Management",
"New Company": "New Company",
"Edit Company": "Edit Company",
"Delete Company": "Delete Company",
"User Management": "User Management",
"New User": "New User",
"Edit User": "Edit User",
"Delete User": "Delete User",
"Reference": "Reference",
"Contact": "Contact",
"Verified": "Verified",
"Type": "Type",
"Owner": "Owner",
"Constructor": "Constructor",
"Subcontractor": "Subcontractor",
"Supplier": "Supplier",
"No role": "No role",
"Active": "Active",
"Inactive": "Inactive",
"Suspended": "Suspended",
"Start Date": "Start Date",
"Est. End": "Est. End",
"Issue": "Issue",
"Issues": "Issues",
"New Issue": "New Issue",
"Open": "Open",
"Resolved": "Resolved",
"Closed": "Closed",
"Priority": "Priority",
"High": "High",
"Medium": "Medium",
"Low": "Low",
"Gantt": "Gantt",
"Report": "Report",
"Reports": "Reports",
"Created at": "Created at",
"Updated at": "Updated at",
"Confirm delete": "Confirm delete",
"This action cannot be undone": "This action cannot be undone",
"No data": "No data",
"Export CSV": "Export CSV",
"Export PDF": "Export PDF",
"Planned": "Planned",
"Started": "Started",
"Map filters": "Map filters",
"Progress: :min% :max%": "Progress: :min% :max%",
"Clear": "Clear",
"Hide panel": "Hide panel",
"Show phases and layers": "Show phases and layers",
"Show images": "Show images",
"Schedule": "Schedule",
"Center map": "Center map",
"Select element": "Select element",
"Search by name, phase or layer...": "Search by name, phase or layer...",
"Element status": "Element status",
"Notes": "Notes",
"Result": "Result",
"No result": "No result",
"Approved": "Approved",
"Conditional": "Conditional",
"Failed": "Failed",
"Registered data": "Registered data",
"Inspection #:id": "Inspection #:id",
"Layer / Phase": "Layer / Phase",
"No templates (info)": "No templates.",
"Create one": "Create one",
"Click on a map element or search above to edit it": "Click on a map element or search above to edit it",
"Date": "Date",
"Inspector": "Inspector",
"View detail": "View detail",
"No inspections registered": "No inspections registered",
"No elements in this project": "No elements in this project",
"Inspections": "Inspections",
"Project data": "Project data",
"Team": "Team",
"Save changes": "Save changes",
"Create project": "Create project",
"Identification": "Identification",
"Location": "Location",
"Click on the map or drag the marker to update the location": "Click on the map or drag the marker to update the location",
"Coordinates": "Coordinates",
"Auto when clicking the map": "Auto when clicking the map",
"No country": "No country",
"Search country...": "Search country...",
"Inspection templates": "Inspection templates",
"Import CSV/Excel": "Import CSV/Excel",
"Copy from project": "Copy from project",
"New template": "New template",
"Edit template": "Edit template",
"Template name": "Template name",
"Associated phase (optional)": "Associated phase (optional)",
"Global project": "Global project",
"Form fields": "Form fields",
"field(s)": "field(s)",
"Internal name": "Internal name",
"Visible label": "Visible label",
"Remove field": "Remove field",
"Min": "Min",
"Max": "Max",
"Step": "Step",
"Options (comma separated)": "Options (comma separated)",
"Add field": "Add field",
"Save template": "Save template",
"No templates yet (table)": "No templates. Use the buttons above to create or import.",
"Delete template confirmation": "Delete this template? This action cannot be undone.",
"Import template from CSV / Excel": "Import template from CSV / Excel",
"File format (one row = one field):": "File format (one row = one field):",
"Download example": "Download example",
"CSV or Excel file": "CSV or Excel file",
"Loading file...": "Loading file...",
"Preview": "Preview",
"Change file": "Change file",
"Create template (action)": "Create template",
"field(s) detected": "field(s) detected",
"Copy template from another project": "Copy template from another project",
"Source project": "Source project",
"Select project...": "Select project...",
"This project has no templates.": "This project has no templates.",
"Select the templates to copy": "Select the templates to copy",
"selected": "selected",
"Select a project to see its templates.": "Select a project to see its templates.",
"Copy": "Copy",
"Back to map": "Back to map",
"Import": "Import",
"or": "or",
"Layers (:count)": "Layers (:count)",
"No layers. Create or import one.": "No layers. Create or import one.",
"elem.": "elem.",
"Export": "Export",
"Bulk assignment": "Bulk assignment",
"Apply template or status to all elements of :layer": "Apply template or status to all elements of :layer",
"No change": "No change",
"Apply to all": "Apply to all",
"Apply changes to all elements of this layer?": "Apply changes to all elements of this layer?",
"Element editor": "Element editor",
"Select a layer to edit": "Select a layer to edit",
"Delayed phases": "Delayed phases",
"Needs attention": "Needs attention",
"No delays": "No delays",
"phases": "phases",
"Open issues": "Open issues",
"critical": "critical",
"Pending inspections": "Pending inspections",
"To do": "To do",
"Completed inspections": "Completed inspections",
"Rejected inspections": "Rejected inspections",
"Need review": "Need review",
"View all": "View all",
"No projects available": "No projects available",
"phase": "phase",
"Recent issues": "Recent issues",
"No open issues": "No open issues",
"No recent inspections": "No recent inspections",
"User": "User",
"No users found": "No users found",
"No companies assigned yet": "No companies assigned yet",
"Select template...": "Select template...",
"Observations...": "Observations...",
"by": "by",
"ago": "ago",
"No inspections yet for this element": "No inspections yet for this element",
"Inspection History": "Inspection History",
"View": "View",
"Media for this element": "Media for this element",
"No media for this element yet": "No media for this element yet",
"Project Media": "Project Media",
"No project media yet": "No project media yet",
"Feature:": "Element:",
"Inspection:": "Inspection:",
"Project Data": "Project Data",
"Name of responsible": "Name of responsible",
"Reports and Analytics": "Reports and Analytics",
"Time range:": "Time range:",
"This week": "This week",
"This month": "This month",
"This quarter": "This quarter",
"This year": "This year",
"Project Progress (last 6 months)": "Project Progress (last 6 months)",
"Inspections by Type": "Inspections by Type",
"Projects by Status": "Projects by Status",
"Average Progress by Project": "Average Progress by Project",
"Total Active Projects": "Total Active Projects",
"Inspections This Month": "Inspections This Month",
"Average Progress": "Average Progress",
"Completed Projects": "Completed Projects",
"Loading data...": "Loading data...",
"Optional": "Optional",
"Expand layers": "Expand layers",
"New user": "New user",
"Search by name or email...": "Search by name or email...",
"No users found (table)": "No users found",
"Select element (label)": "Select element",
"Search by name, layer or phase...": "Search by name, layer or phase...",
"No elements found": "No elements found",
"No media yet": "No media yet",
"Manage the companies that participate in projects": "Manage the companies that participate in projects",
"Search companies by name or tax ID...": "Search companies by name or tax ID...",
"Complete the company information. Fields marked with * are required.": "Complete the company information. Fields marked with * are required.",
"Validation errors": "Validation errors",
"Tax ID": "Tax ID",
"E.g.: B12345678": "E.g.: B12345678",
"Nickname": "Nickname",
"E.g.: Acme Construct": "E.g.: Acme Construct",
"Select a status": "Select a status",
"Company Type": "Company Type",
"Select a type": "Select a type",
"Phone": "Phone",
"Website": "Website",
"Company Logo": "Company Logo",
"Select file...": "Select file...",
"Logo preview": "Logo preview",
"Additional notes": "Additional notes",
"No companies registered. Create your first company using the button above.": "No companies registered. Create your first company using the button above.",
"Logo of": "Logo of",
"No tax ID": "No tax ID",
"Delete company confirmation": "Delete this company? This action cannot be undone.",
"Company list": "Company list",
"Add Phase": "Add Phase",
"Update": "Update",
"Delete file confirmation": "Delete this file? This action cannot be undone.",
"Back to map": "Back to map",
"Create generic templates that can be used in any phase of the project": "Create generic templates that can be used in any phase of the project",
"In Progress": "In Progress",
"Select a project to see its templates.": "Select a project to see its templates.",
"Select a project to view details": "Select a project to view details",
"No description available": "No description available",
"completed": "completed",
"Back to projects": "Back to projects",
"Not defined": "Not defined",
"Progress overview": "Progress overview",
"General progress": "General progress",
"Progress by phase": "Progress by phase",
"No phases defined for this project": "No phases defined for this project",
"Progress gallery": "Progress gallery",
"Change orders": "Change orders",
"Requested": "Requested",
"Amount": "Amount",
"Approve": "Approve",
"Reject": "Reject",
"No pending change orders": "No pending change orders",
"Pending": "Pending",
"Total": "Total",
"Inspections": "Inspections",
"My Projects": "My Projects",
"Editable": "Editable",
"Name of responsible": "Name of responsible",
"Select template...": "Select template..."
}
+252 -3
View File
@@ -128,9 +128,8 @@
"Longitude": "Longitud",
"Register inspection": "Registrar inspección",
"Files of element": "Archivos del elemento",
"Fases and layers": "Fases y capas",
"Phases and layers": "Fases y capas",
"Elements": "Elementos",
"Log Out": "Cerrar sesión",
"optional": "opcional",
"each": "cada",
"Image": "Imagen",
@@ -146,5 +145,255 @@
"Viewer": "Espectador",
"Remove": "Eliminar",
"No users assigned yet": "Sin usuarios asignados",
"Select": "Seleccionar"
"Select": "Seleccionar",
"Log Out": "Cerrar sesión",
"Company": "Empresa",
"Companies": "Empresas",
"Company Management": "Gestión de empresas",
"New Company": "Nueva empresa",
"Edit Company": "Editar empresa",
"Delete Company": "Eliminar empresa",
"User Management": "Gestión de usuarios",
"New User": "Nuevo usuario",
"Edit User": "Editar usuario",
"Delete User": "Eliminar usuario",
"Reference": "Referencia",
"Contact": "Contacto",
"Verified": "Verificado",
"Type": "Tipo",
"Owner": "Promotor",
"Constructor": "Constructora",
"Subcontractor": "Subcontratista",
"Supplier": "Proveedor",
"No role": "Sin rol",
"Active": "Activo",
"Inactive": "Inactivo",
"Suspended": "Suspendido",
"Start Date": "Fecha inicio",
"Est. End": "Fin estimado",
"Issue": "Incidencia",
"Issues": "Incidencias",
"New Issue": "Nueva incidencia",
"Open": "Abierta",
"Resolved": "Resuelta",
"Closed": "Cerrada",
"Priority": "Prioridad",
"High": "Alta",
"Medium": "Media",
"Low": "Baja",
"Gantt": "Gantt",
"Report": "Informe",
"Reports": "Informes",
"Created at": "Creado el",
"Updated at": "Actualizado el",
"Confirm delete": "Confirmar eliminación",
"This action cannot be undone": "Esta acción no se puede deshacer",
"No data": "Sin datos",
"Export CSV": "Exportar CSV",
"Export PDF": "Exportar PDF",
"Planned": "Planificado",
"Started": "Iniciado",
"Map filters": "Filtros del mapa",
"Progress: :min% :max%": "Progreso: :min% :max%",
"Clear": "Limpiar",
"Hide panel": "Ocultar panel",
"Show phases and layers": "Mostrar fases y capas",
"Show images": "Mostrar imágenes",
"Schedule": "Cronograma",
"Center map": "Centrar mapa",
"Select element": "Seleccionar elemento",
"Search by name, phase or layer...": "Buscar por nombre, fase o capa...",
"Element status": "Estado del elemento",
"Notes": "Notas",
"Result": "Resultado",
"No result": "Sin resultado",
"Approved": "Aprobada",
"Conditional": "Condicional",
"Failed": "Fallida",
"Registered data": "Datos registrados",
"Inspection #:id": "Inspección #:id",
"Layer / Phase": "Capa / Fase",
"No templates (info)": "No hay templates.",
"Create one": "Crear uno",
"Click on a map element or search above to edit it": "Haz clic en un elemento del mapa o búscalo arriba para editarlo",
"Date": "Fecha",
"Inspector": "Inspector",
"View detail": "Ver detalle",
"No inspections registered": "No hay inspecciones registradas",
"No elements in this project": "No hay elementos en este proyecto",
"Inspections": "Inspecciones",
"Project data": "Datos del proyecto",
"Team": "Equipo",
"Save changes": "Guardar cambios",
"Create project": "Crear proyecto",
"Identification": "Identificación",
"Location": "Ubicación",
"Click on the map or drag the marker to update the location": "Pulsa en el mapa o arrastra el marcador para actualizar la ubicación.",
"Coordinates": "Coordenadas",
"Auto when clicking the map": "Auto al pulsar el mapa",
"No country": "— Sin especificar —",
"Search country...": "Buscar país…",
"Inspection templates": "Templates de inspección",
"Import CSV/Excel": "Importar CSV/Excel",
"Copy from project": "Copiar de proyecto",
"New template": "Nuevo template",
"Edit template": "Editar template",
"Template name": "Nombre del template",
"Associated phase (optional)": "Fase asociada (opcional)",
"Global project": "Global del proyecto",
"Form fields": "Campos del formulario",
"field(s)": "campo(s)",
"Internal name": "Nombre interno",
"Visible label": "Etiqueta visible",
"Remove field": "Quitar",
"Min": "Mín",
"Max": "Máx",
"Step": "Paso",
"Options (comma separated)": "Opciones (separadas por coma)",
"Add field": "Agregar campo",
"Save template": "Guardar template",
"No templates yet (table)": "No hay templates. Usa los botones de arriba para crear o importar.",
"Delete template confirmation": "¿Eliminar este template? Esta acción no se puede deshacer.",
"Import template from CSV / Excel": "Importar template desde CSV / Excel",
"File format (one row = one field):": "Formato del archivo (una fila = un campo):",
"Download example": "Descargar ejemplo",
"CSV or Excel file": "Archivo CSV o Excel",
"Loading file...": "Cargando archivo...",
"Preview": "Previsualizar",
"Change file": "Cambiar archivo",
"Create template (action)": "Crear template",
"field(s) detected": "campo(s) detectados",
"Copy template from another project": "Copiar template de otro proyecto",
"Source project": "Proyecto origen",
"Select project...": "Seleccionar proyecto...",
"This project has no templates.": "Este proyecto no tiene templates.",
"Select the templates to copy": "Selecciona los templates a copiar",
"selected": "seleccionados",
"Select a project to see its templates.": "Selecciona un proyecto para ver sus templates.",
"Copy": "Copiar",
"Back to map": "Volver al mapa",
"Import": "Importar",
"or": "o",
"Layers (:count)": "Capas (:count)",
"No layers. Create or import one.": "Sin capas. Crea o importa una.",
"elem.": "elem.",
"Export": "Exportar",
"Bulk assignment": "Asignación masiva",
"Apply template or status to all elements of :layer": "Aplica template o estado a todos los elementos de :layer",
"No change": "Sin cambio",
"Apply to all": "Aplicar a todos",
"Apply changes to all elements of this layer?": "¿Aplicar cambios a todos los elementos de esta capa?",
"Element editor": "Editor de elementos",
"Select a layer to edit": "Selecciona una capa para editar",
"Delayed phases": "Fases con retraso",
"Needs attention": "Requiere atención",
"No delays": "Sin retrasos",
"phases": "fases",
"Open issues": "Issues abiertos",
"critical": "críticos",
"Pending inspections": "Insp. pendientes",
"To do": "Por realizar",
"Completed inspections": "Insp. completadas",
"Rejected inspections": "Insp. rechazadas",
"Need review": "Requieren revisión",
"View all": "Ver todos",
"No projects available": "No hay proyectos disponibles",
"phase": "fase",
"Recent issues": "Issues recientes",
"No open issues": "Sin issues abiertos",
"No recent inspections": "Sin inspecciones recientes",
"User": "Usuario",
"No users found": "No se encontraron usuarios",
"No companies assigned yet": "Sin empresas asignadas",
"Select template...": "Seleccionar plantilla...",
"Observations...": "Observaciones...",
"by": "por",
"ago": "hace",
"No inspections yet for this element": "Sin inspecciones para este elemento",
"Inspection History": "Historial de inspecciones",
"View": "Ver",
"Media for this element": "Archivos de este elemento",
"No media for this element yet": "Sin archivos para este elemento",
"Project Media": "Archivos del proyecto",
"No project media yet": "Sin archivos del proyecto",
"Feature:": "Elemento:",
"Inspection:": "Inspección:",
"Project Data": "Datos del proyecto",
"Name of responsible": "Nombre del responsable",
"Reports and Analytics": "Reportes y Analítica",
"Time range:": "Rango de tiempo:",
"This week": "Esta semana",
"This month": "Este mes",
"This quarter": "Este trimestre",
"This year": "Este año",
"Project Progress (last 6 months)": "Progreso de Proyectos (últimos 6 meses)",
"Inspections by Type": "Inspecciones por Tipo",
"Projects by Status": "Distribución de Proyectos por Estado",
"Average Progress by Project": "Progreso Promedio por Proyecto",
"Total Active Projects": "Total Proyectos Activos",
"Inspections This Month": "Inspecciones Este Mes",
"Average Progress": "Promedio de Progreso",
"Completed Projects": "Proyectos Completados",
"Loading data...": "Cargando datos...",
"Optional": "Opcional",
"Expand layers": "Expandir capas",
"New user": "Nuevo usuario",
"Search by name or email...": "Buscar por nombre o email…",
"No users found (table)": "No se encontraron usuarios",
"Select element (label)": "Seleccionar elemento",
"Search by name, layer or phase...": "Buscar por nombre, capa o fase...",
"No elements found": "No se encontraron elementos",
"No media yet": "Sin archivos aún",
"Manage the companies that participate in projects": "Gestione las empresas que participan en los proyectos",
"Search companies by name or tax ID...": "Buscar empresas por nombre o NIF...",
"Complete the company information. Fields marked with * are required.": "Complete la información de la empresa. Los campos marcados con * son obligatorios.",
"Validation errors": "Errores de validación",
"Tax ID": "NIF/NIE/CIF",
"E.g.: B12345678": "Ej: B12345678",
"Nickname": "Apodo",
"E.g.: Acme Construct": "Ej: Acme Construct",
"Select a status": "Seleccione un estado",
"Company Type": "Tipo de Empresa",
"Select a type": "Seleccione un tipo",
"Phone": "Teléfono",
"Website": "Sitio Web",
"Company Logo": "Logo de la Empresa",
"Select file...": "Seleccionar archivo...",
"Logo preview": "Vista previa del logo",
"Additional notes": "Notas Adicionales",
"No companies registered. Create your first company using the button above.": "No hay empresas registradas. Cree su primera empresa usando el botón de arriba.",
"Logo of": "Logo de",
"No tax ID": "Sin NIF/CIF",
"Delete company confirmation": "¿Eliminar esta empresa? Esta acción no se puede deshacer.",
"Company list": "Lista de Empresas",
"Add Phase": "Agregar Fase",
"Update": "Actualizar",
"Delete file confirmation": "¿Eliminar este archivo? Esta acción no se puede deshacer.",
"Back to map": "Volver al mapa",
"Create generic templates that can be used in any phase of the project": "Crea templates genéricos que puedan usarse en cualquier fase del proyecto",
"In Progress": "En obra",
"Select a project to see its templates.": "Selecciona un proyecto para ver sus templates.",
"Select a project to view details": "Seleccione un proyecto para ver detalles",
"No description available": "Sin descripción disponible",
"completed": "completado",
"Back to projects": "Volver a proyectos",
"Not defined": "No definida",
"Progress overview": "Resumen de Progreso",
"General progress": "Progreso General",
"Progress by phase": "Progreso por Fase",
"No phases defined for this project": "No hay fases definidas para este proyecto",
"Progress gallery": "Galería de Progreso",
"Change orders": "Órdenes de Cambio",
"Requested": "Solicitado",
"Amount": "Monto",
"Approve": "Aprobar",
"Reject": "Rechazar",
"No pending change orders": "No hay órdenes de cambio pendientes",
"Pending": "Pendiente",
"Total": "Total",
"Inspections": "Inspecciones",
"My Projects": "Mis proyectos",
"Editable": "Editable",
"Name of responsible": "Nombre del responsable",
"Select template...": "Seleccionar plantilla..."
}
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
'failed' => 'Las credenciales introducidas no son válidas.',
'password' => 'La contraseña indicada es incorrecta.',
'throttle' => 'Demasiados intentos de acceso. Por favor, inténtalo de nuevo en :seconds segundos.',
];
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'previous' => '&laquo; Anterior',
'next' => 'Siguiente &raquo;',
];
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
'reset' => 'Tu contraseña ha sido restablecida.',
'sent' => 'Te hemos enviado un enlace para restablecer tu contraseña.',
'throttled' => 'Por favor, espera antes de volver a intentarlo.',
'token' => 'Este token de restablecimiento de contraseña no es válido.',
'user' => 'No encontramos ningún usuario con esa dirección de correo.',
];
+194
View File
@@ -0,0 +1,194 @@
<?php
return [
'accepted' => 'El campo :attribute debe ser aceptado.',
'accepted_if' => 'El campo :attribute debe ser aceptado cuando :other es :value.',
'active_url' => 'El campo :attribute debe ser una URL válida.',
'after' => 'El campo :attribute debe ser una fecha posterior a :date.',
'after_or_equal' => 'El campo :attribute debe ser una fecha posterior o igual a :date.',
'alpha' => 'El campo :attribute solo debe contener letras.',
'alpha_dash' => 'El campo :attribute solo debe contener letras, números, guiones y guiones bajos.',
'alpha_num' => 'El campo :attribute solo debe contener letras y números.',
'any_of' => 'El campo :attribute no es válido.',
'array' => 'El campo :attribute debe ser un array.',
'ascii' => 'El campo :attribute solo debe contener caracteres alfanuméricos de un solo byte y símbolos.',
'before' => 'El campo :attribute debe ser una fecha anterior a :date.',
'before_or_equal' => 'El campo :attribute debe ser una fecha anterior o igual a :date.',
'between' => [
'array' => 'El campo :attribute debe tener entre :min y :max elementos.',
'file' => 'El campo :attribute debe estar entre :min y :max kilobytes.',
'numeric' => 'El campo :attribute debe estar entre :min y :max.',
'string' => 'El campo :attribute debe tener entre :min y :max caracteres.',
],
'boolean' => 'El campo :attribute debe ser verdadero o falso.',
'can' => 'El campo :attribute contiene un valor no autorizado.',
'confirmed' => 'La confirmación del campo :attribute no coincide.',
'contains' => 'Al campo :attribute le falta un valor obligatorio.',
'current_password' => 'La contraseña es incorrecta.',
'date' => 'El campo :attribute debe ser una fecha válida.',
'date_equals' => 'El campo :attribute debe ser una fecha igual a :date.',
'date_format' => 'El campo :attribute debe coincidir con el formato :format.',
'decimal' => 'El campo :attribute debe tener :decimal decimales.',
'declined' => 'El campo :attribute debe ser rechazado.',
'declined_if' => 'El campo :attribute debe ser rechazado cuando :other es :value.',
'different' => 'El campo :attribute y :other deben ser diferentes.',
'digits' => 'El campo :attribute debe tener :digits dígitos.',
'digits_between' => 'El campo :attribute debe tener entre :min y :max dígitos.',
'dimensions' => 'El campo :attribute tiene dimensiones de imagen inválidas.',
'distinct' => 'El campo :attribute tiene un valor duplicado.',
'doesnt_contain' => 'El campo :attribute no debe contener ninguno de los siguientes valores: :values.',
'doesnt_end_with' => 'El campo :attribute no debe terminar con uno de los siguientes valores: :values.',
'doesnt_start_with' => 'El campo :attribute no debe comenzar con uno de los siguientes valores: :values.',
'email' => 'El campo :attribute debe ser una dirección de correo válida.',
'encoding' => 'El campo :attribute debe estar codificado en :encoding.',
'ends_with' => 'El campo :attribute debe terminar con uno de los siguientes valores: :values.',
'enum' => 'El :attribute seleccionado no es válido.',
'exists' => 'El :attribute seleccionado no es válido.',
'extensions' => 'El campo :attribute debe tener una de las siguientes extensiones: :values.',
'file' => 'El campo :attribute debe ser un archivo.',
'filled' => 'El campo :attribute debe tener un valor.',
'gt' => [
'array' => 'El campo :attribute debe tener más de :value elementos.',
'file' => 'El campo :attribute debe ser mayor que :value kilobytes.',
'numeric' => 'El campo :attribute debe ser mayor que :value.',
'string' => 'El campo :attribute debe tener más de :value caracteres.',
],
'gte' => [
'array' => 'El campo :attribute debe tener :value elementos o más.',
'file' => 'El campo :attribute debe ser mayor o igual a :value kilobytes.',
'numeric' => 'El campo :attribute debe ser mayor o igual a :value.',
'string' => 'El campo :attribute debe tener :value caracteres o más.',
],
'hex_color' => 'El campo :attribute debe ser un color hexadecimal válido.',
'image' => 'El campo :attribute debe ser una imagen.',
'in' => 'El :attribute seleccionado no es válido.',
'in_array' => 'El campo :attribute debe existir en :other.',
'in_array_keys' => 'El campo :attribute debe contener al menos una de las siguientes claves: :values.',
'integer' => 'El campo :attribute debe ser un número entero.',
'ip' => 'El campo :attribute debe ser una dirección IP válida.',
'ipv4' => 'El campo :attribute debe ser una dirección IPv4 válida.',
'ipv6' => 'El campo :attribute debe ser una dirección IPv6 válida.',
'json' => 'El campo :attribute debe ser una cadena JSON válida.',
'list' => 'El campo :attribute debe ser una lista.',
'lowercase' => 'El campo :attribute debe estar en minúsculas.',
'lt' => [
'array' => 'El campo :attribute debe tener menos de :value elementos.',
'file' => 'El campo :attribute debe ser menor que :value kilobytes.',
'numeric' => 'El campo :attribute debe ser menor que :value.',
'string' => 'El campo :attribute debe tener menos de :value caracteres.',
],
'lte' => [
'array' => 'El campo :attribute no debe tener más de :value elementos.',
'file' => 'El campo :attribute debe ser menor o igual a :value kilobytes.',
'numeric' => 'El campo :attribute debe ser menor o igual a :value.',
'string' => 'El campo :attribute debe tener :value caracteres o menos.',
],
'mac_address' => 'El campo :attribute debe ser una dirección MAC válida.',
'max' => [
'array' => 'El campo :attribute no debe tener más de :max elementos.',
'file' => 'El campo :attribute no debe ser mayor que :max kilobytes.',
'numeric' => 'El campo :attribute no debe ser mayor que :max.',
'string' => 'El campo :attribute no debe tener más de :max caracteres.',
],
'max_digits' => 'El campo :attribute no debe tener más de :max dígitos.',
'mimes' => 'El campo :attribute debe ser un archivo de tipo: :values.',
'mimetypes' => 'El campo :attribute debe ser un archivo de tipo: :values.',
'min' => [
'array' => 'El campo :attribute debe tener al menos :min elementos.',
'file' => 'El campo :attribute debe tener al menos :min kilobytes.',
'numeric' => 'El campo :attribute debe ser al menos :min.',
'string' => 'El campo :attribute debe tener al menos :min caracteres.',
],
'min_digits' => 'El campo :attribute debe tener al menos :min dígitos.',
'missing' => 'El campo :attribute debe estar ausente.',
'missing_if' => 'El campo :attribute debe estar ausente cuando :other es :value.',
'missing_unless' => 'El campo :attribute debe estar ausente a menos que :other sea :value.',
'missing_with' => 'El campo :attribute debe estar ausente cuando :values está presente.',
'missing_with_all' => 'El campo :attribute debe estar ausente cuando :values están presentes.',
'multiple_of' => 'El campo :attribute debe ser un múltiplo de :value.',
'not_in' => 'El :attribute seleccionado no es válido.',
'not_regex' => 'El formato del campo :attribute no es válido.',
'numeric' => 'El campo :attribute debe ser un número.',
'password' => [
'letters' => 'El campo :attribute debe contener al menos una letra.',
'mixed' => 'El campo :attribute debe contener al menos una letra mayúscula y una minúscula.',
'numbers' => 'El campo :attribute debe contener al menos un número.',
'symbols' => 'El campo :attribute debe contener al menos un símbolo.',
'uncompromised' => 'El :attribute proporcionado ha aparecido en una filtración de datos. Elige un :attribute diferente.',
],
'present' => 'El campo :attribute debe estar presente.',
'present_if' => 'El campo :attribute debe estar presente cuando :other es :value.',
'present_unless' => 'El campo :attribute debe estar presente a menos que :other sea :value.',
'present_with' => 'El campo :attribute debe estar presente cuando :values está presente.',
'present_with_all' => 'El campo :attribute debe estar presente cuando :values están presentes.',
'prohibited' => 'El campo :attribute está prohibido.',
'prohibited_if' => 'El campo :attribute está prohibido cuando :other es :value.',
'prohibited_if_accepted' => 'El campo :attribute está prohibido cuando :other es aceptado.',
'prohibited_if_declined' => 'El campo :attribute está prohibido cuando :other es rechazado.',
'prohibited_unless' => 'El campo :attribute está prohibido a menos que :other esté en :values.',
'prohibits' => 'El campo :attribute prohíbe que :other esté presente.',
'regex' => 'El formato del campo :attribute no es válido.',
'required' => 'El campo :attribute es obligatorio.',
'required_array_keys' => 'El campo :attribute debe contener entradas para: :values.',
'required_if' => 'El campo :attribute es obligatorio cuando :other es :value.',
'required_if_accepted' => 'El campo :attribute es obligatorio cuando :other es aceptado.',
'required_if_declined' => 'El campo :attribute es obligatorio cuando :other es rechazado.',
'required_unless' => 'El campo :attribute es obligatorio a menos que :other esté en :values.',
'required_with' => 'El campo :attribute es obligatorio cuando :values está presente.',
'required_with_all' => 'El campo :attribute es obligatorio cuando :values están presentes.',
'required_without' => 'El campo :attribute es obligatorio cuando :values no está presente.',
'required_without_all' => 'El campo :attribute es obligatorio cuando ninguno de :values está presente.',
'same' => 'El campo :attribute debe coincidir con :other.',
'size' => [
'array' => 'El campo :attribute debe contener :size elementos.',
'file' => 'El campo :attribute debe pesar :size kilobytes.',
'numeric' => 'El campo :attribute debe ser :size.',
'string' => 'El campo :attribute debe tener :size caracteres.',
],
'starts_with' => 'El campo :attribute debe comenzar con uno de los siguientes valores: :values.',
'string' => 'El campo :attribute debe ser una cadena de texto.',
'timezone' => 'El campo :attribute debe ser una zona horaria válida.',
'unique' => 'El :attribute ya está en uso.',
'uploaded' => 'El campo :attribute no se pudo subir.',
'uppercase' => 'El campo :attribute debe estar en mayúsculas.',
'url' => 'El campo :attribute debe ser una URL válida.',
'ulid' => 'El campo :attribute debe ser un ULID válido.',
'uuid' => 'El campo :attribute debe ser un UUID válido.',
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
'attributes' => [
'name' => 'nombre',
'email' => 'correo electrónico',
'password' => 'contraseña',
'address' => 'dirección',
'phone' => 'teléfono',
'description' => 'descripción',
'start_date' => 'fecha de inicio',
'end_date' => 'fecha de fin',
'end_date_estimated' => 'fecha estimada de fin',
'reference' => 'referencia',
'status' => 'estado',
'type' => 'tipo',
'color' => 'color',
'progress_percent' => 'porcentaje de progreso',
'tax_id' => 'NIF/CIF',
'country' => 'país',
'city' => 'ciudad',
'latitude' => 'latitud',
'longitude' => 'longitud',
'logo' => 'logo',
'avatar' => 'avatar',
'role' => 'rol',
'company_id' => 'empresa',
'current_password' => 'contraseña actual',
'new_password' => 'nueva contraseña',
'new_password_confirmation' => 'confirmación de nueva contraseña',
],
];
+39
View File
@@ -0,0 +1,39 @@
<?php
return [
'All' => 'Todos',
'All Columns' => 'Todas las columnas',
'Applied Filters' => 'Filtros aplicados',
'Applied Sorting' => 'Ordenación aplicada',
'Bulk Actions' => 'Acciones masivas',
'Bulk Actions Confirm' => '¿Estás seguro?',
'Clear' => 'Limpiar',
'Columns' => 'Columnas',
'Debugging Values' => 'Valores de depuración',
'Deselect All' => 'Deseleccionar todo',
'Done Reordering' => 'Reordenación finalizada',
'Filters' => 'Filtros',
'not_applicable' => 'N/A',
'No' => 'No',
'No items found, try to broaden your search' => 'Sin resultados. Intenta ampliar la búsqueda.',
'of' => 'de',
'Remove filter option' => 'Quitar filtro',
'Remove sort option' => 'Quitar ordenación',
'Reorder' => 'Reordenar',
'results' => 'resultados',
'row' => 'fila',
'rows' => 'filas',
'rows, do you want to select all' => 'filas, ¿deseas seleccionarlas todas?',
'Search' => 'Buscar',
'Select All' => 'Seleccionar todo',
'Select All On Page' => 'Seleccionar todo en la página',
'Showing' => 'Mostrando',
'to' => 'a',
'Yes' => 'Sí',
'You are currently selecting all' => 'Actualmente estás seleccionando todo',
'You are not connected to the internet' => 'No tienes conexión a internet',
'You have selected' => 'Has seleccionado',
'Per Page' => 'Por página',
'Export' => 'Exportar',
'Loading' => 'Cargando',
];
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ConstruProgress",
"short_name": "ConstruProg",
"name": "Avante",
"short_name": "Avante",
"description": "App para gestión de proyectos de construcción",
"start_url": "/",
"display": "standalone",
+163 -11
View File
@@ -1,30 +1,182 @@
const CACHE_NAME = 'construprogress-cache-v1';
const urlsToCache = [
const CACHE_NAME = 'avante-cache-v2';
const DATA_CACHE_NAME = 'avante-data-cache-v1';
// Files to cache for offline functionality
const FILES_TO_CACHE = [
'/',
'/dashboard',
'/projects',
'/projects-list',
'/projects/templates',
'/reports/dashboard',
'/client',
'/client/projects',
'/manifest.json',
'/css/app.css',
'/js/app.js',
// Add other assets as needed
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css',
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
'https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css',
'https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js',
'https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap',
'/icons/icon-72x72.png',
'/icons/icon-96x96.png',
'/icons/icon-128x128.png',
'/icons/icon-144x144.png',
'/icons/icon-152x152.png',
'/icons/icon-192x192.png',
'/icons/icon-384x384.png',
'/icons/icon-512x512.png'
];
// Install the service worker and cache the app shell
self.addEventListener('install', (event) => {
console.log('[ServiceWorker] Install');
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
.then((cache) => {
console.log('[ServiceWorker] Caching app shell');
return cache.addAll(FILES_TO_CACHE);
})
);
self.skipWaiting();
});
// Activate the service worker and clean up old caches
self.addEventListener('activate', (event) => {
console.log('[ServiceWorker] Activate');
event.waitUntil(
caches.keys().then((keyList) => {
return Promise.all(
keyList.map((key) => {
if (key !== CACHE_NAME && key !== DATA_CACHE_NAME) {
console.log('[ServiceWorker] Removing old cache', key);
return caches.delete(key);
}
})
);
})
);
self.clients.claim();
});
// Fetch strategy: Network first, falling back to cache
self.addEventListener('fetch', (event) => {
// Handle API requests differently - cache first, then network
if (event.request.url.includes('/api/') || event.request.url.includes('/offline/')) {
event.respondWith(
caches.open(DATA_CACHE_NAME)
.then((cache) => {
return fetch(event.request)
.then((response) => {
// Clone the response to put in cache and return original
if (response.status === 200) {
cache.put(event.request.url, response.clone());
}
return response;
})
.catch(() => {
// If network fails, try to get from cache
return caches.match(event.request);
})
})
);
return;
}
// For everything else, use cache first with network fallback
event.respondWith(
caches.match(event.request)
.then((response) => {
if (response) {
return response;
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
// If not in cache, fetch from network and cache it
return caches.open(CACHE_NAME)
.then((cache) => {
return fetch(event.request)
.then((networkResponse) => {
// Don't cache non-successful responses
if (networkResponse && networkResponse.status === 200 && networkResponse.type === 'basic') {
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
});
});
})
.catch(() => {
// If both cache and network fail, show offline fallback
if (event.request.mode === 'navigate') {
return caches.match('/');
}
return fetch(event.request);
})
);
});
// Handle background sync for offline actions
self.addEventListener('sync', (event) => {
console.log('[ServiceWorker] Background syncing', event.tag);
if (event.tag === 'offline-sync') {
event.waitUntil(syncOfflineActions());
}
});
// Handle push notifications
self.addEventListener('push', (event) => {
console.log('[ServiceWorker] Push received');
const options = {
body: event.data ? event.data.text() : 'Tienes una nueva notificación',
icon: '/icons/icon-192x192.png',
badge: '/icons/icon-72x72.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: 1
}
};
event.waitUntil(
self.registration.showNotification('Avante', options)
);
});
self.addEventListener('notificationclick', (event) => {
console.log('[ServiceWorker] Notification click: ', event.notification);
event.notification.close();
// Determine what to open based on notification data
const urlToOpen = '/client'; // Default to client portal
event.waitUntil(
clients.matchAll({
type: 'window',
includeUncontrolled: true
}).then((clientList) => {
for (const client of clientList) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});
// Helper function to sync offline actions
async function syncOfflineActions() {
console.log('[ServiceWorker] Syncing offline actions');
// This would typically make a request to your backend to sync pending actions
// For now, we'll just log it
try {
const response = await fetch('/offline/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
return response.json();
} catch (error) {
console.error('[ServiceWorker] Sync failed:', error);
throw error;
}
}
+85 -14
View File
@@ -2,28 +2,99 @@ import './bootstrap';
import localforage from 'localforage';
// Sync pending actions when online
window.addEventListener('online', () => {
fetch('/offline/sync', { method: 'POST', headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content } })
.then(res => res.json())
.then(data => console.log('Synced:', data));
});
// Function to store offline progress update
window.offlineProgressUpdate = function(phaseId, progress, comment, location) {
const payload = { phase_id: phaseId, progress, comment, location };
// Generic function to queue any offline action
window.queueOfflineAction = function(action, payload) {
const pendingAction = { action, payload };
if (navigator.onLine) {
// Send immediately
fetch('/offline/pending', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
body: JSON.stringify({ action: 'progress_update', payload })
}).then(() => alert('Actualizado online'));
body: JSON.stringify(pendingAction)
})
.then(res => res.json())
.then(data => {
if (data.queued) {
console.log('Action queued:', action);
// Register background sync to process the queue
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
return navigator.serviceWorker.ready.then(registration => {
return registration.sync.register('offline-sync');
});
}
} else {
console.error('Failed to queue action:', data);
}
})
.catch(err => {
console.error('Error queuing action:', err);
});
} else {
// Store in IndexedDB (via localforage)
localforage.getItem('pendingOffline').then(pending => {
const list = pending || [];
list.push(payload);
list.push(pendingAction);
localforage.setItem('pendingOffline', list);
alert('Guardado localmente, se sincronizará al recuperar internet');
console.log('Action stored offline:', action);
// Register background sync for when we come back online
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
return navigator.serviceWorker.ready.then(registration => {
return registration.sync.register('offline-sync');
});
}
}).catch(err => {
console.error('Error storing offline action:', err);
});
}
}
// Function to store offline progress update (for backward compatibility)
window.offlineProgressUpdate = function(phaseId, progress, comment, location) {
const payload = { phase_id: phaseId, progress, comment, location };
window.queueOfflineAction('progress_update', payload);
};
// Function to capture and store image for offline upload
window.captureAndStoreImage = async (file, phaseId, description = '') => {
try {
// Convert file to base64
const base64 = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result.split(',')[1]); // Get just the base64 part
reader.onerror = reject;
reader.readAsDataURL(file);
});
const payload = {
file: base64,
path: `media/${new Date().getTime()}_${file.name}`, // Simple path, could be improved
model_type: 'App\\Models\\Phase',
model_id: phaseId,
name: file.name,
description: description,
mime_type: file.type
};
window.queueOfflineAction('media_upload', payload);
return true;
} catch (error) {
console.error('Error capturing image:', error);
return false;
}
};
// Sync pending actions when online
window.addEventListener('online', () => {
// Trigger a sync attempt
fetch('/offline/sync', { method: 'POST', headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content } })
.then(res => res.json())
.then(data => {
console.log('Synced:', data);
})
.catch(err => {
console.error('Error triggering sync:', err);
});
});
// Also, we can listen for the service worker's message to update the UI if needed
// But for now, we'll rely on the service worker's notification and the online event.
+25
View File
@@ -0,0 +1,25 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center justify-between">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Roles & permissions') }}
</h2>
<div class="flex items-center gap-2">
<a href="{{ route('admin.permissions') }}" class="btn btn-outline btn-sm gap-1" wire:navigate>
<x-heroicon-o-table-cells class="w-4 h-4" /> {{ __('Matrix view') }}
</a>
<a href="{{ route('admin.roles.create') }}" class="btn btn-primary btn-sm gap-1" wire:navigate>
<x-heroicon-o-plus class="w-4 h-4" /> {{ __('New role') }}
</a>
</div>
</div>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white rounded-lg shadow p-6">
<livewire:role-table />
</div>
</div>
</div>
</x-app-layout>
+17 -12
View File
@@ -1,21 +1,26 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Administrator') }} {{ __('Users') }}
</h2>
<div class="flex items-center justify-between">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Users') }}
</h2>
<div class="flex items-center gap-2">
<a href="{{ route('admin.roles') }}" class="btn btn-outline btn-sm gap-1" wire:navigate>
<x-heroicon-o-shield-check class="w-4 h-4" /> {{ __('Roles & permissions') }}
</a>
<a href="{{ route('admin.users.create') }}" class="btn btn-primary btn-sm gap-1" wire:navigate>
<x-heroicon-o-plus class="w-4 h-4" /> {{ __('New user') }}
</a>
</div>
</div>
</x-slot>
<div class="py-4">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8 flex flex-wrap items-center gap-4">
<a href="{{ route('projects.list') }}" class="btn btn-outline btn-primary">📁 Ver proyectos</a>
<a href="{{ route('admin.users') }}" class="btn btn-outline btn-secondary">👥 Gestión de usuarios</a>
</div>
</div>
<div class="py-12">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white rounded-lg shadow p-6">
@livewire('admin-users')
<livewire:user-table />
</div>
</div>
</div>
+128
View File
@@ -0,0 +1,128 @@
<x-guest-layout>
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<h1 class="text-2xl font-bold text-gray-900 mb-6">
Bienvenido, {{ auth()->user()->name }}
</h1>
<div class="grid gap-6 mb-8">
<div class="lg:col-span-3">
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">
Mis Proyectos Activos
</h2>
<livewire:client-projects />
</div>
</div>
<div class="lg:col-span-1">
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">
Notificaciones
</h2>
<div class="space-y-4">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-600" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.483l1.227.614a1 1 0 001.216-.483l1.227-.614a1 1 0 00.483-1.216l-.614-1.227a1 1 0 00-.483-1.216l-.614-1.227a1 1 0 00-1.216-.483l-1.227.614a1 1 0 00-.483 1.216l.614 1.227zm1.11-5.656a1 1 0 10-1.414 1.414l1.293 1.293a1 1 0 001.414 0l1.293-1.293a1 1 0 00-1.414-1.414l-1.293-1.293z" clip-rule="evenodd"/>
</svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-sm font-medium text-gray-900">
Proyecto actualizado
</h3>
<p class="text-sm text-gray-500">
Se han añadido nuevas fotos al proyecto "Centro Comercial Norte"
</p>
<p class="text-xs text-gray-400">
Hace 2 horas
</p>
</div>
</div>
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-600" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-sm font-medium text-gray-900">
Orden de cambio aprobada
</h3>
<p class="text-sm text-gray-500">
La orden de cambio #123 ha sido aprobada
</p>
<p class="text-xs text-gray-400">
Hace 1 día
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="grid gap-6">
<div class="lg:col-span-6">
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">
Galería de Progreso
</h2>
<div class="gallery-grid">
<!-- Placeholder for gallery items -->
<div class="gallery-item bg-gray-100 flex items-center justify-center h-48">
<span class="text-gray-500">Próximamente: Fotos del avance</span>
</div>
</div>
</div>
</div>
<div class="lg:col-span-6">
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">
Órdenes de Cambio Pendientes
</h2>
<div class="space-y-4">
<div class="change-order-card change-order-pending">
<h3 class="text-sm font-medium text-gray-900 mb-2">
Orden de cambio #124
</h3>
<p class="text-sm text-gray-500 mb-2">
Solicitud de ampliación de zona de almacenamiento
</p>
<div class="flex items-center space-x-3 mt-2">
<button class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
Aprobar
</button>
<button class="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700">
Rechazar
</button>
</div>
</div>
<div class="change-order-card change-order-pending">
<h3 class="text-sm font-medium text-gray-900 mb-2">
Orden de cambio #125
</h3>
<p class="text-sm text-gray-500 mb-2">
Cambio de material en acabados interiores
</p>
<div class="flex items-center space-x-3 mt-2">
<button class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
Aprobar
</button>
<button class="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700">
Rechazar
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</x-guest-layout>
+345 -99
View File
@@ -5,109 +5,355 @@
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
{{-- Stats cards --}}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm text-gray-500 uppercase tracking-wide">{{ __('Active projects') }}</div>
<div class="text-3xl font-bold mt-1">{{ $stats['active_projects'] }}</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm text-gray-500 uppercase tracking-wide">{{ __('Total projects') }}</div>
<div class="text-3xl font-bold mt-1">{{ $stats['total_projects'] }}</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm text-gray-500 uppercase tracking-wide">{{ __('Total phases') }}</div>
<div class="text-3xl font-bold mt-1">{{ $stats['total_phases'] }}</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm text-gray-500 uppercase tracking-wide">{{ __('Total features') }}</div>
<div class="text-3xl font-bold mt-1">{{ $stats['total_features'] }}</div>
</div>
</div>
<div class="py-8">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
{{-- Global progress bar --}}
<div class="bg-white rounded-lg shadow p-6 mb-8">
<h3 class="text-lg font-semibold mb-2">{{ __('Global progress') }}</h3>
<div class="w-full bg-gray-200 rounded-full h-4">
<div class="bg-primary h-4 rounded-full transition-all" style="width: {{ $stats['global_progress'] }}%"></div>
</div>
<p class="text-right text-sm text-gray-500 mt-1">{{ $stats['global_progress'] }}%</p>
</div>
{{-- ============================================================
ROW 1: Project stats (4 columns)
============================================================ --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{{-- Recent projects --}}
<div class="bg-white rounded-lg shadow p-6 mb-8">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">{{ __('Recent projects') }}</h3>
<a href="{{ route('projects.list') }}" class="text-sm text-primary hover:underline">{{ __('View Map') }}</a>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>{{ __('Name') }}</th>
<th>{{ __('Status') }}</th>
<th>{{ __('Phases') }}</th>
<th>{{ __('Progress') }}</th>
<th></th>
</tr>
</thead>
<tbody>
@forelse($recentProjects as $project)
<tr>
<td class="font-medium">{{ $project->name }}</td>
<td>
@php
$badgeClass = match($project->status) {
'planning' => 'badge-ghost',
'in_progress' => 'badge-primary',
'paused' => 'badge-warning',
'completed' => 'badge-success',
default => 'badge-ghost'
};
@endphp
<span class="badge {{ $badgeClass }}">{{ __(ucfirst(str_replace('_', ' ', $project->status))) }}</span>
</td>
<td>{{ $project->phases_count }}</td>
<td>
@php $avg = $project->phases->avg('progress_percent'); @endphp
<div class="flex items-center gap-2">
<div class="w-24 bg-gray-200 rounded-full h-2.5">
<div class="bg-primary h-2.5 rounded-full" style="width: {{ $avg }}%"></div>
</div>
<span class="text-xs">{{ round($avg) }}%</span>
</div>
</td>
<td>
<a href="{{ route('projects.map', $project) }}" class="btn btn-xs btn-outline">{{ __('Map') }}</a>
</td>
</tr>
@empty
<tr><td colspan="5" class="text-center text-gray-400 py-4">{{ __('No results') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
{{-- Recent inspections --}}
@if($recentInspections->isNotEmpty())
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold mb-4">{{ __('Recent inspections') }}</h3>
<div class="space-y-2">
@foreach($recentInspections as $inspection)
<div class="border rounded p-3 flex justify-between items-center">
<div>
<span class="font-medium">{{ $inspection->template?->name ?? __('Inspection') }}</span>
<span class="text-sm text-gray-500 ml-2">{{ $inspection->feature?->name }}</span>
</div>
<span class="text-xs text-gray-400">{{ $inspection->created_at->diffForHumans() }}</span>
{{-- Proyectos activos --}}
<a href="{{ route('projects.list') }}" class="card bg-base-100 shadow hover:shadow-md transition-shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Proyectos activos</p>
<p class="mt-1 text-3xl font-bold text-blue-600">
{{ $stats['active_projects'] }}
<span class="text-lg font-normal text-gray-400">/ {{ $stats['total_projects'] }}</span>
</p>
</div>
@endforeach
<div class="p-3 bg-blue-100 rounded-full">
<x-heroicon-o-building-office class="w-6 h-6 text-blue-600" />
</div>
</div>
</div>
</a>
{{-- Avance global --}}
<div class="card bg-base-100 shadow hover:shadow-md transition-shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Avance global</p>
<p class="mt-1 text-3xl font-bold text-green-600">{{ $stats['global_progress'] }}%</p>
<div class="mt-2 w-full bg-gray-200 rounded-full h-2">
<div class="bg-green-500 h-2 rounded-full transition-all" style="width: {{ $stats['global_progress'] }}%"></div>
</div>
</div>
<div class="p-3 bg-green-100 rounded-full ml-3 shrink-0">
<x-heroicon-o-chart-bar class="w-6 h-6 text-green-600" />
</div>
</div>
</div>
</div>
@endif
{{-- Fases con retraso --}}
<div class="card bg-base-100 shadow hover:shadow-md transition-shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Fases con retraso</p>
<p class="mt-1 text-3xl font-bold {{ $stats['delayed_phases'] > 0 ? 'text-red-600' : 'text-gray-400' }}">
{{ $stats['delayed_phases'] }}
</p>
@if($stats['delayed_phases'] > 0)
<p class="text-xs text-red-500 mt-0.5">Requiere atención</p>
@else
<p class="text-xs text-gray-400 mt-0.5">Sin retrasos</p>
@endif
</div>
<div class="p-3 {{ $stats['delayed_phases'] > 0 ? 'bg-red-100' : 'bg-gray-100' }} rounded-full">
<x-heroicon-o-clock class="w-6 h-6 {{ $stats['delayed_phases'] > 0 ? 'text-red-600' : 'text-gray-400' }}" />
</div>
</div>
</div>
</div>
{{-- Elementos totales --}}
<div class="card bg-base-100 shadow hover:shadow-md transition-shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Elementos totales</p>
<p class="mt-1 text-3xl font-bold text-indigo-600">{{ $stats['total_features'] }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ $stats['total_phases'] }} fases</p>
</div>
<div class="p-3 bg-indigo-100 rounded-full">
<x-heroicon-o-map-pin class="w-6 h-6 text-indigo-600" />
</div>
</div>
</div>
</div>
</div>
{{-- ============================================================
ROW 2: Issues & Inspections (4 columns)
============================================================ --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{{-- Issues abiertos --}}
<div class="card bg-base-100 shadow hover:shadow-md transition-shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Issues abiertos</p>
<p class="mt-1 text-3xl font-bold text-orange-600">{{ $stats['open_issues'] }}</p>
@if($stats['critical_issues'] > 0)
<p class="text-xs text-red-600 font-semibold mt-0.5">{{ $stats['critical_issues'] }} críticos</p>
@else
<p class="text-xs text-gray-400 mt-0.5">0 críticos</p>
@endif
</div>
<div class="p-3 bg-orange-100 rounded-full">
<x-heroicon-o-exclamation-triangle class="w-6 h-6 text-orange-600" />
</div>
</div>
</div>
</div>
{{-- Inspecciones pendientes --}}
<div class="card bg-base-100 shadow hover:shadow-md transition-shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Insp. pendientes</p>
<p class="mt-1 text-3xl font-bold text-yellow-600">{{ $stats['pending_inspections'] }}</p>
<p class="text-xs text-gray-400 mt-0.5">Por realizar</p>
</div>
<div class="p-3 bg-yellow-100 rounded-full">
<x-heroicon-o-clipboard-document-list class="w-6 h-6 text-yellow-600" />
</div>
</div>
</div>
</div>
{{-- Inspecciones completadas --}}
<div class="card bg-base-100 shadow hover:shadow-md transition-shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Insp. completadas</p>
<p class="mt-1 text-3xl font-bold text-green-600">{{ $stats['completed_inspections'] }}</p>
<p class="text-xs text-gray-400 mt-0.5">Aprobadas</p>
</div>
<div class="p-3 bg-green-100 rounded-full">
<x-heroicon-o-check-circle class="w-6 h-6 text-green-600" />
</div>
</div>
</div>
</div>
{{-- Inspecciones rechazadas --}}
<div class="card bg-base-100 shadow hover:shadow-md transition-shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Insp. rechazadas</p>
<p class="mt-1 text-3xl font-bold {{ $stats['rejected_inspections'] > 0 ? 'text-red-600' : 'text-gray-400' }}">
{{ $stats['rejected_inspections'] }}
</p>
<p class="text-xs text-gray-400 mt-0.5">Requieren revisión</p>
</div>
<div class="p-3 {{ $stats['rejected_inspections'] > 0 ? 'bg-red-100' : 'bg-gray-100' }} rounded-full">
<x-heroicon-o-x-circle class="w-6 h-6 {{ $stats['rejected_inspections'] > 0 ? 'text-red-600' : 'text-gray-400' }}" />
</div>
</div>
</div>
</div>
</div>
{{-- ============================================================
MAIN CONTENT: Two-column layout
============================================================ --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- LEFT COLUMN (2/3): Recent projects --}}
<div class="lg:col-span-2">
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">Proyectos recientes</h3>
<a href="{{ route('projects.list') }}" class="btn btn-sm btn-outline btn-primary">
Ver todos
</a>
</div>
@if($recentProjects->isEmpty())
<div class="text-center py-10 text-gray-400">
<x-heroicon-o-building-office class="w-12 h-12 mx-auto mb-2 opacity-30" />
<p>No hay proyectos disponibles</p>
</div>
@else
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
@foreach($recentProjects as $project)
@php
$avg = $project->phases->avg('progress_percent') ?? 0;
$statusConfig = match($project->status) {
'in_progress' => ['badge' => 'badge-primary', 'bar' => 'bg-blue-500', 'label' => 'En progreso'],
'completed' => ['badge' => 'badge-success', 'bar' => 'bg-green-500', 'label' => 'Completado'],
'paused' => ['badge' => 'badge-warning', 'bar' => 'bg-yellow-500', 'label' => 'Pausado'],
'planning' => ['badge' => 'badge-ghost', 'bar' => 'bg-gray-400', 'label' => 'Planificación'],
default => ['badge' => 'badge-ghost', 'bar' => 'bg-gray-400', 'label' => ucfirst(str_replace('_', ' ', $project->status))],
};
@endphp
<div class="border border-base-200 rounded-lg p-4 hover:border-primary hover:shadow-sm transition-all">
<div class="flex items-start justify-between mb-2">
<h4 class="font-semibold text-sm leading-tight flex-1 mr-2 truncate" title="{{ $project->name }}">
{{ $project->name }}
</h4>
<span class="badge badge-sm {{ $statusConfig['badge'] }} shrink-0">
{{ $statusConfig['label'] }}
</span>
</div>
<div class="flex items-center gap-1 text-xs text-gray-500 mb-3">
<x-heroicon-o-rectangle-stack class="w-3.5 h-3.5" />
<span>{{ $project->phases_count }} {{ $project->phases_count === 1 ? 'fase' : 'fases' }}</span>
</div>
<div class="space-y-1">
<div class="flex justify-between text-xs text-gray-500">
<span>Progreso</span>
<span class="font-medium">{{ round($avg) }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div class="{{ $statusConfig['bar'] }} h-1.5 rounded-full transition-all" style="width: {{ $avg }}%"></div>
</div>
</div>
<div class="mt-3 flex justify-end gap-1">
<a href="{{ route('projects.dashboard', $project) }}" class="btn btn-xs btn-outline gap-1">
<x-heroicon-o-squares-2x2 class="w-3 h-3" />
Dashboard
</a>
<a href="{{ route('projects.map', $project) }}" class="btn btn-xs btn-outline gap-1">
<x-heroicon-o-map class="w-3 h-3" />
Mapa
</a>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
</div>
{{-- RIGHT COLUMN (1/3): Issues + Inspections --}}
<div class="lg:col-span-1 space-y-5">
{{-- Issues recientes --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between mb-3">
<h3 class="text-base font-semibold">Issues recientes</h3>
<x-heroicon-o-exclamation-triangle class="w-5 h-5 text-orange-500" />
</div>
@if(isset($recentIssues) && $recentIssues->isNotEmpty())
<div class="space-y-2">
@foreach($recentIssues as $issue)
@php
$priorityConfig = match($issue->priority ?? 'medium') {
'critical' => ['badge' => 'badge-error', 'label' => 'Crítico'],
'high' => ['badge' => 'badge-warning', 'label' => 'Alto'],
'medium' => ['badge' => 'badge-info', 'label' => 'Medio'],
'low' => ['badge' => 'badge-ghost', 'label' => 'Bajo'],
default => ['badge' => 'badge-ghost', 'label' => ucfirst($issue->priority ?? '')],
};
@endphp
<div class="flex items-start gap-2 p-2.5 rounded-lg bg-base-200 hover:bg-base-300 transition-colors">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 mb-0.5">
<span class="badge badge-xs {{ $priorityConfig['badge'] }}">{{ $priorityConfig['label'] }}</span>
</div>
<p class="text-sm font-medium truncate" title="{{ $issue->title }}">{{ $issue->title }}</p>
<p class="text-xs text-gray-500 truncate">
@if($issue->feature)
<x-heroicon-o-map-pin class="w-3 h-3 inline" /> {{ $issue->feature->name }}
@elseif($issue->project)
<x-heroicon-o-building-office class="w-3 h-3 inline" /> {{ $issue->project->name }}
@endif
</p>
@if($issue->reporter)
<p class="text-xs text-gray-400 mt-0.5">
<x-heroicon-o-user class="w-3 h-3 inline" /> {{ $issue->reporter->name }}
</p>
@endif
</div>
</div>
@endforeach
</div>
@else
<div class="text-center py-6 text-gray-400">
<x-heroicon-o-check-circle class="w-8 h-8 mx-auto mb-1 opacity-30" />
<p class="text-sm">Sin issues abiertos</p>
</div>
@endif
</div>
</div>
{{-- Inspecciones recientes --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between mb-3">
<h3 class="text-base font-semibold">Inspecciones recientes</h3>
<x-heroicon-o-clipboard-document-list class="w-5 h-5 text-yellow-500" />
</div>
@if($recentInspections->isNotEmpty())
<div class="space-y-2">
@foreach($recentInspections as $inspection)
@php
$inspStatusConfig = match($inspection->status ?? 'pending') {
'completed' => ['badge' => 'badge-success', 'label' => 'Completada'],
'pending' => ['badge' => 'badge-warning', 'label' => 'Pendiente'],
'rejected' => ['badge' => 'badge-error', 'label' => 'Rechazada'],
'in_progress' => ['badge' => 'badge-info', 'label' => 'En curso'],
default => ['badge' => 'badge-ghost', 'label' => ucfirst($inspection->status ?? '')],
};
@endphp
<div class="flex items-start gap-2 p-2.5 rounded-lg bg-base-200 hover:bg-base-300 transition-colors">
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between gap-1 mb-0.5">
<p class="text-sm font-medium truncate">
{{ $inspection->template?->name ?? 'Inspección' }}
</p>
<span class="badge badge-xs {{ $inspStatusConfig['badge'] }} shrink-0">{{ $inspStatusConfig['label'] }}</span>
</div>
@if($inspection->feature)
<p class="text-xs text-gray-500 truncate">
<x-heroicon-o-map-pin class="w-3 h-3 inline" /> {{ $inspection->feature->name }}
</p>
@elseif($inspection->project)
<p class="text-xs text-gray-500 truncate">
<x-heroicon-o-building-office class="w-3 h-3 inline" /> {{ $inspection->project->name }}
</p>
@endif
<p class="text-xs text-gray-400 mt-0.5">{{ $inspection->created_at->diffForHumans() }}</p>
</div>
</div>
@endforeach
</div>
@else
<div class="text-center py-6 text-gray-400">
<x-heroicon-o-clipboard-document-list class="w-8 h-8 mx-auto mb-1 opacity-30" />
<p class="text-sm">Sin inspecciones recientes</p>
</div>
@endif
</div>
</div>
</div>
{{-- end right column --}}
</div>
{{-- end main content --}}
</div>
</div>
</x-app-layout>
</x-app-layout>
+132
View File
@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Avante') }} - Portal Cliente</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- PWA -->
<link rel="manifest" href="{{ mix('manifest.json') }}">
<style>
.client-portal {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.project-card {
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.project-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.gallery-item {
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.change-order-card {
border-left: 4px solid;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 0 0.5rem 0.5rem 0;
}
.change-order-pending { border-left-color: #f59e0b; background-color: #fffbeb; }
.change-order-approved { border-left-color: #10b981; background-color: #ecfdf5; }
.change-order-rejected { border-left-color: #ef4444; background-color: #fef2f2; }
</style>
</head>
<body class="font-sans antialiased bg-gray-50">
<div class="min-h-screen">
<!-- Header -->
<header class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<div class="flex-shrink-0">
<img src="{{ asset('logo.png') }}" alt="Avante" class="h-8 w-auto" onerror="this.onerror=null;this.src='https://via.placeholder.com/150x40?text=Avante'; this.alt='Avante Logo'">
</div>
<div class="hidden md:ml-6 md:flex md:space-x-4">
<a href="{{ url('/client/projects') }}" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-50">{{ __('My Projects') }}</a>
<a href="{{ url('/client/profile') }}" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-50">{{ __('Profile') }}</a>
</div>
</div>
<div class="hidden md:block">
<div class="ml-4 flex items-center md:ml-6">
<livewire:user-nav />
</div>
</div>
<div class="hidden md:block">
@livewire('language-switcher')
</div>
</div>
</div>
</header>
<!-- Mobile menu -->
<nav class="md:hidden" id="mobile-menu">
<div class="px-2 pt-2 pb-3 space-y-1">
<a href="{{ url('/client/projects') }}" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:bg-gray-50">{{ __('My Projects') }}</a>
<a href="{{ url('/client/profile') }}" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:bg-gray-50">{{ __('Profile') }}</a>
</div>
</nav>
<!-- Main Content -->
<main class="mt-16">
<div class="client-portal">
{{ $slot }}
</div>
</main>
</div>
@stack('scripts')
@livewireScripts
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(function(registration) {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
}).catch(function(err) {
console.log('ServiceWorker registration failed: ', err);
});
}
// Mobile menu toggle
document.addEventListener('DOMContentLoaded', function() {
const menuBtn = document.querySelector('[data-dropdown-toggle]');
const mobileMenu = document.getElementById('mobile-menu');
if (menuBtn && mobileMenu) {
menuBtn.addEventListener('click', function() {
mobileMenu.classList.toggle('hidden');
});
}
});
</script>
</body>
</html>
+82 -34
View File
@@ -1,53 +1,101 @@
<div>
@if(session()->has('message'))
<div class="alert alert-success mb-2 text-sm">{{ session('message') }}</div>
@endif
@if(session()->has('error'))
<div class="alert alert-error mb-2 text-sm">{{ session('error') }}</div>
@if(session('notify'))
<div class="alert alert-success mb-4">
<x-heroicon-o-check-circle class="w-5 h-5" />
{{ session('notify') }}
</div>
@endif
{{-- ── Cabecera ─────────────────────────────────────────────────────────── --}}
<div class="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
<label class="input input-bordered input-sm flex items-center gap-2 flex-1 max-w-sm">
<x-heroicon-o-magnifying-glass class="w-4 h-4 opacity-50" />
<input type="text" wire:model.live.debounce.300ms="search"
class="grow" placeholder="Buscar por nombre o email…" />
</label>
<a href="{{ route('admin.users.create') }}" class="btn btn-primary btn-sm gap-1 shrink-0" wire:navigate>
<x-heroicon-o-plus class="w-4 h-4" />
Nuevo usuario
</a>
</div>
{{-- ── Tabla ────────────────────────────────────────────────────────────── --}}
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>{{ __('Name') }}</th>
<th>{{ __('Email') }}</th>
<th>{{ __('Role') }}</th>
<th>{{ __('Language') }}</th>
<th>{{ __('Actions') }}</th>
<th>Usuario</th>
<th>Rol</th>
<th>Verificado</th>
<th class="w-24"></th>
</tr>
</thead>
<tbody>
@foreach($users as $user)
<tr>
<td class="font-medium">{{ $user->name }}</td>
<td class="text-sm">{{ $user->email }}</td>
@forelse($this->users as $u)
<tr wire:key="user-{{ $u->id }}">
<td>
<div class="flex flex-wrap gap-1">
@foreach($user->roles as $role)
<span class="badge badge-sm {{ $role->name === 'Admin' ? 'badge-error' : 'badge-primary' }}">
{{ __($role->name) }}
</span>
@endforeach
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-8">
<span class="text-xs">{{ strtoupper(substr($u->name, 0, 1)) }}</span>
</div>
</div>
<div>
<p class="font-semibold text-sm">{{ $u->name }}</p>
<p class="text-xs text-gray-500">{{ $u->email }}</p>
</div>
</div>
</td>
<td class="text-sm">{{ strtoupper($user->locale ?? 'en') }}</td>
<td>
@can('assign users')
<select wire:change="updateRole({{ $user->id }}, $event.target.value)"
class="select select-bordered select-xs"
@if(Auth::id() === $user->id && $user->hasRole('Admin')) disabled @endif>
@foreach($roles as $role)
<option value="{{ $role->name }}" @selected($user->hasRole($role->name))>
{{ __($role->name) }}
</option>
@endforeach
</select>
@endcan
<div class="flex flex-wrap gap-1">
@foreach($u->roles as $role)
<span class="badge badge-sm {{ $role->name === 'Admin' ? 'badge-error' : 'badge-primary' }}">
{{ $role->name }}
</span>
@endforeach
@if($u->roles->isEmpty())
<span class="badge badge-sm badge-ghost">Sin rol</span>
@endif
</div>
</td>
<td>
@if($u->email_verified_at)
<x-heroicon-o-check-circle class="w-5 h-5 text-success" />
@else
<x-heroicon-o-clock class="w-5 h-5 text-warning" />
@endif
</td>
<td>
<div class="flex justify-end gap-1">
<a href="{{ route('admin.users.show', $u) }}"
class="btn btn-xs btn-outline" title="Ver" wire:navigate>
<x-heroicon-o-eye class="w-3.5 h-3.5" />
</a>
<a href="{{ route('admin.users.edit', $u) }}"
class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate>
<x-heroicon-o-pencil class="w-3.5 h-3.5" />
</a>
@if($u->id !== auth()->id())
<button wire:click="deleteUser({{ $u->id }})"
wire:confirm="¿Eliminar a '{{ $u->name }}'? Se perderán todos sus datos."
class="btn btn-xs btn-outline btn-error" title="Eliminar">
<x-heroicon-o-trash class="w-3.5 h-3.5" />
</button>
@endif
</div>
</td>
</tr>
@endforeach
@empty
<tr>
<td colspan="4" class="text-center text-gray-400 py-8">
<x-heroicon-o-users class="w-10 h-10 mx-auto mb-1 opacity-25" />
<p class="text-sm">No se encontraron usuarios</p>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,225 @@
<div>
@if(!$selectedProject)
<!-- Project Selection -->
<div class="mb-6">
<h2 class="text-xl font-bold mb-4">{{ __('Select a project to view details') }}</h2>
<div class="space-y-4">
@foreach($projects as $project)
<div class="project-card cursor-pointer hover:shadow-lg transition-shadow"
wire:click="selectProject({{ $project['id'] }})">
<div class="p-4">
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ $project['name'] }}</h3>
<p class="text-sm text-gray-500 mb-2">
{{ $project['description'] ?? __('No description available') }}
</p>
<div class="flex items-center justify-between">
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
{{ ucfirst($project['pivot']['role_in_project']) }}
</span>
<span class="text-sm font-medium">
@php
$progress = collect($project['phases'])->avg('progress_percent') ?? 0;
@endphp
{{ number_format($progress, 1) }}% {{ __('completed') }}
</span>
</div>
</div>
</div>
@endforeach
</div>
</div>
@else
<!-- Project Details -->
<div class="mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold">{{ $projectDetails['name'] }}</h2>
<button wire:click="selectedProject = null"
class="px-3 py-1 bg-gray-200 text-gray-700 text-sm rounded hover:bg-gray-300">
{{ __('Back to projects') }}
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-sm font-medium text-gray-500 mb-2">{{ __('Status') }}</h3>
<p class="text-2xl font-bold text-gray-900">
{{ __(ucfirst(str_replace('_', ' ', $projectDetails['status'] ?? ''))) }}
</p>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-sm font-medium text-gray-500 mb-2">{{ __('Start date') }}</h3>
<p class="text-2xl font-bold text-gray-900">
{{ $projectDetails['start_date'] ?? __('Not defined') }}
</p>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-sm font-medium text-gray-500 mb-2">{{ __('Estimated end date') }}</h3>
<p class="text-2xl font-bold text-gray-900">
{{ $projectDetails['end_date'] ?? __('Not defined') }}
</p>
</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg mb-6">
<h3 class="text-sm font-medium text-gray-500 mb-2">{{ __('Description') }}</h3>
<p class="text-gray-700">
{{ $projectDetails['description'] ?? __('No description available') }}
</p>
</div>
</div>
<!-- Progress Overview -->
<div class="mb-6">
<h2 class="text-xl font-bold mb-4">{{ __('Progress overview') }}</h2>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium">{{ __('General progress') }}</h3>
<div class="text-2xl font-bold text-green-600">
{{ number_format($projectDetails['progress'] ?? 0, 1) }}%
</div>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 mb-4">
<div class="bg-green-600 h-2.5 rounded-full"
style="width: {{ min(max($projectDetails['progress'] ?? 0, 0), 100) }}%"></div>
</div>
<div class="text-sm text-gray-500">
{{ $projectDetails['progress'] ?? 0 }}% {{ __('completed') }}
</div>
</div>
</div>
<!-- Phases Progress -->
<div class="mb-6">
<h2 class="text-xl font-bold mb-4">{{ __('Progress by phase') }}</h2>
@php
$project = \App\Models\Project::find($selectedProject);
$phases = $project->phases ?? collect();
@endphp
@if($phases->isNotEmpty())
<div class="space-y-4">
@foreach($phases as $phase)
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-medium">{{ $phase->name }}</h3>
<span class="px-2 py-1 bg-indigo-100 text-indigo-800 text-xs rounded-full">
{{ __('Phase') }} {{ $phase->id }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div class="bg-indigo-600 h-2.5 rounded-full"
style="width: {{ min(max($phase->progress_percent ?? 0, 0), 100) }}%"></div>
</div>
<div class="text-sm text-gray-500 mt-1">
{{ $phase->progress_percent ?? 0 }}% {{ __('completed') }}
</div>
@if($phase->features->isNotEmpty())
<div class="mt-3 pt-2 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-600 mb-2">{{ __('Features') }}:</h4>
<div class="space-y-1 text-sm">
@foreach($phase->features as $feature)
<div class="flex items-start">
<span class="flex-shrink-0"></span>
<span class="ml-2">
{{ $feature->name }}:
<span class="font-medium">{{ $feature->completion_status ?? __('Pending') }}</span>
</span>
</div>
@endforeach
</div>
</div>
@endif
</div>
@endforeach
</div>
@else
<div class="bg-gray-50 p-6 text-center rounded-lg">
<p class="text-gray-500">{{ __('No phases defined for this project') }}</p>
</div>
@endif
</div>
<!-- Gallery -->
<div class="mb-6">
<h2 class="text-xl font-bold mb-4">{{ __('Progress gallery') }}</h2>
<div class="gallery-grid">
@foreach($galleryImages as $image)
<div class="gallery-item">
<img src="{{ $image['url'] }}"
alt="{{ $image['title'] }}"
class="w-full h-48 object-cover">
<div class="p-3">
<h4 class="text-sm font-medium">{{ $image['title'] }}</h4>
<p class="text-xs text-gray-500">{{ $image['date'] }}</p>
</div>
</div>
@endforeach
</div>
</div>
<!-- Change Orders -->
<div class="mb-6">
<h2 class="text-xl font-bold mb-4">{{ __('Change orders') }}</h2>
@if($changeOrders->isNotEmpty())
<div class="space-y-4">
@foreach($changeOrders as $order)
<div class="change-order-card change-order-{{ strtolower($order['status']) }} p-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-medium">{{ $order['title'] }}</h3>
<span class="px-2 py-1 text-xs rounded-full
@if($order['status'] == 'pending') bg-yellow-100 text-yellow-800
@elseif($order['status'] == 'approved') bg-green-100 text-green-800
@elseif($order['status'] == 'rejected') bg-red-100 text-red-800
@endif">
{{ ucfirst($order['status']) }}
</span>
</div>
<p class="text-gray-600 mb-2">{{ $order['description'] }}</p>
<div class="flex items-center space-x-3 text-sm">
<span class="mr-4">
<span class="font-medium">{{ __('Requested') }}:</span> {{ $order['requested_at'] }}
</span>
<span class="mr-4">
<span class="font-medium">{{ __('Amount') }}:</span> ${{ number_format($order['amount'], 2) }}
</span>
</div>
@if($order['status'] == 'pending')
<div class="mt-3 pt-2 border-t border-gray-200">
<div class="flex items-center space-x-3">
<button wire:click="approveChangeOrder({{ $order['id'] }})"
class="flex-1 px-3 py-2 bg-green-600 text-white text-sm rounded hover:bg-green-700 disabled:opacity-50">
{{ __('Approve') }}
</button>
<button wire:click="rejectChangeOrder({{ $order['id'] }})"
class="flex-1 px-3 py-2 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:opacity-50">
{{ __('Reject') }}
</button>
</div>
</div>
@endif
</div>
@endforeach
</div>
@else
<div class="bg-gray-50 p-6 text-center rounded-lg">
<p class="text-gray-500">{{ __('No pending change orders') }}</p>
</div>
@endif
</div>
@endif
</div>
@@ -0,0 +1,246 @@
<div>
<x-slot name="header">
<div class="flex items-center gap-3">
<a href="{{ route('companies.manage') }}" class="btn btn-ghost btn-sm px-2" wire:navigate>
<x-heroicon-o-arrow-left class="w-4 h-4" />
</a>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ $company ? 'Editar empresa: ' . $company->name : 'Nueva empresa' }}
</h2>
</div>
</x-slot>
<div class="py-8">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
@if(session('notify'))
<div class="alert alert-success mb-4">{{ session('notify') }}</div>
@endif
<div class="card bg-base-100 shadow">
<div class="card-body p-8">
<form wire:submit.prevent="save" class="space-y-0">
@if($errors->any())
<div class="alert alert-error text-sm mb-6">
<x-heroicon-o-exclamation-circle class="w-5 h-5 shrink-0" />
<ul class="list-disc pl-3 space-y-0.5">
@foreach($errors->all() as $e) <li>{{ $e }}</li> @endforeach
</ul>
</div>
@endif
{{-- ── Macro para fila de formulario horizontal ──────────── --}}
{{-- Patrón: flex row, label w-48 shrink-0, campo flex-1 --}}
{{-- ── Sección: Identificación ──────────────────────────── --}}
<div class="pb-6 mb-6 border-b border-base-200">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
Identificación
</h3>
<div class="space-y-4">
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Nombre registrado <span class="text-error">*</span>
</label>
<div class="flex-1">
<input type="text" wire:model="name"
class="input input-bordered w-full"
placeholder="Constructora Ejemplo, S.L." />
@error('name') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Apodo / comercial
</label>
<div class="flex-1">
<input type="text" wire:model="apodo"
class="input input-bordered w-full"
placeholder="Ejemplo Constr." />
</div>
</div>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
NIF / CIF / Tax ID
</label>
<div class="flex-1">
<input type="text" wire:model="tax_id"
class="input input-bordered w-full"
placeholder="B12345678" />
@error('tax_id') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Tipo de empresa <span class="text-error">*</span>
</label>
<div class="flex-1">
<select wire:model="type" class="select select-bordered w-full">
<option value="owner">Promotor / Propietario</option>
<option value="constructor">Constructor principal</option>
<option value="subcontractor">Subcontratista</option>
<option value="consultant">Consultor / Ingeniería</option>
<option value="supplier">Proveedor</option>
<option value="other">Otro</option>
</select>
</div>
</div>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Estado <span class="text-error">*</span>
</label>
<div class="flex-1">
<select wire:model="estado" class="select select-bordered w-full max-w-xs">
<option value="activo">Activo</option>
<option value="inactivo">Inactivo</option>
<option value="suspendido">Suspendido</option>
</select>
</div>
</div>
</div>
</div>
{{-- ── Sección: Contacto ─────────────────────────────────── --}}
<div class="pb-6 mb-6 border-b border-base-200">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
Contacto
</h3>
<div class="space-y-4">
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Dirección
</label>
<div class="flex-1">
<textarea wire:model="address" rows="2"
class="textarea textarea-bordered w-full"
placeholder="Calle, número, ciudad, CP, país"></textarea>
</div>
</div>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Teléfono
</label>
<div class="flex-1">
<label class="input input-bordered flex items-center gap-2">
<x-heroicon-o-phone class="w-4 h-4 opacity-40 shrink-0" />
<input type="tel" wire:model="phone" class="grow"
placeholder="+34 600 123 456" />
</label>
</div>
</div>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Email
</label>
<div class="flex-1">
<label class="input input-bordered flex items-center gap-2">
<x-heroicon-o-envelope class="w-4 h-4 opacity-40 shrink-0" />
<input type="email" wire:model="email" class="grow"
placeholder="contacto@empresa.com" />
</label>
@error('email') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Sitio web
</label>
<div class="flex-1">
<label class="input input-bordered flex items-center gap-2">
<x-heroicon-o-globe-alt class="w-4 h-4 opacity-40 shrink-0" />
<input type="url" wire:model="website" class="grow"
placeholder="https://www.empresa.com" />
</label>
@error('website') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
</div>
</div>
{{-- ── Sección: Logo ─────────────────────────────────────── --}}
<div class="pb-6 mb-6 border-b border-base-200">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
Logo
</h3>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
{{ $company?->logo_path ? 'Reemplazar logo' : 'Subir logo' }}
<p class="text-xs text-gray-400 font-normal mt-0.5">PNG / JPG, máx. 2 MB</p>
</label>
<div class="flex-1 flex items-start gap-4">
{{-- Preview --}}
@if($logo)
<img src="{{ $logo->temporaryUrl() }}" alt="Preview"
class="w-16 h-16 object-contain border border-base-300 rounded-lg shrink-0" />
@elseif($company?->logo_path && \Illuminate\Support\Facades\Storage::disk('public')->exists($company->logo_path))
<img src="{{ \Illuminate\Support\Facades\Storage::disk('public')->url($company->logo_path) }}"
alt="Logo actual"
class="w-16 h-16 object-contain border border-base-300 rounded-lg shrink-0" />
@else
<div class="w-16 h-16 bg-base-200 rounded-lg flex items-center justify-center shrink-0">
<x-heroicon-o-building-office class="w-7 h-7 opacity-30" />
</div>
@endif
<div class="flex-1">
<input type="file" wire:model="logo" accept="image/*"
class="file-input file-input-bordered w-full" />
<div wire:loading wire:target="logo" class="text-xs text-info mt-1">Subiendo…</div>
@error('logo') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
</div>
</div>
{{-- ── Sección: Notas ────────────────────────────────────── --}}
<div class="pb-6 mb-6 border-b border-base-200">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">
Notas internas
</h3>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Observaciones
<p class="text-xs text-gray-400 font-normal mt-0.5">Información interna</p>
</label>
<div class="flex-1">
<textarea wire:model="notes" rows="3"
class="textarea textarea-bordered w-full"
placeholder="Condiciones especiales, observaciones…"></textarea>
</div>
</div>
</div>
{{-- ── Botones ───────────────────────────────────────────── --}}
<div class="flex items-center justify-between pt-2">
<a href="{{ route('companies.manage') }}" class="btn btn-outline gap-1" wire:navigate>
<x-heroicon-o-x-mark class="w-4 h-4" />
Cancelar
</a>
<button type="submit" class="btn btn-primary gap-2"
wire:loading.attr="disabled" wire:target="save">
<span wire:loading wire:target="save" class="loading loading-spinner loading-sm"></span>
<x-heroicon-o-check class="w-4 h-4" />
{{ $company ? 'Guardar cambios' : 'Crear empresa' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,28 @@
<x-slot name="header">
<div>
<h2 class="text-2xl font-bold text-gray-800">{{ __('Company Management') }}</h2>
<p class="text-sm text-gray-500 mt-1">{{ __('Manage the companies that participate in projects') }}</p>
</div>
</x-slot>
<div class="py-12">
@if(session('message'))
<div class="alert alert-success mb-4">
<x-heroicon-o-check-circle class="w-5 h-5" />
{{ session('message') }}
</div>
@endif
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white rounded-lg shadow p-6">
<div class="flex justify-end mb-4">
<a href="{{ route('companies.create') }}" class="btn btn-primary btn-sm gap-1" wire:navigate>
<x-heroicon-o-plus class="w-4 h-4" />
{{ __('New Company') }}
</a>
</div>
<livewire:company-table />
</div>
</div>
</div>
@@ -0,0 +1,592 @@
<div>
<x-slot name="header">
{{-- ── Header de la empresa ─────────────────────────────────────────────── --}}
<div class="flex items-start justify-between gap-4 flex-wrap">
{{-- Izquierda: logo + datos --}}
<div class="flex items-start gap-4">
{{-- Logo --}}
@if($company->logo_path && \Illuminate\Support\Facades\Storage::disk('public')->exists($company->logo_path))
<img src="{{ \Illuminate\Support\Facades\Storage::disk('public')->url($company->logo_path) }}"
class="w-16 h-16 rounded-xl object-contain border border-base-300 bg-white shadow shrink-0"
alt="Logo {{ $company->name }}" />
@else
<div class="w-16 h-16 rounded-xl bg-base-200 flex items-center justify-center shadow shrink-0">
<x-heroicon-o-building-office-2 class="w-8 h-8 opacity-30" />
</div>
@endif
{{-- Datos --}}
<div>
<div class="flex flex-wrap items-center gap-2">
<h2 class="font-bold text-xl leading-tight">{{ $company->name }}</h2>
@if($company->apodo)
<span class="text-gray-400 font-normal text-base">"{{ $company->apodo }}"</span>
@endif
{{-- Tipo --}}
@php
$typeBadge = match($company->type) {
'owner' => ['badge-success', 'Promotor'],
'constructor' => ['badge-primary', 'Constructor'],
'subcontractor' => ['badge-secondary','Subcontratista'],
'consultant' => ['badge-info', 'Consultor'],
'supplier' => ['badge-warning', 'Proveedor'],
default => ['badge-ghost', 'Otro'],
};
@endphp
<span class="badge {{ $typeBadge[0] }}">{{ $typeBadge[1] }}</span>
</div>
{{-- NIF --}}
@if($company->tax_id)
<p class="text-xs text-gray-400 mt-0.5">NIF/CIF: {{ $company->tax_id }}</p>
@endif
{{-- Contacto inline --}}
<div class="flex flex-wrap items-center gap-x-4 gap-y-0.5 mt-1.5 text-sm text-gray-500">
@if($company->email)
<span class="flex items-center gap-1">
<x-heroicon-o-envelope class="w-3.5 h-3.5 opacity-60 shrink-0" />
{{ $company->email }}
</span>
@endif
@if($company->phone)
<span class="flex items-center gap-1">
<x-heroicon-o-phone class="w-3.5 h-3.5 opacity-60 shrink-0" />
{{ $company->phone }}
</span>
@endif
@if($company->address)
<span class="flex items-center gap-1 max-w-xs">
<x-heroicon-o-map-pin class="w-3.5 h-3.5 opacity-60 shrink-0" />
<span class="truncate">{{ $company->address }}</span>
</span>
@endif
@if($company->website)
<a href="{{ $company->website }}" target="_blank" rel="noopener"
class="flex items-center gap-1 text-primary hover:underline">
<x-heroicon-o-globe-alt class="w-3.5 h-3.5 shrink-0" />
{{ parse_url($company->website, PHP_URL_HOST) ?? $company->website }}
</a>
@endif
</div>
</div>
</div>
{{-- Derecha: estado + botones --}}
<div class="flex flex-col items-end gap-2">
{{-- Estado --}}
@php
$estadoBadge = match($company->estado ?? 'activo') {
'activo' => ['badge-success', 'Activo'],
'inactivo' => ['badge-ghost', 'Inactivo'],
'suspendido' => ['badge-error', 'Suspendido'],
default => ['badge-ghost', ucfirst($company->estado ?? '')],
};
@endphp
<span class="badge {{ $estadoBadge[0] }} badge-md">{{ $estadoBadge[1] }}</span>
{{-- Botones --}}
<div class="flex gap-2 mt-1">
<a href="{{ route('companies.edit', $company) }}"
class="btn btn-outline btn-sm gap-1" wire:navigate>
<x-heroicon-o-pencil class="w-4 h-4" />
Editar
</a>
<a href="{{ route('companies.manage') }}"
class="btn btn-ghost btn-sm gap-1" wire:navigate>
<x-heroicon-o-arrow-left class="w-4 h-4" />
Volver
</a>
</div>
</div>
</div>
</x-slot>
{{-- ── Contenido principal ──────────────────────────────────────────────── --}}
<div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8 space-y-4">
{{-- Tabs --}}
<div role="tablist" class="tabs tabs-bordered">
<button role="tab" wire:click="setTab('summary')"
class="tab gap-2 {{ $activeTab === 'summary' ? 'tab-active font-semibold' : '' }}">
<x-heroicon-o-chart-bar class="w-4 h-4" />
Resumen
</button>
<button role="tab" wire:click="setTab('people')"
class="tab gap-2 {{ $activeTab === 'people' ? 'tab-active font-semibold' : '' }}">
<x-heroicon-o-users class="w-4 h-4" />
Personas
<span class="badge badge-sm badge-outline">{{ $usersCount }}</span>
</button>
<button role="tab" wire:click="setTab('projects')"
class="tab gap-2 {{ $activeTab === 'projects' ? 'tab-active font-semibold' : '' }}">
<x-heroicon-o-folder-open class="w-4 h-4" />
Proyectos
<span class="badge badge-sm badge-outline">{{ $projectsCount }}</span>
</button>
<button role="tab" wire:click="setTab('notes')"
class="tab gap-2 {{ $activeTab === 'notes' ? 'tab-active font-semibold' : '' }}">
<x-heroicon-o-document-text class="w-4 h-4" />
Notas
@if($company->notes)
<span class="badge badge-sm badge-primary"></span>
@endif
</button>
</div>
{{-- ════════════════════════════════════════════════════════════════════
TAB: RESUMEN
════════════════════════════════════════════════════════════════════ --}}
@if($activeTab === 'summary')
<div class="space-y-4">
{{-- KPIs --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="card bg-base-100 shadow">
<div class="card-body p-4 items-center text-center">
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center mb-1">
<x-heroicon-o-users class="w-5 h-5 text-blue-600" />
</div>
<p class="text-3xl font-bold">{{ $usersCount }}</p>
<p class="text-xs text-gray-500 mt-0.5">Personas</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body p-4 items-center text-center">
<div class="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center mb-1">
<x-heroicon-o-folder-open class="w-5 h-5 text-indigo-600" />
</div>
<p class="text-3xl font-bold">{{ $projectsCount }}</p>
<p class="text-xs text-gray-500 mt-0.5">Proyectos</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body p-4 items-center text-center">
<div class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center mb-1">
<x-heroicon-o-arrow-trending-up class="w-5 h-5 text-green-600" />
</div>
<p class="text-3xl font-bold">{{ $avgProgress }}<span class="text-lg font-normal text-gray-400">%</span></p>
<p class="text-xs text-gray-500 mt-0.5">Progreso medio</p>
@if($projectsCount > 0)
<progress class="progress progress-success w-full h-1 mt-1"
value="{{ $avgProgress }}" max="100"></progress>
@endif
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body p-4 items-center text-center">
<div class="w-10 h-10 rounded-full bg-orange-100 flex items-center justify-center mb-1">
<x-heroicon-o-exclamation-triangle class="w-5 h-5 text-orange-500" />
</div>
<p class="text-3xl font-bold {{ $openIssues > 0 ? 'text-warning' : '' }}">{{ $openIssues }}</p>
<p class="text-xs text-gray-500 mt-0.5">Issues abiertos</p>
</div>
</div>
</div>
{{-- Proyectos con progreso --}}
@if($company->projects->isNotEmpty())
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<h3 class="font-semibold text-sm mb-3 flex items-center gap-2">
<x-heroicon-o-chart-bar-square class="w-4 h-4 text-primary" />
Estado de proyectos
</h3>
<div class="space-y-3">
@foreach($company->projects as $p)
@php
$avg = $p->phases->avg('progress_percent') ?? 0;
$pStatusBadge = match($p->status) {
'in_progress' => ['badge-primary', 'En progreso'],
'completed' => ['badge-success', 'Completado'],
'paused' => ['badge-warning', 'Pausado'],
'planning' => ['badge-ghost', 'Planificación'],
default => ['badge-ghost', ucfirst($p->status)],
};
@endphp
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
<a href="{{ route('projects.dashboard', $p) }}"
class="font-medium text-sm hover:text-primary transition-colors truncate" wire:navigate>
{{ $p->name }}
</a>
<span class="badge badge-xs {{ $pStatusBadge[0] }} shrink-0">{{ $pStatusBadge[1] }}</span>
</div>
<div class="flex items-center gap-2">
<progress class="progress progress-primary flex-1 h-1.5"
value="{{ round($avg) }}" max="100"></progress>
<span class="text-xs text-gray-400 shrink-0 w-8 text-right">{{ round($avg) }}%</span>
</div>
</div>
@if($p->pivot->role_in_project)
<span class="badge badge-sm badge-outline shrink-0">{{ $p->pivot->role_in_project }}</span>
@endif
</div>
@endforeach
</div>
</div>
</div>
@endif
{{-- Ficha empresa --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<h3 class="font-semibold text-sm mb-3 flex items-center gap-2">
<x-heroicon-o-identification class="w-4 h-4 text-primary" />
Ficha
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2 text-sm">
@foreach([
['NIF/CIF', $company->tax_id],
['Tipo', $typeBadge[1]],
['Estado', $estadoBadge[1]],
['Teléfono', $company->phone],
['Email', $company->email],
['Dirección', $company->address],
['Web', $company->website],
] as [$label, $val])
@if($val)
<div class="flex gap-2 py-1.5 border-b border-base-200">
<span class="text-gray-400 w-24 shrink-0">{{ $label }}</span>
@if($label === 'Web')
<a href="{{ $val }}" target="_blank" rel="noopener"
class="text-primary hover:underline truncate">{{ $val }}</a>
@else
<span class="font-medium truncate">{{ $val }}</span>
@endif
</div>
@endif
@endforeach
</div>
</div>
</div>
</div>
@endif
{{-- ════════════════════════════════════════════════════════════════════
TAB: PERSONAS
════════════════════════════════════════════════════════════════════ --}}
@if($activeTab === 'people')
<div class="space-y-4">
{{-- Acciones --}}
<div class="flex flex-wrap gap-3">
{{-- Crear nuevo usuario --}}
<a href="{{ route('admin.users.create') }}"
class="btn btn-primary btn-sm gap-1" wire:navigate>
<x-heroicon-o-user-plus class="w-4 h-4" />
Crear nuevo usuario
</a>
{{-- Asignar existente --}}
@if($assignableUsers->isNotEmpty())
<div class="flex items-center gap-2"
x-data="{ open: false }">
<button @click="open = !open" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-link class="w-4 h-4" />
Asignar usuario existente
</button>
<div x-show="open" x-cloak class="flex items-center gap-2 flex-wrap">
<select wire:model="assignUserId" class="select select-bordered select-sm min-w-[200px]">
<option value=""> Seleccionar </option>
@foreach($assignableUsers as $u)
<option value="{{ $u->id }}">
{{ $u->name }}
@if($u->company) (actual: {{ $u->company->apodo ?: $u->company->name }}) @endif
</option>
@endforeach
</select>
<button wire:click="assignUser" class="btn btn-primary btn-sm gap-1">
<x-heroicon-o-check class="w-4 h-4" />
Asignar
</button>
@error('assignUserId')
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
</div>
@endif
</div>
{{-- Lista personas --}}
@if($company->users->isEmpty())
<div class="card bg-base-100 shadow">
<div class="card-body items-center text-center py-10 text-gray-400">
<x-heroicon-o-users class="w-10 h-10 opacity-25 mb-2" />
<p class="text-sm">Ninguna persona asociada a esta empresa.</p>
</div>
</div>
@else
<div class="card bg-base-100 shadow overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Persona</th>
<th>Rol</th>
<th>Estado</th>
<th>Contacto</th>
<th class="w-20"></th>
</tr>
</thead>
<tbody>
@foreach($company->users as $u)
@php
$uStatusBadge = match($u->status ?? 'active') {
'active' => ['badge-success', 'Activo'],
'inactive' => ['badge-ghost', 'Inactivo'],
'suspended' => ['badge-error', 'Suspendido'],
default => ['badge-ghost', ucfirst($u->status ?? '')],
};
@endphp
<tr wire:key="user-{{ $u->id }}">
<td>
<div class="flex items-center gap-3">
<div class="avatar placeholder shrink-0">
<div class="bg-neutral text-neutral-content rounded-full w-8">
<span class="text-xs">
{{ strtoupper(substr($u->first_name ?: $u->name, 0, 1)) }}{{ strtoupper(substr($u->last_name ?: '', 0, 1)) }}
</span>
</div>
</div>
<div>
<p class="font-semibold text-sm">
@if($u->title) <span class="text-gray-400 font-normal">{{ $u->title }}</span> @endif
{{ $u->first_name && $u->last_name
? $u->first_name . ' ' . $u->last_name
: $u->name }}
</p>
<p class="text-xs text-gray-400">{{ $u->email }}</p>
</div>
</div>
</td>
<td>
<div class="flex flex-wrap gap-1">
@foreach($u->roles as $role)
<span class="badge badge-xs {{ $role->name === 'Admin' ? 'badge-error' : 'badge-primary' }}">
{{ $role->name }}
</span>
@endforeach
@if($u->roles->isEmpty())
<span class="badge badge-xs badge-ghost">Sin rol</span>
@endif
</div>
</td>
<td>
<span class="badge badge-sm {{ $uStatusBadge[0] }}">{{ $uStatusBadge[1] }}</span>
</td>
<td class="text-xs text-gray-500">
@if($u->phone) <div>{{ $u->phone }}</div> @endif
</td>
<td>
<div class="flex justify-end gap-1">
<a href="{{ route('admin.users.show', $u) }}"
class="btn btn-xs btn-outline" title="Ver" wire:navigate>
<x-heroicon-o-eye class="w-3.5 h-3.5" />
</a>
<button wire:click="removeUser({{ $u->id }})"
wire:confirm="¿Desvincular a {{ $u->name }} de esta empresa?"
class="btn btn-xs btn-outline btn-warning" title="Desvincular">
<x-heroicon-o-link-slash class="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@endif
{{-- ════════════════════════════════════════════════════════════════════
TAB: PROYECTOS
════════════════════════════════════════════════════════════════════ --}}
@if($activeTab === 'projects')
<div class="space-y-4">
{{-- Formulario asignar --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<h3 class="font-semibold text-sm mb-3 flex items-center gap-2">
<x-heroicon-o-plus-circle class="w-4 h-4 text-primary" />
Vincular a proyecto
</h3>
@if($availableProjects->isEmpty())
<p class="text-sm text-gray-400">La empresa ya está vinculada a todos los proyectos.</p>
@else
<div class="flex flex-wrap items-end gap-3">
<div class="form-control flex-1 min-w-[200px]">
<label class="label-text text-xs mb-1">Proyecto</label>
<select wire:model="addProjectId" class="select select-bordered select-sm w-full">
<option value=""> Seleccionar </option>
@foreach($availableProjects as $p)
<option value="{{ $p->id }}">{{ $p->name }}</option>
@endforeach
</select>
@error('addProjectId') <p class="text-error text-xs mt-0.5">{{ $message }}</p> @enderror
</div>
<div class="form-control flex-1 min-w-[180px]">
<label class="label-text text-xs mb-1">
Rol en el proyecto <span class="text-error">*</span>
</label>
<input type="text" wire:model="addProjectRole"
class="input input-bordered input-sm w-full"
placeholder="ej: Constructor principal" />
@error('addProjectRole') <p class="text-error text-xs mt-0.5">{{ $message }}</p> @enderror
</div>
<button wire:click="assignProject" class="btn btn-primary btn-sm gap-1 shrink-0">
<x-heroicon-o-plus class="w-4 h-4" />
Vincular
</button>
</div>
@endif
</div>
</div>
{{-- Lista proyectos --}}
@if($company->projects->isEmpty())
<div class="card bg-base-100 shadow">
<div class="card-body items-center text-center py-10 text-gray-400">
<x-heroicon-o-folder-open class="w-10 h-10 opacity-25 mb-2" />
<p class="text-sm">Sin proyectos vinculados.</p>
</div>
</div>
@else
<div class="card bg-base-100 shadow overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Proyecto</th>
<th>Rol de la empresa</th>
<th>Estado</th>
<th>Progreso</th>
<th class="w-16"></th>
</tr>
</thead>
<tbody>
@foreach($company->projects as $project)
@php
$avg = $project->phases->avg('progress_percent') ?? 0;
$psCfg = match($project->status) {
'in_progress' => ['badge-primary', 'En progreso'],
'completed' => ['badge-success', 'Completado'],
'paused' => ['badge-warning', 'Pausado'],
'planning' => ['badge-ghost', 'Planificación'],
default => ['badge-ghost', ucfirst($project->status)],
};
@endphp
<tr wire:key="proj-{{ $project->id }}">
<td>
<a href="{{ route('projects.dashboard', $project) }}"
class="font-medium hover:text-primary transition-colors" wire:navigate>
{{ $project->name }}
</a>
@if($project->address)
<p class="text-xs text-gray-400 truncate max-w-[200px]">{{ $project->address }}</p>
@endif
</td>
<td>
<span class="badge badge-sm badge-outline">
{{ $project->pivot->role_in_project }}
</span>
</td>
<td>
<span class="badge badge-sm {{ $psCfg[0] }}">{{ $psCfg[1] }}</span>
</td>
<td>
<div class="flex items-center gap-2">
<progress class="progress progress-primary w-20 h-1.5"
value="{{ round($avg) }}" max="100"></progress>
<span class="text-xs text-gray-500">{{ round($avg) }}%</span>
</div>
</td>
<td>
<button wire:click="removeProject({{ $project->id }})"
wire:confirm="¿Desvincular a '{{ $company->name }}' del proyecto '{{ $project->name }}'?"
class="btn btn-xs btn-outline btn-error" title="Desvincular">
<x-heroicon-o-x-mark class="w-3.5 h-3.5" />
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@endif
{{-- ════════════════════════════════════════════════════════════════════
TAB: NOTAS
════════════════════════════════════════════════════════════════════ --}}
@if($activeTab === 'notes')
<div class="card bg-base-100 shadow max-w-2xl">
<div class="card-body p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-base flex items-center gap-2">
<x-heroicon-o-document-text class="w-5 h-5 text-primary" />
Notas internas
</h3>
@if(!$editingNotes)
<button wire:click="$set('editingNotes', true)"
class="btn btn-sm btn-outline gap-1">
<x-heroicon-o-pencil class="w-3.5 h-3.5" />
Editar
</button>
@endif
</div>
@if($editingNotes)
<textarea wire:model="notes" rows="10"
class="textarea textarea-bordered w-full text-sm"
placeholder="Añade notas, historial de relación, condiciones contractuales, observaciones…"
autofocus></textarea>
<div class="flex justify-end gap-2 mt-3">
<button wire:click="$set('editingNotes', false)"
class="btn btn-outline btn-sm">Cancelar</button>
<button wire:click="saveNotes"
class="btn btn-primary btn-sm gap-1">
<x-heroicon-o-check class="w-4 h-4" />
Guardar
</button>
</div>
@else
@if($company->notes)
<div class="prose prose-sm max-w-none text-gray-700 whitespace-pre-line bg-base-200 rounded-lg p-4 min-h-[120px]">
{{ $company->notes }}
</div>
@else
<div class="text-center py-12 text-gray-400">
<x-heroicon-o-document-text class="w-10 h-10 mx-auto opacity-25 mb-2" />
<p class="text-sm">Sin notas.</p>
<button wire:click="$set('editingNotes', true)"
class="btn btn-sm btn-outline mt-3 gap-1">
<x-heroicon-o-plus class="w-4 h-4" />
Añadir nota
</button>
</div>
@endif
@endif
</div>
</div>
@endif
</div>
</div>
</div>
@@ -0,0 +1,403 @@
<div>
{{-- ================================================================
HEADER
================================================================ --}}
<div class="flex flex-wrap items-center justify-between gap-3 mb-5">
<div>
<h2 class="text-xl font-bold">Issues del proyecto</h2>
<p class="text-sm text-base-content/60">Gestión de incidencias y problemas</p>
</div>
<button
wire:click="openForm()"
class="btn btn-primary btn-sm gap-2"
>
<x-heroicon-o-plus class="w-4 h-4" />
Nuevo Issue
</button>
</div>
{{-- ================================================================
STATS BAR
================================================================ --}}
@php
$countOpen = $issues->where('status', 'open')->count();
$countInReview = $issues->where('status', 'in_review')->count();
$countResolved = $issues->where('status', 'resolved')->count();
$countClosed = $issues->where('status', 'closed')->count();
@endphp
<div class="flex flex-wrap gap-2 mb-5">
<div class="stat bg-base-200 rounded-box py-2 px-4 flex-1 min-w-[120px]">
<div class="stat-title text-xs">Abiertos</div>
<div class="stat-value text-error text-2xl">{{ $countOpen }}</div>
</div>
<div class="stat bg-base-200 rounded-box py-2 px-4 flex-1 min-w-[120px]">
<div class="stat-title text-xs">En revisión</div>
<div class="stat-value text-warning text-2xl">{{ $countInReview }}</div>
</div>
<div class="stat bg-base-200 rounded-box py-2 px-4 flex-1 min-w-[120px]">
<div class="stat-title text-xs">Resueltos</div>
<div class="stat-value text-success text-2xl">{{ $countResolved }}</div>
</div>
<div class="stat bg-base-200 rounded-box py-2 px-4 flex-1 min-w-[120px]">
<div class="stat-title text-xs">Cerrados</div>
<div class="stat-value text-base-content/50 text-2xl">{{ $countClosed }}</div>
</div>
<div class="stat bg-base-200 rounded-box py-2 px-4 flex-1 min-w-[120px]">
<div class="stat-title text-xs">Total</div>
<div class="stat-value text-2xl">{{ $issues->count() }}</div>
</div>
</div>
{{-- ================================================================
ISSUES TABLE
================================================================ --}}
@if($issues->isEmpty())
<div class="flex flex-col items-center justify-center py-16 text-base-content/40">
<x-heroicon-o-bug-ant class="w-16 h-16 mb-3" />
<p class="text-lg font-semibold">Sin issues registrados</p>
<p class="text-sm">Crea el primer issue con el botón "Nuevo Issue".</p>
</div>
@else
<div class="overflow-x-auto rounded-box border border-base-300">
<table class="table table-sm w-full">
<thead class="bg-base-200">
<tr>
<th class="w-28">Prioridad</th>
<th>Título</th>
<th class="hidden md:table-cell">Feature</th>
<th class="w-28">Estado</th>
<th class="hidden lg:table-cell w-36">Asignado a</th>
<th class="hidden lg:table-cell w-28">Fecha</th>
<th class="w-36 text-right">Acciones</th>
</tr>
</thead>
<tbody>
@foreach($issues as $issue)
<tr wire:key="issue-{{ $issue->id }}" class="hover">
{{-- Prioridad --}}
<td>
@php
$pClass = match($issue->priority) {
'critical' => 'badge-purple',
'high' => 'badge-error',
'medium' => 'badge-warning',
'low' => 'badge-ghost',
default => 'badge-ghost',
};
$pLabel = match($issue->priority) {
'critical' => 'Crítico',
'high' => 'Alto',
'medium' => 'Medio',
'low' => 'Bajo',
default => ucfirst($issue->priority),
};
@endphp
<span
class="badge badge-sm font-semibold
{{ $issue->priority === 'critical' ? 'text-white' : '' }}"
style="background-color: {{ $issue->priority_color }}; color: {{ $issue->priority === 'critical' || $issue->priority === 'high' ? '#fff' : '#1f2937' }}; border-color: transparent;"
>
{{ $pLabel }}
</span>
</td>
{{-- Título + descripción breve --}}
<td>
<div class="font-medium text-sm leading-tight">{{ $issue->title }}</div>
@if($issue->description)
<div class="text-xs text-base-content/50 truncate max-w-xs">{{ Str::limit($issue->description, 60) }}</div>
@endif
@if($issue->reporter)
<div class="text-xs text-base-content/40 mt-0.5">
Reportado por {{ $issue->reporter->name }}
</div>
@endif
</td>
{{-- Feature --}}
<td class="hidden md:table-cell">
@if($issue->feature)
<span class="badge badge-outline badge-sm">{{ $issue->feature->name }}</span>
@else
<span class="text-base-content/30 text-xs"></span>
@endif
</td>
{{-- Estado --}}
<td>
@php
$sLabel = match($issue->status) {
'open' => 'Abierto',
'in_review' => 'En revisión',
'resolved' => 'Resuelto',
'closed' => 'Cerrado',
default => ucfirst($issue->status),
};
@endphp
<span
class="badge badge-sm"
style="background-color: {{ $issue->status_color }}; color: #fff; border-color: transparent;"
>
{{ $sLabel }}
</span>
</td>
{{-- Asignado a --}}
<td class="hidden lg:table-cell">
@if($issue->assignee)
<div class="flex items-center gap-1.5">
<span class="w-6 h-6 rounded-full bg-primary text-primary-content flex items-center justify-center text-xs font-bold flex-shrink-0">
{{ strtoupper(substr($issue->assignee->name, 0, 1)) }}
</span>
<span class="text-sm truncate">{{ $issue->assignee->name }}</span>
</div>
@else
<span class="text-base-content/30 text-xs">Sin asignar</span>
@endif
</td>
{{-- Fecha --}}
<td class="hidden lg:table-cell text-xs text-base-content/50">
{{ $issue->created_at->format('d/m/Y') }}
@if($issue->resolved_at)
<div class="text-success">Res. {{ $issue->resolved_at->format('d/m/Y') }}</div>
@endif
</td>
{{-- Acciones --}}
<td class="text-right">
<div class="flex items-center justify-end gap-1 flex-wrap">
{{-- Editar --}}
<button
wire:click="openForm({{ $issue->id }})"
class="btn btn-xs btn-ghost tooltip"
data-tip="Editar"
>
<x-heroicon-o-pencil class="w-3.5 h-3.5" />
</button>
{{-- Resolver --}}
@if(in_array($issue->status, ['open', 'in_review']))
<button
wire:click="resolve({{ $issue->id }})"
wire:loading.attr="disabled"
wire:target="resolve({{ $issue->id }})"
class="btn btn-xs btn-success tooltip"
data-tip="Marcar como resuelto"
>
<x-heroicon-o-check class="w-3.5 h-3.5" />
</button>
@endif
{{-- Cerrar --}}
@if($issue->status !== 'closed')
<button
wire:click="close({{ $issue->id }})"
wire:loading.attr="disabled"
wire:target="close({{ $issue->id }})"
class="btn btn-xs btn-neutral tooltip"
data-tip="Cerrar issue"
>
<x-heroicon-o-x-mark class="w-3.5 h-3.5" />
</button>
@endif
{{-- Eliminar --}}
<button
wire:click="delete({{ $issue->id }})"
wire:loading.attr="disabled"
wire:target="delete({{ $issue->id }})"
wire:confirm="¿Eliminar este issue? Esta acción no se puede deshacer."
class="btn btn-xs btn-error btn-outline tooltip"
data-tip="Eliminar"
>
<x-heroicon-o-trash class="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
{{-- ================================================================
MODAL FORM (create / edit)
================================================================ --}}
@if($showForm)
{{-- Overlay --}}
<div
class="fixed inset-0 z-40 bg-black/50"
wire:click="closeForm()"
></div>
{{-- Modal panel --}}
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
>
<div class="bg-base-100 rounded-box shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
{{-- Modal header --}}
<div class="flex items-center justify-between p-5 border-b border-base-300">
<h3 class="text-lg font-bold">
{{ $editingIssue ? 'Editar Issue' : 'Nuevo Issue' }}
</h3>
<button wire:click="closeForm()" class="btn btn-sm btn-ghost btn-circle">
<x-heroicon-o-x-mark class="w-4 h-4" />
</button>
</div>
{{-- Modal body --}}
<form wire:submit.prevent="save" class="p-5 space-y-4">
{{-- Título --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Título <span class="text-error">*</span></span>
</label>
<input
type="text"
wire:model="title"
class="input input-bordered w-full @error('title') input-error @enderror"
placeholder="Describe brevemente el problema..."
autofocus
/>
@error('title')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
{{-- Descripción --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Descripción</span>
</label>
<textarea
wire:model="description"
class="textarea textarea-bordered w-full h-24 resize-y @error('description') textarea-error @enderror"
placeholder="Detalla el problema, contexto, pasos para reproducirlo..."
></textarea>
@error('description')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
{{-- Prioridad + Estado --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Prioridad <span class="text-error">*</span></span>
</label>
<select
wire:model="priority"
class="select select-bordered w-full @error('priority') select-error @enderror"
>
<option value="low">Baja</option>
<option value="medium">Media</option>
<option value="high">Alta</option>
<option value="critical">Crítica</option>
</select>
@error('priority')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Estado <span class="text-error">*</span></span>
</label>
<select
wire:model.live="status"
class="select select-bordered w-full @error('status') select-error @enderror"
>
<option value="open">Abierto</option>
<option value="in_review">En revisión</option>
<option value="resolved">Resuelto</option>
<option value="closed">Cerrado</option>
</select>
@error('status')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
{{-- Asignado a --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Asignado a</span>
</label>
<select
wire:model="assignedTo"
class="select select-bordered w-full @error('assignedTo') select-error @enderror"
>
<option value="">Sin asignar</option>
@foreach($projectUsers as $user)
<option value="{{ $user->id }}">{{ $user->name }} &ndash; {{ $user->email }}</option>
@endforeach
</select>
@error('assignedTo')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
{{-- Notas de resolución (visible when status = resolved or closed) --}}
@if(in_array($status, ['resolved', 'closed']))
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Notas de resolución</span>
<span class="label-text-alt text-base-content/50">Opcional</span>
</label>
<textarea
wire:model="resolutionNotes"
class="textarea textarea-bordered w-full h-20 resize-y @error('resolutionNotes') textarea-error @enderror"
placeholder="Describe cómo se resolvió el problema..."
></textarea>
@error('resolutionNotes')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
@endif
{{-- Modal footer --}}
<div class="flex items-center justify-end gap-3 pt-2 border-t border-base-300">
<button
type="button"
wire:click="closeForm()"
class="btn btn-ghost"
>
Cancelar
</button>
<button
type="submit"
wire:loading.attr="disabled"
wire:target="save"
class="btn btn-primary gap-2"
>
<span wire:loading.remove wire:target="save">
<x-heroicon-o-check class="w-4 h-4" />
</span>
<span wire:loading wire:target="save" class="loading loading-spinner loading-sm"></span>
{{ $editingIssue ? 'Actualizar Issue' : 'Crear Issue' }}
</button>
</div>
</form>
</div>
</div>
@endif
</div>
@@ -1,4 +1,5 @@
<div class="flex items-center gap-1">
<div class="flex items-center gap-1"
x-on:locale-changed.window="window.location.reload()">
@foreach(['en' => '🇬🇧 EN', 'es' => '🇪🇸 ES'] as $code => $label)
<button wire:click="switchLanguage('{{ $code }}')"
class="btn btn-xs {{ $currentLocale === $code ? 'btn-primary' : 'btn-ghost' }}"
@@ -1,57 +0,0 @@
<div>
@if(session()->has('message'))
<div class="alert alert-success mb-2">{{ session('message') }}</div>
@endif
@if(session()->has('error'))
<div class="alert alert-error mb-2">{{ session('error') }}</div>
@endif
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">{{ __("Upload Layer") }}</h2>
<form wire:submit.prevent="upload" class="space-y-4">
<div class="form-control">
<label class="label">{{ __("Project") }}</label>
<select wire:model.live="projectId" class="select select-bordered" required>
<option value="">{{ __("Select project") }}...</option>
@foreach($projects as $p)
<option value="{{ $p->id }}">{{ $p->name }}</option>
@endforeach
</select>
</div>
<div class="form-control">
<label class="label">{{ __("Phase") }}</label>
<select wire:model.live="phaseId" class="select select-bordered" required @if(!$projectId) disabled @endif>
<option value="">{{ __("Select phase") }}...</option>
@foreach($phases as $ph)
<option value="{{ $ph->id }}">{{ $ph->name }}</option>
@endforeach
</select>
</div>
<div class="form-control">
<label class="label">{{ __("Layer name") }}</label>
<input type="text" wire:model="layerName" class="input input-bordered" placeholder="Ej: Cimentación" required />
@error('layerName') <span class="text-error text-sm">{{ $message }}</span> @enderror
</div>
<div class="form-control">
<label class="label">{{ __("Color") }}</label>
<input type="color" wire:model="layer{{ __("Color") }}" class="input input-bordered w-20" />
</div>
<div class="form-control">
<label class="label">{{ __("File") }} (GeoJSON, KML, KMZ, Shapefile .zip, DWG)</label>
<input type="file" wire:model="uploadFile" class="file-input file-input-bordered" accept=".geojson,.kml,.kmz,.zip,.shp,.dwg" />
@error('uploadFile') <span class="text-error text-sm">{{ $message }}</span> @enderror
</div>
<button type="submit" class="btn btn-primary w-full">
{{ __("Upload Layer") }}
</button>
</form>
</div>
</div>
</div>
@@ -22,7 +22,7 @@
<input type="color" wire:model="layerColor" class="input input-bordered">
</div>
<div class="form-control">
<label class="label">Archivo (GeoJSON, KML, Shapefile.zip, DWG)</label></br>
<label class="label">{{ __('File') }} (GeoJSON, KML, Shapefile.zip, DWG)</label></br>
<input type="file" wire:model="uploadFile" class="file-input file-input-bordered">
@error('uploadFile') <span class="text-error">{{ $message }}</span> @enderror
</div>
@@ -49,13 +49,13 @@
</span>
</div>
<div>
<button wire:click="selectLayer({{ $layer->id }})" class="btn btn-xs btn-info">✏️ Editar</button>
<button wire:click="deleteLayer({{ $layer->id }})" class="btn btn-xs btn-error" onclick="return confirm('¿{{ __("Delete layer") }}?')">🗑️</button>
<button wire:click="selectLayer({{ $layer->id }})" class="btn btn-xs btn-info">✏️ {{ __('Edit') }}</button>
<button wire:click="deleteLayer({{ $layer->id }})" wire:confirm="{{ __('Delete layer') }}?" class="btn btn-xs btn-error">🗑️</button>
</div>
</div>
@endforeach
@if($layers->isEmpty())
<p class="text-center">{{ __("No results") }}. Crea una o importa.</p>
<p class="text-center">{{ __("No layers. Create or import one.") }}</p>
@endif
</div>
</div>
@@ -69,8 +69,8 @@
<h2 class="card-title">{{ __("Edit") }}</h2>
@if($selectedLayer)
<div class="mt-3 flex gap-2">
<button id="saveDrawingBtn" class="btn btn-primary">💾 Guardar cambios</button>
<button wire:click="cancelEditing" class="btn btn-outline">Cancelar edición</button>
<button id="saveDrawingBtn" class="btn btn-primary">💾 {{ __('Save changes') }}</button>
<button wire:click="cancelEditing" class="btn btn-outline">{{ __('Cancel') }}</button>
</div>
@endif
<div id="permanentMap" style="flex: 1; min-height: 500px; width: 100%; background: #e2e8f0;" wire:ignore></div>
@@ -158,10 +158,10 @@
onEachFeature: (f, l) => {
l.feature = f;
const props = f.properties;
const content = `<b>${props.name || 'Elemento'}</b><br>
Progreso: ${props.progress || 0}%<br>
Responsable: ${props.responsible || '-'}<br>
<em>Editable</em>`;
const content = `<b>${props.name || @js(__('Feature'))}</b><br>
@js(__('Progress')): ${props.progress || 0}%<br>
@js(__('Responsible')): ${props.responsible || '-'}<br>
<em>@js(__('Editable'))</em>`;
l.bindPopup(content);
}
});
@@ -43,6 +43,12 @@ new class extends Component
</x-nav-link>
</div>
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<x-nav-link :href="route('companies.manage')" :active="request()->routeIs('companies.manage')" wire:navigate>
{{ __('Companies') }}
</x-nav-link>
</div>
@can('manage all')
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<x-nav-link :href="route('admin.users')" :active="request()->routeIs('admin.users')" wire:navigate>
@@ -57,6 +63,11 @@ new class extends Component
@livewire('language-switcher')
</div>
<!-- Notification Bell -->
<div class="hidden sm:flex sm:items-center sm:ms-2">
@livewire('notification-bell')
</div>
<!-- Settings Dropdown -->
<div class="hidden sm:flex sm:items-center sm:ms-2">
<x-dropdown align="right" width="48">

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