Compare commits
27 Commits
c832d4f3da
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5092896a1e | |||
| 938e704a67 | |||
| 828e70fbe2 | |||
| da0c8bd134 | |||
| 316e0ede39 | |||
| 564b433a62 | |||
| 7df6d208d9 | |||
| 860c502f32 | |||
| 8101f22413 | |||
| fe57388f05 | |||
| 75c07aa0d4 | |||
| 558b1732aa | |||
| 19fef5aa25 | |||
| 238310180f | |||
| 0fca7387e0 | |||
| ffd377cd39 | |||
| 24976e28da | |||
| de68638d7c | |||
| 3fd4d62df1 | |||
| 25f61cdb7d | |||
| 6e66f707d5 | |||
| 941dbd5997 | |||
| c44958ac16 | |||
| ee3086c34b | |||
| a24c8a2c2e | |||
| f8a1310c0f | |||
| 7d854ffb0a |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use App\Models\Project;
|
||||
|
||||
class ProjectEditTabs extends Component
|
||||
{
|
||||
public Project $project;
|
||||
public string $activeTab = 'project-data';
|
||||
|
||||
public function mount(Project $project)
|
||||
{
|
||||
$this->project = $project;
|
||||
}
|
||||
|
||||
public function setActiveTab($tab)
|
||||
{
|
||||
$this->activeTab = $tab;
|
||||
}
|
||||
|
||||
public function tabChanged($tab, $projectId)
|
||||
{
|
||||
if ($projectId == $this->project->id) {
|
||||
$this->activeTab = $tab;
|
||||
}
|
||||
}
|
||||
|
||||
public function updateProject()
|
||||
{
|
||||
$this->project->save();
|
||||
|
||||
session()->flash('message', __('Project updated successfully.'));
|
||||
$this->dispatch('project-updated');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project-edit-tabs');
|
||||
}
|
||||
}
|
||||
+109
-53
@@ -3,83 +3,139 @@
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Layout;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class ProjectForm extends Component
|
||||
{
|
||||
public $projectId = null;
|
||||
public $name = '';
|
||||
public $address = '';
|
||||
public $lat = null;
|
||||
public $lng = null;
|
||||
public $country = '';
|
||||
public $start_date = '';
|
||||
public $end_date_estimated = '';
|
||||
public $status = 'planning';
|
||||
public ?Project $project = null;
|
||||
|
||||
protected $rules = [
|
||||
'name' => 'required|string|max:255',
|
||||
'address' => 'required|string',
|
||||
'lat' => 'nullable|numeric',
|
||||
'lng' => 'nullable|numeric',
|
||||
'start_date' => 'required|date',
|
||||
'end_date_estimated' => 'nullable|date',
|
||||
'status' => 'required|in:planning,in_progress,paused,completed',
|
||||
];
|
||||
// Identification
|
||||
public string $name = '';
|
||||
public string $reference = '';
|
||||
public string $status = 'planning';
|
||||
|
||||
public function mount($projectId = null)
|
||||
// Location
|
||||
public string $address = '';
|
||||
public string $country = '';
|
||||
public string $lat = '';
|
||||
public string $lng = '';
|
||||
|
||||
// Planning
|
||||
public string $startDate = '';
|
||||
public string $endDateEstimated = '';
|
||||
|
||||
public function mount(?Project $project = null): void
|
||||
{
|
||||
if ($projectId) {
|
||||
$this->projectId = $projectId;
|
||||
$project = Project::findOrFail($projectId);
|
||||
$this->name = $project->name;
|
||||
$this->address = $project->address;
|
||||
$this->lat = $project->lat;
|
||||
$this->lng = $project->lng;
|
||||
$this->start_date = $project->start_date->format('Y-m-d');
|
||||
$this->end_date_estimated = $project->end_date_estimated?->format('Y-m-d');
|
||||
$this->status = $project->status;
|
||||
// country? we don't have stored, maybe we can leave blank or compute from lat/lng? We'll leave blank for now.
|
||||
if ($project && $project->exists) {
|
||||
Gate::authorize('edit projects', $project);
|
||||
$this->project = $project;
|
||||
$this->name = $project->name;
|
||||
$this->reference = $project->reference ?? '';
|
||||
$this->status = $project->status;
|
||||
$this->address = $project->address;
|
||||
$this->country = $project->country ?? '';
|
||||
$this->lat = (string) ($project->lat ?? '');
|
||||
$this->lng = (string) ($project->lng ?? '');
|
||||
$this->startDate = $project->start_date->format('Y-m-d');
|
||||
$this->endDateEstimated = $project->end_date_estimated?->format('Y-m-d') ?? '';
|
||||
} else {
|
||||
Gate::authorize('create projects');
|
||||
$this->startDate = today()->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
public function setCoordinates($lat, $lng)
|
||||
// Called from JS after map click / marker drag + reverse geocode
|
||||
public function setLocation(string $lat, string $lng, string $address = '', string $country = ''): void
|
||||
{
|
||||
$this->lat = $lat;
|
||||
$this->lng = $lng;
|
||||
// Optionally, we could trigger reverse geocoding here via JS and update address and country.
|
||||
// But we'll do that entirely in JavaScript for better UX.
|
||||
// We'll emit an event to JS to fetch address.
|
||||
$this->dispatch('coordinatesUpdated', lat: $lat, lng: $lng);
|
||||
if ($address) $this->address = $address;
|
||||
if ($country) $this->country = strtolower($country);
|
||||
}
|
||||
|
||||
public function save()
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'reference' => 'nullable|string|max:100',
|
||||
'status' => 'required|in:planning,in_progress,paused,completed',
|
||||
'address' => 'required|string',
|
||||
'country' => 'nullable|string|size:2',
|
||||
'lat' => 'nullable|numeric|between:-90,90',
|
||||
'lng' => 'nullable|numeric|between:-180,180',
|
||||
'startDate' => 'required|date',
|
||||
'endDateEstimated' => 'nullable|date|after_or_equal:startDate',
|
||||
];
|
||||
}
|
||||
|
||||
protected $validationAttributes = [
|
||||
'name' => 'nombre',
|
||||
'reference' => 'referencia',
|
||||
'status' => 'estado',
|
||||
'address' => 'dirección',
|
||||
'country' => 'país',
|
||||
'lat' => 'latitud',
|
||||
'lng' => 'longitud',
|
||||
'startDate' => 'fecha de inicio',
|
||||
'endDateEstimated' => 'fecha de fin estimada',
|
||||
];
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
if ($this->projectId) {
|
||||
$project = Project::findOrFail($this->projectId);
|
||||
$data = [
|
||||
'name' => $this->name,
|
||||
'reference' => $this->reference ?: null,
|
||||
'status' => $this->status,
|
||||
'address' => $this->address,
|
||||
'country' => $this->country ?: null,
|
||||
'lat' => $this->lat ?: null,
|
||||
'lng' => $this->lng ?: null,
|
||||
'start_date' => $this->startDate,
|
||||
'end_date_estimated' => $this->endDateEstimated ?: null,
|
||||
];
|
||||
|
||||
if ($this->project && $this->project->exists) {
|
||||
$this->project->update($data);
|
||||
session()->flash('notify', 'Proyecto actualizado correctamente.');
|
||||
} else {
|
||||
$project = new Project();
|
||||
$project->created_by = auth()->id();
|
||||
$project = Project::create(array_merge($data, ['created_by' => Auth::id()]));
|
||||
$project->users()->attach(Auth::id(), ['role_in_project' => 'supervisor']);
|
||||
session()->flash('notify', 'Proyecto creado correctamente.');
|
||||
}
|
||||
|
||||
$project->name = $this->name;
|
||||
$project->address = $this->address;
|
||||
$project->lat = $this->lat;
|
||||
$project->lng = $this->lng;
|
||||
$project->start_date = $this->start_date;
|
||||
$project->end_date_estimated = $this->end_date_estimated;
|
||||
$project->status = $this->status;
|
||||
$project->save();
|
||||
|
||||
session()->flash('message', 'Project saved successfully.');
|
||||
|
||||
return redirect()->route('projects.index');
|
||||
$this->redirect(route('projects.index'), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.projects.project-form');
|
||||
return view('livewire.projects.project-form', [
|
||||
'countryList' => $this->countryList(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO alpha-2 (lowercase, matches flagcdn) => display name.
|
||||
*/
|
||||
private function countryList(): array
|
||||
{
|
||||
return [
|
||||
'es' => 'España', 'pt' => 'Portugal', 'fr' => 'Francia', 'it' => 'Italia',
|
||||
'de' => 'Alemania', 'gb' => 'Reino Unido', 'ie' => 'Irlanda', 'nl' => 'Países Bajos',
|
||||
'be' => 'Bélgica', 'ch' => 'Suiza', 'at' => 'Austria', 'lu' => 'Luxemburgo',
|
||||
'se' => 'Suecia', 'no' => 'Noruega', 'dk' => 'Dinamarca', 'fi' => 'Finlandia',
|
||||
'pl' => 'Polonia', 'cz' => 'Chequia', 'gr' => 'Grecia', 'ro' => 'Rumanía',
|
||||
'us' => 'Estados Unidos', 'ca' => 'Canadá', 'mx' => 'México', 'gt' => 'Guatemala',
|
||||
'cr' => 'Costa Rica', 'pa' => 'Panamá', 'co' => 'Colombia', 've' => 'Venezuela',
|
||||
'ec' => 'Ecuador', 'pe' => 'Perú', 'bo' => 'Bolivia', 'cl' => 'Chile',
|
||||
'ar' => 'Argentina', 'uy' => 'Uruguay', 'py' => 'Paraguay', 'br' => 'Brasil',
|
||||
'do' => 'República Dominicana', 'ma' => 'Marruecos', 'gq' => 'Guinea Ecuatorial',
|
||||
'ao' => 'Angola', 'cv' => 'Cabo Verde', 'us' => 'Estados Unidos',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+237
-122
@@ -10,27 +10,28 @@ use App\Models\Layer;
|
||||
use App\Models\Feature;
|
||||
use App\Models\Inspection;
|
||||
use App\Models\InspectionTemplate;
|
||||
use App\Models\Issue;
|
||||
|
||||
class ProjectMap extends Component
|
||||
{
|
||||
public Project $project;
|
||||
public $phases;
|
||||
public $activeLayers = [];
|
||||
public $activeLayers = []; // Now stores Layer IDs (not Phase IDs)
|
||||
public $showLayerModal = false;
|
||||
|
||||
// Editor properties
|
||||
public $selectedFeature = null; // será instancia de Feature
|
||||
public $selectedFeature = null;
|
||||
public $selectedPhaseId = null;
|
||||
public $editProgress = 0;
|
||||
public $editComment = '';
|
||||
public $editResponsible = '';
|
||||
public $editPhotos = [];
|
||||
public $formFullscreen = false;
|
||||
// Tab management
|
||||
public $activeTab = 'edit'; // edit, features, inspections
|
||||
public $allFeatures = [];
|
||||
public $allInspections = [];
|
||||
|
||||
// Tab management
|
||||
public $activeTab = 'edit';
|
||||
public $allFeatures;
|
||||
public $allInspections;
|
||||
|
||||
// Templates e inspecciones
|
||||
public $templates = [];
|
||||
@@ -42,19 +43,61 @@ class ProjectMap extends Component
|
||||
public $showFeatureImages = false;
|
||||
public $featureImageMarkers = [];
|
||||
|
||||
// Tab management
|
||||
public $activeTab = 'edit'; // edit or list
|
||||
// Filters
|
||||
public $filterStatus = '';
|
||||
public $filterResponsible = '';
|
||||
public $filterProgressMin = 0;
|
||||
public $filterProgressMax = 100;
|
||||
public $showFilters = false;
|
||||
|
||||
// Inspection workflow
|
||||
public $inspectionResult = '';
|
||||
public $inspectionNotes = '';
|
||||
|
||||
// Issues
|
||||
public $openIssuesCount = 0;
|
||||
|
||||
// Inspection viewer
|
||||
public $viewingInspection = null;
|
||||
|
||||
public function mount(Project $project)
|
||||
{
|
||||
$this->project = $project;
|
||||
// Cargar fases con sus capas y los features de esas capas (para mostrarlos en el mapa)
|
||||
$this->phases = $project->phases()->with(['layers' => function ($q) {
|
||||
$q->withCount('features');
|
||||
}, 'layers.features'])->get();
|
||||
// Por defecto mostrar todas las capas activas (todas las fases que tengan alguna capa con features)
|
||||
$this->activeLayers = $this->phases->pluck('id')->toArray();
|
||||
$this->authorizeProjectAccess();
|
||||
|
||||
$this->phases = $project->phases()->with([
|
||||
'layers' => fn($q) => $q->withCount('features'),
|
||||
'layers.features',
|
||||
'layers.features.images',
|
||||
])->get();
|
||||
|
||||
// Initialize activeLayers with ALL layer IDs (not phase IDs)
|
||||
$this->activeLayers = $this->phases
|
||||
->flatMap(fn($p) => $p->layers->pluck('id'))
|
||||
->map(fn($id) => (int) $id)
|
||||
->toArray();
|
||||
|
||||
$this->loadTemplates();
|
||||
|
||||
$this->allFeatures = Feature::whereHas('layer.phase', function($q) use ($project) {
|
||||
$q->where('project_id', $project->id);
|
||||
})->with(['layer.phase', 'template'])->get();
|
||||
|
||||
$this->allInspections = Inspection::where('project_id', $project->id)
|
||||
->with(['feature.layer.phase', 'template', 'user'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$this->openIssuesCount = Issue::where('project_id', $project->id)
|
||||
->where('status', 'open')
|
||||
->count();
|
||||
}
|
||||
|
||||
private function authorizeProjectAccess(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
if ($user->hasRole('Admin')) return;
|
||||
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403);
|
||||
}
|
||||
|
||||
public function loadTemplates()
|
||||
@@ -62,90 +105,129 @@ class ProjectMap extends Component
|
||||
$this->templates = InspectionTemplate::where('project_id', $this->project->id)->get();
|
||||
}
|
||||
|
||||
public function toggleLayer($phaseId)
|
||||
// ─── Layer / Phase visibility ────────────────────────────────────────────────
|
||||
|
||||
public function toggleLayer($layerId)
|
||||
{
|
||||
if (in_array($phaseId, $this->activeLayers)) {
|
||||
$this->activeLayers = array_diff($this->activeLayers, [$phaseId]);
|
||||
$layerId = (int) $layerId;
|
||||
if (in_array($layerId, $this->activeLayers)) {
|
||||
$this->activeLayers = array_values(array_diff($this->activeLayers, [$layerId]));
|
||||
} else {
|
||||
$this->activeLayers[] = $phaseId;
|
||||
$this->activeLayers[] = $layerId;
|
||||
}
|
||||
$this->dispatch('layersUpdated', $this->activeLayers);
|
||||
}
|
||||
|
||||
public function openLayerModal()
|
||||
public function togglePhase($phaseId)
|
||||
{
|
||||
$this->showLayerModal = true;
|
||||
$phase = $this->phases->find($phaseId);
|
||||
if (!$phase) return;
|
||||
$layerIds = $phase->layers->pluck('id')->map(fn($id) => (int) $id)->toArray();
|
||||
$allActive = !empty($layerIds) && collect($layerIds)->every(fn($id) => in_array($id, $this->activeLayers));
|
||||
if ($allActive) {
|
||||
$this->activeLayers = array_values(array_diff($this->activeLayers, $layerIds));
|
||||
} else {
|
||||
$this->activeLayers = array_values(array_unique(array_merge($this->activeLayers, $layerIds)));
|
||||
}
|
||||
$this->dispatch('layersUpdated', $this->activeLayers);
|
||||
}
|
||||
|
||||
public function closeLayerModal()
|
||||
public function openLayerModal() { $this->showLayerModal = true; }
|
||||
public function closeLayerModal() { $this->showLayerModal = false; }
|
||||
|
||||
// ─── Filters ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function updatedFilterStatus() { $this->applyFilters(); }
|
||||
public function updatedFilterResponsible() { $this->applyFilters(); }
|
||||
public function updatedFilterProgressMin() { $this->applyFilters(); }
|
||||
public function updatedFilterProgressMax() { $this->applyFilters(); }
|
||||
|
||||
public function applyFilters()
|
||||
{
|
||||
$this->showLayerModal = false;
|
||||
$filtered = $this->allFeatures->filter(function($f) {
|
||||
if ($this->filterStatus && $f->status !== $this->filterStatus) return false;
|
||||
if ($this->filterResponsible && !str_contains(strtolower($f->responsible ?? ''), strtolower($this->filterResponsible))) return false;
|
||||
if ($f->progress < $this->filterProgressMin || $f->progress > $this->filterProgressMax) return false;
|
||||
return true;
|
||||
});
|
||||
$this->dispatch('filtersChanged', $filtered->pluck('id')->values()->toArray());
|
||||
}
|
||||
|
||||
public function clearFilters()
|
||||
{
|
||||
$this->filterStatus = '';
|
||||
$this->filterResponsible = '';
|
||||
$this->filterProgressMin = 0;
|
||||
$this->filterProgressMax = 100;
|
||||
$this->dispatch('filtersChanged', $this->allFeatures->pluck('id')->values()->toArray());
|
||||
}
|
||||
|
||||
// ─── Feature status ─────────────────────────────────────────────────────────
|
||||
|
||||
public function editFeatureStatus($status)
|
||||
{
|
||||
if (!$this->selectedFeature) return;
|
||||
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
|
||||
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||
$feature->status = $status;
|
||||
if ($status === 'completed') $feature->progress = 100;
|
||||
if ($status === 'planned') $feature->progress = 0;
|
||||
$feature->save();
|
||||
$this->selectedFeature = $feature;
|
||||
$this->editProgress = $feature->progress;
|
||||
$this->allFeatures = $this->allFeatures->map(fn($f) => $f->id === $feature->id ? $feature : $f);
|
||||
$this->dispatch('featureStatusChanged', $feature->id, $feature->status, $feature->status_color);
|
||||
$this->dispatch('notify', 'Estado actualizado');
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar el progreso de un Feature y recalcular el progreso de la fase.
|
||||
*/
|
||||
public function updateProgress($featureId, $newProgress, $comment = null)
|
||||
{
|
||||
$feature = Feature::findOrFail($featureId);
|
||||
$feature = Feature::with('layer.phase')->findOrFail($featureId);
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user->can('update progress') && !$user->hasRole('Admin')) {
|
||||
$this->dispatch('notify', 'Sin permisos');
|
||||
return;
|
||||
}
|
||||
|
||||
$oldProgress = $feature->progress;
|
||||
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||
$feature->progress = min(100, max(0, $newProgress));
|
||||
$feature->save();
|
||||
|
||||
// Recalcular el progreso de la fase (promedio de todos sus features)
|
||||
$phase = Phase::find($feature->layer->phase_id);
|
||||
$phase = $feature->layer->phase;
|
||||
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
|
||||
$phase->save();
|
||||
|
||||
// Registrar la actualización en progress_updates
|
||||
$phase->progressUpdates()->create([
|
||||
'user_id' => $user->id,
|
||||
'user_id' => $user->id,
|
||||
'progress_percent' => $phase->progress_percent,
|
||||
'comment' => $comment,
|
||||
'comment' => $comment,
|
||||
]);
|
||||
|
||||
$this->dispatch('progressUpdated', $featureId, $feature->progress);
|
||||
$this->dispatch('notify', 'Progreso actualizado');
|
||||
|
||||
// Si el feature seleccionado es el mismo, actualizar la propiedad local
|
||||
if ($this->selectedFeature && $this->selectedFeature->id == $featureId) {
|
||||
$this->selectedFeature->progress = $feature->progress;
|
||||
$this->editProgress = $feature->progress;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seleccionar un Feature al hacer clic en el mapa.
|
||||
*/
|
||||
public function selectFeature($featureId)
|
||||
{
|
||||
$this->selectedFeature = null;
|
||||
$feature = Feature::with('template')->find($featureId);
|
||||
$feature = Feature::with(['template', 'layer.phase'])->find($featureId);
|
||||
if (!$feature) return;
|
||||
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||
|
||||
$this->selectedFeature = $feature;
|
||||
$this->selectedPhaseId = $feature->layer->phase_id;
|
||||
$this->editProgress = $feature->progress;
|
||||
$this->editResponsible = $feature->responsible ?? '';
|
||||
$this->editPhotos = $feature->properties['photos'] ?? [];
|
||||
$this->selectedFeature = $feature;
|
||||
$this->selectedPhaseId = $feature->layer->phase_id;
|
||||
$this->editProgress = $feature->progress;
|
||||
$this->editResponsible = $feature->responsible ?? '';
|
||||
$this->editPhotos = $feature->properties['photos'] ?? [];
|
||||
$this->selectedTemplateId = $feature->template_id;
|
||||
$this->activeTab = 'edit';
|
||||
|
||||
$this->loadInspectionHistory();
|
||||
$this->resetInspectionForm();
|
||||
|
||||
$this->dispatch('featureSelected', $featureId);
|
||||
$this->dispatch('featureSelected', $featureId, $feature->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargar el historial de inspecciones del feature seleccionado.
|
||||
*/
|
||||
public function loadInspectionHistory()
|
||||
{
|
||||
if (!$this->selectedFeature) {
|
||||
@@ -158,12 +240,11 @@ class ProjectMap extends Component
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiniciar el formulario de inspección según el template seleccionado.
|
||||
*/
|
||||
public function resetInspectionForm()
|
||||
{
|
||||
$this->inspectionFormData = [];
|
||||
$this->inspectionResult = '';
|
||||
$this->inspectionNotes = '';
|
||||
if ($this->selectedTemplateId) {
|
||||
$template = InspectionTemplate::find($this->selectedTemplateId);
|
||||
if ($template) {
|
||||
@@ -174,19 +255,16 @@ class ProjectMap extends Component
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guardar una nueva inspección.
|
||||
*/
|
||||
public function saveInspection()
|
||||
{
|
||||
if (!$this->selectedFeature || !$this->selectedTemplateId) {
|
||||
$this->dispatch('notify', 'Selecciona un elemento y un template.');
|
||||
return;
|
||||
}
|
||||
$feature = Feature::with('layer.phase')->find($this->selectedFeature->id);
|
||||
if (!$feature || $feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||
|
||||
$this->validate([
|
||||
'selectedTemplateId' => 'required|exists:inspection_templates,id',
|
||||
]);
|
||||
$this->validate(['selectedTemplateId' => 'required|exists:inspection_templates,id']);
|
||||
|
||||
$template = InspectionTemplate::find($this->selectedTemplateId);
|
||||
foreach ($template->fields as $field) {
|
||||
@@ -197,70 +275,117 @@ class ProjectMap extends Component
|
||||
}
|
||||
|
||||
$inspection = Inspection::create([
|
||||
'project_id' => $this->project->id,
|
||||
'layer_id' => $this->selectedFeature->layer_id,
|
||||
'feature_id' => $this->selectedFeature->id,
|
||||
'template_id' => $this->selectedTemplateId,
|
||||
'user_id' => auth()->id(),
|
||||
'data' => $this->inspectionFormData,
|
||||
'project_id' => $this->project->id,
|
||||
'layer_id' => $this->selectedFeature->layer_id,
|
||||
'feature_id' => $this->selectedFeature->id,
|
||||
'template_id' => $this->selectedTemplateId,
|
||||
'user_id' => auth()->id(),
|
||||
'inspector_user_id' => auth()->id(),
|
||||
'status' => 'completed',
|
||||
'completed_at' => now(),
|
||||
'result' => $this->inspectionResult ?: null,
|
||||
'notes' => $this->inspectionNotes ?: null,
|
||||
'data' => $this->inspectionFormData,
|
||||
]);
|
||||
|
||||
// Si el template tiene un campo llamado 'progress', actualizar el progreso del feature
|
||||
if (isset($this->inspectionFormData['progress'])) {
|
||||
$this->updateProgress($this->selectedFeature->id, (int)$this->inspectionFormData['progress'], 'Inspección registrada');
|
||||
if ($this->inspectionResult === 'fail') {
|
||||
Issue::create([
|
||||
'project_id' => $this->project->id,
|
||||
'feature_id' => $this->selectedFeature->id,
|
||||
'inspection_id' => $inspection->id,
|
||||
'title' => 'Fallo en inspección: ' . ($template->name ?? 'Sin nombre'),
|
||||
'description' => $this->inspectionNotes,
|
||||
'priority' => 'high',
|
||||
'status' => 'open',
|
||||
'reported_by' => auth()->id(),
|
||||
]);
|
||||
$this->openIssuesCount = Issue::where('project_id', $this->project->id)
|
||||
->where('status', 'open')->count();
|
||||
$this->dispatch('notify', 'Inspección fallida — Issue creado automáticamente');
|
||||
} else {
|
||||
if (isset($this->inspectionFormData['progress'])) {
|
||||
$this->updateProgress($this->selectedFeature->id, (int)$this->inspectionFormData['progress'], 'Inspección registrada');
|
||||
}
|
||||
$this->dispatch('notify', 'Inspección guardada correctamente');
|
||||
}
|
||||
|
||||
// Reload global list
|
||||
$this->allInspections = Inspection::where('project_id', $this->project->id)
|
||||
->with(['feature.layer.phase', 'template', 'user'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$this->loadInspectionHistory();
|
||||
$this->resetInspectionForm();
|
||||
$this->dispatch('notify', 'Inspección guardada correctamente');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asignar un template al feature seleccionado.
|
||||
*/
|
||||
public function assignTemplateToFeature($templateId)
|
||||
{
|
||||
if (!$this->selectedFeature) return;
|
||||
|
||||
$this->selectedFeature->template_id = $templateId;
|
||||
$this->selectedFeature->save();
|
||||
|
||||
$template = InspectionTemplate::where('id', $templateId)
|
||||
->where('project_id', $this->project->id)->first();
|
||||
if (!$template) abort(403);
|
||||
$feature = Feature::findOrFail($this->selectedFeature->id);
|
||||
$feature->template_id = $templateId;
|
||||
$feature->save();
|
||||
$this->selectedFeature = $feature;
|
||||
$this->selectedTemplateId = $templateId;
|
||||
$this->resetInspectionForm();
|
||||
$this->dispatch('notify', 'Template asignado al elemento');
|
||||
}
|
||||
|
||||
/**
|
||||
* Guardar progreso y responsable del feature seleccionado.
|
||||
*/
|
||||
public function saveFeatureProgress()
|
||||
{
|
||||
if (!$this->selectedFeature) return;
|
||||
|
||||
$this->selectedFeature->progress = min(100, max(0, (int)$this->editProgress));
|
||||
$this->selectedFeature->responsible = $this->editResponsible;
|
||||
$this->selectedFeature->save();
|
||||
|
||||
// Recalcular progreso de la fase
|
||||
$phase = Phase::find($this->selectedFeature->layer->phase_id);
|
||||
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
|
||||
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
|
||||
$feature->progress = min(100, max(0, (int)$this->editProgress));
|
||||
$feature->responsible = $this->editResponsible;
|
||||
$feature->save();
|
||||
$this->selectedFeature = $feature;
|
||||
$phase = $feature->layer->phase;
|
||||
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
|
||||
$phase->save();
|
||||
|
||||
$this->dispatch('progressUpdated', $phase->id, $phase->progress_percent);
|
||||
$this->dispatch('notify', 'Progreso guardado');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuando cambia el template seleccionado, reiniciar el formulario.
|
||||
*/
|
||||
public function onTemplateChange()
|
||||
{
|
||||
$this->resetInspectionForm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mostrar imágenes en el mapa.
|
||||
*/
|
||||
// ─── Inspection viewer ───────────────────────────────────────────────────────
|
||||
|
||||
public function viewInspection($id)
|
||||
{
|
||||
$ins = Inspection::where('project_id', $this->project->id)
|
||||
->with(['feature.layer.phase', 'template', 'user'])
|
||||
->find($id);
|
||||
if (!$ins) return;
|
||||
$this->viewingInspection = [
|
||||
'id' => $ins->id,
|
||||
'feature_name' => $ins->feature?->name ?? '—',
|
||||
'layer_name' => $ins->feature?->layer?->name ?? '—',
|
||||
'phase_name' => $ins->feature?->layer?->phase?->name ?? '—',
|
||||
'template_name' => $ins->template?->name ?? '—',
|
||||
'user_name' => $ins->user?->name ?? '—',
|
||||
'date' => $ins->created_at->format('d/m/Y H:i'),
|
||||
'status' => $ins->status,
|
||||
'result' => $ins->result,
|
||||
'notes' => $ins->notes,
|
||||
'data' => $ins->data ?? [],
|
||||
'fields' => $ins->template?->fields ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
public function closeViewInspection()
|
||||
{
|
||||
$this->viewingInspection = null;
|
||||
}
|
||||
|
||||
// ─── Feature images ──────────────────────────────────────────────────────────
|
||||
|
||||
public function toggleFeatureImages()
|
||||
{
|
||||
$this->showFeatureImages = !$this->showFeatureImages;
|
||||
@@ -268,44 +393,31 @@ class ProjectMap extends Component
|
||||
$this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargar marcadores de imágenes para el mapa.
|
||||
*/
|
||||
public function loadFeatureImageMarkers()
|
||||
{
|
||||
if (!$this->showFeatureImages) {
|
||||
$this->featureImageMarkers = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->showFeatureImages) { $this->featureImageMarkers = []; return; }
|
||||
$markers = [];
|
||||
foreach ($this->phases as $phase) {
|
||||
foreach ($phase->layers as $layer) {
|
||||
foreach ($layer->features as $feature) {
|
||||
$image = $feature->images()->first();
|
||||
$image = $feature->images->first();
|
||||
if ($image) {
|
||||
$geo = $feature->geometry;
|
||||
$geo = $feature->geometry;
|
||||
$coords = null;
|
||||
if ($geo && isset($geo['coordinates'])) {
|
||||
if ($geo['type'] === 'Point') {
|
||||
$coords = [
|
||||
'lat' => $geo['coordinates'][1],
|
||||
'lng' => $geo['coordinates'][0],
|
||||
];
|
||||
$coords = ['lat' => $geo['coordinates'][1], 'lng' => $geo['coordinates'][0]];
|
||||
} elseif (in_array($geo['type'], ['Polygon', 'LineString'])) {
|
||||
$coords = [
|
||||
'lat' => $geo['coordinates'][0][1] ?? null,
|
||||
'lng' => $geo['coordinates'][0][0] ?? null,
|
||||
];
|
||||
$coords = ['lat' => $geo['coordinates'][0][1] ?? null, 'lng' => $geo['coordinates'][0][0] ?? null];
|
||||
}
|
||||
}
|
||||
if ($coords && $coords['lat'] && $coords['lng']) {
|
||||
$markers[] = [
|
||||
'feature_id' => $feature->id,
|
||||
'name' => $feature->name,
|
||||
'lat' => $coords['lat'],
|
||||
'lng' => $coords['lng'],
|
||||
'image_url' => $image->url,
|
||||
'name' => $feature->name,
|
||||
'lat' => $coords['lat'],
|
||||
'lng' => $coords['lng'],
|
||||
'image_url' => $image->url,
|
||||
'image_name' => $image->name,
|
||||
];
|
||||
}
|
||||
@@ -319,16 +431,19 @@ class ProjectMap extends Component
|
||||
public function toggleFullscreen()
|
||||
{
|
||||
$this->formFullscreen = !$this->formFullscreen;
|
||||
if (!$this->formFullscreen) {
|
||||
$this->dispatch('mapResize');
|
||||
}
|
||||
if (!$this->formFullscreen) $this->dispatch('mapResize');
|
||||
}
|
||||
|
||||
public function setActiveTab($tab)
|
||||
{
|
||||
$this->activeTab = $tab;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.projects.project-map', [
|
||||
'project' => $this->project,
|
||||
'phases' => $this->phases,
|
||||
'phases' => $this->phases,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?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 RoleForm extends Component
|
||||
{
|
||||
public ?Role $role = null;
|
||||
|
||||
public string $name = '';
|
||||
public string $description = '';
|
||||
public array $rolePermissions = [];
|
||||
|
||||
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 ?? '';
|
||||
$this->rolePermissions = $role->permissions->pluck('name')->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
$isProtected = in_array($this->role->name, self::PROTECTED_ROLES, true);
|
||||
if (! $isProtected) {
|
||||
$this->role->name = $this->name;
|
||||
}
|
||||
$this->role->description = $this->description ?: null;
|
||||
$this->role->save();
|
||||
|
||||
$perms = $this->rolePermissions;
|
||||
if ($this->role->name === 'Admin' && ! in_array(self::CORE_PERMISSION, $perms, true)) {
|
||||
$perms[] = self::CORE_PERMISSION;
|
||||
}
|
||||
$this->role->syncPermissions($perms);
|
||||
} else {
|
||||
$role = Role::create([
|
||||
'name' => $this->name,
|
||||
'description' => $this->description ?: null,
|
||||
]);
|
||||
if (! empty($this->rolePermissions)) {
|
||||
$role->syncPermissions($this->rolePermissions);
|
||||
}
|
||||
}
|
||||
|
||||
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', [
|
||||
'permissions' => Permission::orderBy('name')->get(),
|
||||
'isProtected' => $this->role && in_array($this->role->name, self::PROTECTED_ROLES, true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?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 RoleManager extends Component
|
||||
{
|
||||
// View modal
|
||||
public ?int $viewingRole = null;
|
||||
|
||||
// Bulk selection
|
||||
public array $selected = [];
|
||||
public bool $selectAll = false;
|
||||
|
||||
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 updatedSelectAll($value): void
|
||||
{
|
||||
$this->selected = $value
|
||||
? Role::pluck('id')->map(fn ($id) => (string) $id)->toArray()
|
||||
: [];
|
||||
}
|
||||
|
||||
// ── View ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function openView(int $id): void
|
||||
{
|
||||
$this->viewingRole = $id;
|
||||
}
|
||||
|
||||
public function closeView(): void
|
||||
{
|
||||
$this->viewingRole = null;
|
||||
}
|
||||
|
||||
// ── Delete (single / bulk) ─────────────────────────────────────────────────
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$role = Role::findOrFail($id);
|
||||
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->selected = array_values(array_diff($this->selected, [(string) $id, $id]));
|
||||
$this->flushCache();
|
||||
$this->dispatch('notify', 'Rol eliminado');
|
||||
}
|
||||
|
||||
public function bulkDelete(): void
|
||||
{
|
||||
$roles = Role::whereIn('id', $this->selected)->get();
|
||||
$deleted = 0;
|
||||
$skipped = 0;
|
||||
foreach ($roles as $role) {
|
||||
if (in_array($role->name, self::PROTECTED_ROLES, true)) { $skipped++; continue; }
|
||||
$role->delete();
|
||||
$deleted++;
|
||||
}
|
||||
$this->selected = [];
|
||||
$this->selectAll = false;
|
||||
$this->flushCache();
|
||||
$msg = "{$deleted} rol(es) eliminados";
|
||||
if ($skipped) $msg .= " ({$skipped} protegido(s) omitido(s))";
|
||||
$this->dispatch('notify', $msg);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.role-manager', [
|
||||
'roles' => Role::with('permissions')->withCount('users')->orderBy('name')->get(),
|
||||
'viewing' => $this->viewingRole
|
||||
? Role::with('permissions')->withCount('users')->find($this->viewingRole)
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'); }
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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
@@ -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})",
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
+252
-2
@@ -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
@@ -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..."
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'previous' => '« Anterior',
|
||||
'next' => 'Siguiente »',
|
||||
|
||||
];
|
||||
@@ -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.',
|
||||
|
||||
];
|
||||
@@ -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
@@ -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',
|
||||
];
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }} – {{ $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') }}
|
||||
—
|
||||
{{ $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') }}
|
||||
•
|
||||
{{ __('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,173 +1,138 @@
|
||||
<div>
|
||||
<div class="max-w-2xl mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ $projectId ? __('Edit Project') : __('New Project') }}</h1>
|
||||
|
||||
<form wire:submit.prevent="save" class="space-y-6">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">{{ __('Name') }}</label>
|
||||
<input type="text" wire:model="name" class="input input-bordered w-full" placeholder="{{ __('Project name') }}" required>
|
||||
<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>
|
||||
<label class="block text-sm font-medium mb-2">{{ __('Address') }}</label>
|
||||
<input type="text" wire:model="address" class="input input-bordered w-full" placeholder="{{ __('Street address, city, etc.') }}">
|
||||
|
||||
<div x-show="tab==='data'">
|
||||
@include('livewire.projects.partials.project-data-form')
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">{{ __('Country') }}</label>
|
||||
<input type="text" wire:model="country" class="input input-bordered w-full" placeholder="{{ __('Country (auto-filled)') }}" readonly>
|
||||
<div x-show="tab==='phases'" x-cloak>
|
||||
<livewire:phase-list :project="$project" :key="'phases-'.$project->id" />
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium mb-2">{{ __('Start Date') }}</label>
|
||||
<input type="date" wire:model="start_date" class="input input-bordered w-full" required>
|
||||
<div x-show="tab==='users'" x-cloak>
|
||||
<livewire:project-users :project="$project" :key="'users-'.$project->id" />
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium mb-2">{{ __('End Date (estimated)') }}</label>
|
||||
<input type="date" wire:model="end_date_estimated" class="input input-bordered w-full">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium mb-2">{{ __('Status') }}</label>
|
||||
<select wire:model="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 x-show="tab==='companies'" x-cloak>
|
||||
<livewire:project-companies :project="$project" :key="'companies-'.$project->id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg p-4">
|
||||
<h2 class="text-xl font-bold mb-4">{{ __('Project Location') }}</h2>
|
||||
<p class="text-sm text-gray-500 mb-2">
|
||||
{{ __('Click on the map to set the project location. The address and country will be filled automatically.') }}
|
||||
</p>
|
||||
<div id="projectMap" style="height: 400px; width: 100%; background: #e2e8f0; border-radius: 0.5rem;"></div>
|
||||
<input type="hidden" wire:model="lat">
|
||||
<input type="hidden" wire:model="lng">
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-4">
|
||||
<button type="button" wire:click="resetForm" class="btn btn-outline">
|
||||
{{ __('Reset') }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{{ $projectId ? __('Update') : __('Create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if(session()->has('message'))
|
||||
<div class="mt-4 p-4 bg-green-50 border-l-4 border-green-400 text-green-700">
|
||||
{{ session('message') }}
|
||||
</div>
|
||||
@else
|
||||
{{-- Alta de proyecto: solo el formulario de datos --}}
|
||||
@include('livewire.projects.partials.project-data-form')
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
let map;
|
||||
let marker;
|
||||
|
||||
// Initialize Leaflet map
|
||||
function initMap() {
|
||||
if (map) return;
|
||||
|
||||
// Default coordinates (can be overridden)
|
||||
const defaultLat = @json($lat ?? 0);
|
||||
const defaultLng = @json($lng ?? 0);
|
||||
|
||||
const center = defaultLat && defaultLng ? [defaultLat, defaultLng] : [0, 0];
|
||||
map = L.map('projectMap').setView(center, 13);
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> & CartoDB'
|
||||
}).addTo(map);
|
||||
|
||||
// Add marker if we have coordinates
|
||||
if (defaultLat && defaultLng) {
|
||||
marker = L.marker([defaultLat, defaultLng], {
|
||||
draggable: true
|
||||
}).addTo(map);
|
||||
|
||||
marker.on('dragend', function(e) {
|
||||
const pos = marker.getLatLng();
|
||||
updateCoordinates(pos.lat, pos.lng);
|
||||
});
|
||||
@push('scripts')
|
||||
<script>
|
||||
(function () {
|
||||
let pmap = null, pmarker = null;
|
||||
|
||||
function setStatus(msg) {
|
||||
const s = document.getElementById('geocode-status');
|
||||
if (s) s.textContent = msg || '';
|
||||
}
|
||||
|
||||
// Handle map clicks to place marker
|
||||
map.on('click', function(e) {
|
||||
const pos = e.latlng;
|
||||
if (marker) {
|
||||
marker.setLatLng(pos);
|
||||
|
||||
function placeMarker(lat, lng) {
|
||||
if (!pmap) return;
|
||||
if (pmarker) {
|
||||
pmarker.setLatLng([lat, lng]);
|
||||
} else {
|
||||
marker = L.marker(pos, {
|
||||
draggable: true
|
||||
}).addTo(map);
|
||||
|
||||
marker.on('dragend', function(e) {
|
||||
const pos = marker.getLatLng();
|
||||
updateCoordinates(pos.lat, pos.lng);
|
||||
pmarker = L.marker([lat, lng], { draggable: true }).addTo(pmap);
|
||||
pmarker.on('dragend', () => {
|
||||
const p = pmarker.getLatLng();
|
||||
pickLocation(p.lat, p.lng);
|
||||
});
|
||||
}
|
||||
updateCoordinates(pos.lat, pos.lng);
|
||||
});
|
||||
}
|
||||
|
||||
// Update coordinates and trigger reverse geocoding
|
||||
function updateCoordinates(lat, lng) {
|
||||
// Update hidden inputs
|
||||
document.querySelector('input[name="lat"]').value = lat;
|
||||
document.querySelector('input[name="lng"]').value = lng;
|
||||
|
||||
// Trigger Livewire event to update coordinates
|
||||
@this.setCoordinates(lat, lng);
|
||||
|
||||
// Reverse geocode to get address and country
|
||||
reverseGeocode(lat, lng);
|
||||
}
|
||||
|
||||
// Reverse geocode using Nominatim (OpenStreetMap)
|
||||
async function reverseGeocode(lat, lng) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=18&addressdetails=1`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'OpenClaw/1.0 (construprogress)'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error('Geocoding request failed');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update address field
|
||||
const addressInput = document.querySelector('input[name="address"]');
|
||||
if (data.display_name) {
|
||||
addressInput.value = data.display_name;
|
||||
}
|
||||
|
||||
// Update country field
|
||||
const countryInput = document.querySelector('input[name="country"]');
|
||||
if (data.address && data.address.country) {
|
||||
countryInput.value = data.address.country;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reverse geocoding:', error);
|
||||
// Don't fail the UI if geocoding fails
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize map when component is ready
|
||||
document.addEventListener('Livewire:load', function() {
|
||||
initMap();
|
||||
});
|
||||
|
||||
// Also initialize on DOMContentLoaded as fallback
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initMap();
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
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: '© <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 flex-1 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>
|
||||
</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,70 +83,94 @@
|
||||
|
||||
{{-- 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 gap-1">
|
||||
<x-heroicon-o-map-pin class="w-4 h-4" /> {{ __('Centered in project') }}
|
||||
</button>
|
||||
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full">
|
||||
📍 {{ __("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 -->
|
||||
<!-- 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">{{ __("Project Map") }}</h2>
|
||||
<button wire:click="toggleFullscreen" class="btn btn-circle btn-sm" title="Pantalla completa">
|
||||
<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>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs box mb-4">
|
||||
<button wire:click="setActiveTab('edit')" class="tab {{ $activeTab === 'edit' ? 'tab-active' : '' }}">{{ __("Edit") }}</button>
|
||||
<button wire:click="setActiveTab('features')" class="tab {{ $activeTab === 'features' ? 'tab-active' : '' }}">{{ __("Features") }}</button>
|
||||
<button wire:click="setActiveTab('inspections')" class="tab {{ $activeTab === 'inspections' ? 'tab-active' : '' }}">{{ __("Inspections") }}</button>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content">
|
||||
<!-- Tab Content: visibility controlled by Livewire conditionals, not DaisyUI -->
|
||||
<div class="mt-2">
|
||||
@if($activeTab === 'edit')
|
||||
@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>
|
||||
|
||||
{{-- {{ __("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>
|
||||
{{-- 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>
|
||||
|
||||
<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>
|
||||
{{-- 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>
|
||||
|
||||
{{-- 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") }}
|
||||
<x-heroicon-o-paper-clip class="w-4 h-4 inline" /> {{ __('Files of element') }}
|
||||
</summary>
|
||||
<div class="p-2">
|
||||
@livewire('media-manager', [
|
||||
@@ -131,14 +179,16 @@
|
||||
], key('media-feature-' . $selectedFeature->id))
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{-- Templates / Inspecciones --}}
|
||||
@if($templates->isNotEmpty())
|
||||
<div class="divider text-xs">{{ __("Inspection") }}</div>
|
||||
<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
|
||||
@@ -164,7 +214,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
|
||||
@@ -178,21 +228,21 @@
|
||||
@endswitch
|
||||
</div>
|
||||
@endforeach
|
||||
<button wire:click="saveInspection" class="btn btn-primary btn-xs w-full mt-1">{{ __("Register inspection") }}</button>
|
||||
<button wire:click="saveInspection" class="btn btn-primary btn-xs w-full mt-1">{{ __('Register inspection') }}</button>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
{{-- {{ __("History") }} de inspecciones --}}
|
||||
{{-- Historial de inspecciones --}}
|
||||
@if($inspectionHistory->isNotEmpty())
|
||||
<div class="divider text-xs">{{ __("History") }}</div>
|
||||
<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="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="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>
|
||||
@@ -203,45 +253,45 @@
|
||||
<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>
|
||||
<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>
|
||||
<a href="{{ route('projects.templates', $project) }}" class="btn btn-primary btn-sm">{{ __('Create') }}</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@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>
|
||||
<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
|
||||
@elseif($activeTab === 'features')
|
||||
<!-- Features Table -->
|
||||
@if($allFeatures->isNotEmpty())
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm table-compact">
|
||||
<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>{{ __("Progress") }}</th>
|
||||
<th>{{ __("Responsible") }}</th>
|
||||
<th>{{ __("Template") }}</th>
|
||||
<th class="w-16"></th>
|
||||
<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" wire:click="selectFeature({{ $feature->id }})">
|
||||
<td>{{ $feature->name }}</td>
|
||||
<td>{{ $feature->layer->name }}</td>
|
||||
<td>{{ $feature->layer->phase->name }}</td>
|
||||
<td>{{ $feature->progress }}%</td>
|
||||
<td>{{ $feature->responsible ?? '-' }}</td>
|
||||
<td>{{ $feature->template?->name ?? '-' }}</td>
|
||||
<td class="justify-end">
|
||||
<button class="btn btn-xs btn-outline btn-primary">✏️</button>
|
||||
<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
|
||||
@@ -250,33 +300,35 @@
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center text-gray-400 py-8">
|
||||
<p class="text-lg">📋</p>
|
||||
<p>{{ __("No features found") }}</p>
|
||||
<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">
|
||||
<table class="table table-sm table-compact">
|
||||
<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-16"></th>
|
||||
<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>
|
||||
<td>{{ $inspection->created_at->format('d/m/Y') }}</td>
|
||||
<td>{{ $inspection->feature->name }}</td>
|
||||
<td>{{ $inspection->template->name }}</td>
|
||||
<td>{{ $inspection->user->name }}</td>
|
||||
<td class="justify-end">
|
||||
<button class="btn btn-xs btn-outline btn-info">👁️</button>
|
||||
<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
|
||||
@@ -285,14 +337,63 @@
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center text-gray-400 py-8">
|
||||
<p class="text-lg">📋</p>
|
||||
<p>{{ __("No inspections found") }}</p>
|
||||
<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>
|
||||
@@ -334,71 +435,82 @@
|
||||
// Prevent multiple initializations
|
||||
if (mapInitialized || map) return;
|
||||
mapInitialized = true;
|
||||
|
||||
|
||||
const center = [{{ $project->lat }}, {{ $project->lng }}];
|
||||
map = L.map('map').setView(center, 16);
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> & CartoDB'
|
||||
}).addTo(map);
|
||||
// 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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> & CartoDB'
|
||||
}),
|
||||
'OpenStreetMap': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <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 © Esri — Source: Esri, Maxar, Earthstar Geographics'
|
||||
}),
|
||||
};
|
||||
baseLayers['{{ __('Streets') }}'].addTo(map);
|
||||
L.control.layers(baseLayers, null, { position: 'topleft' }).addTo(map);
|
||||
|
||||
// Cargar fases y sus features
|
||||
// Cargar capas y sus features (cada capa = un grupo Leaflet independiente)
|
||||
@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,
|
||||
])
|
||||
];
|
||||
})->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;
|
||||
// Escape all user-generated content for HTML context
|
||||
const safeName = escapeHtml(props.name || 'Elemento');
|
||||
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}')">✏️ Editar</button>`;
|
||||
layer.bindPopup(content);
|
||||
layer.on('click', function() { selectFeature(featId); });
|
||||
}
|
||||
});
|
||||
layers[{{ $phase->id }}] = phaseLayer;
|
||||
@if(in_array($phase->id, $activeLayers))
|
||||
phaseLayer.addTo(map);
|
||||
@endif
|
||||
}
|
||||
})()
|
||||
@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()
|
||||
];
|
||||
@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); // Reduced from 200ms to 100ms
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function updateCombinedBounds() {
|
||||
@@ -409,9 +521,9 @@
|
||||
const layer = layers[id];
|
||||
if (map.hasLayer(layer) && typeof layer.getBounds === 'function') {
|
||||
const b = layer.getBounds();
|
||||
if (b.isValid()) {
|
||||
combinedBounds.extend(b);
|
||||
hasBounds = true;
|
||||
if (b.isValid()) {
|
||||
combinedBounds.extend(b);
|
||||
hasBounds = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -420,10 +532,7 @@
|
||||
|
||||
function zoomToAllFeatures() {
|
||||
if (!map) return;
|
||||
|
||||
// Update combined bounds if needed
|
||||
updateCombinedBounds();
|
||||
|
||||
if (combinedBounds && combinedBounds.isValid()) {
|
||||
map.fitBounds(combinedBounds, { padding: [20, 20] });
|
||||
} else {
|
||||
@@ -439,32 +548,29 @@
|
||||
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();
|
||||
L.marker(latlng).addTo(map).bindPopup('{{ __('My location') }}').openPopup();
|
||||
map.setView(latlng, 16);
|
||||
}, () => alert('No se pudo obtener la ubicación'));
|
||||
}, () => alert('{{ __('No results') }}'));
|
||||
} else {
|
||||
alert('Geolocalización no soportada');
|
||||
alert('{{ __('No results') }}');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('livewire:init', function () {
|
||||
setTimeout(initMap, 50); // Reduced from 100ms to 50ms
|
||||
setTimeout(initMap, 50);
|
||||
|
||||
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);
|
||||
// Update combined bounds when adding a layer
|
||||
updateCombinedBounds();
|
||||
}
|
||||
} else {
|
||||
if (map.hasLayer(layers[id])) {
|
||||
map.removeLayer(layers[id]);
|
||||
// Update combined bounds when removing a layer
|
||||
updateCombinedBounds();
|
||||
}
|
||||
}
|
||||
@@ -473,9 +579,8 @@
|
||||
});
|
||||
|
||||
Livewire.on('centerMap', zoomToAllFeatures);
|
||||
Livewire.on('mapResize', () => {
|
||||
Livewire.on('mapResize', () => {
|
||||
if (map) {
|
||||
// Throttle resize events to prevent excessive calls
|
||||
if (!this.resizeTimeout) {
|
||||
this.resizeTimeout = setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
@@ -485,14 +590,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
// Update bounds when removing image markers layer
|
||||
if (imageMarkersLayer) {
|
||||
map.removeLayer(imageMarkersLayer);
|
||||
imageMarkersLayer = null;
|
||||
updateCombinedBounds();
|
||||
}
|
||||
if (s && m && m.length > 0) {
|
||||
@@ -504,10 +607,9 @@
|
||||
iconAnchor: [10, 10]
|
||||
});
|
||||
m.forEach(marker => {
|
||||
// Validate URL and sanitize name for security
|
||||
const safeUrl = isValidUrl(marker.image_url) ? marker.image_url : '';
|
||||
const safeName = escapeHtml(marker.image_name || '');
|
||||
if (safeUrl) { // Only add marker if URL is valid
|
||||
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}')" />`;
|
||||
@@ -516,21 +618,16 @@
|
||||
.addTo(imageMarkersLayer);
|
||||
}
|
||||
});
|
||||
// Update bounds when adding image markers layer
|
||||
updateCombinedBounds();
|
||||
}
|
||||
});
|
||||
|
||||
// Modal para ver imagen al hacer clic
|
||||
window.openViewer = function(url, name) {
|
||||
// Validate URL and sanitize name for security
|
||||
if (!isValidUrl(url)) {
|
||||
console.error('Invalid URL provided to openViewer:', url);
|
||||
return;
|
||||
}
|
||||
|
||||
const safeName = escapeHtml(name);
|
||||
|
||||
if (imageViewerModal) imageViewerModal.remove();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'imageViewerModal';
|
||||
@@ -546,4 +643,4 @@
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@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,126 @@
|
||||
<div class="py-8 max-w-6xl mx-auto sm:px-6 lg:px-8">
|
||||
|
||||
{{-- Cabecera --}}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-800">{{ __('Roles & permissions') }}</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ __('Manage role groups and the permissions assigned to each.') }}</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{{-- Barra de acciones en grupo --}}
|
||||
@if(count($selected) > 0)
|
||||
<div class="flex items-center justify-between bg-base-200 rounded-lg px-4 py-2 mb-3">
|
||||
<span class="text-sm">{{ count($selected) }} {{ __('selected') }}</span>
|
||||
<button wire:click="bulkDelete"
|
||||
wire:confirm="{{ __('Delete the selected roles? Protected roles will be skipped.') }}"
|
||||
class="btn btn-error btn-sm gap-1">
|
||||
<x-heroicon-o-trash class="w-4 h-4" /> {{ __('Delete selected') }}
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Tabla de roles --}}
|
||||
<div class="overflow-x-auto border border-base-300 rounded-lg bg-white">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-10">
|
||||
<input type="checkbox" class="checkbox checkbox-sm" wire:model.live="selectAll" />
|
||||
</th>
|
||||
<th>{{ __('Name') }}</th>
|
||||
<th>{{ __('Description') }}</th>
|
||||
<th class="text-center">{{ __('Permissions') }}</th>
|
||||
<th class="text-center">{{ __('Users') }}</th>
|
||||
<th class="w-32 text-right">{{ __('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($roles as $role)
|
||||
<tr wire:key="role-{{ $role->id }}" class="hover">
|
||||
<td>
|
||||
<input type="checkbox" class="checkbox checkbox-sm"
|
||||
value="{{ $role->id }}" wire:model.live="selected" />
|
||||
</td>
|
||||
<td class="font-semibold">
|
||||
{{ $role->name }}
|
||||
@if(in_array($role->name, ['Admin'], true))
|
||||
<span class="badge badge-ghost badge-xs ml-1">{{ __('protected') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="text-sm text-gray-500 max-w-xs truncate">{{ $role->description ?: '—' }}</td>
|
||||
<td class="text-center">
|
||||
<span class="badge badge-outline badge-sm">{{ $role->permissions->count() }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge badge-ghost badge-sm">{{ $role->users_count }}</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button wire:click="openView({{ $role->id }})" class="btn btn-ghost btn-xs" title="{{ __('View') }}">
|
||||
<x-heroicon-o-eye class="w-4 h-4" />
|
||||
</button>
|
||||
<a href="{{ route('admin.roles.edit', $role->id) }}" class="btn btn-ghost btn-xs text-info" title="{{ __('Edit') }}" wire:navigate>
|
||||
<x-heroicon-o-pencil class="w-4 h-4" />
|
||||
</a>
|
||||
@unless(in_array($role->name, ['Admin'], true))
|
||||
<button wire:click="delete({{ $role->id }})"
|
||||
wire:confirm="{{ __('Delete role') }} '{{ $role->name }}'?"
|
||||
class="btn btn-ghost btn-xs text-error" title="{{ __('Delete') }}">
|
||||
<x-heroicon-o-trash class="w-4 h-4" />
|
||||
</button>
|
||||
@endunless
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="6" class="text-center text-gray-400 py-8">{{ __('No roles') }}</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- ════════════════ MODAL VER ════════════════ --}}
|
||||
@if($viewing)
|
||||
<div class="modal modal-open z-[1500]">
|
||||
<div class="modal-box max-w-lg">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="font-bold text-lg flex items-center gap-2">
|
||||
<x-heroicon-o-shield-check class="w-5 h-5 text-primary" /> {{ $viewing->name }}
|
||||
</h3>
|
||||
<button wire:click="closeView" class="btn btn-sm btn-circle btn-ghost"><x-heroicon-o-x-mark class="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 mb-1">{{ $viewing->description ?: __('No description') }}</p>
|
||||
<p class="text-xs text-gray-400 mb-4">{{ $viewing->users_count }} {{ __('users') }} · {{ $viewing->permissions->count() }} {{ __('permissions') }}</p>
|
||||
|
||||
<div class="divider text-xs">{{ __('Permissions') }}</div>
|
||||
@if($viewing->permissions->isEmpty())
|
||||
<p class="text-sm text-gray-400">{{ __('No permissions') }}</p>
|
||||
@else
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@foreach($viewing->permissions as $perm)
|
||||
<span class="badge badge-primary badge-sm">{{ $perm->name }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="modal-action">
|
||||
<a href="{{ route('admin.roles.edit', $viewing->id) }}" class="btn btn-sm btn-info gap-1" wire:navigate>
|
||||
<x-heroicon-o-pencil class="w-4 h-4" /> {{ __('Edit') }}
|
||||
</a>
|
||||
<button wire:click="closeView" class="btn btn-sm">{{ __('Close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop bg-black/40" wire:click="closeView"></div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -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,73 @@
|
||||
<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>
|
||||
|
||||
{{-- Permisos --}}
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-40 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
{{ __('Permissions') }}
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1 border border-base-300 rounded-lg p-3 max-h-72 overflow-y-auto">
|
||||
@foreach($permissions as $perm)
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer py-0.5">
|
||||
<input type="checkbox" class="checkbox checkbox-sm checkbox-primary"
|
||||
value="{{ $perm->name }}" wire:model="rolePermissions" />
|
||||
{{ $perm->name }}
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -1,37 +0,0 @@
|
||||
<x-app-layout>
|
||||
<div class="max-w-2xl mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold mb-4">{{ __('Create Project') }}</h1>
|
||||
<form action="{{ route('projects.store') }}" method="POST" class="space-y-4">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="label">{{ __('Project name') }}</label>
|
||||
<input type="text" name="name" class="input input-bordered w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">{{ __('Address') }}</label>
|
||||
<input type="text" name="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" class="input input-bordered w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">{{ __('Longitude') }}</label>
|
||||
<input type="number" step="any" name="lng" class="input input-bordered w-full" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label">{{ __('Start date') }}</label>
|
||||
<input type="date" name="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" class="input input-bordered w-full">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full">{{ __('Create') }} {{ __('Project') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -1,157 +0,0 @@
|
||||
<x-app-layout>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div x-data="{ tabActivo: 1 }">
|
||||
<div role="tablist" class="tabs tabs-border">
|
||||
<a role="tab" class="tab" :class="{ 'tab-active': tabActivo === 1 }" @click.prevent="tabActivo = 1">{{ __("Project Data") }}</a>
|
||||
<a role="tab" class="tab" :class="{ 'tab-active': tabActivo === 2 }" @click.prevent="tabActivo = 2">{{ __("Phases") }}</a>
|
||||
<a role="tab" class="tab" :class="{ 'tab-active': tabActivo === 3 }" @click.prevent="tabActivo = 3">{{ __("Users") }}</a>
|
||||
<a role="tab" class="tab" :class="{ 'tab-active': tabActivo === 4 }" @click.prevent="tabActivo = 4">{{ __("Companies") }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Los contenedores del contenido -->
|
||||
<div class="py-4" x-show="tabActivo === 1">
|
||||
<h1 class="text-2xl font-bold mb-4">{{ __('Edit Project') }}: {{ $project->name }}</h1>
|
||||
<form action="{{ route('projects.update', $project) }}" method="POST" class="space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<table class="w-full mb-8">
|
||||
<tbody>
|
||||
{{-- Nombre --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-bootom font-bold">
|
||||
{{ __('Reference') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<input type="text"
|
||||
name="reference"
|
||||
value="{{ old('reference', $project->reference) }}"
|
||||
class="input w-64"
|
||||
required
|
||||
autofocus>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{-- Nombre --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-bootom font-bold">
|
||||
{{ __('Name') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<input type="text"
|
||||
name="name"
|
||||
value="{{ old('name', $project->name) }}"
|
||||
class="input w-64"
|
||||
required
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{-- Dirección --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-bottom font-bold">
|
||||
{{ __('Address') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<input type="text"
|
||||
name="address"
|
||||
value="{{ old('address', $project->address) }}"
|
||||
class="input w-1/2"
|
||||
required>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{-- Coordenadas --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-bottom font-bold">
|
||||
{{ __('Coordinates') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label">{{ __('Latitude') }}</label>
|
||||
<input type="number"
|
||||
step="any"
|
||||
name="lat"
|
||||
value="{{ old('lat', $project->lat) }}"
|
||||
class="input w-64"
|
||||
required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">{{ __('Longitude') }}</label>
|
||||
<input type="number"
|
||||
step="any"
|
||||
name="lng"
|
||||
value="{{ old('lng', $project->lng) }}"
|
||||
class="input w-64"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{-- Estatus --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-bottom ">
|
||||
{{ __('Status') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<select name="status" class="select select-bordered w-full">
|
||||
<option value="planning" @selected($project->status == 'planning')>{{ __('Planning') }}</option>
|
||||
<option value="in_progress" @selected($project->status == 'in_progress')>{{ __('In progress') }}</option>
|
||||
<option value="paused" @selected($project->status == 'paused')>{{ __('Paused') }}</option>
|
||||
<option value="completed" @selected($project->status == 'completed')>{{ __('Completed') }}</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{-- Fecha de Inicio --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-bottom font-bold">
|
||||
{{ __('Start date') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<input type="date"
|
||||
name="start_date"
|
||||
value="{{ old('start_date', $project->start_date->format('Y-m-d')) }}"
|
||||
class="input w-64"
|
||||
required>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{-- Fechas de finalización --}}
|
||||
<tr>
|
||||
<td class="w-1/4 py-3 pr-4 align-bottom">
|
||||
{{ __('Estimated end date') }}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<input type="date"
|
||||
name="end_date_estimated"
|
||||
value="{{ old('end_date_estimated', $project->end_date_estimated?->format('Y-m-d')) }}"
|
||||
class="input w-64">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<button type="submit" class="btn btn-primary">{{ __('Update') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="py-4" x-show="tabActivo === 2">
|
||||
<h2 class="text-xl font-bold mb-2">{{ __('Phases') }}</h2>
|
||||
<livewire:phase-list :project="$project" />
|
||||
</div>
|
||||
<div class="py-4" x-show="tabActivo === 3">
|
||||
|
||||
<h2 class="text-xl font-bold mb-2">{{ __('Users') }}</h2>
|
||||
<livewire:project-users :project="$project" />
|
||||
</div>
|
||||
<div class="py-4" x-show="tabActivo === 4">
|
||||
<h2 class="text-xl font-bold mb-2">{{ __('Companies') }}</h2>
|
||||
<livewire:project-companies :project="$project" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -1,11 +1,29 @@
|
||||
<x-app-layout>
|
||||
<div class="py-12">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">{{ __('Projects') }}</h1>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Proyectos</h2>
|
||||
@can('create projects')
|
||||
<a href="{{ route('projects.create') }}" class="btn btn-primary">+ {{ __('New Project') }}</a>
|
||||
<a href="{{ route('projects.create') }}" class="btn btn-primary btn-sm gap-1" wire:navigate>
|
||||
<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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Nuevo proyecto
|
||||
</a>
|
||||
@endcan
|
||||
</div>
|
||||
<livewire:project-table />
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success mb-4 shadow">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="bg-white rounded-xl shadow p-6">
|
||||
<livewire:project-table />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Archivos del proyecto: {{ $project->name }}
|
||||
{{ __('Project files') }}: {{ $project->name }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="mb-4">
|
||||
<a href="{{ route('projects.map', $project) }}" class="btn btn-outline btn-sm">← Volver al mapa</a>
|
||||
<a href="{{ route('projects.map', $project) }}" class="btn btn-outline btn-sm">← {{ __('Back to map') }}</a>
|
||||
</div>
|
||||
|
||||
@livewire('media-manager', [
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<x-app-layout>
|
||||
<div class="mb-6">
|
||||
<button wire:click="$emit('showTemplateForm')"
|
||||
<button wire:click="$emit('showTemplateForm')"
|
||||
class="btn btn-primary btn-lg">
|
||||
+ Nuevo template de inspección
|
||||
+ {{ __('New template') }}
|
||||
</button>
|
||||
<p class="text-sm text-muted mb-4">
|
||||
Crea templates genéricos que puedan usarse en cualquier fase del proyecto
|
||||
{{ __('Create generic templates that can be used in any phase of the project') }}
|
||||
</p>
|
||||
</div>
|
||||
<livewire/template-manager :project="$project" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user