Compare commits
20 Commits
c832d4f3da
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,135 @@
|
||||
<?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 $editing = false;
|
||||
public $editingId = null;
|
||||
|
||||
public $title = '';
|
||||
public $description = '';
|
||||
public $status = 'open';
|
||||
public $priority = 'medium';
|
||||
public $featureId = null;
|
||||
public $inspectionId = null;
|
||||
public $assignedTo = null;
|
||||
|
||||
public $issues = [];
|
||||
|
||||
public function mount(Project $project)
|
||||
{
|
||||
$this->project = $project;
|
||||
$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 create()
|
||||
{
|
||||
$this->reset(['title', 'description', 'status', 'priority', 'featureId', 'inspectionId', 'assignedTo', 'editingId']);
|
||||
$this->status = 'open';
|
||||
$this->priority = 'medium';
|
||||
$this->editing = true;
|
||||
}
|
||||
|
||||
public function edit($issueId)
|
||||
{
|
||||
$issue = Issue::findOrFail($issueId);
|
||||
$this->editingId = $issue->id;
|
||||
$this->title = $issue->title;
|
||||
$this->description = $issue->description ?? '';
|
||||
$this->status = $issue->status;
|
||||
$this->priority = $issue->priority;
|
||||
$this->featureId = $issue->feature_id;
|
||||
$this->inspectionId = $issue->inspection_id;
|
||||
$this->assignedTo = $issue->assigned_to;
|
||||
$this->editing = true;
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'status' => 'required|in:' . implode(',', Issue::STATUSES),
|
||||
'priority' => 'required|in:' . implode(',', Issue::PRIORITIES),
|
||||
]);
|
||||
|
||||
if ($this->editingId) {
|
||||
$issue = Issue::findOrFail($this->editingId);
|
||||
$issue->update([
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'status' => $this->status,
|
||||
'priority' => $this->priority,
|
||||
'feature_id' => $this->featureId,
|
||||
'inspection_id' => $this->inspectionId,
|
||||
'assigned_to' => $this->assignedTo,
|
||||
]);
|
||||
} else {
|
||||
$issue = Issue::create([
|
||||
'project_id' => $this->project->id,
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'status' => $this->status,
|
||||
'priority' => $this->priority,
|
||||
'feature_id' => $this->featureId,
|
||||
'inspection_id' => $this->inspectionId,
|
||||
'reported_by' => Auth::id(),
|
||||
'assigned_to' => $this->assignedTo,
|
||||
]);
|
||||
|
||||
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->editing = false;
|
||||
$this->loadIssues();
|
||||
$this->dispatch('notify', 'Issue guardado correctamente');
|
||||
}
|
||||
|
||||
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 cancel()
|
||||
{
|
||||
$this->editing = false;
|
||||
$this->reset(['title', 'description', 'status', 'priority', 'featureId', 'inspectionId', 'assignedTo', 'editingId']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+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,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})",
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
+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',
|
||||
];
|
||||
@@ -7,15 +7,20 @@
|
||||
|
||||
<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>
|
||||
<a href="{{ route('projects.list') }}" class="btn btn-outline btn-primary">📁 {{ __('Projects') }}</a>
|
||||
<a href="{{ route('admin.users') }}" class="btn btn-outline btn-secondary">👥 {{ __('User Management') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
@livewire('admin-users')
|
||||
<div class="flex justify-end mb-4">
|
||||
<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>
|
||||
<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-3xl 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,21 @@
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@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>
|
||||
<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
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<livewire:company-table />
|
||||
</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,89 @@
|
||||
<div>
|
||||
{{-- Issue form --}}
|
||||
@if($editing)
|
||||
<div class="card bg-base-100 shadow mb-4">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-base">
|
||||
{{ $editingId ? 'Editar Issue' : 'Nuevo Issue' }}
|
||||
</h3>
|
||||
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Título *</span></label>
|
||||
<input type="text" wire:model="title" class="input input-bordered" placeholder="Título del issue" />
|
||||
@error('title') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Descripción</span></label>
|
||||
<textarea wire:model="description" class="textarea textarea-bordered" rows="3" placeholder="Descripción..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Estado</span></label>
|
||||
<select wire:model="status" class="select select-bordered">
|
||||
<option value="open">Abierto</option>
|
||||
<option value="in_review">En revisión</option>
|
||||
<option value="resolved">Resuelto</option>
|
||||
<option value="closed">Cerrado</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Prioridad</span></label>
|
||||
<select wire:model="priority" class="select select-bordered">
|
||||
<option value="low">Baja</option>
|
||||
<option value="medium">Media</option>
|
||||
<option value="high">Alta</option>
|
||||
<option value="critical">Crítica</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end mt-2">
|
||||
<button wire:click="cancel" class="btn btn-ghost btn-sm">Cancelar</button>
|
||||
<button wire:click="save" class="btn btn-primary btn-sm">Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex justify-end mb-3">
|
||||
<button wire:click="create" class="btn btn-primary btn-sm">
|
||||
+ Nuevo Issue
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Issue list --}}
|
||||
<div class="space-y-2">
|
||||
@forelse($issues as $issue)
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200">
|
||||
<div class="card-body py-3 px-4">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-sm">{{ $issue->title }}</p>
|
||||
@if($issue->description)
|
||||
<p class="text-xs text-base-content/60 mt-0.5 line-clamp-2">{{ $issue->description }}</p>
|
||||
@endif
|
||||
<div class="flex gap-2 mt-1">
|
||||
<span class="badge badge-xs" style="background-color: {{ $issue->priority_color }}; color: #fff;">
|
||||
{{ ucfirst($issue->priority) }}
|
||||
</span>
|
||||
<span class="badge badge-xs badge-outline">
|
||||
{{ ucfirst(str_replace('_', ' ', $issue->status)) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1 shrink-0">
|
||||
<button wire:click="edit({{ $issue->id }})" class="btn btn-ghost btn-xs">Editar</button>
|
||||
<button wire:click="delete({{ $issue->id }})"
|
||||
wire:confirm="¿Eliminar este issue?"
|
||||
class="btn btn-ghost btn-xs text-error">Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-center text-sm text-base-content/50 py-6">No hay issues registrados</p>
|
||||
@endforelse
|
||||
</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-4xl 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") }} (%)'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-3xl 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-5xl 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-8">
|
||||
<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">
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Informe de Proyecto — {{ $project->name }}</title>
|
||||
<style>
|
||||
/* --------------------------------------------------------
|
||||
Base styles
|
||||
-------------------------------------------------------- */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Arial, Helvetica, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
background: #fff;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a { color: #2563eb; text-decoration: none; }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Layout
|
||||
-------------------------------------------------------- */
|
||||
.page-wrapper {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Header
|
||||
-------------------------------------------------------- */
|
||||
.report-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
border-bottom: 2px solid #2563eb;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.logo-placeholder {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: #dbeafe;
|
||||
border: 2px solid #93c5fd;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
color: #2563eb;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.report-header-info {
|
||||
flex: 1;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.report-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1e3a8a;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.report-subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.report-meta {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.report-meta strong { display: block; color: #1f2937; font-size: 13px; }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Section titles
|
||||
-------------------------------------------------------- */
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1e3a8a;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 6px;
|
||||
margin-bottom: 14px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Stats summary
|
||||
-------------------------------------------------------- */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Tables
|
||||
-------------------------------------------------------- */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f1f5f9;
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
tr:hover td { background: #f9fafb; }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Phase section
|
||||
-------------------------------------------------------- */
|
||||
.phase-block {
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.phase-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: #f8fafc;
|
||||
border-left: 5px solid #3b82f6;
|
||||
}
|
||||
|
||||
.phase-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.phase-progress-bar-wrap {
|
||||
background: #e5e7eb;
|
||||
border-radius: 6px;
|
||||
height: 8px;
|
||||
width: 160px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.phase-progress-bar {
|
||||
height: 8px;
|
||||
border-radius: 6px;
|
||||
background: #22c55e;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.phase-meta {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Status badges
|
||||
-------------------------------------------------------- */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 7px;
|
||||
border-radius: 9999px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.badge-planned { background: #f3f4f6; color: #6b7280; }
|
||||
.badge-started { background: #dbeafe; color: #1d4ed8; }
|
||||
.badge-in_progress { background: #fef3c7; color: #92400e; }
|
||||
.badge-completed { background: #d1fae5; color: #065f46; }
|
||||
.badge-verified { background: #ede9fe; color: #5b21b6; }
|
||||
.badge-default { background: #f3f4f6; color: #6b7280; }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Print button (hidden on print)
|
||||
-------------------------------------------------------- */
|
||||
.print-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.print-btn:hover { background: #1d4ed8; }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Print media rules
|
||||
-------------------------------------------------------- */
|
||||
@media print {
|
||||
.print-btn { display: none !important; }
|
||||
|
||||
body { font-size: 11px; }
|
||||
|
||||
.page-wrapper { max-width: 100%; padding: 16px; }
|
||||
|
||||
.report-header { page-break-inside: avoid; }
|
||||
|
||||
.phase-block { page-break-inside: avoid; }
|
||||
|
||||
a { color: inherit; }
|
||||
|
||||
.stats-grid { grid-template-columns: repeat(5, 1fr); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-wrapper">
|
||||
|
||||
{{-- Print button (hidden on print) --}}
|
||||
<button class="print-btn" onclick="window.print()">
|
||||
🖶 {{ __('Imprimir / Guardar PDF') }}
|
||||
</button>
|
||||
|
||||
{{-- ====================================================
|
||||
HEADER
|
||||
===================================================== --}}
|
||||
<div class="report-header">
|
||||
<div class="logo-placeholder">LOGO<br>EMPRESA</div>
|
||||
<div class="report-header-info">
|
||||
<div class="report-title">{{ $project->name }}</div>
|
||||
@if($project->address)
|
||||
<div class="report-subtitle">{{ $project->address }}</div>
|
||||
@endif
|
||||
<div class="report-subtitle" style="margin-top:8px;">
|
||||
@if($project->start_date)
|
||||
Inicio: <strong style="color:#1f2937">{{ $project->start_date->format('d/m/Y') }}</strong>
|
||||
@endif
|
||||
@if($project->end_date_estimated)
|
||||
• Fin estimado: <strong style="color:#1f2937">{{ $project->end_date_estimated->format('d/m/Y') }}</strong>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="report-meta">
|
||||
<strong>Informe de Proyecto</strong>
|
||||
Generado el {{ now()->format('d/m/Y H:i') }}<br>
|
||||
Estado:
|
||||
<span class="badge {{ $project->status === 'completed' ? 'badge-completed' : ($project->status === 'in_progress' ? 'badge-in_progress' : 'badge-planned') }}">
|
||||
{{ ucfirst(str_replace('_', ' ', $project->status ?? 'N/A')) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ====================================================
|
||||
SUMMARY STATS
|
||||
===================================================== --}}
|
||||
<div class="section-title">Resumen General</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ $stats['total_features'] }}</div>
|
||||
<div class="stat-label">Total elementos</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="color:#22c55e;">{{ $stats['completed_features'] }}</div>
|
||||
<div class="stat-label">Completados</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="color:#f59e0b;">{{ $stats['avg_progress'] }}%</div>
|
||||
<div class="stat-label">Progreso medio</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="color:#6366f1;">{{ $stats['total_inspections'] }}</div>
|
||||
<div class="stat-label">Inspecciones</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="color:{{ $stats['open_issues'] > 0 ? '#ef4444' : '#22c55e' }};">{{ $stats['open_issues'] }}</div>
|
||||
<div class="stat-label">Issues abiertos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ====================================================
|
||||
PHASES
|
||||
===================================================== --}}
|
||||
<div class="section-title">Detalle por Fase</div>
|
||||
|
||||
@forelse($phases as $phase)
|
||||
@php
|
||||
$phaseFeatures = $phase->layers->flatMap(fn($l) => $l->features);
|
||||
$phaseColor = $phase->color ?? '#3b82f6';
|
||||
@endphp
|
||||
<div class="phase-block" style="border-left-color:{{ $phaseColor }};">
|
||||
<div class="phase-header" style="border-left-color:{{ $phaseColor }};">
|
||||
<div>
|
||||
<div class="phase-name">{{ $phase->name }}</div>
|
||||
<div class="phase-meta">
|
||||
@if($phase->planned_start)
|
||||
{{ $phase->planned_start->format('d/m/Y') }}
|
||||
—
|
||||
{{ $phase->planned_end?->format('d/m/Y') ?? 'Sin fecha fin' }}
|
||||
@else
|
||||
Sin fechas planificadas
|
||||
@endif
|
||||
• {{ $phaseFeatures->count() }} elementos
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div style="font-size:16px;font-weight:700;color:{{ $phaseColor }};">{{ $phase->progress_percent ?? 0 }}%</div>
|
||||
<div class="phase-progress-bar-wrap" style="margin-top:4px;">
|
||||
<div class="phase-progress-bar"
|
||||
style="width:{{ min(100, $phase->progress_percent ?? 0) }}%;background:{{ $phaseColor }};"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($phaseFeatures->count() > 0)
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Elemento</th>
|
||||
<th>Estado</th>
|
||||
<th>Progreso</th>
|
||||
<th>Responsable</th>
|
||||
<th>Última inspección</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($phaseFeatures as $feature)
|
||||
@php
|
||||
$lastInspection = $feature->inspections->sortByDesc('created_at')->first();
|
||||
@endphp
|
||||
<tr>
|
||||
<td>{{ $feature->name ?? 'Sin nombre' }}</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ $feature->status ?? 'default' }}">
|
||||
{{ match($feature->status) {
|
||||
'planned' => 'Planificado',
|
||||
'started' => 'Iniciado',
|
||||
'in_progress' => 'En progreso',
|
||||
'completed' => 'Completado',
|
||||
'verified' => 'Verificado',
|
||||
default => ($feature->status ?? 'N/A'),
|
||||
} }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<div style="flex:1;background:#e5e7eb;border-radius:4px;height:6px;min-width:60px;">
|
||||
<div style="height:6px;border-radius:4px;background:{{ $phaseColor }};width:{{ min(100, $feature->progress ?? 0) }}%;"></div>
|
||||
</div>
|
||||
<span style="font-size:11px;color:#6b7280;white-space:nowrap;">{{ $feature->progress ?? 0 }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ $feature->responsible ?? ($feature->responsibleUser?->name ?? '—') }}</td>
|
||||
<td>{{ $lastInspection?->created_at?->format('d/m/Y') ?? '—' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@else
|
||||
<div style="padding:12px 14px;color:#9ca3af;font-style:italic;font-size:12px;">
|
||||
Sin elementos registrados en esta fase.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div style="padding:16px;color:#9ca3af;text-align:center;font-style:italic;">
|
||||
No hay fases registradas en este proyecto.
|
||||
</div>
|
||||
@endforelse
|
||||
|
||||
{{-- ====================================================
|
||||
Footer
|
||||
===================================================== --}}
|
||||
<div style="margin-top:32px;padding-top:12px;border-top:1px solid #e5e7eb;display:flex;justify-content:space-between;font-size:11px;color:#9ca3af;">
|
||||
<span>ConstProgress — Sistema de Gestión de Obras</span>
|
||||
<span>{{ now()->format('d/m/Y H:i') }}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,145 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>Laravel</title>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,600&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Styles -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="antialiased font-sans">
|
||||
<div class="bg-gray-50 text-black/50 dark:bg-black dark:text-white/50">
|
||||
<img id="background" class="absolute -left-20 top-0 max-w-[877px]" src="https://laravel.com/assets/img/welcome/background.svg" />
|
||||
<div class="relative min-h-screen flex flex-col items-center justify-center selection:bg-[#FF2D20] selection:text-white">
|
||||
<div class="relative w-full max-w-2xl px-6 lg:max-w-7xl">
|
||||
<header class="grid grid-cols-2 items-center gap-2 py-10 lg:grid-cols-3">
|
||||
<div class="flex lg:justify-center lg:col-start-2">
|
||||
<svg class="h-12 w-auto text-white lg:h-16 lg:text-[#FF2D20]" viewBox="0 0 62 65" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M61.8548 14.6253C61.8778 14.7102 61.8895 14.7978 61.8897 14.8858V28.5615C61.8898 28.737 61.8434 28.9095 61.7554 29.0614C61.6675 29.2132 61.5409 29.3392 61.3887 29.4265L49.9104 36.0351V49.1337C49.9104 49.4902 49.7209 49.8192 49.4118 49.9987L25.4519 63.7916C25.3971 63.8227 25.3372 63.8427 25.2774 63.8639C25.255 63.8714 25.2338 63.8851 25.2101 63.8913C25.0426 63.9354 24.8666 63.9354 24.6991 63.8913C24.6716 63.8838 24.6467 63.8689 24.6205 63.8589C24.5657 63.8389 24.5084 63.8215 24.456 63.7916L0.501061 49.9987C0.348882 49.9113 0.222437 49.7853 0.134469 49.6334C0.0465019 49.4816 0.000120578 49.3092 0 49.1337L0 8.10652C0 8.01678 0.0124642 7.92953 0.0348998 7.84477C0.0423783 7.8161 0.0598282 7.78993 0.0697995 7.76126C0.0884958 7.70891 0.105946 7.65531 0.133367 7.6067C0.152063 7.5743 0.179485 7.54812 0.20192 7.51821C0.230588 7.47832 0.256763 7.43719 0.290416 7.40229C0.319084 7.37362 0.356476 7.35243 0.388883 7.32751C0.425029 7.29759 0.457436 7.26518 0.498568 7.2415L12.4779 0.345059C12.6296 0.257786 12.8015 0.211853 12.9765 0.211853C13.1515 0.211853 13.3234 0.257786 13.475 0.345059L25.4531 7.2415H25.4556C25.4955 7.26643 25.5292 7.29759 25.5653 7.32626C25.5977 7.35119 25.6339 7.37362 25.6625 7.40104C25.6974 7.43719 25.7224 7.47832 25.7523 7.51821C25.7735 7.54812 25.8021 7.5743 25.8196 7.6067C25.8483 7.65656 25.8645 7.70891 25.8844 7.76126C25.8944 7.78993 25.9118 7.8161 25.9193 7.84602C25.9423 7.93096 25.954 8.01853 25.9542 8.10652V33.7317L35.9355 27.9844V14.8846C35.9355 14.7973 35.948 14.7088 35.9704 14.6253C35.9792 14.5954 35.9954 14.5692 36.0053 14.5405C36.0253 14.4882 36.0427 14.4346 36.0702 14.386C36.0888 14.3536 36.1163 14.3274 36.1375 14.2975C36.1674 14.2576 36.1923 14.2165 36.2272 14.1816C36.2559 14.1529 36.292 14.1317 36.3244 14.1068C36.3618 14.0769 36.3942 14.0445 36.4341 14.0208L48.4147 7.12434C48.5663 7.03694 48.7383 6.99094 48.9133 6.99094C49.0883 6.99094 49.2602 7.03694 49.4118 7.12434L61.3899 14.0208C61.4323 14.0457 61.4647 14.0769 61.5021 14.1055C61.5333 14.1305 61.5694 14.1529 61.5981 14.1803C61.633 14.2165 61.6579 14.2576 61.6878 14.2975C61.7103 14.3274 61.7377 14.3536 61.7551 14.386C61.7838 14.4346 61.8 14.4882 61.8199 14.5405C61.8312 14.5692 61.8474 14.5954 61.8548 14.6253ZM59.893 27.9844V16.6121L55.7013 19.0252L49.9104 22.3593V33.7317L59.8942 27.9844H59.893ZM47.9149 48.5566V37.1768L42.2187 40.4299L25.953 49.7133V61.2003L47.9149 48.5566ZM1.99677 9.83281V48.5566L23.9562 61.199V49.7145L12.4841 43.2219L12.4804 43.2194L12.4754 43.2169C12.4368 43.1945 12.4044 43.1621 12.3682 43.1347C12.3371 43.1097 12.3009 43.0898 12.2735 43.0624L12.271 43.0586C12.2386 43.0275 12.2162 42.9888 12.1887 42.9539C12.1638 42.9203 12.1339 42.8916 12.114 42.8567L12.1127 42.853C12.0903 42.8156 12.0766 42.7707 12.0604 42.7283C12.0442 42.6909 12.023 42.656 12.013 42.6161C12.0005 42.5688 11.998 42.5177 11.9931 42.4691C11.9881 42.4317 11.9781 42.3943 11.9781 42.3569V15.5801L6.18848 12.2446L1.99677 9.83281ZM12.9777 2.36177L2.99764 8.10652L12.9752 13.8513L22.9541 8.10527L12.9752 2.36177H12.9777ZM18.1678 38.2138L23.9574 34.8809V9.83281L19.7657 12.2459L13.9749 15.5801V40.6281L18.1678 38.2138ZM48.9133 9.14105L38.9344 14.8858L48.9133 20.6305L58.8909 14.8846L48.9133 9.14105ZM47.9149 22.3593L42.124 19.0252L37.9323 16.6121V27.9844L43.7219 31.3174L47.9149 33.7317V22.3593ZM24.9533 47.987L39.59 39.631L46.9065 35.4555L36.9352 29.7145L25.4544 36.3242L14.9907 42.3482L24.9533 47.987Z" fill="currentColor"/></svg>
|
||||
</div>
|
||||
@if (Route::has('login'))
|
||||
<livewire:welcome.navigation />
|
||||
@endif
|
||||
</header>
|
||||
|
||||
<main class="mt-6">
|
||||
<div class="grid gap-6 lg:grid-cols-2 lg:gap-8">
|
||||
<a
|
||||
href="https://laravel.com/docs"
|
||||
id="docs-card"
|
||||
class="flex flex-col items-start gap-6 overflow-hidden rounded-lg bg-white p-6 shadow-[0px_14px_34px_0px_rgba(0,0,0,0.08)] ring-1 ring-white/[0.05] transition duration-300 hover:text-black/70 hover:ring-black/20 focus:outline-none focus-visible:ring-[#FF2D20] md:row-span-3 lg:p-10 lg:pb-10 dark:bg-zinc-900 dark:ring-zinc-800 dark:hover:text-white/70 dark:hover:ring-zinc-700 dark:focus-visible:ring-[#FF2D20]"
|
||||
>
|
||||
<div id="screenshot-container" class="relative flex w-full flex-1 items-stretch">
|
||||
<img
|
||||
src="https://laravel.com/assets/img/welcome/docs-light.svg"
|
||||
alt="Laravel documentation screenshot"
|
||||
class="aspect-video h-full w-full flex-1 rounded-[10px] object-top object-cover drop-shadow-[0px_4px_34px_rgba(0,0,0,0.06)] dark:hidden"
|
||||
onerror="
|
||||
document.getElementById('screenshot-container').classList.add('!hidden');
|
||||
document.getElementById('docs-card').classList.add('!row-span-1');
|
||||
document.getElementById('docs-card-content').classList.add('!flex-row');
|
||||
document.getElementById('background').classList.add('!hidden');
|
||||
"
|
||||
/>
|
||||
<img
|
||||
src="https://laravel.com/assets/img/welcome/docs-dark.svg"
|
||||
alt="Laravel documentation screenshot"
|
||||
class="hidden aspect-video h-full w-full flex-1 rounded-[10px] object-top object-cover drop-shadow-[0px_4px_34px_rgba(0,0,0,0.25)] dark:block"
|
||||
/>
|
||||
<div
|
||||
class="absolute -bottom-16 -left-16 h-40 w-[calc(100%+8rem)] bg-gradient-to-b from-transparent via-white to-white dark:via-zinc-900 dark:to-zinc-900"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex items-center gap-6 lg:items-end">
|
||||
<div id="docs-card-content" class="flex items-start gap-6 lg:flex-col">
|
||||
<div class="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#FF2D20]/10 sm:size-16">
|
||||
<svg class="size-5 sm:size-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#FF2D20" d="M23 4a1 1 0 0 0-1.447-.894L12.224 7.77a.5.5 0 0 1-.448 0L2.447 3.106A1 1 0 0 0 1 4v13.382a1.99 1.99 0 0 0 1.105 1.79l9.448 4.728c.14.065.293.1.447.1.154-.005.306-.04.447-.105l9.453-4.724a1.99 1.99 0 0 0 1.1-1.789V4ZM3 6.023a.25.25 0 0 1 .362-.223l7.5 3.75a.251.251 0 0 1 .138.223v11.2a.25.25 0 0 1-.362.224l-7.5-3.75a.25.25 0 0 1-.138-.22V6.023Zm18 11.2a.25.25 0 0 1-.138.224l-7.5 3.75a.249.249 0 0 1-.329-.099.249.249 0 0 1-.033-.12V9.772a.251.251 0 0 1 .138-.224l7.5-3.75a.25.25 0 0 1 .362.224v11.2Z"/><path fill="#FF2D20" d="m3.55 1.893 8 4.048a1.008 1.008 0 0 0 .9 0l8-4.048a1 1 0 0 0-.9-1.785l-7.322 3.706a.506.506 0 0 1-.452 0L4.454.108a1 1 0 0 0-.9 1.785H3.55Z"/></svg>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 sm:pt-5 lg:pt-0">
|
||||
<h2 class="text-xl font-semibold text-black dark:text-white">Documentation</h2>
|
||||
|
||||
<p class="mt-4 text-sm/relaxed">
|
||||
Laravel has wonderful documentation covering every aspect of the framework. Whether you are a newcomer or have prior experience with Laravel, we recommend reading our documentation from beginning to end.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg class="size-6 shrink-0 stroke-[#FF2D20]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75"/></svg>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://laracasts.com"
|
||||
class="flex items-start gap-4 rounded-lg bg-white p-6 shadow-[0px_14px_34px_0px_rgba(0,0,0,0.08)] ring-1 ring-white/[0.05] transition duration-300 hover:text-black/70 hover:ring-black/20 focus:outline-none focus-visible:ring-[#FF2D20] lg:pb-10 dark:bg-zinc-900 dark:ring-zinc-800 dark:hover:text-white/70 dark:hover:ring-zinc-700 dark:focus-visible:ring-[#FF2D20]"
|
||||
>
|
||||
<div class="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#FF2D20]/10 sm:size-16">
|
||||
<svg class="size-5 sm:size-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><g fill="#FF2D20"><path d="M24 8.25a.5.5 0 0 0-.5-.5H.5a.5.5 0 0 0-.5.5v12a2.5 2.5 0 0 0 2.5 2.5h19a2.5 2.5 0 0 0 2.5-2.5v-12Zm-7.765 5.868a1.221 1.221 0 0 1 0 2.264l-6.626 2.776A1.153 1.153 0 0 1 8 18.123v-5.746a1.151 1.151 0 0 1 1.609-1.035l6.626 2.776ZM19.564 1.677a.25.25 0 0 0-.177-.427H15.6a.106.106 0 0 0-.072.03l-4.54 4.543a.25.25 0 0 0 .177.427h3.783c.027 0 .054-.01.073-.03l4.543-4.543ZM22.071 1.318a.047.047 0 0 0-.045.013l-4.492 4.492a.249.249 0 0 0 .038.385.25.25 0 0 0 .14.042h5.784a.5.5 0 0 0 .5-.5v-2a2.5 2.5 0 0 0-1.925-2.432ZM13.014 1.677a.25.25 0 0 0-.178-.427H9.101a.106.106 0 0 0-.073.03l-4.54 4.543a.25.25 0 0 0 .177.427H8.4a.106.106 0 0 0 .073-.03l4.54-4.543ZM6.513 1.677a.25.25 0 0 0-.177-.427H2.5A2.5 2.5 0 0 0 0 3.75v2a.5.5 0 0 0 .5.5h1.4a.106.106 0 0 0 .073-.03l4.54-4.543Z"/></g></svg>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 sm:pt-5">
|
||||
<h2 class="text-xl font-semibold text-black dark:text-white">Laracasts</h2>
|
||||
|
||||
<p class="mt-4 text-sm/relaxed">
|
||||
Laracasts offers thousands of video tutorials on Laravel, PHP, and JavaScript development. Check them out, see for yourself, and massively level up your development skills in the process.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<svg class="size-6 shrink-0 self-center stroke-[#FF2D20]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75"/></svg>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://laravel-news.com"
|
||||
class="flex items-start gap-4 rounded-lg bg-white p-6 shadow-[0px_14px_34px_0px_rgba(0,0,0,0.08)] ring-1 ring-white/[0.05] transition duration-300 hover:text-black/70 hover:ring-black/20 focus:outline-none focus-visible:ring-[#FF2D20] lg:pb-10 dark:bg-zinc-900 dark:ring-zinc-800 dark:hover:text-white/70 dark:hover:ring-zinc-700 dark:focus-visible:ring-[#FF2D20]"
|
||||
>
|
||||
<div class="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#FF2D20]/10 sm:size-16">
|
||||
<svg class="size-5 sm:size-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><g fill="#FF2D20"><path d="M8.75 4.5H5.5c-.69 0-1.25.56-1.25 1.25v4.75c0 .69.56 1.25 1.25 1.25h3.25c.69 0 1.25-.56 1.25-1.25V5.75c0-.69-.56-1.25-1.25-1.25Z"/><path d="M24 10a3 3 0 0 0-3-3h-2V2.5a2 2 0 0 0-2-2H2a2 2 0 0 0-2 2V20a3.5 3.5 0 0 0 3.5 3.5h17A3.5 3.5 0 0 0 24 20V10ZM3.5 21.5A1.5 1.5 0 0 1 2 20V3a.5.5 0 0 1 .5-.5h14a.5.5 0 0 1 .5.5v17c0 .295.037.588.11.874a.5.5 0 0 1-.484.625L3.5 21.5ZM22 20a1.5 1.5 0 1 1-3 0V9.5a.5.5 0 0 1 .5-.5H21a1 1 0 0 1 1 1v10Z"/><path d="M12.751 6.047h2a.75.75 0 0 1 .75.75v.5a.75.75 0 0 1-.75.75h-2A.75.75 0 0 1 12 7.3v-.5a.75.75 0 0 1 .751-.753ZM12.751 10.047h2a.75.75 0 0 1 .75.75v.5a.75.75 0 0 1-.75.75h-2A.75.75 0 0 1 12 11.3v-.5a.75.75 0 0 1 .751-.753ZM4.751 14.047h10a.75.75 0 0 1 .75.75v.5a.75.75 0 0 1-.75.75h-10A.75.75 0 0 1 4 15.3v-.5a.75.75 0 0 1 .751-.753ZM4.75 18.047h7.5a.75.75 0 0 1 .75.75v.5a.75.75 0 0 1-.75.75h-7.5A.75.75 0 0 1 4 19.3v-.5a.75.75 0 0 1 .75-.753Z"/></g></svg>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 sm:pt-5">
|
||||
<h2 class="text-xl font-semibold text-black dark:text-white">Laravel News</h2>
|
||||
|
||||
<p class="mt-4 text-sm/relaxed">
|
||||
Laravel News is a community driven portal and newsletter aggregating all of the latest and most important news in the Laravel ecosystem, including new package releases and tutorials.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<svg class="size-6 shrink-0 self-center stroke-[#FF2D20]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75"/></svg>
|
||||
</a>
|
||||
|
||||
<div class="flex items-start gap-4 rounded-lg bg-white p-6 shadow-[0px_14px_34px_0px_rgba(0,0,0,0.08)] ring-1 ring-white/[0.05] lg:pb-10 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<div class="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#FF2D20]/10 sm:size-16">
|
||||
<svg class="size-5 sm:size-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<g fill="#FF2D20">
|
||||
<path
|
||||
d="M16.597 12.635a.247.247 0 0 0-.08-.237 2.234 2.234 0 0 1-.769-1.68c.001-.195.03-.39.084-.578a.25.25 0 0 0-.09-.267 8.8 8.8 0 0 0-4.826-1.66.25.25 0 0 0-.268.181 2.5 2.5 0 0 1-2.4 1.824.045.045 0 0 0-.045.037 12.255 12.255 0 0 0-.093 3.86.251.251 0 0 0 .208.214c2.22.366 4.367 1.08 6.362 2.118a.252.252 0 0 0 .32-.079 10.09 10.09 0 0 0 1.597-3.733ZM13.616 17.968a.25.25 0 0 0-.063-.407A19.697 19.697 0 0 0 8.91 15.98a.25.25 0 0 0-.287.325c.151.455.334.898.548 1.328.437.827.981 1.594 1.619 2.28a.249.249 0 0 0 .32.044 29.13 29.13 0 0 0 2.506-1.99ZM6.303 14.105a.25.25 0 0 0 .265-.274 13.048 13.048 0 0 1 .205-4.045.062.062 0 0 0-.022-.07 2.5 2.5 0 0 1-.777-.982.25.25 0 0 0-.271-.149 11 11 0 0 0-5.6 2.815.255.255 0 0 0-.075.163c-.008.135-.02.27-.02.406.002.8.084 1.598.246 2.381a.25.25 0 0 0 .303.193 19.924 19.924 0 0 1 5.746-.438ZM9.228 20.914a.25.25 0 0 0 .1-.393 11.53 11.53 0 0 1-1.5-2.22 12.238 12.238 0 0 1-.91-2.465.248.248 0 0 0-.22-.187 18.876 18.876 0 0 0-5.69.33.249.249 0 0 0-.179.336c.838 2.142 2.272 4 4.132 5.353a.254.254 0 0 0 .15.048c1.41-.01 2.807-.282 4.117-.802ZM18.93 12.957l-.005-.008a.25.25 0 0 0-.268-.082 2.21 2.21 0 0 1-.41.081.25.25 0 0 0-.217.2c-.582 2.66-2.127 5.35-5.75 7.843a.248.248 0 0 0-.09.299.25.25 0 0 0 .065.091 28.703 28.703 0 0 0 2.662 2.12.246.246 0 0 0 .209.037c2.579-.701 4.85-2.242 6.456-4.378a.25.25 0 0 0 .048-.189 13.51 13.51 0 0 0-2.7-6.014ZM5.702 7.058a.254.254 0 0 0 .2-.165A2.488 2.488 0 0 1 7.98 5.245a.093.093 0 0 0 .078-.062 19.734 19.734 0 0 1 3.055-4.74.25.25 0 0 0-.21-.41 12.009 12.009 0 0 0-10.4 8.558.25.25 0 0 0 .373.281 12.912 12.912 0 0 1 4.826-1.814ZM10.773 22.052a.25.25 0 0 0-.28-.046c-.758.356-1.55.635-2.365.833a.25.25 0 0 0-.022.48c1.252.43 2.568.65 3.893.65.1 0 .2 0 .3-.008a.25.25 0 0 0 .147-.444c-.526-.424-1.1-.917-1.673-1.465ZM18.744 8.436a.249.249 0 0 0 .15.228 2.246 2.246 0 0 1 1.352 2.054c0 .337-.08.67-.23.972a.25.25 0 0 0 .042.28l.007.009a15.016 15.016 0 0 1 2.52 4.6.25.25 0 0 0 .37.132.25.25 0 0 0 .096-.114c.623-1.464.944-3.039.945-4.63a12.005 12.005 0 0 0-5.78-10.258.25.25 0 0 0-.373.274c.547 2.109.85 4.274.901 6.453ZM9.61 5.38a.25.25 0 0 0 .08.31c.34.24.616.561.8.935a.25.25 0 0 0 .3.127.631.631 0 0 1 .206-.034c2.054.078 4.036.772 5.69 1.991a.251.251 0 0 0 .267.024c.046-.024.093-.047.141-.067a.25.25 0 0 0 .151-.23A29.98 29.98 0 0 0 15.957.764a.25.25 0 0 0-.16-.164 11.924 11.924 0 0 0-2.21-.518.252.252 0 0 0-.215.076A22.456 22.456 0 0 0 9.61 5.38Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 sm:pt-5">
|
||||
<h2 class="text-xl font-semibold text-black dark:text-white">Vibrant Ecosystem</h2>
|
||||
|
||||
<p class="mt-4 text-sm/relaxed">
|
||||
Laravel's robust library of first-party tools and libraries, such as <a href="https://forge.laravel.com" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white dark:focus-visible:ring-[#FF2D20]">Forge</a>, <a href="https://vapor.laravel.com" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Vapor</a>, <a href="https://nova.laravel.com" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Nova</a>, <a href="https://envoyer.io" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Envoyer</a>, and <a href="https://herd.laravel.com" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Herd</a> help you take your projects to the next level. Pair them with powerful open source libraries like <a href="https://laravel.com/docs/billing" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Cashier</a>, <a href="https://laravel.com/docs/dusk" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Dusk</a>, <a href="https://laravel.com/docs/broadcasting" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Echo</a>, <a href="https://laravel.com/docs/horizon" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Horizon</a>, <a href="https://laravel.com/docs/sanctum" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Sanctum</a>, <a href="https://laravel.com/docs/telescope" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Telescope</a>, and more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="py-16 text-center text-sm text-black dark:text-white/70">
|
||||
Laravel v{{ Illuminate\Foundation\Application::VERSION }} (PHP v{{ PHP_VERSION }})
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+70
-29
@@ -7,6 +7,8 @@ use App\Http\Controllers\OfflineSyncController;
|
||||
use App\Livewire\ProjectMap;
|
||||
use App\Livewire\ProjectList;
|
||||
use App\Livewire\PhaseProgress;
|
||||
use App\Livewire\PhaseGantt;
|
||||
use App\Http\Controllers\ProjectReportController;
|
||||
|
||||
use App\Http\Controllers\Auth\AuthenticatedSessionController;
|
||||
use App\Http\Controllers\Auth\ConfirmablePasswordController;
|
||||
@@ -36,39 +38,61 @@ Route::middleware(['auth'])->group(function () {
|
||||
// Dashboard principal (vista con estadísticas y lista de proyectos)
|
||||
Route::get('/dashboard', function () {
|
||||
$user = \Illuminate\Support\Facades\Auth::user();
|
||||
$projectIds = \App\Models\Project::accessibleBy($user)->pluck('id');
|
||||
|
||||
$projects = \App\Models\Project::accessibleBy($user)
|
||||
->withCount('phases')
|
||||
->with('phases')
|
||||
->latest()
|
||||
->take(5)
|
||||
->get();
|
||||
->with(['phases' => fn($q) => $q->orderBy('order')])
|
||||
->latest()->take(6)->get();
|
||||
|
||||
$allProjects = \App\Models\Project::accessibleBy($user);
|
||||
$activeProjects = (clone $allProjects)->where('status', 'in_progress');
|
||||
$totalPhases = \App\Models\Phase::whereIn('project_id', (clone $allProjects)->pluck('id'))->count();
|
||||
$totalFeatures = \App\Models\Feature::whereIn('layer_id', function($q) use ($allProjects) {
|
||||
$q->select('id')->from('layers')->whereIn('project_id', (clone $allProjects)->pluck('id'));
|
||||
})->count();
|
||||
$activeProjects = \App\Models\Project::accessibleBy($user)->where('status', 'in_progress')->count();
|
||||
$totalProjects = \App\Models\Project::accessibleBy($user)->count();
|
||||
$totalPhases = \App\Models\Phase::whereIn('project_id', $projectIds)->count();
|
||||
$totalFeatures = \App\Models\Feature::whereHas('layer.phase', fn($q) => $q->whereIn('project_id', $projectIds))->count();
|
||||
$globalProgress = \App\Models\Phase::whereIn('project_id', $projectIds)->avg('progress_percent') ?? 0;
|
||||
|
||||
$globalProgress = \App\Models\Phase::whereIn('project_id', (clone $allProjects)->pluck('id'))->avg('progress_percent') ?? 0;
|
||||
$openIssues = \App\Models\Issue::whereIn('project_id', $projectIds)->where('status', 'open')->count();
|
||||
$criticalIssues = \App\Models\Issue::whereIn('project_id', $projectIds)->where('status', 'open')->where('priority', 'critical')->count();
|
||||
|
||||
$inspections = \App\Models\Inspection::whereIn('project_id', (clone $allProjects)->pluck('id'))
|
||||
->with(['template', 'feature'])
|
||||
->latest()
|
||||
->take(5)
|
||||
->get();
|
||||
$pendingInspections = \App\Models\Inspection::whereIn('project_id', $projectIds)->where('status', 'pending')->count();
|
||||
$completedInspections = \App\Models\Inspection::whereIn('project_id', $projectIds)->where('status', 'completed')->count();
|
||||
$rejectedInspections = \App\Models\Inspection::whereIn('project_id', $projectIds)->where('status', 'rejected')->count();
|
||||
|
||||
$recentInspections = \App\Models\Inspection::whereIn('project_id', $projectIds)
|
||||
->with(['template', 'feature', 'project'])
|
||||
->latest()->take(5)->get();
|
||||
|
||||
$recentIssues = \App\Models\Issue::whereIn('project_id', $projectIds)
|
||||
->with(['feature', 'reporter', 'project'])
|
||||
->where('status', '!=', 'closed')
|
||||
->orderByRaw("FIELD(priority,'critical','high','medium','low')")
|
||||
->take(5)->get();
|
||||
|
||||
// Projects with delay (planned_end exceeded and not completed)
|
||||
$delayedPhases = \App\Models\Phase::whereIn('project_id', $projectIds)
|
||||
->whereNotNull('planned_end')
|
||||
->where('planned_end', '<', now())
|
||||
->where('progress_percent', '<', 100)
|
||||
->with('project')
|
||||
->count();
|
||||
|
||||
return view('dashboard', [
|
||||
'stats' => [
|
||||
'active_projects' => $activeProjects->count(),
|
||||
'total_projects' => $allProjects->count(),
|
||||
'total_phases' => $totalPhases,
|
||||
'total_features' => $totalFeatures,
|
||||
'global_progress' => round($globalProgress),
|
||||
'active_projects' => $activeProjects,
|
||||
'total_projects' => $totalProjects,
|
||||
'total_phases' => $totalPhases,
|
||||
'total_features' => $totalFeatures,
|
||||
'global_progress' => round($globalProgress),
|
||||
'open_issues' => $openIssues,
|
||||
'critical_issues' => $criticalIssues,
|
||||
'pending_inspections' => $pendingInspections,
|
||||
'completed_inspections'=> $completedInspections,
|
||||
'rejected_inspections' => $rejectedInspections,
|
||||
'delayed_phases' => $delayedPhases,
|
||||
],
|
||||
'recentProjects' => $projects,
|
||||
'recentInspections' => $inspections,
|
||||
'recentProjects' => $projects,
|
||||
'recentInspections' => $recentInspections,
|
||||
'recentIssues' => $recentIssues,
|
||||
]);
|
||||
})->name('dashboard');
|
||||
Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboard');
|
||||
@@ -79,9 +103,12 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Gestión de proyectos (CRUD completo)
|
||||
// Gestión de proyectos
|
||||
// ------------------------------------------------------------
|
||||
Route::resource('projects', ProjectController::class);
|
||||
// Create/Edit handled by unified Livewire component
|
||||
Route::get('/projects/create', \App\Livewire\ProjectForm::class)->name('projects.create');
|
||||
Route::get('/projects/{project}/edit', \App\Livewire\ProjectForm::class)->name('projects.edit');
|
||||
Route::resource('projects', ProjectController::class)->except(['create', 'edit']);
|
||||
// Ruta personalizada para ver el mapa de un proyecto específico
|
||||
Route::get('/projects/{project}/map', [ProjectController::class, 'map'])->name('projects.map');
|
||||
// Ruta para que el componente Livewire muestre/gestione el progreso de una fase
|
||||
@@ -95,6 +122,16 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa
|
||||
// Rutas para el LayerManager:
|
||||
Route::get('/projects/{project}/phases/{phase}/layers/manage', \App\Livewire\LayerManager::class)->name('layers.manage');
|
||||
|
||||
// Cronograma Gantt y reporte del proyecto
|
||||
Route::get('/projects/{project}/gantt', PhaseGantt::class)->name('projects.gantt');
|
||||
Route::get('/projects/{project}/report', [ProjectReportController::class, 'show'])->name('projects.report');
|
||||
|
||||
// Issues del proyecto
|
||||
Route::get('/projects/{project}/issues', \App\Livewire\IssueManager::class)->name('projects.issues');
|
||||
|
||||
// Dashboard por proyecto
|
||||
Route::get('/projects/{project}/dashboard', \App\Livewire\ProjectDashboard::class)->name('projects.dashboard');
|
||||
|
||||
// Cliente: portal cliente
|
||||
Route::middleware(['auth', 'role:client'])->prefix('client')->name('client.')->group(function () {
|
||||
Route::get('/', function () {
|
||||
@@ -104,16 +141,20 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa
|
||||
|
||||
// Admin: gestión de usuarios y roles
|
||||
Route::middleware(['can:manage all'])->prefix('admin')->name('admin.')->group(function () {
|
||||
Route::get('/users', function () {
|
||||
return view('admin.users');
|
||||
})->name('users');
|
||||
Route::get('/users', function () { return view('admin.users'); })->name('users');
|
||||
Route::get('/users/create', \App\Livewire\UserForm::class)->name('users.create');
|
||||
Route::get('/users/{user}', \App\Livewire\UserView::class)->name('users.show');
|
||||
Route::get('/users/{user}/edit', \App\Livewire\UserForm::class)->name('users.edit');
|
||||
});
|
||||
|
||||
// Gestor de medios
|
||||
Route::get('/projects/{project}/media', function (\App\Models\Project $project) {
|
||||
return view('projects.media', compact('project'));
|
||||
})->name('projects.media');
|
||||
Route::get('/companies', \App\Livewire\CompanyManagement::class)->name('companies.manage');
|
||||
Route::get('/companies', \App\Livewire\CompanyManagement::class)->name('companies.manage');
|
||||
Route::get('/companies/create', \App\Livewire\CompanyForm::class)->name('companies.create');
|
||||
Route::get('/companies/{company}', \App\Livewire\CompanyView::class)->name('companies.show');
|
||||
Route::get('/companies/{company}/edit', \App\Livewire\CompanyForm::class)->name('companies.edit');
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Sincronización offline (para trabajadores en campo)
|
||||
|
||||
Reference in New Issue
Block a user