Compare commits

36 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
108 changed files with 9821 additions and 2364 deletions
+1
View File
@@ -22,3 +22,4 @@
Homestead.json
Homestead.yaml
Thumbs.db
.claude/worktrees/
@@ -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);
}
}
+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');
}
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\Attributes\Layout;
use App\Models\Company;
use Illuminate\Support\Facades\Storage;
#[Layout('layouts.app')]
class CompanyForm extends Component
{
use WithFileUploads;
public ?Company $company = null;
// Form fields
public string $name = '';
public string $apodo = '';
public string $tax_id = '';
public string $estado = 'activo';
public string $type = 'other';
public string $address = '';
public string $phone = '';
public string $email = '';
public string $website = '';
public string $notes = '';
public $logo = null;
public function mount(?Company $company = null): void
{
if ($company && $company->exists) {
$this->company = $company;
$this->name = $company->name;
$this->apodo = $company->apodo ?? '';
$this->tax_id = $company->tax_id ?? '';
$this->estado = $company->estado ?? 'activo';
$this->type = $company->type ?? 'other';
$this->address = $company->address ?? '';
$this->phone = $company->phone ?? '';
$this->email = $company->email ?? '';
$this->website = $company->website ?? '';
$this->notes = $company->notes ?? '';
}
}
protected function rules(): array
{
$id = $this->company?->id ?? 'NULL';
return [
'name' => 'required|string|max:255',
'apodo' => 'nullable|string|max:100',
'tax_id' => "nullable|string|max:50|unique:companies,tax_id,{$id}",
'estado' => 'required|in:activo,inactivo,suspendido',
'type' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other',
'address' => 'nullable|string',
'phone' => 'nullable|string|max:30',
'email' => 'nullable|email|max:255',
'website' => 'nullable|url|max:255',
'notes' => 'nullable|string',
'logo' => 'nullable|image|max:2048',
];
}
public function save(): void
{
$this->validate();
$data = [
'name' => $this->name,
'apodo' => $this->apodo ?: null,
'tax_id' => $this->tax_id ?: null,
'estado' => $this->estado,
'type' => $this->type,
'address' => $this->address ?: null,
'phone' => $this->phone ?: null,
'email' => $this->email ?: null,
'website' => $this->website ?: null,
'notes' => $this->notes ?: null,
];
if ($this->logo) {
// Delete old logo when replacing
if ($this->company?->logo_path) {
Storage::disk('public')->delete($this->company->logo_path);
}
$data['logo_path'] = $this->logo->store('company-logos', 'public');
}
if ($this->company && $this->company->exists) {
$this->company->update($data);
session()->flash('notify', 'Empresa actualizada correctamente.');
} else {
Company::create($data);
session()->flash('notify', 'Empresa creada correctamente.');
}
$this->redirect(route('companies.manage'), navigate: true);
}
public function render()
{
return view('livewire.company-form');
}
}
+41 -210
View File
@@ -3,234 +3,65 @@
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\Attributes\Layout;
use App\Models\Company;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Response;
#[Layout('layouts.app')]
class CompanyManagement extends Component
{
use WithFileUploads;
// Form state
public $name = '';
public $tax_id = '';
public $address = '';
public $email = '';
public $website = '';
public $type = 'other';
public $notes = '';
public $apodo = '';
public $estado = 'activo';
public $logo = null;
// UI state
public $showCreateForm = false;
public $showEditForm = false;
public $editingCompanyId = null;
public $search = '';
// Filter state
public $filterType = '';
public $filterEstado = '';
// Validation rules
protected $rules = [
'name' => 'required|string|max:255',
'apodo' => 'nullable|string|max:100',
'tax_id' => 'nullable|string|max:50|unique:companies,tax_id,',
'estado' => 'required|in:activo,inactivo,suspendido',
'address' => 'nullable|string',
'phone' => 'nullable|string|max:20',
'email' => 'nullable|email|max:255',
'website' => 'nullable|url|max:255',
'type' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other',
'notes' => 'nullable|string',
'logo' => 'nullable|image|max:2048', // 2MB max
];
public function mount()
{
$this->resetForm();
}
public function resetForm()
{
$this->name = '';
$this->tax_id = '';
$this->address = '';
$this->phone = '';
$this->email = '';
$this->website = '';
$this->type = 'other';
$this->notes = '';
$this->apodo = '';
$this->estado = 'activo';
$this->logo = null;
$this->editingCompanyId = null;
$this->showCreateForm = false;
$this->showEditForm = false;
$this->resetErrorBag();
$this->resetValidation();
}
public function resetFilters()
{
$this->search = '';
$this->filterType = '';
$this->filterEstado = '';
}
public function toggleCreateForm()
{
$this->showCreateForm = !$this->showCreateForm;
if ($this->showCreateForm) {
$this->showEditForm = false;
$this->resetForm();
}
}
public function editCompany(Company $company)
{
$this->editingCompanyId = $company->id;
$this->name = $company->name;
$this->tax_id = $company->tax_id;
$this->address = $company->address;
$this->phone = $company->phone;
$this->email = $company->email;
$this->website = $company->website;
$this->type = $company->type;
$this->notes = $company->notes;
$this->apodo = $company->apodo;
$this->estado = $company->estado;
// Note: logo is not populated for security reasons
$this->showEditForm = true;
$this->showCreateForm = false;
}
public function updateCompany()
{
$this->validate();
$company = Company::findOrFail($this->editingCompanyId);
$data = [
'name' => $this->name,
'tax_id' => $this->tax_id,
'address' => $this->address,
'phone' => $this->phone,
'email' => $this->email,
'website' => $this->website,
'type' => $this->type,
'notes' => $this->notes,
];
if ($this->logo) {
$logoPath = $this->logo->store('company-logos', 'public');
$data['logo_path'] = $logoPath;
}
$company->update($data);
session()->flash('message', 'Empresa actualizada correctamente.');
$this->resetForm();
}
public function createCompany()
{
$this->validate();
$data = [
'name' => $this->name,
'tax_id' => $this->tax_id,
'address' => $this->address,
'phone' => $this->phone,
'email' => $this->email,
'website' => $this->website,
'type' => $this->type,
'notes' => $this->notes,
];
if ($this->logo) {
$logoPath = $this->logo->store('company-logos', 'public');
$data['logo_path'] = $logoPath;
}
Company::create($data);
session()->flash('message', 'Empresa creada correctamente.');
$this->resetForm();
}
public function deleteCompany(Company $company)
{
$company->delete(); // Soft delete
session()->flash('message', 'Empresa eliminada correctamente.');
}
public string $search = '';
public string $filterType = '';
public string $filterEstado = '';
public function getCompaniesProperty()
{
return Company::when($this->search, function ($query) {
$query->where('name', 'like', '%' . $this->search . '%')
->orWhere('apodo', 'like', '%' . $this->search . '%')
->orWhere('tax_id', 'like', '%' . $this->search . '%');
})
->when($this->filterType, function ($query) {
$query->where('type', $this->filterType);
})
->when($this->filterEstado, function ($query) {
$query->where('estado', $this->filterEstado);
})
->withCount('projects') // Eager load project count
->orderBy('name')
->get();
return Company::when($this->search, function ($q) {
$s = '%' . $this->search . '%';
$q->where(fn($q2) => $q2
->where('name', 'like', $s)
->orWhere('apodo', 'like', $s)
->orWhere('tax_id', 'like', $s));
})
->when($this->filterType, fn($q) => $q->where('type', $this->filterType))
->when($this->filterEstado, fn($q) => $q->where('estado', $this->filterEstado))
->withCount('projects')
->orderBy('name')
->get();
}
public function deleteCompany(Company $company): void
{
if ($company->logo_path) {
Storage::disk('public')->delete($company->logo_path);
}
$company->delete();
$this->dispatch('notify', 'Empresa eliminada.');
}
public function exportCsv()
{
$companies = $this->getCompaniesProperty();
// Create CSV content
$headers = [
"Content-type: text/csv",
"Content-Disposition: attachment; filename=empresas.csv",
"Pragma: no-cache",
"Cache-Control: must-revalidate, post-check=0, pre-check=0",
"Expires: 0"
];
$callback = function() use ($companies) {
return response()->streamDownload(function () use ($companies) {
$handle = fopen('php://output', 'w');
// Add BOM for UTF-8 in Excel
fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF));
// Header row
fputcsv($handle, ['Nombre', 'Apodo', 'NIF/Tax ID', 'Tipo', 'Estado', 'Dirección', 'Teléfono', 'Email', 'Website', 'Proyectos Asociados', 'Fecha Creación']);
foreach ($companies as $company) {
fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($handle, ['Nombre', 'Apodo', 'NIF/Tax ID', 'Tipo', 'Estado', 'Dirección', 'Teléfono', 'Email', 'Website', 'Proyectos', 'Creación']);
foreach ($companies as $c) {
fputcsv($handle, [
$company->name,
$company->apodo ?? '',
$company->tax_id ?? '',
$company->type,
$company->estado,
$company->address ?? '',
$company->phone ?? '',
$company->email ?? '',
$company->website ?? '',
$company->projects_count ?? 0,
$company->created_at ? $company->created_at->format('d/m/Y H:i') : ''
$c->name, $c->apodo ?? '', $c->tax_id ?? '',
$c->type, $c->estado, $c->address ?? '',
$c->phone ?? '', $c->email ?? '', $c->website ?? '',
$c->projects_count ?? 0,
$c->created_at?->format('d/m/Y'),
]);
}
fclose($handle);
};
return response()->stream($callback, 200, $headers);
}, 'empresas.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
public function render()
{
return view('livewire.company-management');
}
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
namespace App\Livewire;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use App\Models\Company;
class CompanyTable extends DataTableComponent
{
protected $model = Company::class;
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects([
'companies.id as id',
'companies.apodo as apodo',
'companies.tax_id as tax_id',
'companies.phone as phone',
'companies.email as email',
'companies.logo_path as logo_path',
'companies.created_at as created_at',
]);
}
public function builder(): Builder
{
return Company::withCount('projects');
}
public function columns(): array
{
return [
Column::make('Empresa', 'name')
->sortable()
->searchable()
->format(function ($value, $row) {
$logoHtml = '';
if ($row->logo_path && Storage::disk('public')->exists($row->logo_path)) {
$url = Storage::disk('public')->url($row->logo_path);
$logoHtml = '<img src="'.e($url).'" class="w-9 h-9 rounded object-contain border border-base-300 shrink-0" />';
} else {
$logoHtml = '<div class="w-9 h-9 rounded bg-base-200 flex items-center justify-center shrink-0 text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-2 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
</div>';
}
$html = '<div class="flex items-center gap-3">'.$logoHtml.'<div>';
$html .= '<p class="font-semibold text-sm leading-tight">'.e($value).'</p>';
if ($row->apodo) $html .= '<p class="text-xs text-gray-500">'.e($row->apodo).'</p>';
if ($row->tax_id) $html .= '<p class="text-xs text-gray-400">NIF: '.e($row->tax_id).'</p>';
$html .= '</div></div>';
return $html;
})
->html(),
Column::make('Tipo', 'type')
->sortable()
->format(function ($value) {
$map = [
'owner' => ['badge-success', 'Promotor'],
'constructor' => ['badge-primary', 'Constructor'],
'subcontractor' => ['badge-secondary', 'Subcontratista'],
'consultant' => ['badge-info', 'Consultor'],
'supplier' => ['badge-warning', 'Proveedor'],
];
[$cls, $label] = $map[$value] ?? ['badge-ghost', 'Otro'];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make('Contacto', 'phone')
->format(function ($value, $row) {
$html = '';
if ($row->phone) {
$html .= '<div class="flex items-center gap-1 text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 opacity-50 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg>
'.e($row->phone).'</div>';
}
if ($row->email) {
$html .= '<div class="flex items-center gap-1 text-xs text-gray-500 max-w-[180px] truncate">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 opacity-50 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
'.e($row->email).'</div>';
}
return $html ?: '<span class="text-gray-300">—</span>';
})
->html(),
Column::make('Estado', 'estado')
->sortable()
->format(function ($value) {
$map = [
'activo' => ['badge-success', 'Activo'],
'inactivo' => ['badge-ghost', 'Inactivo'],
'suspendido' => ['badge-error', 'Suspendido'],
];
[$cls, $label] = $map[$value ?? 'activo'] ?? ['badge-ghost', ucfirst($value ?? 'activo')];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make('Proyectos')
->label(fn ($row) =>
'<span class="badge badge-outline badge-sm">'.(int)($row->projects_count ?? 0).'</span>'
)
->html(),
Column::make('Acciones')
->label(function ($row) {
$ver = route('companies.show', $row->id);
$editar = route('companies.edit', $row->id);
$name = addslashes($row->name);
$html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>';
$html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>';
$html .= '<button wire:click="deleteCompany('.$row->id.')"
wire:confirm="¿Eliminar \''.$name.'\'? Esta acción no se puede deshacer."
class="btn btn-xs btn-outline btn-error" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>';
$html .= '</div>';
return $html;
})
->html(),
];
}
public function filters(): array
{
return [
SelectFilter::make('Tipo', 'type')
->options([
'' => 'Tipo: todos',
'owner' => 'Promotor',
'constructor' => 'Constructor',
'subcontractor' => 'Subcontratista',
'consultant' => 'Consultor',
'supplier' => 'Proveedor',
'other' => 'Otro',
])
->filter(fn (Builder $query, string $value) => $query->where('type', $value)),
SelectFilter::make('Estado', 'estado')
->options([
'' => 'Estado: todos',
'activo' => 'Activo',
'inactivo' => 'Inactivo',
'suspendido' => 'Suspendido',
])
->filter(fn (Builder $query, string $value) => $query->where('estado', $value)),
];
}
public function deleteCompany(int $id): void
{
$company = Company::findOrFail($id);
if ($company->logo_path) {
Storage::disk('public')->delete($company->logo_path);
}
$company->delete();
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Company;
use App\Models\Project;
use App\Models\User;
use App\Models\Issue;
use Illuminate\Support\Facades\Auth;
#[Layout('layouts.app')]
class CompanyView extends Component
{
public Company $company;
public string $activeTab = 'summary';
// Projects tab
public ?int $addProjectId = null;
public string $addProjectRole = '';
public $availableProjects;
// People tab
public ?int $assignUserId = null;
public $assignableUsers;
// Notes tab
public string $notes = '';
public bool $editingNotes = false;
// Stats (computed once in mount, refreshed on mutations)
public int $usersCount = 0;
public int $projectsCount = 0;
public float $avgProgress = 0.0;
public int $openIssues = 0;
public function mount(Company $company): void
{
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,
]);
}
}
}
+77 -62
View File
@@ -4,9 +4,8 @@ namespace App\Livewire;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Columns\{BooleanColumn, ButtonGroupColumn, LinkColumn, ImageColumn};
use Rappasoft\LaravelLivewireTables\Views\Filters\{DateFilter, MultiSelectFilter, SelectFilter};
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use App\Models\Project;
class ProjectTable extends DataTableComponent
@@ -17,86 +16,102 @@ class ProjectTable extends DataTableComponent
{
$this->setPrimaryKey('id')
->setDefaultSort('created_at', 'desc')
->setTableAttributes(['class' => 'table-auto w-full']);
->setSortingPillsEnabled(false)
->setAdditionalSelects(['projects.id as id', 'projects.created_at as created_at']);
}
$this->setThAttributes(function(Column $column) {
return ['class' => 'px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'];
});
$this->setTdAttributes(function(Column $column) {
return ['class' => 'px-4 py-2 whitespace-nowrap text-sm text-gray-900'];
});
public function builder(): Builder
{
return Project::accessibleBy(Auth::user())
->with('phases');
}
public function columns(): array
{
return [
Column::make(__('ID'), 'id')
Column::make('Referencia', 'reference')
->sortable()
->searchable()
->format(function ($value, $row) {
$url = route('projects.dashboard', $row->id);
return $value
? '<a href="'.$url.'" class="font-mono text-xs text-primary hover:underline" wire:navigate>'.e($value).'</a>'
: '<span class="text-gray-300">—</span>';
})
->html(),
Column::make(__('Name'), 'name')
->sortable()
->searchable(),
Column::make(__('Project Name'), 'name')
->sortable()
->searchable(),
Column::make(__('Address'), 'address')
->sortable()
->searchable(),
->searchable()
->format(fn ($value) => $value
? '<span class="truncate block max-w-xs" title="'.e($value).'">'.e($value).'</span>'
: '<span class="text-gray-400">—</span>')
->html(),
Column::make(__('Status'), 'status')
->sortable(),
->sortable()
->format(function ($value) {
$map = [
'planning' => ['badge-ghost', 'Planificación'],
'in_progress' => ['badge-primary', 'En progreso'],
'paused' => ['badge-warning', 'Pausado'],
'completed' => ['badge-success', 'Completado'],
];
[$cls, $label] = $map[$value] ?? ['badge-ghost', ucfirst($value)];
return '<span class="badge '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make(__('Progress'))
->label(function ($row) {
$avg = $row->phases->avg('progress_percent') ?? 0;
$pct = round($avg);
return '
<div class="flex items-center gap-2 min-w-[100px]">
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div class="bg-primary h-2 rounded-full" style="width:'.$pct.'%"></div>
</div>
<span class="text-xs text-gray-500 w-8 text-right">'.$pct.'%</span>
</div>';
})
->html(),
Column::make(__('Start Date'), 'start_date')
->sortable()
->format(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : ''),
->format(fn ($value) => $value ? $value->format('d/m/Y') : ''),
Column::make(__('Estimated End Date'), 'end_date_estimated')
Column::make(__('Est. End'), 'end_date_estimated')
->sortable()
->format(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : ''),
->format(fn ($value) => $value ? $value->format('d/m/Y') : ''),
Column::make(__('Actions'))
->label(function ($row) {
$confirm = __('Are you sure you want to delete this project?');
return '
<div class="flex space-x-2">
<a href="'.route('projects.edit', $row->id).'" class="btn btn-sm">'.__('Edit').'</a>
<form action="'.route('projects.destroy', $row->id).'" method="POST" onsubmit="return confirm(\''.$confirm.'\');">
'.csrf_field().'
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="btn btn-sm">'.__('Delete').'</button>
</form>
</div>';
})
->html(),
->label(function ($row) {
$dashboard = route('projects.dashboard', $row->id);
$map = route('projects.map', $row->id);
$edit = route('projects.edit', $row->id);
ButtonGroupColumn::make(__('Actions'))
->attributes(function($row) {
return [
'class' => 'space-x-2',
];
})
->buttons([
LinkColumn::make('Edit')
->title(fn($row) => __('Edit'))
->location(fn($row) => route('projects.edit', $row->id))
->attributes(function($row) {
return [
'target' => '_blank',
'class' => 'text-blue-500 hover:underline',
];
}),
$canEdit = Auth::user()->can('edit projects');
LinkColumn::make('View') // make() has no effect in this case but needs to be set anyway
->title(fn($row) => __('View'))
->location(fn($row) => route('projects.map', $row->id))
->attributes(function($row) {
return [
'class' => 'text-blue-500 hover:underline',
];
}),
]),
$html = '<div class="flex items-center gap-1">';
$html .= '<a href="'.$dashboard.'" class="btn btn-xs btn-outline" title="Dashboard" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
</a>';
$html .= '<a href="'.$map.'" class="btn btn-xs btn-outline" title="Mapa" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
</a>';
if ($canEdit) {
$html .= '<a href="'.$edit.'" class="btn btn-xs btn-warning" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>';
}
$html .= '</div>';
return $html;
})
->html(),
];
}
@@ -104,4 +119,4 @@ class ProjectTable extends DataTableComponent
{
return [];
}
}
}
@@ -3,11 +3,13 @@
namespace App\Livewire\Reports;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Inspection;
use Carbon\Carbon;
#[Layout('layouts.app')]
class ReportsDashboard extends Component
{
public $dateRange = 'month'; // week, month, quarter, year
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Illuminate\Support\Facades\Auth;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
#[Layout('layouts.app')]
class RoleForm extends Component
{
public ?Role $role = null;
public string $name = '';
public string $description = '';
private const PROTECTED_ROLES = ['Admin'];
private const CORE_PERMISSION = 'manage all';
public function mount(?Role $role = null): void
{
abort_unless(Auth::user()?->can(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);
}
}
+6
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -26,6 +27,11 @@ class Company extends Model
protected $dates = ['deleted_at'];
// Relationships
public function users()
{
return $this->hasMany(User::class);
}
public function projects()
{
return $this->belongsToMany(Project::class, 'company_project')
+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');
}
}
}
+4 -3
View File
@@ -4,14 +4,15 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Project extends Model
{
use HasFactory;
use HasFactory;
use HasFactory, SoftDeletes;
protected $fillable = [
'name', 'address', 'lat', 'lng', 'start_date', 'end_date_estimated', 'status', 'created_by'
'name', 'reference', 'address', 'country', 'lat', 'lng',
'start_date', 'end_date_estimated', 'status', 'created_by',
];
protected $casts = [
+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 {
//
@@ -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',
];
+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>
+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>
+4 -4
View File
@@ -74,8 +74,8 @@
<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">Mis Proyectos</a>
<a href="{{ url('/client/profile') }}" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-50">Perfil</a>
<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">
@@ -93,8 +93,8 @@
<!-- 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">Mis Proyectos</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">Perfil</a>
<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>
+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>
@@ -2,16 +2,16 @@
@if(!$selectedProject)
<!-- Project Selection -->
<div class="mb-6">
<h2 class="text-xl font-bold mb-4">Seleccione un proyecto para ver detalles</h2>
<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"
<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'] ?? 'Sin descripción disponible' }}
{{ $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">
@@ -21,7 +21,7 @@
@php
$progress = collect($project['phases'])->avg('progress_percent') ?? 0;
@endphp
{{ number_format($progress, 1) }}% completado
{{ number_format($progress, 1) }}% {{ __('completed') }}
</span>
</div>
</div>
@@ -34,84 +34,75 @@
<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"
<button wire:click="selectedProject = null"
class="px-3 py-1 bg-gray-200 text-gray-700 text-sm rounded hover:bg-gray-300">
Volver a proyectos
{{ __('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">Estado</h3>
<h3 class="text-sm font-medium text-gray-500 mb-2">{{ __('Status') }}</h3>
<p class="text-2xl font-bold text-gray-900">
@php
$statuses = [
'planning' => 'Planificación',
'in_progress' => 'En progreso',
'on_hold' => 'En espera',
'completed' => 'Completado',
'cancelled' => 'Cancelado'
];
echo $statuses[$projectDetails['status']] ?? ucfirst($projectDetails['status']);
@endphp
{{ __(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">Fecha de inicio</h3>
<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'] ?? 'No definida' }}
{{ $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">Fecha estimada</h3>
<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'] ?? 'No definida' }}
{{ $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">Descripción</h3>
<h3 class="text-sm font-medium text-gray-500 mb-2">{{ __('Description') }}</h3>
<p class="text-gray-700">
{{ $projectDetails['description'] ?? 'No hay descripción disponible' }}
{{ $projectDetails['description'] ?? __('No description available') }}
</p>
</div>
</div>
<!-- Progress Overview -->
<div class="mb-6">
<h2 class="text-xl font-bold mb-4">Resumen de Progreso</h2>
<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">Progreso General</h3>
<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"
<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 }}% completado
{{ $projectDetails['progress'] ?? 0 }}% {{ __('completed') }}
</div>
</div>
</div>
<!-- Phases Progress -->
<div class="mb-6">
<h2 class="text-xl font-bold mb-4">Progreso por Fase</h2>
<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)
@@ -119,29 +110,29 @@
<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">
Fase {{ $phase->id }}
{{ __('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"
<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 }}% completado
{{ $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">Características:</h4>
<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 ?? 'Pendiente' }}</span>
{{ $feature->name }}:
<span class="font-medium">{{ $feature->completion_status ?? __('Pending') }}</span>
</span>
</div>
@endforeach
@@ -153,20 +144,20 @@
</div>
@else
<div class="bg-gray-50 p-6 text-center rounded-lg">
<p class="text-gray-500">No hay fases definidas para este proyecto</p>
<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">Galería de Progreso</h2>
<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'] }}"
<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>
@@ -176,18 +167,18 @@
@endforeach
</div>
</div>
<!-- Change Orders -->
<div class="mb-6">
<h2 class="text-xl font-bold mb-4">Órdenes de Cambio</h2>
<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
<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
@@ -195,28 +186,28 @@
{{ 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">Solicitado:</span> {{ $order['requested_at'] }}
<span class="font-medium">{{ __('Requested') }}:</span> {{ $order['requested_at'] }}
</span>
<span class="mr-4">
<span class="font-medium">Monto:</span> ${{ number_format($order['amount'], 2) }}
<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">
Aprobar
{{ __('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">
Rechazar
{{ __('Reject') }}
</button>
</div>
</div>
@@ -226,7 +217,7 @@
</div>
@else
<div class="bg-gray-50 p-6 text-center rounded-lg">
<p class="text-gray-500">No hay órdenes de cambio pendientes</p>
<p class="text-gray-500">{{ __('No pending change orders') }}</p>
</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>
@@ -1,327 +1,28 @@
<div>
<div class="mb-6">
<h2 class="text-2xl font-bold text-gray-800 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-blue-500 mr-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h10m-9-3h8m-7 0h7M8 13v2a2 2 0 002 2h5a2 2 0 002-2v-2m0 0V9a2 2 0 00-2-2H5a2 2 0 00-2 2v2Z" />
</svg>
Gestión de Empresas
</h2>
<p class="text-gray-600 mt-2">Gestione las empresas que participan en los proyectos</p>
<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="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg text-green-800">
<div class="alert alert-success mb-4">
<x-heroicon-o-check-circle class="w-5 h-5" />
{{ session('message') }}
</div>
@endif
<div class="space-y-6">
<!-- Búsqueda y Botón de Nueva Empresa -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between">
<div class="w-full md:w-1/2">
<input type="text"
wire:model.live="search"
placeholder="Buscar empresas por nombre o NIF..."
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
<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>
<div class="w-full md:w-1/3 mt-4 md:mt-0">
<button wire:click="toggleCreateForm"
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg flex items-center justify-center transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Nueva Empresa
</button>
</div>
</div>
<!-- Formulario de Creación/Edición -->
<div wire:ignore.self x-cloak>
<div x-show="@entangle('showCreateForm')" x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="bg-white rounded-lg shadow-md p-6">
<div class="mb-4">
<h3 class="text-xl font-semibold text-gray-800 flex items-center">
{{ $editingCompanyId ? 'Editar Empresa' : 'Crear Nueva Empresa' }}
</h3>
<p class="text-gray-600 mt-1">
Complete la información de la empresa. Los campos marcados con * son obligatorios.
</p>
</div>
@if($errors->any())
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<strong>Errores de validación:</strong>
<ul class="list-disc pl-5 mt-2 text-sm text-red-600">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form wire:submit.prevent="{{$editingCompanyId ? 'updateCompany' : 'createCompany'}}"
enctype="multipart/form-data"
class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
<input type="text"
wire:model="name"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">NIF/NIE/CIF *</label>
<input type="text"
wire:model="tax_id"
placeholder="Ej: B12345678"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Apodo</label>
<input type="text"
wire:model="apodo"
placeholder="Ej: Acme Construct"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Estado *</label>
<select wire:model="estado"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
<option value="">Seleccione un estado</option>
<option value="activo">Activo</option>
<option value="inactivo">Inactivo</option>
<option value="suspendido">Suspendido</option>
</select>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Dirección</label>
<textarea wire:model="address"
rows="3"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Tipo de Empresa *</label>
<select wire:model="type"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
<option value="">Seleccione un tipo</option>
<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="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Teléfono</label>
<input type="tel"
wire:model="phone"
placeholder="+34 600 123 456"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email"
wire:model="email"
placeholder="contacto@empresa.com"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Sitio Web</label>
<input type="url"
wire:model="website"
placeholder="https://www.empresa.com"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Logo de la Empresa</label>
<div class="flex flex-col">
<label class="cursor-pointer text-blue-600 hover:text-blue-800">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16v-2a2 2 0 012-2h2a2 2 0 012 2v2m-4 0h.01M12 12a2 2 0 100-4 2 2 0 000 4zm4.5-6.75a.75.75 0 100-1.5.75.75 0 000 1.5zm0 0h.01M7 10h.01M14 10h.01M10.363 5.636a.75.75 0 10-1.06-1.06l-.47 1.242A12.038 12.038 0 0112 9.042c1.373 0 2.702.28 3.901.784l1.242-.47a.75.75 0 10-1.06-1.06l-.469-1.241a9.038 9.038 0 00-2.342-.348z" />
</svg>
Seleccionar archivo...
</label>
<input type="file"
wire:model="logo"
accept="image/*"
class="mt-2 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
@if($logo)
<div class="mt-3 flex items-center">
<img src="{{ $logo->temporaryUrl() }}"
alt="Vista previa del logo"
class="h-12 w-12 object-contain border border-gray-200 rounded-lg">
<button type="button"
wire:click="logo = null"
class="ml-3 text-xs text-red-600 hover:text-red-800">
Eliminar
</button>
</div>
@endif
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Notas Adicionales</label>
<textarea wire:model="notes"
rows="4"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"></textarea>
</div>
<div class="flex items-center justify-end pt-4 space-x-3">
<button type="button"
wire:click="resetForm"
class="px-5 py-3 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors">
Cancelar
</button>
<button type="submit"
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-lg transition-colors">
{{$editingCompanyId ? 'Actualizar Empresa' : 'Crear Empresa'}}
</button>
</div>
</form>
</div>
</div>
<!-- Lista de Empresas -->
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-800 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2z" />
</svg>
Lista de Empresas ({{ $companies->count() }})
</h3>
</div>
@if($companies->isEmpty())
<div class="px-6 py-8 text-center text-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-gray-300 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2z" />
</svg>
<p class="mt-2">No hay empresas registradas. Cree su primera empresa usando el botón de arriba.</p>
</div>
@else
<div class="divide-y divide-gray-200">
@foreach($companies as $company)
<div class="px-6 py-4 flex flex-col md:flex-row md:items-start md:justify-between">
<div class="flex-1 md:w-1/2">
<div class="flex items-start space-x-3">
@if($company->logo_path && Storage::disk('public')->exists($company->logo_path))
<img src="{{ Storage::disk('public')->url($company->logo_path) }}"
alt="Logo de {{ $company->name }}"
class="h-12 w-12 object-contain border border-gray-200 rounded-lg flex-shrink-0">
@else
<div class="h-12 w-12 flex items-center justify-center bg-gray-100 rounded-lg text-gray-400 flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 2.25c-1.236 0-2.241.404-3.038 1.08a9.027 9.027 0 00-2.481 7.35c.178.404.317.845.418 1.306a4.42 4.42 0 001.266 2.05c.703.073 1.415.112 2.125.112a4.417 4.417 0 002.125-.112c.703 0 1.415-.039 2.125-.112a4.42 4.42 0 001.266-2.05a4.415 4.415 0 00.418-1.306c.797-.676 1.797-1.076 2.481-1.076A9.027 9.027 0 0018.978 9.68a11.025 11.025 0 01-4.597-.45z" />
</svg>
</div>
@endif
<div>
<h4 class="font-semibold text-gray-900">{{ $company->name }}</h4>
<p class="text-sm text-gray-600 truncate">
@if($company->tax_id)
{{ $company->tax_id }}
@else
Sin NIF/CIF
@endif
</p>
@if($company->type)
<span class="inline-block mt-1 px-2 py-0.5 text-xs font-medium
@if($company->type === 'owner') bg-green-100 text-green-800
@elseif($company->type === 'constructor') bg-blue-100 text-blue-800
@elseif($company->type === 'subcontractor') bg-purple-100 text-purple-800
@elseif($company->type === 'consultant') bg-indigo-100 text-indigo-800
@elseif($company->type === 'supplier') bg-yellow-100 text-yellow-800
@else bg-gray-100 text-gray-800
endif
rounded">
{{ ucfirst($company->type) }}
</span>
@endif
</div>
</div>
</div>
<div class="mt-4 md:mt-0 md:w-1/2 text-right space-y-2">
<div class="text-sm text-gray-500 space-y-1">
@if($company->address)
<div class="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mt-0.5 text-gray-400 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.5 1.5 0 01-2.121-1.06L7 12.764l-.646.647a1 1 0 01-1.415-1.415l1.22-1.22a1.5 1.5 0 012.121-.39l3.707 3.707a1.5 1.5 0 011.06 2.12z" />
</svg>
<span>{{ $company->address }}</span>
</div>
@endif
@if($company->phone)
<div class="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mt-0.5 text-gray-400 mr-2" 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.8.52l1.68-1.4a1 1 0 01.82-.52h4a2 2 0 012 2v5.5a2 2 0 01-2 2H5a2 2 0 01-2-2V5z" />
</svg>
<span>{{ $company->phone }}</span>
</div>
@endif
@if($company->email)
<div class="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mt-0.5 text-gray-400 mr-2" 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 2v12z" />
</svg>
<span>{{ $company->email }}</span>
</div>
@endif
</div>
<div class="flex justify-end space-x-2">
<button wire:click="editCompany({{ $company->id }})"
class="text-sm text-blue-600 hover:text-blue-800 font-medium flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
Editar
</button>
<button wire:click="deleteCompany({{ $company->id }})"
class="text-sm text-red-600 hover:text-red-800 font-medium flex items-center"
onclick="return confirm('¿Está seguro de que desea eliminar esta empresa? Esta acción no se puede deshacer.')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" 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-10h-3a2 2 0 00-2 2v2a2 2 0 002 2h3zm-3-4h1a2 2 0 012 2v2a2 2 0 01-2 2h-1V9a2 2 0 012-2z" />
</svg>
Eliminar
</button>
</div>
</div>
</div>
@if(!$loop->last)
<div class="border-t border-gray-200"></div>
@endforeach
</div>
@endif
<livewire:company-table />
</div>
</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">
@@ -29,7 +29,7 @@
</div>
<div class="form-control">
<label class="label-text">{{ __("Description") }}</label>
<input type="text" wire:model="uploadDescription" class="input input-bordered input-sm" placeholder="Opcional" />
<input type="text" wire:model="uploadDescription" class="input input-bordered input-sm" placeholder="{{ __('Optional') }}" />
</div>
</div>
@@ -53,7 +53,7 @@
</div>
<button wire:click.stop="deleteMedia({{ $media->id }})"
class="absolute top-0 right-0 bg-error text-white text-xs w-4 h-4 rounded-full opacity-0 group-hover:opacity-100 transition-opacity leading-none"
onclick="return confirm('¿Borrar {{ $media->name }}?')">×</button>
wire:confirm="{{ __('Delete file confirmation') }}">×</button>
</div>
@endforeach
</div>
@@ -83,7 +83,7 @@
<span class="text-xs text-gray-400">{{ $media->formatted_size }}</span>
<button wire:click="deleteMedia({{ $media->id }})"
class="btn btn-xs btn-ghost text-error"
onclick="return confirm('¿Borrar {{ $media->name }}?')">×</button>
wire:confirm="{{ __('Delete file confirmation') }}">×</button>
</div>
@endforeach
</div>
@@ -95,7 +95,7 @@
@if($mediaItems->isEmpty())
<div class="text-center text-gray-400 py-6 text-sm">
<p class="text-2xl mb-2">📁</p>
<p>{{ __("No files yet") }}. Sube imágenes o documentos.</p>
<p>{{ __("No files yet") }}</p>
</div>
@endif
@@ -0,0 +1,87 @@
<div class="relative" wire:poll.30s="loadNotifications">
<!-- Bell button -->
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-circle" role="button">
<div class="indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
@if($unreadCount > 0)
<span class="badge badge-xs badge-error indicator-item">
{{ $unreadCount > 99 ? '99+' : $unreadCount }}
</span>
@endif
</div>
</label>
<div tabindex="0" class="dropdown-content z-[50] menu p-0 shadow-lg bg-base-100 rounded-box w-80 mt-1 border border-base-200">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-base-200">
<span class="font-semibold text-base-content">Notificaciones</span>
@if($unreadCount > 0)
<button wire:click="markAllAsRead"
class="text-xs text-primary hover:underline focus:outline-none">
Marcar todas
</button>
@endif
</div>
<!-- Notification list -->
<ul class="max-h-80 overflow-y-auto divide-y divide-base-200">
@forelse($notifications as $notification)
@php
$data = is_array($notification['data']) ? $notification['data'] : json_decode($notification['data'], true);
$isUnread = is_null($notification['read_at']);
$createdAt = \Carbon\Carbon::parse($notification['created_at']);
@endphp
<li class="flex items-start gap-3 px-4 py-3 {{ $isUnread ? 'bg-primary/5' : '' }} hover:bg-base-200 transition-colors">
<!-- Dot indicator -->
<div class="mt-1 shrink-0">
@if($isUnread)
<span class="inline-block w-2 h-2 rounded-full bg-primary"></span>
@else
<span class="inline-block w-2 h-2 rounded-full bg-base-300"></span>
@endif
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<p class="text-sm text-base-content leading-snug">
{{ $data['message'] ?? 'Notificación' }}
</p>
<p class="text-xs text-base-content/50 mt-1">
{{ $createdAt->diffForHumans() }}
</p>
</div>
<!-- Mark as read -->
@if($isUnread)
<button wire:click="markAsRead('{{ $notification['id'] }}')"
class="shrink-0 text-base-content/40 hover:text-primary focus:outline-none"
title="Marcar como leída">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" 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>
</button>
@endif
</li>
@empty
<li class="px-4 py-8 text-center text-sm text-base-content/50">
No hay notificaciones
</li>
@endforelse
</ul>
<!-- Footer -->
@if(count($notifications) > 0 && $unreadCount > 0)
<div class="border-t border-base-200 px-4 py-2 text-center">
<button wire:click="markAllAsRead"
class="btn btn-ghost btn-xs w-full text-primary">
Marcar todas como leídas
</button>
</div>
@endif
</div>
</div>
</div>
@@ -4,7 +4,7 @@
@endif
<table class="table table-sm">
<thead>
<tr><th>Nombre</th><th>Progreso</th><th>Color</th><th>Acciones</th></tr>
<tr><th>{{ __('Name') }}</th><th>{{ __('Progress') }}</th><th>{{ __('Color') }}</th><th>{{ __('Actions') }}</th></tr>
</thead>
<tbody>
@foreach($phases as $phase)
@@ -18,12 +18,12 @@
</td>
<td><div class="w-6 h-6 rounded" style="background: {{ $phase->color }}"></div></td>
<td>
<a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-info">Actualizar</a>
<button wire:click="deletePhase({{ $phase->id }})" class="btn btn-xs btn-error">Eliminar</button>
<a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-info">{{ __('Update') }}</a>
<button wire:click="deletePhase({{ $phase->id }})" class="btn btn-xs btn-error">{{ __('Delete') }}</button>
</td>
</tr>
@endforeach
</tbody>
</table>
<button wire:click="addPhase" class="btn btn-sm btn-secondary mt-2">+ Agregar Fase</button>
<button wire:click="addPhase" class="btn btn-sm btn-secondary mt-2">+ {{ __('Add Phase') }}</button>
</div>
@@ -1,126 +0,0 @@
<div>
<!-- Tabs -->
<div class="tab-toggle">
<input type="radio" name="tabs-project-edit-{{ $project->id }}" id="tab-project-data-{{ $project->id }}"
{{ $activeTab === 'project-data' ? 'checked' : '' }} class="tab-toggle" />
<label for="tab-project-data-{{ $project->id }}" class="tab {{ $activeTab === 'project-data' ? 'tab-active' : '' }}">
{{ __('Project Data') }}
</label>
<input type="radio" name="tabs-project-edit-{{ $project->id }}" id="tab-phases-{{ $project->id }}"
{{ $activeTab === 'phases' ? 'checked' : '' }} class="tab-toggle" />
<label for="tab-phases-{{ $project->id }}" class="tab {{ $activeTab === 'phases' ? 'tab-active' : '' }}">
{{ __('Phases') }}
</label>
<input type="radio" name="tabs-project-edit-{{ $project->id }}" id="tab-users-{{ $project->id }}"
{{ $activeTab === 'users' ? 'checked' : '' }} class="tab-toggle" />
<label for="tab-users-{{ $project->id }}" class="tab {{ $activeTab === 'users' ? 'tab-active' : '' }}">
{{ __('Users') }}
</label>
<input type="radio" name="tabs-project-edit-{{ $project->id }}" id="tab-companies-{{ $project->id }}"
{{ $activeTab === 'companies' ? 'checked' : '' }} class="tab-toggle" />
<label for="tab-companies-{{ $project->id }}" class="tab {{ $activeTab === 'companies' ? 'tab-active' : '' }}">
{{ __('Companies') }}
</label>
</div>
<!-- Tab Content -->
<div class="tab-content">
<!-- Project Data Tab -->
<div id="tab-project-data-{{ $project->id }}"
class="tab-content-base p-4 {{ $activeTab === 'project-data' ? '' : 'hidden' }}">
<form wire:submit.prevent="updateProject" class="space-y-4">
@csrf
@method('PUT')
<div>
<label class="label">{{ __('Name') }}</label>
<input type="text" name="name"
wire:model.debounce.500ms="project.name"
class="input input-bordered w-full" required>
</div>
<div>
<label class="label">{{ __('Address') }}</label>
<input type="text" name="address"
wire:model.debounce.500ms="project.address"
class="input input-bordered w-full" required>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">{{ __('Latitude') }}</label>
<input type="number" step="any" name="lat"
wire:model.debounce.500ms="project.lat"
class="input input-bordered w-full" required>
</div>
<div>
<label class="label">{{ __('Longitude') }}</label>
<input type="number" step="any" name="lng"
wire:model.debounce.500ms="project.lng"
class="input input-bordered w-full" required>
</div>
</div>
<div>
<label class="label">{{ __('Status') }}</label>
<select name="status" wire:model="project.status"
class="select select-bordered w-full">
<option value="planning">{{ __('Planning') }}</option>
<option value="in_progress">{{ __('In progress') }}</option>
<option value="paused">{{ __('Paused') }}</option>
<option value="completed">{{ __('Completed') }}</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">{{ __('Start date') }}</label>
<input type="date" name="start_date"
wire:model.debounce.500ms="project.start_date"
class="input input-bordered w-full" required>
</div>
<div>
<label class="label">{{ __('Estimated end date') }}</label>
<input type="date" name="end_date_estimated"
wire:model.debounce.500ms="project.end_date_estimated"
class="input input-bordered w-full">
</div>
</div>
<button type="submit" class="btn btn-primary w-full">
{{ __('Update') }}
</button>
</form>
</div>
<!-- Phases Tab -->
<div id="tab-phases-{{ $project->id }}"
class="tab-content-base p-4 {{ $activeTab === 'phases' ? '' : 'hidden' }}">
<h2 class="text-xl font-bold mb-2">{{ __('Phases') }}</h2>
<livewire:phase-list :project="$project" />
</div>
<!-- Users Tab -->
<div id="tab-users-{{ $project->id }}"
class="tab-content-base p-4 {{ $activeTab === 'users' ? '' : 'hidden' }}">
<h2 class="text-xl font-bold mb-2">{{ __('Users') }}</h2>
<livewire:project-users :project="$project" />
</div>
</div>
</div>
{{-- Alpine.js for tab switching --}}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('projectTabs', () => ({
activeTab: '{{ $activeTab }}',
projectId: {{ $project->id }},
setTab(tab) {
this.activeTab = tab;
// Update the Livewire component
this.$dispatch('tabChanged', {
tab: tab,
projectId: this.projectId
});
}
}));
});
</script>
@@ -0,0 +1,270 @@
<div class="card bg-base-100 shadow">
<div class="card-body p-8">
<form wire:submit.prevent="save">
@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
{{-- ══════════════════════════════════════════════════════════════════
1. 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 <span class="text-error">*</span>
</label>
<div class="flex-1">
<input type="text" wire:model="name"
class="input input-bordered w-full"
placeholder="Edificio Residencial Las Palmas"
autofocus />
@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">
Referencia
<p class="text-xs text-gray-400 font-normal mt-0.5">Código interno o expediente</p>
</label>
<div class="flex-1">
<input type="text" wire:model="reference"
class="input input-bordered w-full max-w-xs"
placeholder="OBR-2026-001" />
@error('reference') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
@if($project)
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">Estado</label>
<div class="flex-1">
<select wire:model="status" class="select select-bordered w-full max-w-xs">
<option value="planning">Planificación</option>
<option value="in_progress">En progreso</option>
<option value="paused">Pausado</option>
<option value="completed">Completado</option>
</select>
@error('status') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
@endif
</div>
</div>
{{-- ══════════════════════════════════════════════════════════════════
2. UBICACIÓ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">Ubicación</h3>
{{-- Search box --}}
<div class="flex gap-2 mb-3">
<label class="input input-bordered input-sm flex items-center gap-2 flex-1">
<x-heroicon-o-magnifying-glass class="w-4 h-4 opacity-40 shrink-0" />
<input type="text" id="map-search-input" class="grow"
placeholder="Buscar dirección, ciudad, lugar…"
autocomplete="off" />
</label>
<button type="button" id="map-search-btn"
class="btn btn-outline btn-sm gap-1 shrink-0">
<x-heroicon-o-magnifying-glass class="w-4 h-4" />
Buscar
</button>
</div>
{{-- Geocode status message --}}
<p id="geocode-status" class="text-xs text-gray-400 mb-2 min-h-[1rem]"></p>
{{-- Map (wire:ignore prevents Livewire morphing from destroying Leaflet) --}}
<div wire:ignore
id="project-location-map"
data-lat="{{ $lat }}"
data-lng="{{ $lng }}"
style="height: 380px; border-radius: 0.5rem; overflow: hidden; z-index: 1;"
class="border border-base-300 shadow-sm mb-4">
</div>
<p class="text-xs text-gray-400 mb-4 flex items-center gap-1">
<x-heroicon-o-cursor-arrow-rays class="w-3.5 h-3.5 opacity-60" />
Pulsa en el mapa o arrastra el marcador para actualizar la ubicación.
</p>
<div class="space-y-4">
{{-- Lat/Lng (read-only, filled by map) --}}
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Coordenadas
<p class="text-xs text-gray-400 font-normal mt-0.5">Auto al pulsar el mapa</p>
</label>
<div class="flex-1 flex items-center gap-3">
<div class="flex-1">
<label class="label-text text-xs mb-0.5">Latitud</label>
<input type="text" wire:model="lat" readonly
id="input-lat"
class="input input-bordered input-sm w-full bg-base-200 font-mono"
placeholder="40.41680000" />
@error('lat') <p class="text-error text-xs mt-0.5">{{ $message }}</p> @enderror
</div>
<span class="text-gray-300 mt-5">/</span>
<div class="flex-1">
<label class="label-text text-xs mb-0.5">Longitud</label>
<input type="text" wire:model="lng" readonly
id="input-lng"
class="input input-bordered input-sm w-full bg-base-200 font-mono"
placeholder="-3.70380000" />
@error('lng') <p class="text-error text-xs mt-0.5">{{ $message }}</p> @enderror
</div>
</div>
</div>
{{-- Dirección --}}
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Dirección <span class="text-error">*</span>
</label>
<div class="flex-1">
<textarea wire:model="address" rows="2"
class="textarea textarea-bordered w-full"
placeholder="Calle Gran Vía 28, 28013 Madrid, España"></textarea>
@error('address') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
{{-- País custom dropdown with flag images (native <select> can't render emoji on Windows) --}}
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">País</label>
<div class="flex-1 max-w-xs">
<div x-data="{ open: false, q: '' }"
@click.outside="open = false; q = ''"
class="relative">
{{-- Trigger button --}}
<button type="button"
@click="open = !open; if(open) $nextTick(() => $refs.qs?.focus())"
class="btn btn-outline w-full justify-start gap-2 font-normal h-12">
@if($country && isset($countryList[$country]))
<img src="https://flagcdn.com/w20/{{ $country }}.png"
class="w-6 h-4 object-cover rounded-sm shrink-0"
onerror="this.style.display='none'" />
<span>{{ $countryList[$country] }}</span>
@else
<span class="text-gray-400"> Sin especificar </span>
@endif
<x-heroicon-o-chevron-up-down class="w-4 h-4 ml-auto opacity-40 shrink-0" />
</button>
{{-- Dropdown panel --}}
<div x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
class="absolute z-50 mt-1 w-full bg-base-100 border border-base-300 rounded-xl shadow-xl overflow-hidden"
style="display:none">
{{-- Search --}}
<div class="p-2 border-b border-base-200">
<input x-ref="qs" x-model="q" type="text"
placeholder="Buscar país…"
class="input input-sm input-bordered w-full"
@keydown.escape="open = false; q = ''" />
</div>
{{-- Clear option --}}
<button type="button"
@click="$wire.set('country', ''); open = false; q = ''"
class="flex items-center gap-2 w-full px-3 py-2 hover:bg-base-200 text-sm text-gray-400 border-b border-base-200">
Sin especificar
</button>
{{-- Country list --}}
<ul class="overflow-y-auto max-h-52 py-1">
@foreach($countryList as $code => $cName)
<li>
<button type="button"
x-show="q === '' || '{{ strtolower(addslashes($cName)) }}'.includes(q.toLowerCase())"
@click="$wire.set('country', '{{ $code }}'); open = false; q = ''"
class="flex items-center gap-2.5 w-full px-3 py-1.5 hover:bg-base-200 text-sm text-left {{ $country === $code ? 'bg-primary/10 font-semibold text-primary' : '' }}">
<img src="https://flagcdn.com/w20/{{ $code }}.png"
class="w-6 h-4 object-cover rounded-sm shrink-0"
loading="lazy"
onerror="this.style.display='none'" />
{{ $cName }}
@if($country === $code)
<x-heroicon-o-check class="w-3.5 h-3.5 ml-auto shrink-0" />
@endif
</button>
</li>
@endforeach
</ul>
</div>
</div>
@error('country') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
</div>
</div>
{{-- ══════════════════════════════════════════════════════════════════
3. PLANIFICACIÓ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">Planificació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">
Fecha inicio <span class="text-error">*</span>
</label>
<div class="flex-1">
<input type="date" wire:model="startDate"
class="input input-bordered w-full max-w-xs" />
@error('startDate') <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">
Fecha fin estimada
<p class="text-xs text-gray-400 font-normal mt-0.5">Vacío = sin fecha límite</p>
</label>
<div class="flex-1">
<input type="date" wire:model="endDateEstimated"
class="input input-bordered w-full max-w-xs" />
@error('endDateEstimated') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
</div>
</div>
{{-- ── Botones ─────────────────────────────────────────────────────── --}}
<div class="flex items-center justify-between pt-2">
<a href="{{ route('projects.index') }}" 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" />
{{ $project ? 'Guardar cambios' : 'Crear proyecto' }}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,275 @@
<div class="p-4 space-y-4">
{{-- Page header --}}
<div class="flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-3">
<a href="{{ route('projects.map', $project) }}"
class="btn btn-sm btn-ghost gap-1">
<x-heroicon-o-arrow-left class="w-4 h-4" />
{{ __('Back to Map') }}
</a>
<h1 class="text-xl font-bold">{{ __('Cronograma') }}: {{ $project->name }}</h1>
</div>
<div class="flex items-center gap-3">
<a href="{{ route('projects.report', $project) }}"
target="_blank"
class="btn btn-sm btn-outline gap-1">
<x-heroicon-o-document-text class="w-4 h-4" />
{{ __('Report PDF') }}
</a>
<span class="text-sm text-base-content/60">
{{ $project->start_date?->format('d/m/Y') ?? __('N/A') }}
&mdash;
{{ $project->end_date_estimated?->format('d/m/Y') ?? __('N/A') }}
</span>
</div>
</div>
{{-- Legend --}}
<div class="flex items-center gap-4 text-sm flex-wrap">
<span class="flex items-center gap-1.5">
<span class="inline-block w-5 h-3 rounded" style="background:#3b82f6"></span>
{{ __('Planificado') }}
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block w-5 h-3 rounded" style="background:#22c55e"></span>
{{ __('Real') }}
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block w-5 h-3 rounded border-2" style="background:#fee2e2;border-color:#ef4444"></span>
{{ __('Retrasado') }}
</span>
</div>
{{-- Editor de fechas por fase (siempre visible) --}}
<div class="bg-base-100 rounded-box border border-base-300 p-4 mb-4">
<h3 class="font-semibold text-sm mb-3">Fechas planificadas y reales por fase</h3>
<div class="space-y-3">
@foreach($phases as $phase)
<div x-data="{
ps: '{{ $phase->planned_start?->format('Y-m-d') ?? '' }}',
pe: '{{ $phase->planned_end?->format('Y-m-d') ?? '' }}',
as_: '{{ $phase->actual_start?->format('Y-m-d') ?? '' }}',
ae: '{{ $phase->actual_end?->format('Y-m-d') ?? '' }}'
}" class="grid grid-cols-2 md:grid-cols-5 gap-2 items-center text-sm border-b pb-3 last:border-0">
<div class="font-medium truncate flex items-center gap-1">
<span class="w-2 h-2 rounded-full inline-block flex-shrink-0" style="background:{{ $phase->color ?? '#3b82f6' }}"></span>
{{ $phase->name }}
</div>
<div class="form-control">
<label class="label-text text-xs text-gray-500">Plan. inicio</label>
<input type="date" x-model="ps" class="input input-xs input-bordered" />
</div>
<div class="form-control">
<label class="label-text text-xs text-gray-500">Plan. fin</label>
<input type="date" x-model="pe" class="input input-xs input-bordered" />
</div>
<div class="form-control">
<label class="label-text text-xs text-gray-500">Real inicio</label>
<input type="date" x-model="as_" class="input input-xs input-bordered" />
</div>
<div class="flex flex-col gap-1">
<label class="label-text text-xs text-gray-500">Real fin</label>
<div class="flex gap-1">
<input type="date" x-model="ae" class="input input-xs input-bordered flex-1" />
<button @click="$wire.updatePhaseDates({{ $phase->id }}, ps, pe, as_, ae)"
class="btn btn-xs btn-primary">
</button>
</div>
</div>
</div>
@endforeach
</div>
</div>
@if(empty($ganttData))
<div class="alert alert-info">
<x-heroicon-o-information-circle class="w-5 h-5" />
<span>Define fechas planificadas arriba para ver el diagrama.</span>
</div>
@else
{{-- Gantt table --}}
<div class="bg-base-100 rounded-box border border-base-300 overflow-x-auto">
<table class="w-full text-sm" style="min-width:900px;">
<thead>
<tr class="border-b border-base-300">
{{-- Phase name column --}}
<th class="text-left px-3 py-2 font-semibold bg-base-200" style="width:200px;min-width:200px;">
{{ __('Fase') }}
</th>
{{-- Month header row --}}
<th class="px-0 py-0 bg-base-200" style="min-width:400px;">
@php
$projectStart = $project->start_date ?? now()->startOfMonth();
$projectEnd = $project->end_date_estimated ?? now()->addMonths(6);
$totalDays = max(1, $projectStart->diffInDays($projectEnd));
// Build month segments
$months = [];
$cursor = $projectStart->copy()->startOfMonth();
while ($cursor->lte($projectEnd)) {
$mStart = $cursor->copy()->max($projectStart);
$mEnd = $cursor->copy()->endOfMonth()->min($projectEnd);
$days = max(1, $mStart->diffInDays($mEnd) + 1);
$widthPct = round(($days / $totalDays) * 100, 2);
$months[] = [
'label' => $cursor->translatedFormat('M Y'),
'width_pct' => $widthPct,
];
$cursor->addMonthNoOverflow();
}
@endphp
<div class="flex w-full border-b border-base-300">
@foreach($months as $month)
<div class="text-center text-xs py-1 font-medium border-r border-base-300 last:border-r-0 truncate"
style="width:{{ $month['width_pct'] }}%;flex-shrink:0;">
{{ $month['label'] }}
</div>
@endforeach
</div>
</th>
{{-- Dates column --}}
<th class="text-left px-3 py-2 font-semibold bg-base-200 whitespace-nowrap" style="width:160px;min-width:160px;">
{{ __('Fechas') }}
</th>
{{-- Status column --}}
<th class="text-center px-3 py-2 font-semibold bg-base-200" style="width:110px;min-width:110px;">
{{ __('Estado') }}
</th>
</tr>
</thead>
<tbody>
@foreach($ganttData as $phase)
<tr class="border-b border-base-300 hover:bg-base-50 transition-colors {{ $phase['is_delayed'] ? 'bg-red-50' : '' }}">
{{-- Phase name --}}
<td class="px-3 py-3" style="width:200px;min-width:200px;vertical-align:middle;">
<div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 rounded-full flex-shrink-0"
style="background:{{ $phase['color'] }}"></span>
<span class="font-medium truncate" title="{{ $phase['name'] }}">
{{ $phase['name'] }}
</span>
</div>
@if($phase['features_count'] > 0)
<div class="ml-5 text-xs text-base-content/50 mt-0.5">
{{ $phase['features_count'] }} {{ __('elementos') }}
</div>
@endif
</td>
{{-- Gantt bar cell --}}
<td class="px-0 py-3" style="vertical-align:middle;">
<div class="relative w-full" style="height:36px;">
{{-- Month grid lines --}}
@php $offset = 0; @endphp
@foreach($months as $i => $month)
@if($i > 0)
<div class="absolute top-0 bottom-0 border-l border-base-300/50"
style="left:{{ $offset }}%;"></div>
@endif
@php $offset += $month['width_pct']; @endphp
@endforeach
{{-- Planned bar --}}
<div class="absolute rounded"
style="
top: 4px;
height: 13px;
left: {{ $phase['p_start_pct'] }}%;
width: {{ max(0.5, $phase['p_width_pct']) }}%;
background: {{ $phase['is_delayed'] ? '#fca5a5' : $phase['color'] }};
border: {{ $phase['is_delayed'] ? '2px solid #ef4444' : 'none' }};
opacity: 0.85;
"
title="{{ __('Planificado') }}: {{ $phase['planned_start'] }} - {{ $phase['planned_end'] }}">
</div>
{{-- Actual bar (if exists) --}}
@if($phase['a_start_pct'] !== null && $phase['a_width_pct'] !== null)
<div class="absolute rounded"
style="
top: 19px;
height: 13px;
left: {{ $phase['a_start_pct'] }}%;
width: {{ max(0.5, $phase['a_width_pct']) }}%;
background: #22c55e;
opacity: 0.85;
"
title="{{ __('Real') }}: {{ $phase['actual_start'] }} - {{ $phase['actual_end'] ?? __('En curso') }}">
</div>
@endif
{{-- Progress label --}}
<div class="absolute inset-0 flex items-center"
style="left: {{ $phase['p_start_pct'] }}%; width: {{ max(0.5, $phase['p_width_pct']) }}%;">
<span class="text-xs font-bold text-white drop-shadow px-1 truncate"
style="font-size:10px; line-height:13px; position:absolute; top:4px; left:2px;">
{{ $phase['progress'] }}%
</span>
</div>
</div>
</td>
{{-- Dates column --}}
<td class="px-3 py-3 text-xs" style="width:160px;min-width:160px;vertical-align:middle;">
<div class="space-y-0.5">
<div class="flex items-center gap-1">
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" style="background:{{ $phase['color'] }}"></span>
<span class="text-base-content/70">{{ $phase['planned_start'] }} {{ $phase['planned_end'] }}</span>
</div>
@if($phase['actual_start'])
<div class="flex items-center gap-1">
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" style="background:#22c55e"></span>
<span class="text-base-content/70">
{{ $phase['actual_start'] }} {{ $phase['actual_end'] ?? __('En curso') }}
</span>
</div>
@endif
</div>
</td>
{{-- Status badge --}}
<td class="px-3 py-3 text-center" style="width:110px;min-width:110px;vertical-align:middle;">
@if($phase['is_delayed'])
<span class="badge badge-error badge-sm gap-1">
<x-heroicon-o-exclamation-triangle class="w-3 h-3" />
{{ __('En retraso') }}
</span>
@elseif($phase['progress'] >= 100)
<span class="badge badge-success badge-sm gap-1">
<x-heroicon-o-check-circle class="w-3 h-3" />
{{ __('Completado') }}
</span>
@elseif($phase['progress'] > 0)
<span class="badge badge-info badge-sm">
{{ $phase['progress'] }}%
</span>
@else
<span class="badge badge-ghost badge-sm">
{{ __('Pendiente') }}
</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{-- Summary footer --}}
<div class="text-xs text-base-content/50 text-right">
{{ count($ganttData) }} {{ __('fases') }}
&bull;
{{ __('Actualizado') }}: {{ now()->format('d/m/Y H:i') }}
</div>
@endif
</div>
@@ -0,0 +1,396 @@
<div>
<x-slot name="header">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="{{ route('projects.list') }}" class="btn btn-ghost btn-sm px-2">
<x-heroicon-o-arrow-left class="w-4 h-4" />
</a>
<div>
<h2 class="font-bold text-xl leading-tight">{{ $project->name }}</h2>
@if($project->description)
<p class="text-sm text-gray-500 leading-tight mt-0.5">{{ Str::limit($project->description, 80) }}</p>
@endif
</div>
@php
$statusCfg = 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(str_replace('_',' ',$project->status))],
};
@endphp
<span class="badge {{ $statusCfg[0] }} badge-sm">{{ $statusCfg[1] }}</span>
</div>
<div class="flex gap-2">
<a href="{{ route('projects.map', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-map class="w-4 h-4" />
Mapa
</a>
<a href="{{ route('projects.gantt', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-calendar-days class="w-4 h-4" />
Gantt
</a>
<a href="{{ route('projects.issues', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-exclamation-triangle class="w-4 h-4" />
Issues
</a>
</div>
</div>
</x-slot>
<div class="py-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
{{-- ── KPIs fila 1 ────────────────────────────────────────────────────── --}}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
{{-- Avance global --}}
<div class="card bg-base-100 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-1.5">
<div class="bg-green-500 h-1.5 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-5 h-5 text-green-600" />
</div>
</div>
</div>
</div>
{{-- Fases --}}
<div class="card bg-base-100 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</p>
<p class="mt-1 text-3xl font-bold text-blue-600">{{ $stats['total_phases'] }}</p>
@if($stats['delayed_phases'] > 0)
<p class="text-xs text-red-500 mt-0.5">{{ $stats['delayed_phases'] }} con retraso</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-blue-100' }} rounded-full">
<x-heroicon-o-rectangle-stack class="w-5 h-5 {{ $stats['delayed_phases'] > 0 ? 'text-red-500' : 'text-blue-600' }}" />
</div>
</div>
</div>
</div>
{{-- Elementos --}}
<div class="card bg-base-100 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</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['completed_features'] }} completados
· {{ $stats['verified_features'] }} verificados
</p>
</div>
<div class="p-3 bg-indigo-100 rounded-full">
<x-heroicon-o-map-pin class="w-5 h-5 text-indigo-600" />
</div>
</div>
</div>
</div>
{{-- Issues --}}
<div class="card bg-base-100 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 {{ $stats['open_issues'] > 0 ? 'text-orange-600' : 'text-gray-400' }}">
{{ $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 {{ $stats['open_issues'] > 0 ? 'bg-orange-100' : 'bg-gray-100' }} rounded-full">
<x-heroicon-o-exclamation-triangle class="w-5 h-5 {{ $stats['open_issues'] > 0 ? 'text-orange-600' : 'text-gray-400' }}" />
</div>
</div>
</div>
</div>
</div>
{{-- ── KPIs fila 2: Inspecciones ────────────────────────────────────────── --}}
<div class="grid grid-cols-3 gap-4">
<div class="card bg-base-100 shadow">
<div class="card-body p-4 flex-row items-center gap-4">
<div class="p-3 bg-gray-100 rounded-full shrink-0">
<x-heroicon-o-clipboard-document-list class="w-5 h-5 text-gray-600" />
</div>
<div>
<p class="text-xs text-gray-500 uppercase tracking-wider">Total inspecciones</p>
<p class="text-2xl font-bold">{{ $stats['total_inspections'] }}</p>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body p-4 flex-row items-center gap-4">
<div class="p-3 bg-green-100 rounded-full shrink-0">
<x-heroicon-o-check-circle class="w-5 h-5 text-green-600" />
</div>
<div>
<p class="text-xs text-gray-500 uppercase tracking-wider">Aprobadas</p>
<p class="text-2xl font-bold text-green-600">{{ $stats['passed_inspections'] }}</p>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body p-4 flex-row items-center gap-4">
<div class="p-3 {{ $stats['failed_inspections'] > 0 ? 'bg-red-100' : 'bg-gray-100' }} rounded-full shrink-0">
<x-heroicon-o-x-circle class="w-5 h-5 {{ $stats['failed_inspections'] > 0 ? 'text-red-600' : 'text-gray-400' }}" />
</div>
<div>
<p class="text-xs text-gray-500 uppercase tracking-wider">Rechazadas</p>
<p class="text-2xl font-bold {{ $stats['failed_inspections'] > 0 ? 'text-red-600' : 'text-gray-400' }}">
{{ $stats['failed_inspections'] }}
</p>
</div>
</div>
</div>
</div>
{{-- ── Main grid: fases + actividad reciente ───────────────────────────── --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- LEFT 2/3: Fases con progreso --}}
<div class="lg:col-span-2 space-y-4">
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex justify-between items-center mb-4">
<h3 class="font-semibold text-base flex items-center gap-2">
<x-heroicon-o-rectangle-stack class="w-4 h-4" />
Fases del proyecto
</h3>
<a href="{{ route('projects.gantt', $project) }}" class="btn btn-xs btn-outline gap-1">
<x-heroicon-o-calendar-days class="w-3.5 h-3.5" />
Gantt
</a>
</div>
@if($phases->isEmpty())
<p class="text-sm text-gray-400 text-center py-6">Sin fases aún.</p>
@else
<div class="space-y-3">
@foreach($phases as $phase)
@php
$pct = round($phase->progress_percent ?? 0);
$isDelayed = $phase->planned_end && $phase->planned_end < now() && $pct < 100;
$barColor = $isDelayed ? 'bg-red-500' : ($pct >= 100 ? 'bg-green-500' : 'bg-blue-500');
$featureCount = $phase->layers->sum('features_count');
@endphp
<div class="border border-base-200 rounded-lg p-3 hover:border-primary/40 transition-colors">
<div class="flex items-start justify-between gap-2 mb-2">
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm truncate">{{ $phase->name }}</p>
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500 mt-0.5">
<span>{{ $phase->layers_count }} capa(s)</span>
<span>·</span>
<span>{{ $featureCount }} elementos</span>
@if($phase->planned_start && $phase->planned_end)
<span>·</span>
<span>{{ $phase->planned_start->format('d/m/y') }} {{ $phase->planned_end->format('d/m/y') }}</span>
@endif
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
@if($isDelayed)
<span class="badge badge-error badge-xs">Retraso</span>
@elseif($pct >= 100)
<span class="badge badge-success badge-xs">Completada</span>
@endif
<span class="text-sm font-bold {{ $isDelayed ? 'text-red-600' : ($pct >= 100 ? 'text-green-600' : 'text-blue-600') }}">
{{ $pct }}%
</span>
</div>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="{{ $barColor }} h-2 rounded-full transition-all" style="width: {{ $pct }}%"></div>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
{{-- Empresas participantes --}}
@if($companies->isNotEmpty())
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<h3 class="font-semibold text-base flex items-center gap-2 mb-3">
<x-heroicon-o-building-office-2 class="w-4 h-4" />
Empresas participantes
</h3>
<div class="flex flex-wrap gap-3">
@foreach($companies as $company)
<div class="flex items-center gap-2 border border-base-200 rounded-lg px-3 py-2">
@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) }}"
alt="" class="w-7 h-7 object-contain rounded" />
@else
<x-heroicon-o-building-office class="w-5 h-5 opacity-40" />
@endif
<div>
<p class="text-xs font-semibold leading-tight">{{ $company->apodo ?: $company->name }}</p>
@if($company->pivot->role_in_project)
<p class="text-xs text-gray-400">{{ $company->pivot->role_in_project }}</p>
@endif
</div>
</div>
@endforeach
</div>
</div>
</div>
@endif
</div>
{{-- RIGHT 1/3: Actividad reciente --}}
<div class="space-y-5">
{{-- Equipo --}}
@if($teamMembers->isNotEmpty())
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<h3 class="font-semibold text-base flex items-center gap-2 mb-3">
<x-heroicon-o-users class="w-4 h-4" />
Equipo ({{ $teamMembers->count() }})
</h3>
<div class="space-y-2">
@foreach($teamMembers->take(8) as $member)
<div class="flex items-center gap-2">
<div class="avatar placeholder shrink-0">
<div class="bg-neutral text-neutral-content rounded-full w-7">
<span class="text-xs">{{ strtoupper(substr($member->name, 0, 1)) }}</span>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ $member->name }}</p>
@if($member->pivot->role_in_project)
<p class="text-xs text-gray-400 truncate">{{ $member->pivot->role_in_project }}</p>
@endif
</div>
@foreach($member->roles->take(1) as $role)
<span class="badge badge-xs {{ $role->name === 'Admin' ? 'badge-error' : 'badge-ghost' }}">{{ $role->name }}</span>
@endforeach
</div>
@endforeach
</div>
</div>
</div>
@endif
{{-- 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="font-semibold text-base flex items-center gap-2">
<x-heroicon-o-exclamation-triangle class="w-4 h-4 text-orange-500" />
Issues abiertos
</h3>
<a href="{{ route('projects.issues', $project) }}" class="btn btn-xs btn-outline">Ver todos</a>
</div>
@if($recentIssues->isEmpty())
<div class="text-center py-4 text-gray-400">
<x-heroicon-o-check-circle class="w-7 h-7 mx-auto mb-1 opacity-25" />
<p class="text-xs">Sin issues abiertos</p>
</div>
@else
<div class="space-y-2">
@foreach($recentIssues as $issue)
@php
$pCfg = match($issue->priority ?? 'medium') {
'critical' => 'badge-error',
'high' => 'badge-warning',
'medium' => 'badge-info',
default => 'badge-ghost',
};
@endphp
<div class="p-2.5 rounded-lg bg-base-200">
<div class="flex items-start gap-1.5">
<span class="badge badge-xs {{ $pCfg }} shrink-0 mt-0.5">{{ ucfirst($issue->priority ?? 'medium') }}</span>
<p class="text-xs font-medium truncate">{{ $issue->title }}</p>
</div>
@if($issue->feature)
<p class="text-xs text-gray-400 mt-0.5 truncate">
<x-heroicon-o-map-pin class="w-2.5 h-2.5 inline" /> {{ $issue->feature->name }}
</p>
@endif
</div>
@endforeach
</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="font-semibold text-base flex items-center gap-2">
<x-heroicon-o-clipboard-document-list class="w-4 h-4 text-yellow-500" />
Inspecciones recientes
</h3>
<a href="{{ route('projects.map', $project) }}?tab=inspections" class="btn btn-xs btn-outline">Ver en mapa</a>
</div>
@if($recentInspections->isEmpty())
<div class="text-center py-4 text-gray-400">
<x-heroicon-o-clipboard-document-list class="w-7 h-7 mx-auto mb-1 opacity-25" />
<p class="text-xs">Sin inspecciones</p>
</div>
@else
<div class="space-y-2">
@foreach($recentInspections as $ins)
@php
$iCfg = match($ins->result ?? '') {
'pass' => ['badge-success', 'OK'],
'fail' => ['badge-error', 'Fallo'],
default => ['badge-ghost', 'Pendiente'],
};
@endphp
<div class="p-2.5 rounded-lg bg-base-200">
<div class="flex items-start justify-between gap-1">
<p class="text-xs font-medium truncate flex-1">
{{ $ins->template?->name ?? 'Inspección' }}
</p>
<span class="badge badge-xs {{ $iCfg[0] }} shrink-0">{{ $iCfg[1] }}</span>
</div>
<div class="flex items-center justify-between mt-0.5">
@if($ins->feature)
<p class="text-xs text-gray-400 truncate">
<x-heroicon-o-map-pin class="w-2.5 h-2.5 inline" /> {{ $ins->feature->name }}
</p>
@endif
<p class="text-xs text-gray-400 shrink-0 ml-1">{{ $ins->created_at->diffForHumans() }}</p>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
</div>
{{-- end right --}}
</div>
{{-- end main grid --}}
</div>
</div>
</div>{{-- end root --}}
@@ -1,3 +1,138 @@
<div>
{{-- In work, do what you enjoy. --}}
<div class="max-w-7xl mx-auto p-4">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold">{{ $project ? __('Edit Project') : __('New Project') }}</h1>
<a href="{{ route('projects.index') }}" class="btn btn-ghost btn-sm gap-1" wire:navigate>
<x-heroicon-o-arrow-left class="w-4 h-4" /> {{ __('Back') }}
</a>
</div>
@if($project)
{{-- Editor con pestañas para el resto de parámetros del proyecto --}}
<div x-data="{ tab: 'data' }">
<div class="flex flex-wrap gap-1 mb-4">
<button type="button" @click="tab='data'" :class="tab==='data' ? 'btn-primary' : 'btn-ghost'" class="btn btn-sm">{{ __('Project Data') }}</button>
<button type="button" @click="tab='phases'" :class="tab==='phases' ? 'btn-primary' : 'btn-ghost'" class="btn btn-sm">{{ __('Phases') }}</button>
<button type="button" @click="tab='users'" :class="tab==='users' ? 'btn-primary' : 'btn-ghost'" class="btn btn-sm">{{ __('Users') }}</button>
<button type="button" @click="tab='companies'" :class="tab==='companies' ? 'btn-primary' : 'btn-ghost'" class="btn btn-sm">{{ __('Companies') }}</button>
</div>
<div x-show="tab==='data'">
@include('livewire.projects.partials.project-data-form')
</div>
<div x-show="tab==='phases'" x-cloak>
<livewire:phase-list :project="$project" :key="'phases-'.$project->id" />
</div>
<div x-show="tab==='users'" x-cloak>
<livewire:project-users :project="$project" :key="'users-'.$project->id" />
</div>
<div x-show="tab==='companies'" x-cloak>
<livewire:project-companies :project="$project" :key="'companies-'.$project->id" />
</div>
</div>
@else
{{-- Alta de proyecto: solo el formulario de datos --}}
@include('livewire.projects.partials.project-data-form')
@endif
</div>
@push('scripts')
<script>
(function () {
let pmap = null, pmarker = null;
function setStatus(msg) {
const s = document.getElementById('geocode-status');
if (s) s.textContent = msg || '';
}
function placeMarker(lat, lng) {
if (!pmap) return;
if (pmarker) {
pmarker.setLatLng([lat, lng]);
} else {
pmarker = L.marker([lat, lng], { draggable: true }).addTo(pmap);
pmarker.on('dragend', () => {
const p = pmarker.getLatLng();
pickLocation(p.lat, p.lng);
});
}
}
async function pickLocation(lat, lng) {
setStatus('{{ __('Loading...') }}');
let address = '', country = '';
try {
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=18&addressdetails=1`);
if (r.ok) {
const d = await r.json();
address = d.display_name || '';
country = (d.address && d.address.country_code) ? d.address.country_code : '';
}
} catch (e) { /* geocoding optional */ }
@this.setLocation(String(lat), String(lng), address, country);
setStatus('');
}
async function searchLocation(q) {
if (!q || !q.trim() || !pmap) return;
setStatus('{{ __('Searching...') }}');
try {
const r = await fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=1&addressdetails=1&q=${encodeURIComponent(q)}`);
const arr = await r.json();
if (arr && arr.length) {
const lat = parseFloat(arr[0].lat), lng = parseFloat(arr[0].lon);
pmap.setView([lat, lng], 16);
placeMarker(lat, lng);
const address = arr[0].display_name || '';
const country = (arr[0].address && arr[0].address.country_code) ? arr[0].address.country_code : '';
@this.setLocation(String(lat), String(lng), address, country);
setStatus('');
} else {
setStatus('{{ __('No results') }}');
}
} catch (e) {
setStatus('{{ __('No results') }}');
}
}
function initProjectLocationMap() {
const el = document.getElementById('project-location-map');
if (!el || el._leafletInit) return;
el._leafletInit = true;
const dLat = parseFloat(el.dataset.lat);
const dLng = parseFloat(el.dataset.lng);
const hasCoords = !isNaN(dLat) && !isNaN(dLng) && (dLat !== 0 || dLng !== 0);
const lat = hasCoords ? dLat : 40.4168;
const lng = hasCoords ? dLng : -3.7038;
pmap = L.map('project-location-map').setView([lat, lng], hasCoords ? 16 : 5);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> & CartoDB'
}).addTo(pmap);
if (hasCoords) placeMarker(lat, lng);
pmap.on('click', (e) => {
placeMarker(e.latlng.lat, e.latlng.lng);
pickLocation(e.latlng.lat, e.latlng.lng);
});
const input = document.getElementById('map-search-input');
const btn = document.getElementById('map-search-btn');
if (btn) btn.addEventListener('click', () => searchLocation(input ? input.value : ''));
if (input) input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); searchLocation(input.value); }
});
setTimeout(() => pmap.invalidateSize(), 200);
}
document.addEventListener('livewire:navigated', initProjectLocationMap);
document.addEventListener('DOMContentLoaded', initProjectLocationMap);
setTimeout(initProjectLocationMap, 300);
})();
</script>
@endpush
</div>
@@ -30,7 +30,14 @@
</div>
</td>
<td>
<a href="{{ route('projects.map', $project) }}" class="btn btn-sm btn-outline">{{ __('Map') }}</a>
<a href="{{ route('projects.dashboard', $project) }}" class="btn btn-sm btn-outline gap-1">
<x-heroicon-o-squares-2x2 class="w-3.5 h-3.5" />
Dashboard
</a>
<a href="{{ route('projects.map', $project) }}" class="btn btn-sm btn-outline gap-1">
<x-heroicon-o-map class="w-3.5 h-3.5" />
{{ __('Map') }}
</a>
@can('edit projects')
<a href="{{ route('projects.edit', $project) }}" class="btn btn-sm btn-warning">{{ __('Edit') }}</a>
@endcan
@@ -1,9 +1,9 @@
{{-- Feature seleccionado --}}
@if($selectedFeature)
<div class="border rounded-lg p-3 mb-3 bg-base-200">
<h3 class="font-bold text-sm">{{ $selectedFeature->name ?? 'Elemento' }}</h3>
<p class="text-xs text-gray-500">Fase: {{ $selectedFeature->layer?->phase?->name ?? '—' }}</p>
<p class="text-xs text-gray-500">Capa: {{ $selectedFeature->layer?->name ?? '—' }}</p>
<h3 class="font-bold text-sm">{{ $selectedFeature->name ?? __('Feature') }}</h3>
<p class="text-xs text-gray-500">{{ __('Phase') }}: {{ $selectedFeature->layer?->phase?->name ?? '—' }}</p>
<p class="text-xs text-gray-500">{{ __('Layer') }}: {{ $selectedFeature->layer?->name ?? '—' }}</p>
</div>
{{-- {{ __("Progress") }} --}}
@@ -17,7 +17,7 @@
<div class="form-control mb-3">
<label class="label-text">{{ __("Responsible") }}</label>
<input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="Nombre" />
<input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="{{ __('Name of responsible') }}" />
</div>
<button wire:click="saveFeatureProgress" class="btn btn-primary btn-sm w-full mb-3">
@@ -41,9 +41,9 @@
@if($templates->isNotEmpty())
<div class="divider text-xs">{{ __("Inspection") }}</div>
<div class="form-control mb-2">
<label class="label-text">Plantilla</label>
<label class="label-text">{{ __('Template') }}</label>
<select wire:model.live="selectedTemplateId" wire:change="onTemplateChange" class="select select-bordered select-sm">
<option value="">Seleccionar plantilla...</option>
<option value="">{{ __('Select template...') }}</option>
@foreach($templates as $t)
<option value="{{ $t->id }}">{{ $t->name }}</option>
@endforeach
@@ -69,7 +69,7 @@
@break
@case('select')
<select wire:model="inspectionFormData.{{ $field['name'] }}" class="select select-bordered select-sm w-full">
<option value="">Seleccionar</option>
<option value="">{{ __('Select') }}</option>
@foreach(explode(',', $field['options'] ?? '') as $opt)
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
@endforeach
@@ -97,7 +97,7 @@
<span class="font-medium">{{ $ins->template?->name ?? '{{ __("Inspection") }}' }}</span>
<span class="text-gray-400">{{ $ins->created_at->diffForHumans() }}</span>
</div>
@if($ins->user)<span class="text-gray-500">por {{ $ins->user->name }}</span>@endif
@if($ins->user)<span class="text-gray-500">{{ __('by') }} {{ $ins->user->name }}</span>@endif
</div>
@endforeach
</div>
@@ -117,6 +117,6 @@
@else
<div class="text-center text-gray-400 py-8">
<p class="text-lg">👆</p>
<p>Haz clic en un elemento del mapa para editarlo</p>
<p>{{ __('Click on a map element or search above to edit it') }}</p>
</div>
@endif
@@ -1,33 +1,57 @@
<div x-data="{ formFullscreen: $wire.entangle('formFullscreen') }"
<div x-data="{ formFullscreen: $wire.entangle('formFullscreen') }"
class="flex flex-col lg:flex-row gap-0 h-screen p-1">
<!-- Columna izquierda: Mapa -->
<div x-show="!formFullscreen" x-cloak class="w-full lg:w-2/3 relative">
<div x-show="!formFullscreen" x-cloak x-data="{ showLayers: true }" class="w-full lg:w-2/3 flex-1 relative">
<div id="map" style="height: 100%; min-height: 500px; width: 100%;" wire:ignore></div>
<!-- Botón para reabrir el panel (solo cuando está colapsado) -->
<button x-show="!showLayers" x-cloak @click="showLayers = true"
class="absolute top-2 right-2 z-[1001] btn btn-sm btn-circle shadow-lg"
title="{{ __('Show/hide panel') }}">
<x-heroicon-o-bars-3 class="w-5 h-5" />
</button>
<!-- Panel lateral de capas -->
<div class="absolute top-2 right-2 z-[1000] bg-base-100 rounded-box shadow-xl p-4 w-72 border border-base-300 text-sm max-h-[calc(100vh-4rem)] overflow-y-auto">
<h3 class="font-semibold text-base mb-2">{{ __("Fases and layers") }}</h3>
<div x-show="showLayers" x-transition
class="absolute top-2 right-2 z-[1000] bg-base-100 rounded-box shadow-xl p-4 w-72 border border-base-300 text-sm max-h-[calc(100vh-4rem)] overflow-y-auto">
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold text-base">{{ __('Phases and layers') }}</h3>
<button @click="showLayers = false" class="btn btn-xs btn-circle btn-ghost" title="{{ __('Show/hide panel') }}">
<x-heroicon-o-x-mark class="w-4 h-4" />
</button>
</div>
<div class="space-y-3">
@foreach($phases as $phase)
<div class="border rounded-lg p-2 {{ in_array($phase->id, $activeLayers) ? 'bg-base-200' : '' }}">
@php
$phaseLayerIds = $phase->layers->pluck('id')->map(fn($i) => (int) $i)->all();
$phaseAllActive = count($phaseLayerIds) > 0 && collect($phaseLayerIds)->every(fn($i) => in_array($i, $activeLayers));
@endphp
<div class="border rounded-lg p-2 {{ $phaseAllActive ? 'bg-base-200' : '' }}">
{{-- Fase: el toggle muestra/oculta TODAS sus capas --}}
<div class="flex items-center gap-2">
<input type="checkbox"
wire:change="toggleLayer({{ $phase->id }})"
@if(in_array($phase->id, $activeLayers)) checked @endif
class="toggle toggle-xs toggle-primary">
wire:change="togglePhase({{ $phase->id }})"
@if($phaseAllActive) checked @endif
class="toggle toggle-xs toggle-primary"
title="{{ __('Show/hide all layers of this phase') }}">
<span style="color: {{ $phase->color }};" class="text-lg"></span>
<span class="flex-1 font-medium text-sm truncate">{{ $phase->name }}</span>
<span class="badge badge-sm {{ $phase->progress_percent >= 100 ? 'badge-success' : 'badge-ghost' }}">{{ $phase->progress_percent }}%</span>
<span class="badge badge-sm {{ $phase->progress_percent >= 100 ? 'badge-success' : 'badge-ghost' }}">{{ $phase->progress_percent }}</span>
</div>
{{-- Capas de esta fase --}}
{{-- Capas de esta fase: cada una con su propio toggle independiente --}}
@if($phase->layers->isNotEmpty())
<div class="ml-7 mt-1 space-y-1">
@foreach($phase->layers as $layer)
<div class="flex items-center gap-1 text-xs text-gray-600">
<span class="w-2 h-2 rounded-full inline-block" style="background: {{ $layer->color ?? '#ccc' }}"></span>
<div class="flex items-center gap-2 text-xs text-gray-600">
<input type="checkbox"
wire:change="toggleLayer({{ $layer->id }})"
@if(in_array((int) $layer->id, $activeLayers)) checked @endif
class="toggle toggle-xs toggle-primary"
title="{{ __('Show/hide layer') }}">
<span class="w-2 h-2 rounded-full inline-block shrink-0" style="background: {{ $layer->color ?? '#ccc' }}"></span>
<span class="flex-1 truncate">{{ $layer->name }}</span>
<span class="badge badge-xs">{{ $layer->features_count ?? $layer->features->count() }} elem.</span>
<span class="badge badge-xs">{{ $layer->features_count ?? $layer->features->count() }} {{ __('elem.') }}</span>
</div>
@endforeach
</div>
@@ -35,11 +59,11 @@
{{-- Botón para ir a gestión de capas de esta fase --}}
<div class="mt-1 ml-7">
<a href="{{ route('layers.manage', [$project, $phase]) }}" class="btn btn-xs btn-outline btn-primary">
✏️ {{ __("Manage Layers") }}
<a href="{{ route('layers.manage', [$project, $phase]) }}" class="btn btn-xs btn-outline btn-primary gap-1">
<x-heroicon-o-pencil-square class="w-3.5 h-3.5" /> {{ __('Manage Layers') }}
</a>
<a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-outline">
📊 {{ __("Progress") }}
<a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-outline gap-1">
<x-heroicon-o-chart-bar class="w-3.5 h-3.5" /> {{ __('Progress') }}
</a>
</div>
</div>
@@ -50,7 +74,7 @@
<div class="mt-3">
<label class="flex items-center gap-2 text-xs cursor-pointer">
<input type="checkbox" wire:change="toggleFeatureImages" @if($showFeatureImages) checked @endif class="checkbox checkbox-xs checkbox-primary" />
🖼️ {{ __("Show images on map") }}
<x-heroicon-o-photo class="w-4 h-4" /> {{ __('Show images on map') }}
@if($featureImageMarkers)
<span class="badge badge-xs">{{ count($featureImageMarkers) }}</span>
@endif
@@ -59,318 +83,564 @@
{{-- Botones generales --}}
<div class="mt-2 space-y-1">
<a href="{{ route('projects.media', $project) }}" class="btn btn-sm btn-outline w-full">
📁 {{ __("Project files") }}
<a href="{{ route('projects.media', $project) }}" class="btn btn-sm btn-outline w-full gap-1">
<x-heroicon-o-folder class="w-4 h-4" /> {{ __('Project files') }}
</a>
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full">
📍 {{ __("Centered in project") }}
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full gap-1">
<x-heroicon-o-map-pin class="w-4 h-4" /> {{ __('Centered in project') }}
</button>
<button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full">
🧭 {{ __("My location") }}
<button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full gap-1">
<x-heroicon-o-viewfinder-circle class="w-4 h-4" /> {{ __('My location') }}
</button>
</div>
</div>
</div>
<!-- Columna derecha: {{ __("Edit") }} de progreso / inspecciones -->
<div class="w-full lg:w-1/3 transition-all duration-300" :class="{'lg:w-full': formFullscreen}">
<div class="card bg-base-100 shadow-xl h-full flex flex-col">
<div class="card-body overflow-y-auto flex-1">
<div class="flex justify-between items-center mb-2">
<h2 class="card-title">{{ __("Edit") }}</h2>
<button wire:click="toggleFullscreen" class="btn btn-circle btn-sm" title="Pantalla completa">
<span x-text="formFullscreen ? '✕' : '⤢'"></span>
<!-- Columna derecha: {{ __('Edit') }} de progreso / inspecciones -->
<div class="w-full lg:w-1/3 transition-all duration-300" :class="{'lg:w-full': formFullscreen}">
<div class="card bg-base-100 shadow-xl h-full flex flex-col">
<div class="card-body overflow-y-auto flex-1">
<div class="flex justify-between items-center gap-2 mb-4">
<!-- Tabs -->
<div class="flex flex-wrap gap-3">
<button wire:click="setActiveTab('edit')" class="btn btn-sm {{ $activeTab === 'edit' ? 'btn-primary' : 'btn-ghost' }}">{{ __('Edit') }}</button>
<button wire:click="setActiveTab('features')" class="btn btn-sm {{ $activeTab === 'features' ? 'btn-primary' : 'btn-ghost' }}">{{ __('Features') }}</button>
<button wire:click="setActiveTab('inspections')" class="btn btn-sm {{ $activeTab === 'inspections' ? 'btn-primary' : 'btn-ghost' }}">{{ __('Inspections') }}</button>
<button wire:click="setActiveTab('issues')" class="btn btn-sm gap-1 {{ $activeTab === 'issues' ? 'btn-primary' : 'btn-ghost' }}">
{{ __('Issues') }}
@if($openIssuesCount > 0)
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
@endif
</button>
</div>
<button wire:click="toggleFullscreen" class="btn btn-circle btn-sm shrink-0" title="{{ __('Fullscreen') }}">
<span x-text="formFullscreen ? '✕' : '⤢'"></span>
</button>
</div>
@if($selectedFeature)
{{-- Feature seleccionado --}}
<div class="border rounded-lg p-3 mb-3 bg-base-200">
<h3 class="font-bold text-sm">{{ $selectedFeature->name ?? 'Elemento' }}</h3>
<p class="text-xs text-gray-500">Fase: {{ $selectedFeature->layer?->phase?->name ?? '—' }}</p>
<p class="text-xs text-gray-500">Capa: {{ $selectedFeature->layer?->name ?? '—' }}</p>
</div>
<!-- Project navigation bar (hidden for now, kept for later) -->
<div class="hidden flex-wrap gap-1 mb-3">
<a href="{{ route('projects.dashboard', $project) }}"
class="btn btn-xs gap-1 {{ request()->routeIs('projects.dashboard') ? 'btn-primary' : 'btn-outline' }}">
<x-heroicon-o-squares-2x2 class="w-3.5 h-3.5" /> {{ __('Dashboard') }}
</a>
<a href="{{ route('projects.map', $project) }}"
class="btn btn-xs gap-1 {{ request()->routeIs('projects.map') ? 'btn-primary' : 'btn-outline' }}">
<x-heroicon-o-map class="w-3.5 h-3.5" /> {{ __('Map') }}
</a>
<a href="{{ route('projects.gantt', $project) }}"
class="btn btn-xs gap-1 {{ request()->routeIs('projects.gantt') ? 'btn-primary' : 'btn-outline' }}">
<x-heroicon-o-calendar-days class="w-3.5 h-3.5" /> {{ __('Gantt') }}
</a>
<a href="{{ route('projects.report', $project) }}"
class="btn btn-xs gap-1 {{ request()->routeIs('projects.report') ? 'btn-primary' : 'btn-outline' }}">
<x-heroicon-o-document-text class="w-3.5 h-3.5" /> {{ __('Report') }}
</a>
<a href="{{ route('projects.issues', $project) }}"
class="btn btn-xs gap-1 {{ request()->routeIs('projects.issues') ? 'btn-error' : 'btn-outline' }}">
<x-heroicon-o-exclamation-triangle class="w-3.5 h-3.5" /> {{ __('Issues') }}
@if($openIssuesCount > 0)
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
@endif
</a>
</div>
{{-- {{ __("Progress") }} --}}
<div class="form-control mb-3">
<label class="label-text">{{ __("Progress") }}: {{ $editProgress }}%</label>
<input type="range" min="0" max="100" wire:model.live="editProgress" class="range range-primary range-sm" />
<div class="flex justify-between text-xs">
<span>0%</span><span>50%</span><span>100%</span>
</div>
</div>
<div class="form-control mb-3">
<label class="label-text">{{ __("Responsible") }}</label>
<input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="Nombre" />
</div>
<button wire:click="saveFeatureProgress" class="btn btn-primary btn-sm w-full mb-3">
💾 {{ __("Save progress") }}
</button>
{{-- Gestor de archivos del feature --}}
<details class="mb-3 border rounded-lg">
<summary class="text-xs font-semibold cursor-pointer p-2 bg-base-200 rounded-t-lg">
📎 {{ __("Files of element") }}
</summary>
<div class="p-2">
@livewire('media-manager', [
'mediableType' => 'App\\Models\\Feature',
'mediableId' => $selectedFeature->id,
], key('media-feature-' . $selectedFeature->id))
</div>
</details>
{{-- Templates / Inspecciones --}}
@if($templates->isNotEmpty())
<div class="divider text-xs">{{ __("Inspection") }}</div>
<div class="form-control mb-2">
<label class="label-text">Plantilla</label>
<select wire:model.live="selectedTemplateId" wire:change="onTemplateChange" class="select select-bordered select-sm">
<option value="">Seleccionar plantilla...</option>
@foreach($templates as $t)
<option value="{{ $t->id }}">{{ $t->name }}</option>
@endforeach
</select>
<!-- Tab Content: visibility controlled by Livewire conditionals, not DaisyUI -->
<div class="mt-2">
@if($activeTab === 'edit')
@if($selectedFeature)
{{-- Título a todo el ancho: progreso (solo número) a la izquierda + nombre --}}
<div class="flex items-center gap-3 mb-4 pb-2 border-b border-base-300">
<span class="badge badge-lg shrink-0 {{ $editProgress >= 100 ? 'badge-success' : ($editProgress > 0 ? 'badge-warning' : 'badge-ghost') }}">{{ $editProgress }}%</span>
<div class="flex-1 min-w-0">
<h3 class="font-bold text-base truncate">{{ $selectedFeature->name ?? __('Feature') }}</h3>
<p class="text-xs text-gray-500 truncate">{{ __('Phase') }}: {{ $selectedFeature->layer?->phase?->name ?? '—' }} · {{ __('Layer') }}: {{ $selectedFeature->layer?->name ?? '—' }}</p>
</div>
</div>
@if($selectedTemplateId && !empty($inspectionFormData))
@php $template = $templates->firstWhere('id', $selectedTemplateId); @endphp
@if($template)
@foreach($template->fields as $field)
<div class="mb-2">
<label class="label-text text-xs">{{ $field['label'] }} @if($field['required'] ?? false)<span class="text-error">*</span>@endif</label>
@switch($field['type'] ?? 'text')
@case('percentage')
<div class="flex items-center gap-1">
<input type="number" wire:model="inspectionFormData.{{ $field['name'] }}" min="0" max="100" class="input input-bordered input-sm w-16" />
<span class="text-xs">%</span>
<input type="range" min="0" max="100" wire:model.live="inspectionFormData.{{ $field['name'] }}" class="range range-primary range-xs flex-1" />
</div>
@break
@case('boolean')
<input type="checkbox" wire:model="inspectionFormData.{{ $field['name'] }}" class="checkbox checkbox-sm" />
@break
@case('select')
<select wire:model="inspectionFormData.{{ $field['name'] }}" class="select select-bordered select-sm w-full">
<option value="">Seleccionar</option>
@foreach(explode(',', $field['options'] ?? '') as $opt)
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
@endforeach
</select>
@break
@case('textarea')
<textarea wire:model="inspectionFormData.{{ $field['name'] }}" rows="2" class="textarea textarea-bordered textarea-sm w-full"></textarea>
@break
@default
<input type="{{ $field['type'] ?? 'text' }}" wire:model="inspectionFormData.{{ $field['name'] }}" class="input input-bordered input-sm w-full" />
@endswitch
</div>
@endforeach
<button wire:click="saveInspection" class="btn btn-primary btn-xs w-full mt-1">{{ __("Register inspection") }}</button>
@endif
@endif
{{-- En pantalla completa el contenido se reparte en columnas --}}
<div :class="formFullscreen ? 'grid grid-cols-1 lg:grid-cols-2 gap-x-8 items-start' : ''">
<div>
{{-- Responsable (se guarda al salir del campo) --}}
<div class="form-control mb-3">
<label class="label-text">{{ __('Responsible') }}</label>
<input type="text" wire:model="editResponsible" wire:blur="saveFeatureProgress" class="input input-bordered input-sm" placeholder="{{ __('Name of responsible') }}" />
</div>
{{-- {{ __("History") }} de inspecciones --}}
@if($inspectionHistory->isNotEmpty())
<div class="divider text-xs">{{ __("History") }}</div>
<div class="space-y-1 max-h-40 overflow-y-auto">
@foreach($inspectionHistory as $ins)
<div class="border rounded p-2 text-xs">
<div class="flex justify-between">
<span class="font-medium">{{ $ins->template?->name ?? '{{ __("Inspection") }}' }}</span>
<span class="text-gray-400">{{ $ins->created_at->diffForHumans() }}</span>
{{-- Gestor de archivos del feature --}}
<details class="mb-3 border rounded-lg">
<summary class="text-xs font-semibold cursor-pointer p-2 bg-base-200 rounded-t-lg">
<x-heroicon-o-paper-clip class="w-4 h-4 inline" /> {{ __('Files of element') }}
</summary>
<div class="p-2">
@livewire('media-manager', [
'mediableType' => 'App\\Models\\Feature',
'mediableId' => $selectedFeature->id,
], key('media-feature-' . $selectedFeature->id))
</div>
</details>
</div>
<div>
{{-- Templates / Inspecciones --}}
@if($templates->isNotEmpty())
<div class="divider text-xs">{{ __('Inspection') }}</div>
<div class="form-control mb-2">
<label class="label-text">{{ __('Template') }}</label>
<select wire:model.live="selectedTemplateId" wire:change="onTemplateChange" class="select select-bordered select-sm">
<option value="">{{ __('Select template...') }}</option>
@foreach($templates as $t)
<option value="{{ $t->id }}">{{ $t->name }}</option>
@endforeach
</select>
</div>
@if($selectedTemplateId && !empty($inspectionFormData))
@php $template = $templates->firstWhere('id', $selectedTemplateId); @endphp
@if($template)
@foreach($template->fields as $field)
<div class="mb-2">
<label class="label-text text-xs">{{ $field['label'] }} @if($field['required'] ?? false)<span class="text-error">*</span>@endif</label>
@switch($field['type'] ?? 'text')
@case('percentage')
<div class="flex items-center gap-1">
<input type="number" wire:model="inspectionFormData.{{ $field['name'] }}" min="0" max="100" class="input input-bordered input-sm w-16" />
<span class="text-xs">%</span>
<input type="range" min="0" max="100" wire:model.live="inspectionFormData.{{ $field['name'] }}" class="range range-primary range-xs flex-1" />
</div>
@break
@case('boolean')
<input type="checkbox" wire:model="inspectionFormData.{{ $field['name'] }}" class="checkbox checkbox-sm" />
@break
@case('select')
<select wire:model="inspectionFormData.{{ $field['name'] }}" class="select select-bordered select-sm w-full">
<option value="">{{ __('Select') }}</option>
@foreach(explode(',', $field['options'] ?? '') as $opt)
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
@endforeach
</select>
@break
@case('textarea')
<textarea wire:model="inspectionFormData.{{ $field['name'] }}" rows="2" class="textarea textarea-bordered textarea-sm w-full"></textarea>
@break
@default
<input type="{{ $field['type'] ?? 'text' }}" wire:model="inspectionFormData.{{ $field['name'] }}" class="input input-bordered input-sm w-full" />
@endswitch
</div>
@if($ins->user)<span class="text-gray-500">por {{ $ins->user->name }}</span>@endif
</div>
@endforeach
@endforeach
<button wire:click="saveInspection" class="btn btn-primary btn-xs w-full mt-1">{{ __('Register inspection') }}</button>
@endif
@endif
{{-- Historial de inspecciones --}}
@if($inspectionHistory->isNotEmpty())
<div class="divider text-xs">{{ __('History') }}</div>
<div class="space-y-1 max-h-40 overflow-y-auto">
@foreach($inspectionHistory as $ins)
<div class="border rounded p-2 text-xs" wire:key="hist-{{ $ins->id }}">
<div class="flex justify-between">
<span class="font-medium">{{ $ins->template?->name ?? __('Inspection') }}</span>
<span class="text-gray-400">{{ $ins->created_at->diffForHumans() }}</span>
</div>
@if($ins->user)<span class="text-gray-500">{{ __('by') }} {{ $ins->user->name }}</span>@endif
</div>
@endforeach
</div>
@endif
@else
<div role="alert" class="alert alert-vertical sm:alert-horizontal">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info h-6 w-6 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="font-bold">{{ __('No templates yet') }}</h3>
<div class="text-xs">{{ __('Create an inspection template') }}.</div>
</div>
<a href="{{ route('projects.templates', $project) }}" class="btn btn-primary btn-sm">{{ __('Create') }}</a>
</div>
@endif
</div>
</div>
@else
<div role="alert" class="alert alert-vertical sm:alert-horizontal">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info h-6 w-6 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="font-bold">{{ __("No templates yet") }}</h3>
<div class="text-xs">{{ __("Create an inspection template") }}.</div>
</div>
<a href="{{ route('projects.templates', $project) }}" class="btn btn-primary btn-sm">{{ __("Create") }}</a>
<div class="text-center text-gray-400 py-8">
<x-heroicon-o-cursor-arrow-rays class="w-10 h-10 mx-auto opacity-30" />
<p>{{ __('Click on a map element or search above to edit it') }}</p>
</div>
@endif
@else
<div class="text-center text-gray-400 py-8">
<p class="text-lg">👆</p>
<p>Haz clic en un elemento del mapa para editarlo</p>
</div>
@elseif($activeTab === 'features')
<!-- Features Table -->
@if($allFeatures->isNotEmpty())
<div class="overflow-x-auto rounded-lg border border-base-300">
<table class="table table-sm table-zebra table-pin-rows">
<thead>
<tr>
<th>{{ __('Feature') }}</th>
<th>{{ __('Layer') }}</th>
<th>{{ __('Phase') }}</th>
<th class="text-center">{{ __('Progress') }}</th>
<th class="w-10"></th>
</tr>
</thead>
<tbody>
@foreach($allFeatures as $feature)
<tr class="hover cursor-pointer" wire:click="selectFeature({{ $feature->id }})" wire:key="feat-{{ $feature->id }}">
<td class="font-medium">{{ $feature->name }}</td>
<td>{{ $feature->layer?->name ?? '—' }}</td>
<td>{{ $feature->layer?->phase?->name ?? '—' }}</td>
<td class="text-center">
<span class="badge badge-sm {{ $feature->progress >= 100 ? 'badge-success' : ($feature->progress > 0 ? 'badge-warning' : 'badge-ghost') }}">{{ $feature->progress }}%</span>
</td>
<td class="text-right">
<x-heroicon-o-chevron-right class="w-4 h-4 opacity-40" />
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center text-gray-400 py-8">
<x-heroicon-o-clipboard-document-list class="w-10 h-10 mx-auto opacity-30" />
<p>{{ __('No elements in this project') }}</p>
</div>
@endif
@elseif($activeTab === 'inspections')
<!-- Inspections Table -->
@if($allInspections->isNotEmpty())
<div class="overflow-x-auto rounded-lg border border-base-300">
<table class="table table-sm table-zebra table-pin-rows">
<thead>
<tr>
<th>{{ __('Date') }}</th>
<th>{{ __('Feature') }}</th>
<th>{{ __('Template') }}</th>
<th>{{ __('User') }}</th>
<th class="w-10"></th>
</tr>
</thead>
<tbody>
@foreach($allInspections as $inspection)
<tr class="hover" wire:key="insp-{{ $inspection->id }}">
<td class="whitespace-nowrap">{{ $inspection->created_at?->format('d/m/Y') ?? '—' }}</td>
<td class="font-medium">{{ $inspection->feature?->name ?? '—' }}</td>
<td>{{ $inspection->template?->name ?? '—' }}</td>
<td>{{ $inspection->user?->name ?? '—' }}</td>
<td class="text-right">
<button wire:click="viewInspection({{ $inspection->id }})" class="btn btn-xs btn-ghost" title="{{ __('View') }}">
<x-heroicon-o-eye class="w-4 h-4" />
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center text-gray-400 py-8">
<x-heroicon-o-clipboard-document-list class="w-10 h-10 mx-auto opacity-30" />
<p>{{ __('No inspections registered') }}</p>
</div>
@endif
@elseif($activeTab === 'issues')
<!-- Issues tab: render embedded IssueManager component -->
@livewire('issue-manager', ['project' => $project], key('issues-tab-' . $project->id))
@endif
</div>
{{-- Visor de inspección --}}
@if($viewingInspection)
<div class="modal modal-open z-[2000]" wire:key="ins-viewer-{{ $viewingInspection['id'] }}">
<div class="modal-box max-w-lg">
<div class="flex justify-between items-start mb-3">
<h3 class="font-bold text-lg">{{ __('Inspection') }} #{{ $viewingInspection['id'] }}</h3>
<button wire:click="closeViewInspection" class="btn btn-sm btn-circle btn-ghost">
<x-heroicon-o-x-mark class="w-5 h-5" />
</button>
</div>
<div class="grid grid-cols-2 gap-2 text-sm mb-2">
<div><span class="text-gray-500">{{ __('Feature') }}:</span> {{ $viewingInspection['feature_name'] }}</div>
<div><span class="text-gray-500">{{ __('Template') }}:</span> {{ $viewingInspection['template_name'] }}</div>
<div><span class="text-gray-500">{{ __('Phase') }}:</span> {{ $viewingInspection['phase_name'] }}</div>
<div><span class="text-gray-500">{{ __('Layer') }}:</span> {{ $viewingInspection['layer_name'] }}</div>
<div><span class="text-gray-500">{{ __('User') }}:</span> {{ $viewingInspection['user_name'] }}</div>
<div><span class="text-gray-500">{{ __('Date') }}:</span> {{ $viewingInspection['date'] }}</div>
</div>
@if(!empty($viewingInspection['fields']))
<div class="divider text-xs">{{ __('Data') }}</div>
<div class="space-y-1 text-sm">
@foreach($viewingInspection['fields'] as $field)
<div class="flex justify-between gap-3 border-b border-base-200 py-1">
<span class="text-gray-500">{{ $field['label'] ?? ($field['name'] ?? '') }}</span>
<span class="font-medium text-right">{{ $viewingInspection['data'][$field['name']] ?? '—' }}</span>
</div>
@endforeach
</div>
@endif
@if(!empty($viewingInspection['notes']))
<div class="divider text-xs">{{ __('Notes') }}</div>
<p class="text-sm whitespace-pre-line">{{ $viewingInspection['notes'] }}</p>
@endif
<div class="modal-action">
<button wire:click="closeViewInspection" class="btn btn-sm">{{ __('Close') }}</button>
</div>
</div>
<div class="modal-backdrop bg-black/40" wire:click="closeViewInspection"></div>
</div>
@endif
</div>
</div>
</div>
@push('styles')
<style>
.leaflet-container { z-index: 0 !important; }
</style>
@endpush
@push('scripts')
<style>
.leaflet-container { z-index: 0 !important; }
</style>
<script>
let map;
const layers = {};
let imageMarkersLayer = null;
let imageViewerModal = null;
<script>
let map;
const layers = {};
let imageMarkersLayer = null;
let imageViewerModal = null;
let mapInitialized = false;
let combinedBounds = null;
function initMap() {
if (map) return;
const center = [{{ $project->lat }}, {{ $project->lng }}];
map = L.map('map').setView(center, 16);
// Utility function to escape HTML
function escapeHtml(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> & CartoDB'
}).addTo(map);
// Utility function to validate URL
function isValidUrl(string) {
try {
new URL(string);
return true;
} catch (_) {
return false;
}
}
// Cargar fases y sus features
@foreach($phases as $phase)
@php
$phaseFeatures = $phase->features()->with('layer.phase')->get();
$fc = [
'type' => 'FeatureCollection',
'features' => $phaseFeatures->map(function($f) {
return [
'type' => 'Feature',
'id' => $f->id,
'geometry' => $f->geometry,
'properties' => array_merge($f->properties ?? [], [
'name' => $f->name,
'progress' => $f->progress,
'responsible' => $f->responsible,
'template_id' => $f->template_id,
'_feature_id' => $f->id,
])
function initMap() {
// Prevent multiple initializations
if (mapInitialized || map) return;
mapInitialized = true;
const center = [{{ $project->lat }}, {{ $project->lng }}];
map = L.map('map').setView(center, 16);
// Capas base seleccionables (calles / OSM / satélite)
const baseLayers = {
'{{ __('Streets') }}': L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> & CartoDB'
}),
'OpenStreetMap': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}),
'{{ __('Satellite') }}': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri — Source: Esri, Maxar, Earthstar Geographics'
}),
};
baseLayers['{{ __('Streets') }}'].addTo(map);
L.control.layers(baseLayers, null, { position: 'topleft' }).addTo(map);
// Cargar capas y sus features (cada capa = un grupo Leaflet independiente)
@foreach($phases as $phase)
@foreach($phase->layers as $layer)
@php
$fc = [
'type' => 'FeatureCollection',
'features' => $layer->features->map(function($f) {
return [
'type' => 'Feature',
'id' => $f->id,
'geometry' => $f->geometry,
'properties' => array_merge($f->properties ?? [], [
'name' => $f->name,
'progress' => $f->progress,
'responsible' => $f->responsible,
'template_id' => $f->template_id,
'_feature_id' => $f->id,
])
];
})->values()->toArray()
];
})->values()->toArray()
];
@endphp
(function() {
const data = @json($fc);
if (data && data.features && data.features.length > 0) {
const phaseLayer = L.geoJSON(data, {
style: { color: '{{ $phase->color }}', weight: 3, opacity: 0.8, fillOpacity: 0.3 },
onEachFeature: function(feature, layer) {
const props = feature.properties || {};
const featId = props._feature_id || feature.id;
let content = `<b>${props.name || 'Elemento'}</b><br>
{{ __("Progress") }}: ${props.progress || 0}%<br>
{{ __("Responsible") }}: ${props.responsible || '-'}<br>
<button class="btn btn-xs btn-primary mt-1" onclick="selectFeature(' + featId + ')">✏️ Editar</button>`;
layer.bindPopup(content);
layer.on('click', function() { selectFeature(featId); });
@endphp
(function() {
const data = @json($fc);
if (data && data.features && data.features.length > 0) {
const layerGroup = L.geoJSON(data, {
style: { color: '{{ $layer->color ?? $phase->color }}', weight: 3, opacity: 0.8, fillOpacity: 0.3 },
onEachFeature: function(feature, lyr) {
const props = feature.properties || {};
const featId = props._feature_id || feature.id;
const safeName = escapeHtml(props.name || '{{ __('Feature') }}');
const safeProgress = escapeHtml(props.progress || 0);
const safeResponsible = escapeHtml(props.responsible || '-');
let content = `<b>${safeName}</b><br>
{{ __('Progress') }}: ${safeProgress}%<br>
{{ __('Responsible') }}: ${safeResponsible}<br>
<button class="btn btn-xs btn-primary mt-1" onclick="selectFeature('${featId}')">{{ __('Edit') }}</button>`;
lyr.bindPopup(content);
lyr.on('click', function() { selectFeature(featId); });
}
});
layers[{{ $layer->id }}] = layerGroup;
@if(in_array((int) $layer->id, $activeLayers))
layerGroup.addTo(map);
@endif
}
})()
@endforeach
@endforeach
// Initialize combined bounds
updateCombinedBounds();
setTimeout(() => {
map.invalidateSize();
zoomToAllFeatures();
}, 100);
}
function updateCombinedBounds() {
if (!map) return;
combinedBounds = L.latLngBounds();
let hasBounds = false;
for (let id in layers) {
const layer = layers[id];
if (map.hasLayer(layer) && typeof layer.getBounds === 'function') {
const b = layer.getBounds();
if (b.isValid()) {
combinedBounds.extend(b);
hasBounds = true;
}
}
}
return hasBounds;
}
function zoomToAllFeatures() {
if (!map) return;
updateCombinedBounds();
if (combinedBounds && combinedBounds.isValid()) {
map.fitBounds(combinedBounds, { padding: [20, 20] });
} else {
map.setView([{{ $project->lat }}, {{ $project->lng }}], 16);
}
}
function selectFeature(featureId) {
@this.selectFeature(featureId);
}
function getUserLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition((pos) => {
const latlng = [pos.coords.latitude, pos.coords.longitude];
L.marker(latlng).addTo(map).bindPopup('{{ __('My location') }}').openPopup();
map.setView(latlng, 16);
}, () => alert('{{ __('No results') }}'));
} else {
alert('{{ __('No results') }}');
}
}
document.addEventListener('livewire:init', function () {
setTimeout(initMap, 50);
Livewire.on('layersUpdated', (activeIds) => {
const ids = Array.isArray(activeIds) ? activeIds[0] : activeIds;
for (let id in layers) {
const lid = parseInt(id);
if (ids.includes(lid)) {
if (!map.hasLayer(layers[id])) {
layers[id].addTo(map);
updateCombinedBounds();
}
} else {
if (map.hasLayer(layers[id])) {
map.removeLayer(layers[id]);
updateCombinedBounds();
}
}
}
zoomToAllFeatures();
});
Livewire.on('centerMap', zoomToAllFeatures);
Livewire.on('mapResize', () => {
if (map) {
if (!this.resizeTimeout) {
this.resizeTimeout = setTimeout(() => {
map.invalidateSize();
this.resizeTimeout = null;
}, 100);
}
}
});
Livewire.on('featureImagesToggled', (show, markers) => {
const m = Array.isArray(markers) ? markers : markers[1];
const s = Array.isArray(show) ? show[0] : show;
if (imageMarkersLayer) {
map.removeLayer(imageMarkersLayer);
imageMarkersLayer = null;
updateCombinedBounds();
}
if (s && m && m.length > 0) {
imageMarkersLayer = L.layerGroup().addTo(map);
const photoIcon = L.divIcon({
html: '<span style="font-size: 20px; filter: drop-shadow(0 2px 2px rgba(0,0,0,0.5));">&#128252;</span>',
className: '',
iconSize: [20, 20],
iconAnchor: [10, 10]
});
m.forEach(marker => {
const safeUrl = isValidUrl(marker.image_url) ? marker.image_url : '';
const safeName = escapeHtml(marker.image_name || '');
if (safeUrl) {
const popupContent = `<b>${safeName}</b><br>
<img src="${safeUrl}" class="max-w-[200px] max-h-[150px] rounded cursor-pointer"
onclick="window.openViewer('${safeUrl}', '${safeName}')" />`;
L.marker([marker.lat, marker.lng], { icon: photoIcon })
.bindPopup(popupContent)
.addTo(imageMarkersLayer);
}
});
layers[{{ $phase->id }}] = phaseLayer;
@if(in_array($phase->id, $activeLayers))
phaseLayer.addTo(map);
@endif
updateCombinedBounds();
}
})();
@endforeach
});
setTimeout(() => {
map.invalidateSize();
zoomToAllFeatures();
}, 200);
}
function zoomToAllFeatures() {
if (!map) return;
const bounds = L.latLngBounds();
let hasBounds = false;
for (let id in layers) {
const layer = layers[id];
if (map.hasLayer(layer) && typeof layer.getBounds === 'function') {
const b = layer.getBounds();
if (b.isValid()) { bounds.extend(b); hasBounds = true; }
}
}
if (hasBounds) map.fitBounds(bounds, { padding: [20, 20] });
else map.setView([{{ $project->lat }}, {{ $project->lng }}], 16);
}
function selectFeature(featureId) {
@this.selectFeature(featureId);
}
function getUserLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition((pos) => {
const latlng = [pos.coords.latitude, pos.coords.longitude];
L.marker(latlng).addTo(map).bindPopup('Tu ubicación').openPopup();
map.setView(latlng, 16);
}, () => alert('No se pudo obtener la ubicación'));
} else {
alert('Geolocalización no soportada');
}
}
document.addEventListener('livewire:init', function () {
setTimeout(initMap, 100);
Livewire.on('layersUpdated', (activeIds) => {
// Livewire wraps single parameters in an array, so we need to extract the actual data
const ids = Array.isArray(activeIds) ? activeIds[0] : activeIds;
for (let id in layers) {
const lid = parseInt(id);
if (ids.includes(lid)) {
if (!map.hasLayer(layers[id])) layers[id].addTo(map);
} else {
if (map.hasLayer(layers[id])) map.removeLayer(layers[id]);
window.openViewer = function(url, name) {
if (!isValidUrl(url)) {
console.error('Invalid URL provided to openViewer:', url);
return;
}
}
zoomToAllFeatures();
);
Livewire.on('centerMap', zoomToAllFeatures);
Livewire.on('mapResize', () => { if (map) setTimeout(() => map.invalidateSize(), 100); });
// Toggle imágenes en mapa
Livewire.on('featureImagesToggled', (show, markers) => {
const m = Array.isArray(markers) ? markers : markers[1];
const s = Array.isArray(show) ? show[0] : show;
if (imageMarkersLayer) { map.removeLayer(imageMarkersLayer); imageMarkersLayer = null; }
if (s && m && m.length > 0) {
imageMarkersLayer = L.layerGroup().addTo(map);
const photoIcon = L.divIcon({
html: '<span style="font-size: 20px; filter: drop-shadow(0 2px 2px rgba(0,0,0,0.5));">🖼️</span>',
className: '',
iconSize: [20, 20],
iconAnchor: [10, 10]
});
m.forEach(marker => {
const popupContent = `<b>${marker.name}</b><br>
<img src="${marker.image_url}" class="max-w-[200px] max-h-[150px] rounded cursor-pointer"
onclick="window.openViewer(' + marker.image_url + ', ' + marker.image_name + ')" />`;
L.marker([marker.lat, marker.lng], { icon: photoIcon })
.bindPopup(popupContent)
.addTo(imageMarkersLayer);
});
}
const safeName = escapeHtml(name);
if (imageViewerModal) imageViewerModal.remove();
const overlay = document.createElement('div');
overlay.id = 'imageViewerModal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:5000;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;padding:20px;cursor:pointer';
overlay.innerHTML = `<div style="position:relative;max-width:90vw;max-height:90vh">
<button onclick="this.closest('#imageViewerModal').remove()" style="position:absolute;top:-30px;right:0;color:white;font-size:24px;background:none;border:none;cursor:pointer"></button>
<img src="${url}" alt="${safeName}" style="max-width:100%;max-height:85vh;object-fit:contain;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,0.5)" />
<p style="color:white;text-align:center;margin-top:8px;font-size:14px">${safeName}</p>
</div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
imageViewerModal = overlay;
};
});
// Modal para ver imagen al hacer clic
window.openViewer = function(url, name) {
if (imageViewerModal) imageViewerModal.remove();
const overlay = document.createElement('div');
overlay.id = 'imageViewerModal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:5000;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;padding:20px;cursor:pointer';
overlay.innerHTML = `<div style="position:relative;max-width:90vw;max-height:90vh">
<button onclick="this.closest('#imageViewerModal').remove()" style="position:absolute;top:-30px;right:0;color:white;font-size:24px;background:none;border:none;cursor:pointer"></button>
<img src="${url}" alt="${name}" style="max-width:100%;max-height:85vh;object-fit:contain;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,0.5)" />
<p style="color:white;text-align:center;margin-top:8px;font-size:14px">${name}</p>
</div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
imageViewerModal = overlay;
};
});
</script>
@endpush
</script>
@endpush
@@ -1,84 +1,84 @@
<div>
<div class="mb-6">
<h2 class="text-xl font-bold mb-4">Reportes y Analítica</h2>
<div class="mb-4">\n <div class="flex space-x-3">\n <a href="{{ route("reports.export.projects") }}"\n class="btn btn-success btn-sm flex items-center">\n <i class="fas fa-file-excel mr-1"></i> Exportar Proyectos\n </a>\n <a href="{{ route("reports.export.phases") }}"\n class="btn btn-info btn-sm flex items-center">\n <i class="fas fa-file-excel mr-1"></i> Exportar Fases\n </a>\n <a href="{{ route("reports.export.inspections") }}"\n class="btn btn-warning btn-sm flex items-center">\n <i class="fas fa-file-excel mr-1"></i> Exportar Inspecciones\n </a>\n </div>\n </div>
<h2 class="text-xl font-bold mb-4">{{ __('Reports and Analytics') }}</h2>
<div class="mb-4">\n <div class="flex space-x-3">\n <a href="{{ route("reports.export.projects") }}"\n class="btn btn-success btn-sm flex items-center">\n <i class="fas fa-file-excel mr-1"></i> {{ __('Export') }} {{ __('Projects') }}\n </a>\n <a href="{{ route("reports.export.phases") }}"\n class="btn btn-info btn-sm flex items-center">\n <i class="fas fa-file-excel mr-1"></i> {{ __('Export') }} {{ __('Phases') }}\n </a>\n <a href="{{ route("reports.export.inspections") }}"\n class="btn btn-warning btn-sm flex items-center">\n <i class="fas fa-file-excel mr-1"></i> {{ __('Export') }} {{ __('Inspections') }}\n </a>\n </div>\n </div>
<div class="flex flex-wrap gap-4 mb-6">
<div class="flex items-center gap-2">
<span class="text-sm font-medium">Rango de tiempo:</span>
<span class="text-sm font-medium">{{ __('Time range:') }}</span>
<select wire:model="dateRange" class="border border-gray-300 rounded px-3 py-1">
<option value="week">Esta semana</option>
<option value="month" selected>Este mes</option>
<option value="quarter">Este trimestre</option>
<option value="year">Este año</option>
<option value="week">{{ __('This week') }}</option>
<option value="month" selected>{{ __('This month') }}</option>
<option value="quarter">{{ __('This quarter') }}</option>
<option value="year">{{ __('This year') }}</option>
</select>
</div>
<button wire:click="loadChartData"
<button wire:click="loadChartData"
class="btn btn-primary btn-sm">
Actualizar
{{ __('Update') }}
</button>
</div>
</div>
@if(isset($chartData['months']))
<div class="grid gap-6 mb-8">
{{-- Gráfico de progreso de proyectos --}}
{{-- Project progress chart --}}
<div class="bg-white rounded-lg shadow p-4">
<h3 class="text-lg font-semibold mb-4">Progreso de Proyectos (últimos 6 meses)</h3>
<h3 class="text-lg font-semibold mb-4">{{ __('Project Progress (last 6 months)') }}</h3>
<div style="position: relative; height: 300px;">
<canvas id="projectProgressChart"></canvas>
</div>
</div>
{{-- Gráfico de inspecciones por tipo --}}
{{-- Inspections by type chart --}}
<div class="bg-white rounded-lg shadow p-4">
<h3 class="text-lg font-semibold mb-4">Inspecciones por Tipo</h3>
<h3 class="text-lg font-semibold mb-4">{{ __('Inspections by Type') }}</h3>
<div style="position: relative; height: 300px;">
<canvas id="inspectionTypesChart"></canvas>
</div>
</div>
{{-- Gráfico de proyectos por estado --}}
{{-- Projects by status chart --}}
<div class="bg-white rounded-lg shadow p-4">
<h3 class="text-lg font-semibold mb-4">Distribución de Proyectos por Estado</h3>
<h3 class="text-lg font-semibold mb-4">{{ __('Projects by Status') }}</h3>
<div style="position: relative; height: 300px;">
<canvas id="projectsByStatusChart"></canvas>
</div>
</div>
{{-- Gráfico de progreso promedio por proyecto --}}
{{-- Average progress by project chart --}}
<div class="bg-white rounded-lg shadow p-4">
<h3 class="text-lg font-semibold mb-4">Progreso Promedio por Proyecto</h3>
<h3 class="text-lg font-semibold mb-4">{{ __('Average Progress by Project') }}</h3>
<div style="position: relative; height: 300px;">
<canvas id="projectPhaseProgressChart"></canvas>
</div>
</div>
</div>
{{-- Tarjetas de métricas clave --}}
{{-- Key metrics cards --}}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white rounded-lg shadow p-4">
<div class="text-sm text-gray-500 uppercase tracking-wide mb-2">
Total Proyectos Activos
{{ __('Total Active Projects') }}
</div>
<div class="text-2xl font-bold">
{{ \App\Models\Project::where('status', 'in_progress')->count() }}
</div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="text-sm text-gray-500 uppercase tracking-wide mb-2">
Inspecciones Este Mes
{{ __('Inspections This Month') }}
</div>
<div class="text-2xl font-bold">
{{ \App\Models\Inspection::whereDate('created_at', '>=', now()->startOfMonth())->count() }}
</div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="text-sm text-gray-500 uppercase tracking-wide mb-2">
Promedio de Progreso
{{ __('Average Progress') }}
</div>
<div class="text-2xl font-bold">
@php
@@ -88,10 +88,10 @@
{{ number_format($avgProgress, 1) }}%
</div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="text-sm text-gray-500 uppercase tracking-wide mb-2">
Proyectos Completados
{{ __('Completed Projects') }}
</div>
<div class="text-2xl font-bold">
{{ \App\Models\Project::where('status', 'completed')->count() }}
@@ -100,7 +100,7 @@
</div>
@else
<div class="bg-white rounded-lg shadow p-6 text-center">
<p class="text-gray-500">Cargando datos...</p>
<p class="text-gray-500">{{ __('Loading data...') }}</p>
</div>
@endif
</div>
@@ -112,17 +112,17 @@
window.addEventListener('livewire:load', function() {
initializeCharts();
});
window.addEventListener('livewire:updated', function() {
initializeCharts();
});
function initializeCharts() {
if (typeof Chart === 'undefined') {
console.warn('Chart.js not loaded');
return;
}
// Destroy existing charts if they exist
const chartIds = ['projectProgressChart', 'inspectionTypesChart', 'projectsByStatusChart', 'projectPhaseProgressChart'];
chartIds.forEach(id => {
@@ -162,7 +162,7 @@
max: 100,
title: {
display: true,
text: 'Progreso (%)'
text: '{{ __("Progress") }} (%)'
}
}
}
@@ -178,7 +178,7 @@
data: {
labels: @json($chartData['inspectionTypes']['labels'] ?? []),
datasets: [{
label: 'Cantidad de inspecciones',
label: '{{ __("Inspections") }}',
data: @json($chartData['inspectionTypes']['data'] ?? []),
backgroundColor: 'rgba(54, 162, 235, 0.5)',
borderColor: 'rgba(54, 162, 235, 1)',
@@ -198,7 +198,7 @@
beginAtZero: true,
title: {
display: true,
text: 'Cantidad'
text: '{{ __("Total") }}'
}
}
}
@@ -214,7 +214,7 @@
data: {
labels: @json($chartData['projectsByStatus']['labels'] ?? []),
datasets: [{
label: 'Proyectos por estado',
label: '{{ __("Projects by Status") }}',
data: @json($chartData['projectsByStatus']['data'] ?? []),
backgroundColor: [
'rgba(255, 99, 132, 0.5)',
@@ -255,13 +255,13 @@
if (projectPhaseProgressCtx) {
// Sort by progress descending
const sortedData = (@json($chartData['projectPhaseProgress'] ?? [])).sort((a, b) => b.progress - a.progress);
new Chart(projectPhaseProgressCtx, {
type: 'bar',
data: {
labels: sortedData.map(item => item.name),
datasets: [{
label: 'Progreso promedio (%)',
label: '{{ __("Average Progress") }} (%)',
data: sortedData.map(item => item.progress),
backgroundColor: 'rgba(75, 192, 192, 0.5)',
borderColor: 'rgba(75, 192, 192, 1)',
@@ -283,7 +283,7 @@
max: 100,
title: {
display: true,
text: 'Progreso (%)'
text: '{{ __("Progress") }} (%)'
}
}
}
@@ -0,0 +1,87 @@
<div class="py-8 max-w-6xl mx-auto sm:px-6 lg:px-8">
<div class="mb-6">
<h2 class="text-2xl font-bold text-gray-800">{{ __('Permission management') }}</h2>
<p class="text-sm text-gray-500 mt-1">{{ __('Tick which permissions each role has. Changes are saved instantly.') }}</p>
</div>
{{-- Crear rol / permiso --}}
<div class="flex flex-wrap items-start gap-6 mb-6">
<form wire:submit.prevent="addRole" class="flex flex-col gap-1">
<div class="flex gap-2">
<input wire:model="newRole" class="input input-bordered input-sm w-48" placeholder="{{ __('New role') }}" />
<button class="btn btn-sm btn-primary gap-1">
<x-heroicon-o-plus class="w-4 h-4" /> {{ __('Role') }}
</button>
</div>
@error('newRole') <span class="text-error text-xs">{{ $message }}</span> @enderror
</form>
<form wire:submit.prevent="addPermission" class="flex flex-col gap-1">
<div class="flex gap-2">
<input wire:model="newPermission" class="input input-bordered input-sm w-48" placeholder="{{ __('New permission') }}" />
<button class="btn btn-sm btn-outline gap-1">
<x-heroicon-o-plus class="w-4 h-4" /> {{ __('Permission') }}
</button>
</div>
@error('newPermission') <span class="text-error text-xs">{{ $message }}</span> @enderror
</form>
</div>
{{-- Matriz Roles × Permisos --}}
<div class="overflow-x-auto border border-base-300 rounded-lg bg-white">
<table class="table table-sm">
<thead>
<tr>
<th class="bg-base-200">{{ __('Permission') }}</th>
@foreach($roles as $role)
<th class="bg-base-200 text-center align-bottom">
<div class="flex flex-col items-center gap-1">
<span class="font-semibold">{{ $role->name }}</span>
@unless(in_array($role->name, ['Admin'], true))
<button wire:click="deleteRole({{ $role->id }})"
wire:confirm="{{ __('Delete role') }} '{{ $role->name }}'?"
class="btn btn-ghost btn-xs text-error" title="{{ __('Delete role') }}">
<x-heroicon-o-trash class="w-3.5 h-3.5" />
</button>
@endunless
</div>
</th>
@endforeach
</tr>
</thead>
<tbody>
@forelse($permissions as $perm)
<tr wire:key="perm-row-{{ $perm->id }}" class="hover">
<td class="font-medium whitespace-nowrap">
<div class="flex items-center gap-2">
<span>{{ $perm->name }}</span>
@if($perm->name !== 'manage all')
<button wire:click="deletePermission({{ $perm->id }})"
wire:confirm="{{ __('Delete permission') }} '{{ $perm->name }}'?"
class="btn btn-ghost btn-xs text-error opacity-40 hover:opacity-100" title="{{ __('Delete permission') }}">
<x-heroicon-o-trash class="w-3.5 h-3.5" />
</button>
@endif
</div>
</td>
@foreach($roles as $role)
<td class="text-center" wire:key="cell-{{ $perm->id }}-{{ $role->id }}">
<input type="checkbox"
class="checkbox checkbox-sm checkbox-primary"
@checked($role->permissions->contains('id', $perm->id))
wire:click="togglePermission({{ $role->id }}, '{{ $perm->name }}')" />
</td>
@endforeach
</tr>
@empty
<tr><td colspan="{{ $roles->count() + 1 }}" class="text-center text-gray-400 py-6">{{ __('No permissions') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
<p class="text-xs text-gray-400 mt-3">
{{ __('The Admin role and the "manage all" permission are protected and cannot be removed.') }}
</p>
</div>
@@ -0,0 +1,59 @@
<div class="py-8 max-w-3xl mx-auto sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-gray-800">
{{ $role ? __('Edit role') : __('New role') }}
</h2>
<a href="{{ route('admin.roles') }}" class="btn btn-ghost btn-sm gap-1" wire:navigate>
<x-heroicon-o-arrow-left class="w-4 h-4" /> {{ __('Back') }}
</a>
</div>
<div class="bg-white rounded-lg shadow p-6">
<form wire:submit.prevent="save" class="space-y-5">
{{-- Nombre --}}
<div class="flex items-start gap-4">
<label class="w-40 shrink-0 pt-2 text-sm font-medium text-gray-700">
{{ __('Name') }} <span class="text-error">*</span>
</label>
<div class="flex-1">
<input type="text" wire:model="name"
class="input input-bordered w-full @error('name') input-error @enderror"
placeholder="{{ __('e.g. Site Supervisor') }}"
@if($isProtected) readonly @endif />
@error('name') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
@if($isProtected)
<p class="text-xs text-gray-400 mt-1">{{ __('This role is protected and cannot be renamed.') }}</p>
@endif
</div>
</div>
{{-- Descripción --}}
<div class="flex items-start gap-4">
<label class="w-40 shrink-0 pt-2 text-sm font-medium text-gray-700">
{{ __('Description') }}
</label>
<div class="flex-1">
<textarea wire:model="description" rows="2"
class="textarea textarea-bordered w-full @error('description') textarea-error @enderror"
placeholder="{{ __('What is this role for?') }}"></textarea>
@error('description') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<p class="text-xs text-gray-400 pl-44">
{{ __('Permissions are assigned from the role view, in the "Permissions" tab.') }}
</p>
<div class="flex items-center justify-end gap-3 pt-2 border-t border-base-200">
<a href="{{ route('admin.roles') }}" class="btn btn-ghost" wire:navigate>{{ __('Cancel') }}</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" />
{{ $role ? __('Update role') : __('Create role') }}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,150 @@
<div class="py-8 max-w-4xl mx-auto sm:px-6 lg:px-8">
{{-- Cabecera: nombre del rol + botón Volver --}}
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
<x-heroicon-o-shield-check class="w-6 h-6 text-primary" />
{{ $role->name }}
@if($isProtected)
<span class="badge badge-ghost badge-sm">{{ __('protected') }}</span>
@endif
</h2>
<a href="{{ route('admin.roles') }}" class="btn btn-ghost btn-sm gap-1" wire:navigate>
<x-heroicon-o-arrow-left class="w-4 h-4" /> {{ __('Back') }}
</a>
</div>
{{-- Tabs --}}
<div class="flex flex-wrap gap-1 mb-4">
<button wire:click="setTab('ficha')" class="btn btn-sm {{ $tab === 'ficha' ? 'btn-primary' : 'btn-ghost' }}">
{{ __('Details') }}
</button>
<button wire:click="setTab('permisos')" class="btn btn-sm {{ $tab === 'permisos' ? 'btn-primary' : 'btn-ghost' }}">
{{ __('Permissions') }}
</button>
</div>
{{-- ═══════════════ TAB FICHA ═══════════════ --}}
@if($tab === 'ficha')
<div class="bg-white rounded-lg shadow p-6 mb-6">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">{{ __('Description') }}</h3>
<p class="text-gray-700">{{ $role->description ?: __('No description') }}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<a href="{{ route('admin.roles.edit', $role->id) }}" class="btn btn-sm btn-info gap-1" wire:navigate>
<x-heroicon-o-pencil class="w-4 h-4" /> {{ __('Edit') }}
</a>
@unless($isProtected)
<button wire:click="delete"
wire:confirm="{{ __('Delete role') }} '{{ $role->name }}'?"
class="btn btn-sm btn-error btn-outline gap-1">
<x-heroicon-o-trash class="w-4 h-4" /> {{ __('Delete') }}
</button>
@endunless
</div>
</div>
</div>
{{-- Usuarios con este rol --}}
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="px-6 py-3 border-b border-base-200">
<h3 class="font-semibold text-gray-700">{{ __('Users with this role') }} ({{ $users->count() }})</h3>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>{{ __('Name') }}</th>
<th>{{ __('Last name') }}</th>
<th>{{ __('Status') }}</th>
</tr>
</thead>
<tbody>
@forelse($users as $u)
<tr wire:key="ru-{{ $u->id }}" class="hover">
<td>
<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(mb_substr($u->first_name ?: $u->name, 0, 1)) }}</span>
</div>
</div>
<div>
<p class="font-medium text-sm leading-tight">{{ $u->first_name ?: $u->name }}</p>
<p class="text-xs text-gray-500">{{ $u->email }}</p>
</div>
</div>
</td>
<td class="text-sm">{{ $u->last_name ?: '—' }}</td>
<td>
@php
[$cls, $label] = match($u->status) {
'active' => ['badge-success', __('Active')],
'inactive' => ['badge-ghost', __('Inactive')],
'suspended' => ['badge-error', __('Suspended')],
default => ['badge-ghost', ucfirst((string) $u->status)],
};
@endphp
<span class="badge badge-sm {{ $cls }}">{{ $label }}</span>
</td>
</tr>
@empty
<tr><td colspan="3" class="text-center text-gray-400 py-8">{{ __('No users with this role') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endif
{{-- ═══════════════ TAB PERMISOS ═══════════════ --}}
@if($tab === 'permisos')
<div class="space-y-3">
@forelse($grouped as $section => $perms)
<div x-data="{ open: true }" class="bg-white rounded-lg shadow overflow-hidden">
{{-- Cabecera del grupo: colapsable + marcar/desmarcar todo --}}
<div class="flex items-center justify-between px-4 py-3 bg-base-200/60">
<button type="button" @click="open = !open" class="flex items-center gap-2 flex-1 text-left">
<span class="transition-transform duration-200" :class="open ? 'rotate-90' : ''">
<x-heroicon-o-chevron-right class="w-4 h-4 text-gray-500" />
</span>
<span class="font-semibold text-gray-700">{{ $section }}</span>
<span class="badge badge-ghost badge-sm">{{ $perms->count() }}</span>
</button>
<div class="flex items-center gap-1 shrink-0">
<button wire:click="setGroup('{{ $section }}', true)" class="btn btn-xs btn-ghost gap-1" title="{{ __('Check all') }}">
<x-heroicon-o-check class="w-3.5 h-3.5" /> {{ __('All') }}
</button>
<button wire:click="setGroup('{{ $section }}', false)" class="btn btn-xs btn-ghost gap-1" title="{{ __('Uncheck all') }}">
<x-heroicon-o-x-mark class="w-3.5 h-3.5" /> {{ __('None') }}
</button>
</div>
</div>
{{-- Lista de permisos: una sola columna (permiso izquierda, switch derecha) --}}
<div x-show="open" x-transition.opacity class="divide-y divide-base-200">
@foreach($perms as $perm)
<label class="flex items-center justify-between gap-3 px-4 py-2.5 cursor-pointer hover:bg-base-100">
<div class="min-w-0">
<span class="text-sm font-medium">{{ $perm->name }}</span>
@if($perm->description)
<p class="text-xs text-gray-400 leading-tight">{{ $perm->description }}</p>
@endif
</div>
<input type="checkbox"
class="toggle toggle-primary toggle-sm shrink-0"
wire:key="perm-{{ $role->id }}-{{ $perm->id }}"
@checked(in_array($perm->name, $rolePerms, true))
wire:click="togglePermission('{{ $perm->name }}')" />
</label>
@endforeach
</div>
</div>
@empty
<p class="text-gray-400 text-sm">{{ __('No permissions') }}</p>
@endforelse
</div>
@endif
</div>
@@ -1,10 +1,10 @@
<div>
<div class="bg-base-100 p-4 rounded shadow">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">📋 Templates de inspección</h2>
<h2 class="text-xl font-bold">📋 {{ __('Inspection templates') }}</h2>
<div>
<button wire:click="newTemplate" class="btn btn-primary btn-sm">
Nuevo template
{{ __('New template') }}
</button>
</div>
</div>
@@ -21,10 +21,10 @@
{{-- Nombre del template --}}
<tr>
<td class="w-1/4 py-3 pr-4 align-top">
{{__('Nombre del template')}}
{{ __('Template name') }}
</td>
<td class="py-3">
<input type="text" wire:model="form.name"
<input type="text" wire:model="form.name"
class="input w-full"
required>
</td>
@@ -33,7 +33,7 @@
{{-- Descripción --}}
<tr>
<td class="w-1/4 py-3 pr-4 align-top">
{{__('Descripción')}}
{{ __('Description') }}
</td>
<td class="py-3">
<textarea wire:model="form.description" class="textarea textarea-bordered w-full" rows="2"></textarea>
@@ -43,11 +43,11 @@
{{-- Fase asociada (opcional) --}}
<tr>
<td class="w-1/4 py-3 pr-4 align-top">
{{__('Fase asociada (opcional)')}}
{{ __('Associated phase (optional)') }}
</td>
<td class="py-3">
<select wire:model="form.phase_id" class="select select-bordered w-full">
<option value="">Ninguna (global para el proyecto)</option>
<option value="">{{ __('Global project') }}</option>
@foreach($phases as $phase)
<option value="{{ $phase->id }}" {{ old('form.phase_id') == $phase->id ? 'selected' : '' }}>
{{ $phase->name }}
@@ -61,22 +61,22 @@
{{-- Campos dinámicos --}}
<div class="border-t pt-4 mt-2">
<h3 class="font-bold mb-3">Campos del formulario</h3>
<h3 class="font-bold mb-3">{{ __('Form fields') }}</h3>
@foreach($form['fields'] as $index => $field)
<div class="border p-3 rounded mb-3 bg-base-100">
{{-- Fila: nombre interno --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Nombre interno</div>
<div class="font-medium">{{ __('Internal name') }}</div>
<div><input type="text" wire:model="form.fields.{{ $index }}.name" placeholder="ej: altura_medida" class="input input-sm w-full"></div>
</div>
{{-- Fila: etiqueta --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Etiqueta visible</div>
<div class="font-medium">{{ __('Visible label') }}</div>
<div><input type="text" wire:model="form.fields.{{ $index }}.label" placeholder="ej: Altura medida (m)" class="input input-sm w-full"></div>
</div>
{{-- Fila: tipo --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Tipo de campo</div>
<div class="font-medium">{{ __('Field type') }}</div>
<div>
<select wire:model="form.fields.{{ $index }}.type" class="select select-sm w-full">
@foreach($fieldTypes as $typeValue => $typeLabel)
@@ -87,37 +87,37 @@
</div>
{{-- Fila: requerido y botón eliminar --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Requerido</div>
<div class="font-medium">{{ __('Required') }}</div>
<div class="flex justify-between items-center">
<input type="checkbox" wire:model="form.fields.{{ $index }}.required" class="checkbox checkbox-sm">
<button type="button" wire:click="removeField({{ $index }})" class="btn btn-xs btn-error">Eliminar campo</button>
<button type="button" wire:click="removeField({{ $index }})" class="btn btn-xs btn-error">{{ __('Remove field') }}</button>
</div>
</div>
{{-- Campos adicionales según tipo --}}
@if(in_array($field['type'], ['integer', 'decimal', 'percentage']))
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Mínimo / Máximo / Paso</div>
<div class="font-medium">{{ __('Min') }} / {{ __('Max') }} / {{ __('Step') }}</div>
<div class="flex gap-2">
<input type="number" wire:model="form.fields.{{ $index }}.min" placeholder="Mín" class="input input-xs w-20">
<input type="number" wire:model="form.fields.{{ $index }}.max" placeholder="Máx" class="input input-xs w-20">
<input type="number" step="any" wire:model="form.fields.{{ $index }}.step" placeholder="Paso" class="input input-xs w-20">
<input type="number" wire:model="form.fields.{{ $index }}.min" placeholder="{{ __('Min') }}" class="input input-xs w-20">
<input type="number" wire:model="form.fields.{{ $index }}.max" placeholder="{{ __('Max') }}" class="input input-xs w-20">
<input type="number" step="any" wire:model="form.fields.{{ $index }}.step" placeholder="{{ __('Step') }}" class="input input-xs w-20">
</div>
</div>
@elseif($field['type'] === 'select')
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Opciones (separadas por coma)</div>
<div class="font-medium">{{ __('Options (comma separated)') }}</div>
<div><input type="text" wire:model="form.fields.{{ $index }}.options" placeholder="ej: Bueno,Regular,Malo" class="input input-sm w-full"></div>
</div>
@endif
</div>
@endforeach
<button type="button" wire:click="addField" class="btn btn-sm btn-secondary mt-2">+ Agregar campo</button>
<button type="button" wire:click="addField" class="btn btn-sm btn-secondary mt-2">+ {{ __('Add field') }}</button>
</div>
<div class="flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">Guardar template</button>
<button type="button" wire:click="cancelForm" class="btn">Cancelar</button>
<button type="submit" class="btn btn-primary">{{ $editingTemplate ? __('Update') : __('Save template') }}</button>
<button type="button" wire:click="cancelForm" class="btn">{{ __('Cancel') }}</button>
</div>
</form>
@endif
@@ -127,11 +127,11 @@
<table class="table table-zebra">
<thead>
<tr>
<th>Nombre</th>
<th>Descripción</th>
<th>Fase</th>
<th>Campos</th>
<th>Acciones</th>
<th>{{ __('Name') }}</th>
<th>{{ __('Description') }}</th>
<th>{{ __('Phase') }}</th>
<th>{{ __('Fields') }}</th>
<th>{{ __('Actions') }}</th>
</tr>
</thead>
<tbody>
@@ -139,22 +139,24 @@
<tr>
<td>{{ $template->name }}</td>
<td>{{ $template->description ?? '-' }}</td>
<td>{{ $template->phase ? $template->phase->name : 'Global' }}</td>
<td>{{ $template->phase ? $template->phase->name : __('Global project') }}</td>
<td>{{ count($template->fields) }}</td>
<td>
<button wire:click="editTemplate({{ $template->id }})" class="btn btn-xs btn-warning">
Editar
{{ __('Edit') }}
</button>
<button wire:click="deleteTemplate({{ $template->id }})" class="btn btn-xs btn-error" onclick="return confirm('¿Eliminar template?')">Eliminar</button>
<button wire:click="deleteTemplate({{ $template->id }})"
wire:confirm="{{ __('Delete template confirmation') }}"
class="btn btn-xs btn-error">{{ __('Delete') }}</button>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center">No hay templates creados. Presiona "Nuevo template" para comenzar.</td>
<td colspan="5" class="text-center">{{ __('No templates yet (table)') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
@@ -0,0 +1,337 @@
<div>
<x-slot name="header">
<div class="flex items-center gap-3">
<a href="{{ route('admin.users') }}" 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">
{{ $user ? 'Editar usuario: ' . $user->name : 'Nuevo usuario' }}
</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">
<x-heroicon-o-check-circle class="w-5 h-5" />
{{ session('notify') }}
</div>
@endif
<div class="card bg-base-100 shadow">
<div class="card-body p-8">
<form wire:submit.prevent="save">
@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
{{-- ══════════════════════════════════════════════════════════
1. INFORMACIÓN PERSONAL
══════════════════════════════════════════════════════════ --}}
<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">
Información personal
</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">
Título de cortesía
</label>
<div class="flex-1">
<select wire:model="title" class="select select-bordered w-full max-w-xs">
<option value=""> Sin título </option>
<option value="Sr.">Sr.</option>
<option value="Sra.">Sra.</option>
<option value="Dr.">Dr.</option>
<option value="Dra.">Dra.</option>
<option value="Ing.">Ing.</option>
<option value="Arq.">Arq.</option>
<option value="Prof.">Prof.</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">
Apellidos <span class="text-error">*</span>
</label>
<div class="flex-1">
<input type="text" wire:model="lastName"
class="input input-bordered w-full"
placeholder="García López" />
@error('lastName') <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">
Nombre <span class="text-error">*</span>
</label>
<div class="flex-1">
<input type="text" wire:model="firstName"
class="input input-bordered w-full"
placeholder="Ana" />
@error('firstName') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
</div>
</div>
{{-- ══════════════════════════════════════════════════════════
2. VALIDACIÓ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">
Validación de acceso
</h3>
<div class="space-y-4">
{{-- Intervalo de fechas --}}
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Válido desde / hasta
<p class="text-xs text-gray-400 font-normal mt-0.5">Vacío = sin límite</p>
</label>
<div class="flex-1 flex items-center gap-2">
<input type="date" wire:model="validFrom"
class="input input-bordered flex-1" />
<span class="text-gray-400 shrink-0"></span>
<input type="date" wire:model="validUntil"
class="input input-bordered flex-1" />
</div>
</div>
@error('validFrom') <div class="flex gap-4"><div class="w-48 shrink-0"></div><p class="text-error text-xs flex-1">{{ $message }}</p></div> @enderror
@error('validUntil') <div class="flex gap-4"><div class="w-48 shrink-0"></div><p class="text-error text-xs flex-1">{{ $message }}</p></div> @enderror
{{-- Contraseña con generador --}}
<div class="flex items-start gap-4"
x-data="{
show: false,
generate() {
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const lower = 'abcdefghjkmnpqrstuvwxyz';
const digits = '23456789';
const symbols = '!@#$%&*';
const all = upper + lower + digits + symbols;
let pwd = upper[Math.floor(Math.random()*upper.length)]
+ lower[Math.floor(Math.random()*lower.length)]
+ digits[Math.floor(Math.random()*digits.length)]
+ symbols[Math.floor(Math.random()*symbols.length)];
for (let i = 4; i < 12; i++) {
pwd += all[Math.floor(Math.random()*all.length)];
}
pwd = pwd.split('').sort(() => Math.random()-0.5).join('');
$wire.set('formPassword', pwd);
this.show = true;
}
}">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Contraseña
@if(!$user) <span class="text-error">*</span> @endif
@if($user)
<p class="text-xs text-gray-400 font-normal mt-0.5">Vacío = no cambiar</p>
@endif
</label>
<div class="flex-1 space-y-2">
<div class="flex gap-2">
<label class="input input-bordered flex items-center gap-2 flex-1">
<x-heroicon-o-lock-closed class="w-4 h-4 opacity-40 shrink-0" />
<input wire:model="formPassword" class="grow"
:type="show ? 'text' : 'password'"
placeholder="{{ $user ? '••••••••' : 'Mínimo 8 caracteres' }}" />
<button type="button" @click="show = !show"
class="opacity-50 hover:opacity-100 transition-opacity">
<template x-if="show">
<x-heroicon-o-eye-slash class="w-4 h-4" />
</template>
<template x-if="!show">
<x-heroicon-o-eye class="w-4 h-4" />
</template>
</button>
</label>
<button type="button" @click="generate()"
class="btn btn-outline btn-sm gap-1 shrink-0"
title="Generar contraseña aleatoria">
<x-heroicon-o-arrow-path class="w-4 h-4" />
Generar
</button>
</div>
@if(!$user)
<p class="text-xs text-gray-400">Mayúsculas, minúsculas, números y símbolo.</p>
@endif
@error('formPassword') <p class="text-error text-xs">{{ $message }}</p> @enderror
</div>
</div>
{{-- Estado --}}
<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="userStatus" class="select select-bordered w-full max-w-xs">
<option value="active">Activo</option>
<option value="inactive">Inactivo</option>
<option value="suspended">Suspendido</option>
</select>
@error('userStatus') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
</div>
</div>
{{-- ══════════════════════════════════════════════════════════
3. 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">
{{-- Empresa --}}
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Empresa <span class="text-error">*</span>
</label>
<div class="flex-1">
<select wire:model.live="companyId" class="select select-bordered w-full">
<option value=""> Seleccionar empresa </option>
@foreach($companies as $company)
<option value="{{ $company->id }}">
{{ $company->apodo ?: $company->name }}
</option>
@endforeach
</select>
@error('companyId') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
{{-- Dirección --}}
<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 space-y-1.5">
<textarea wire:model="address" rows="2"
class="textarea textarea-bordered w-full"
placeholder="Calle, número, ciudad, CP, país"></textarea>
@if($companyId)
<button type="button" wire:click="copyCompanyAddress"
class="btn btn-xs btn-outline gap-1">
<x-heroicon-o-document-duplicate class="w-3.5 h-3.5" />
Copiar dirección de la empresa
</button>
@endif
</div>
</div>
{{-- Teléfono --}}
<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>
{{-- Email --}}
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Email <span class="text-error">*</span>
</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="ana@empresa.com" />
</label>
@error('email') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
</div>
</div>
{{-- ══════════════════════════════════════════════════════════
4. PERMISOS
══════════════════════════════════════════════════════════ --}}
<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">
Permisos
</h3>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Rol <span class="text-error">*</span>
<p class="text-xs text-gray-400 font-normal mt-0.5">Define los permisos del usuario</p>
</label>
<div class="flex-1">
<select wire:model="formRole" class="select select-bordered w-full max-w-xs">
@foreach($roles as $role)
<option value="{{ $role->name }}">{{ $role->name }}</option>
@endforeach
</select>
@error('formRole') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
</div>
{{-- ══════════════════════════════════════════════════════════
5. 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">
Notas
<p class="text-xs text-gray-400 font-normal mt-0.5">Solo visible para administradores</p>
</label>
<div class="flex-1">
<textarea wire:model="notes" rows="4"
class="textarea textarea-bordered w-full"
placeholder="Observaciones, historial, información relevante…"></textarea>
</div>
</div>
</div>
{{-- ── Botones ───────────────────────────────────────────── --}}
<div class="flex items-center justify-between pt-2">
<a href="{{ route('admin.users') }}" 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" />
{{ $user ? 'Guardar cambios' : 'Crear usuario' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,552 @@
<div>
<x-slot name="header">
{{-- ── Header del usuario ───────────────────────────────────────────── --}}
<div class="flex items-start justify-between gap-4 flex-wrap">
{{-- Izquierda: avatar + datos --}}
<div class="flex items-start gap-4">
{{-- Avatar --}}
<div class="w-14 h-14 rounded-full bg-primary flex items-center justify-center shrink-0 shadow">
<span class="text-xl font-bold text-primary-content">
{{ strtoupper(substr($user->first_name ?: $user->name, 0, 1)) }}{{ strtoupper(substr($user->last_name ?: '', 0, 1)) }}
</span>
</div>
{{-- Nombre + datos de contacto --}}
<div>
<h2 class="font-bold text-xl leading-tight">
@if($user->title) <span class="text-gray-500 font-normal">{{ $user->title }}</span> @endif
{{ $user->first_name && $user->last_name
? $user->first_name . ' ' . $user->last_name
: $user->name }}
</h2>
{{-- Empresa --}}
@if($user->company)
<div class="flex items-center gap-1.5 mt-0.5">
@if($user->company->logo_path && \Illuminate\Support\Facades\Storage::disk('public')->exists($user->company->logo_path))
<img src="{{ \Illuminate\Support\Facades\Storage::disk('public')->url($user->company->logo_path) }}"
class="w-4 h-4 object-contain rounded" alt="" />
@else
<x-heroicon-o-building-office class="w-3.5 h-3.5 text-gray-400" />
@endif
<span class="text-sm text-gray-600">{{ $user->company->apodo ?: $user->company->name }}</span>
</div>
@endif
{{-- Contacto inline --}}
<div class="flex flex-wrap items-center gap-x-4 gap-y-0.5 mt-1 text-sm text-gray-500">
@if($user->email)
<span class="flex items-center gap-1">
<x-heroicon-o-envelope class="w-3.5 h-3.5 opacity-60" />
{{ $user->email }}
</span>
@endif
@if($user->phone)
<span class="flex items-center gap-1">
<x-heroicon-o-phone class="w-3.5 h-3.5 opacity-60" />
{{ $user->phone }}
</span>
@endif
@if($user->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">{{ $user->address }}</span>
</span>
@endif
</div>
</div>
</div>
{{-- Derecha: estado + validez + botones --}}
<div class="flex flex-col items-end gap-2">
<div class="flex items-center gap-2">
{{-- Estado --}}
@php
$statusBadge = match($user->status ?? 'active') {
'active' => ['badge-success', 'Activo'],
'inactive' => ['badge-ghost', 'Inactivo'],
'suspended' => ['badge-error', 'Suspendido'],
default => ['badge-ghost', ucfirst($user->status ?? '')],
};
@endphp
<span class="badge {{ $statusBadge[0] }} badge-md">{{ $statusBadge[1] }}</span>
{{-- Rol principal --}}
@foreach($user->roles->take(1) as $role)
<span class="badge {{ $role->name === 'Admin' ? 'badge-error' : 'badge-primary' }} badge-md">
{{ $role->name }}
</span>
@endforeach
</div>
{{-- Validez --}}
@if($user->valid_from || $user->valid_until)
@php
$now = now();
$from = $user->valid_from;
$until = $user->valid_until;
$isExpired = $until && $until->lt($now);
$expireSoon = !$isExpired && $until && $until->diffInDays($now) <= 30;
$notStarted = $from && $from->gt($now);
$validColor = $isExpired || $notStarted ? 'text-error' : ($expireSoon ? 'text-warning' : 'text-gray-400');
@endphp
<p class="text-xs {{ $validColor }} flex items-center gap-1">
<x-heroicon-o-calendar-days class="w-3.5 h-3.5" />
@if($from && $until)
{{ $from->format('d/m/Y') }} {{ $until->format('d/m/Y') }}
@elseif($from)
Desde {{ $from->format('d/m/Y') }}
@else
Hasta {{ $until->format('d/m/Y') }}
@endif
@if($isExpired) <span class="font-semibold">(Expirado)</span>
@elseif($notStarted) <span class="font-semibold">(No activo aún)</span>
@elseif($expireSoon) <span class="font-semibold">(Expira pronto)</span>
@endif
</p>
@endif
{{-- Botones --}}
<div class="flex gap-2 mt-1">
<a href="{{ route('admin.users.edit', $user) }}"
class="btn btn-outline btn-sm gap-1" wire:navigate>
<x-heroicon-o-pencil class="w-4 h-4" />
Editar
</a>
<a href="{{ route('admin.users') }}"
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>
{{-- ── Tabs ──────────────────────────────────────────────────────────────── --}}
<div class="py-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-4">
<div role="tablist" class="tabs tabs-bordered">
<button role="tab" wire:click="setTab('permissions')"
class="tab gap-2 {{ $activeTab === 'permissions' ? 'tab-active font-semibold' : '' }}">
<x-heroicon-o-shield-check class="w-4 h-4" />
Permisos
</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">{{ $user->projects->count() }}</span>
</button>
<button role="tab" wire:click="setTab('activity')"
class="tab gap-2 {{ $activeTab === 'activity' ? 'tab-active font-semibold' : '' }}">
<x-heroicon-o-clock class="w-4 h-4" />
Actividad
</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($user->notes)
<span class="badge badge-sm badge-primary"></span>
@endif
</button>
</div>
{{-- ════════════════════════════════════════════════════════════════════
TAB: PERMISOS
════════════════════════════════════════════════════════════════════ --}}
@if($activeTab === 'permissions')
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{{-- Roles --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-6">
<h3 class="font-semibold text-base flex items-center gap-2 mb-4">
<x-heroicon-o-shield-check class="w-5 h-5 text-primary" />
Roles asignados
</h3>
@if($user->roles->isEmpty())
<p class="text-sm text-gray-400">Sin roles asignados.</p>
@else
<div class="space-y-2">
@foreach($user->roles as $role)
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg">
<span class="badge {{ $role->name === 'Admin' ? 'badge-error' : 'badge-primary' }} badge-lg">
{{ $role->name }}
</span>
</div>
@endforeach
</div>
@endif
</div>
</div>
{{-- Validez y estado --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-6">
<h3 class="font-semibold text-base flex items-center gap-2 mb-4">
<x-heroicon-o-calendar-days class="w-5 h-5 text-primary" />
Validez de acceso
</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between items-center py-2 border-b border-base-200">
<span class="text-gray-500">Estado</span>
<span class="badge {{ $statusBadge[0] }}">{{ $statusBadge[1] }}</span>
</div>
<div class="flex justify-between items-center py-2 border-b border-base-200">
<span class="text-gray-500">Válido desde</span>
<span class="font-medium">
{{ $user->valid_from ? $user->valid_from->format('d/m/Y') : '— (sin límite)' }}
</span>
</div>
<div class="flex justify-between items-center py-2 border-b border-base-200">
<span class="text-gray-500">Válido hasta</span>
<span class="font-medium {{ isset($isExpired) && $isExpired ? 'text-error font-bold' : '' }}">
{{ $user->valid_until ? $user->valid_until->format('d/m/Y') : '— (sin límite)' }}
</span>
</div>
<div class="flex justify-between items-center py-2">
<span class="text-gray-500">Email verificado</span>
@if($user->email_verified_at)
<span class="flex items-center gap-1 text-success text-xs font-medium">
<x-heroicon-o-check-circle class="w-4 h-4" />
{{ $user->email_verified_at->format('d/m/Y') }}
</span>
@else
<span class="text-warning text-xs flex items-center gap-1">
<x-heroicon-o-clock class="w-4 h-4" />
Pendiente
</span>
@endif
</div>
</div>
</div>
</div>
{{-- Empresa --}}
@if($user->company)
<div class="card bg-base-100 shadow md:col-span-2">
<div class="card-body p-6">
<h3 class="font-semibold text-base flex items-center gap-2 mb-3">
<x-heroicon-o-building-office-2 class="w-5 h-5 text-primary" />
Empresa
</h3>
<div class="flex items-center gap-4">
@if($user->company->logo_path && \Illuminate\Support\Facades\Storage::disk('public')->exists($user->company->logo_path))
<img src="{{ \Illuminate\Support\Facades\Storage::disk('public')->url($user->company->logo_path) }}"
class="w-14 h-14 object-contain border border-base-300 rounded-lg" alt="" />
@else
<div class="w-14 h-14 bg-base-200 rounded-lg flex items-center justify-center">
<x-heroicon-o-building-office class="w-7 h-7 opacity-30" />
</div>
@endif
<div>
<p class="font-semibold">{{ $user->company->name }}</p>
@if($user->company->apodo)
<p class="text-sm text-gray-500">{{ $user->company->apodo }}</p>
@endif
@if($user->company->email)
<p class="text-xs text-gray-400">{{ $user->company->email }}</p>
@endif
</div>
@php
$typeBadge = match($user->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] }} ml-auto">{{ $typeBadge[1] }}</span>
</div>
</div>
</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" />
Asignar proyecto
</h3>
@if($availableProjects->isEmpty())
<p class="text-sm text-gray-400">El usuario ya está asignado a todos los proyectos disponibles.</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-[160px]">
<label class="label-text text-xs mb-1">Rol en proyecto</label>
<input type="text" wire:model="addProjectRole"
class="input input-bordered input-sm w-full"
placeholder="ej: Jefe de obra" />
</div>
<button wire:click="assignProject" class="btn btn-primary btn-sm gap-1 shrink-0">
<x-heroicon-o-plus class="w-4 h-4" />
Asignar
</button>
</div>
@endif
</div>
</div>
{{-- Lista proyectos --}}
@if($user->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 asignados.</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</th>
<th>Estado</th>
<th>Progreso</th>
<th class="w-16"></th>
</tr>
</thead>
<tbody>
@foreach($user->projects as $project)
@php
$avg = $project->phases->avg('progress_percent') ?? 0;
$sCfg = 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 class="text-sm text-gray-600">
{{ $project->pivot->role_in_project ?? '—' }}
</td>
<td>
<span class="badge badge-sm {{ $sCfg[0] }}">{{ $sCfg[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="¿Desasignar a {{ $user->name }} del proyecto '{{ $project->name }}'?"
class="btn btn-xs btn-outline btn-error" title="Desasignar">
<x-heroicon-o-x-mark class="w-3.5 h-3.5" />
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@endif
{{-- ════════════════════════════════════════════════════════════════════
TAB: ACTIVIDAD
════════════════════════════════════════════════════════════════════ --}}
@if($activeTab === 'activity')
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{{-- Inspecciones --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<h3 class="font-semibold text-base flex items-center gap-2 mb-3">
<x-heroicon-o-clipboard-document-list class="w-5 h-5 text-yellow-500" />
Últimas inspecciones
</h3>
@if($recentInspections->isEmpty())
<div class="text-center py-8 text-gray-400">
<x-heroicon-o-clipboard-document-list class="w-8 h-8 mx-auto opacity-25 mb-1" />
<p class="text-sm">Sin inspecciones registradas</p>
</div>
@else
<div class="space-y-2">
@foreach($recentInspections as $ins)
@php
$rCfg = match($ins->result ?? '') {
'pass' => ['badge-success', 'OK'],
'fail' => ['badge-error', 'Fallo'],
default => ['badge-ghost', '—'],
};
@endphp
<div class="p-2.5 rounded-lg bg-base-200 text-sm">
<div class="flex items-start justify-between gap-2">
<span class="font-medium truncate flex-1">
{{ $ins->template?->name ?? 'Inspección' }}
</span>
<span class="badge badge-xs {{ $rCfg[0] }} shrink-0">{{ $rCfg[1] }}</span>
</div>
<div class="flex items-center justify-between mt-0.5 text-xs text-gray-400">
<span class="truncate">
@if($ins->feature?->layer?->phase?->project)
<x-heroicon-o-folder-open class="w-3 h-3 inline" />
{{ $ins->feature->layer->phase->project->name }}
@endif
</span>
<span class="shrink-0 ml-1">{{ $ins->created_at->diffForHumans() }}</span>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
{{-- Issues reportados --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<h3 class="font-semibold text-base flex items-center gap-2 mb-3">
<x-heroicon-o-exclamation-triangle class="w-5 h-5 text-orange-500" />
Issues reportados
</h3>
@if($recentIssues->isEmpty())
<div class="text-center py-8 text-gray-400">
<x-heroicon-o-check-circle class="w-8 h-8 mx-auto opacity-25 mb-1" />
<p class="text-sm">Sin issues reportados</p>
</div>
@else
<div class="space-y-2">
@foreach($recentIssues as $issue)
@php
$pCfg = match($issue->priority ?? 'medium') {
'critical' => ['badge-error', 'Crítico'],
'high' => ['badge-warning', 'Alto'],
'medium' => ['badge-info', 'Medio'],
default => ['badge-ghost', 'Bajo'],
};
$stCfg = match($issue->status ?? 'open') {
'open' => 'text-orange-500',
'closed' => 'text-green-500',
default => 'text-gray-400',
};
@endphp
<div class="p-2.5 rounded-lg bg-base-200 text-sm">
<div class="flex items-start justify-between gap-2">
<span class="font-medium truncate flex-1">{{ $issue->title }}</span>
<span class="badge badge-xs {{ $pCfg[0] }} shrink-0">{{ $pCfg[1] }}</span>
</div>
<div class="flex items-center justify-between mt-0.5 text-xs text-gray-400">
<span class="truncate">
@if($issue->project)
<x-heroicon-o-folder-open class="w-3 h-3 inline" />
{{ $issue->project->name }}
@endif
</span>
<span class="{{ $stCfg }} shrink-0 ml-1 font-medium">
{{ ucfirst($issue->status ?? 'open') }}
</span>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
</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, observaciones o información relevante sobre este usuario…"
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($user->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]">
{{ $user->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>

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