Compare commits

...

4 Commits

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 18:05:53 +02:00
97 changed files with 7771 additions and 1075 deletions
+1
View File
@@ -22,3 +22,4 @@
Homestead.json
Homestead.yaml
Thumbs.db
.claude/worktrees/
+87 -28
View File
@@ -13,14 +13,26 @@ use Illuminate\Support\Facades\Storage;
class OfflineSyncController extends Controller
{
/**
* Allowed mediable model types (whitelist to prevent RCE via dynamic instantiation).
*/
private const ALLOWED_MEDIABLE_TYPES = [
'project' => \App\Models\Project::class,
'phase' => \App\Models\Phase::class,
'layer' => \App\Models\Layer::class,
'feature' => \App\Models\Feature::class,
'inspection' => \App\Models\Inspection::class,
'issue' => \App\Models\Issue::class,
];
public function storePending(Request $request)
{
$payload = $request->validate([
'action' => 'required|in:progress_update,inspection,feature_create,media_upload,task_complete',
'payload' => 'required|array',
]);
$pending = PendingSync::create([
'user_id' => Auth::id() ?? 1,
PendingSync::create([
'user_id' => Auth::id(),
'action' => $payload['action'],
'payload' => $payload['payload'],
]);
@@ -32,65 +44,111 @@ class OfflineSyncController extends Controller
$user = Auth::user();
$pendings = PendingSync::where('user_id', $user->id)->whereNull('synced_at')->get();
$results = [];
foreach ($pendings as $pending) {
$result = ['id' => $pending->id, 'action' => $pending->action, 'success' => false, 'error' => null];
try {
if ($pending->action === 'progress_update') {
$phase = Phase::find($pending->payload['phase_id']);
$phaseId = (int) ($pending->payload['phase_id'] ?? 0);
$progress = (int) ($pending->payload['progress'] ?? 0);
$progress = max(0, min(100, $progress));
$phase = Phase::find($phaseId);
if ($phase) {
$phase->progress_percent = $pending->payload['progress'];
// Verify user has access to this phase's project
if (!$user->hasRole('Admin') && !$phase->project->users()->where('user_id', $user->id)->exists()) {
$result['error'] = 'Access denied to this project.';
} else {
$phase->progress_percent = $progress;
$phase->save();
$phase->progressUpdates()->create([
'user_id' => $user->id,
'progress_percent' => $pending->payload['progress'],
'comment' => $pending->payload['comment'] ?? '',
'location' => $pending->payload['location'] ?? null,
'progress_percent' => $progress,
'comment' => substr($pending->payload['comment'] ?? '', 0, 500),
]);
}
$result['success'] = true;
}
} else {
$result['error'] = 'Phase not found.';
}
} elseif ($pending->action === 'inspection') {
$inspection = Inspection::create($pending->payload);
$p = $pending->payload;
$inspection = Inspection::create([
'project_id' => (int) ($p['project_id'] ?? 0),
'feature_id' => isset($p['feature_id']) ? (int) $p['feature_id'] : null,
'layer_id' => isset($p['layer_id']) ? (int) $p['layer_id'] : null,
'template_id' => isset($p['template_id'])? (int) $p['template_id']: null,
'user_id' => $user->id,
'inspector_user_id' => $user->id,
'status' => 'completed',
'completed_at' => now(),
'result' => in_array($p['result'] ?? '', Inspection::RESULTS) ? $p['result'] : null,
'notes' => substr($p['notes'] ?? '', 0, 2000),
'data' => is_array($p['data'] ?? null) ? $p['data'] : [],
]);
$result['success'] = true;
$result['data'] = ['inspection_id' => $inspection->id];
} elseif ($pending->action === 'feature_create') {
$feature = Feature::create($pending->payload);
$p = $pending->payload;
$feature = Feature::create([
'layer_id' => (int) ($p['layer_id'] ?? 0),
'name' => substr($p['name'] ?? 'Elemento', 0, 255),
'geometry' => is_array($p['geometry'] ?? null) ? $p['geometry'] : null,
'properties' => is_array($p['properties'] ?? null) ? $p['properties'] : [],
'template_id' => isset($p['template_id']) ? (int) $p['template_id'] : null,
'progress' => max(0, min(100, (int) ($p['progress'] ?? 0))),
'status' => in_array($p['status'] ?? '', Feature::STATUSES) ? $p['status'] : 'planned',
'responsible' => isset($p['responsible']) ? substr($p['responsible'], 0, 255) : null,
]);
$result['success'] = true;
$result['data'] = ['feature_id' => $feature->id];
} elseif ($pending->action === 'media_upload') {
// Assuming payload has: 'file' (base64), 'path', 'model_type', 'model_id'
// We'll decode the base64 and store the file
if (isset($pending->payload['file'], $pending->payload['path'])) {
$decoded = base64_decode($pending->payload['file']);
// Restrict path to safe uploads directory
$safePath = 'uploads/' . ltrim(basename($pending->payload['path']), '/');
$decoded = base64_decode($pending->payload['file'], true);
if ($decoded !== false) {
$path = Storage::put($pending->payload['path'], $decoded);
// Attach to model if model_type and model_id are provided
Storage::disk('public')->put($safePath, $decoded);
// Whitelist-based model type resolution (prevents RCE)
if (isset($pending->payload['model_type'], $pending->payload['model_id'])) {
$model = new $pending->payload['model_type'];
$model = $model->find($pending->payload['model_id']);
$typeKey = strtolower(trim($pending->payload['model_type']));
if (array_key_exists($typeKey, self::ALLOWED_MEDIABLE_TYPES)) {
$modelClass = self::ALLOWED_MEDIABLE_TYPES[$typeKey];
$model = $modelClass::find((int) $pending->payload['model_id']);
if ($model) {
$model->media()->create([
'name' => $pending->payload['name'] ?? 'unnamed',
'path' => $path,
'mime_type' => $pending->payload['mime_type'] ?? 'application/octet-stream',
'disk' => 'public',
'name' => substr($pending->payload['name'] ?? 'unnamed', 0, 255),
'file_path' => $safePath,
'file_type' => substr($pending->payload['mime_type'] ?? 'application/octet-stream', 0, 100),
'file_extension' => pathinfo($safePath, PATHINFO_EXTENSION),
'file_size' => strlen($decoded),
'category' => 'other',
'uploaded_by' => $user->id,
]);
}
}
}
$result['success'] = true;
$result['data'] = ['path' => $path];
$result['data'] = ['path' => $safePath];
} else {
$result['error'] = 'Failed to decode base64 file';
$result['error'] = 'Failed to decode base64 file.';
}
} else {
$result['error'] = 'Missing file or path in payload';
$result['error'] = 'Missing file or path in payload.';
}
} elseif ($pending->action === 'task_complete') {
// Example: mark a task as complete (you can adjust as needed)
// For now, just log and mark as success
\Log::info('Task completed offline', $pending->payload);
// No-op placeholder, just mark as synced
$result['success'] = true;
} else {
$result['error'] = 'Unknown action type';
$result['error'] = 'Unknown action type.';
}
} catch (\Exception $e) {
$result['error'] = $e->getMessage();
@@ -103,6 +161,7 @@ class OfflineSyncController extends Controller
$results[] = $result;
}
return response()->json(['synced' => $results]);
}
}
@@ -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);
}
}
@@ -11,21 +11,25 @@ use App\Exports\ProjectsExport;
use App\Exports\PhasesExport;
use App\Exports\InspectionsExport;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class ExportController extends Controller
{
public function exportProjects(Request $request)
{
Gate::authorize('manage all');
return Excel::download(new ProjectsExport, 'projects.xlsx');
}
public function exportPhases(Request $request)
{
Gate::authorize('manage all');
return Excel::download(new PhasesExport, 'phases.xlsx');
}
public function exportInspections(Request $request)
{
Gate::authorize('manage all');
return Excel::download(new InspectionsExport, 'inspections.xlsx');
}
}
+2 -2
View File
@@ -41,9 +41,9 @@ class SetLocale
}
}
// 4. Default to English
// 4. Default to app locale
if (!$locale) {
$locale = 'en';
$locale = config('app.locale', 'es');
}
App::setLocale($locale);
+13
View File
@@ -45,6 +45,19 @@ class AdminUsers extends Component
$this->dispatch('notify', 'Rol actualizado.');
}
public function deleteUser(int $userId): void
{
if (!Auth::user()->hasRole('Admin')) abort(403);
if ($userId === Auth::id()) {
session()->flash('error', 'No puedes eliminarte a ti mismo.');
return;
}
User::findOrFail($userId)->delete();
session()->flash('message', 'Usuario eliminado.');
$this->loadUsers();
}
public function render()
{
return view('livewire.admin-users');
+71 -79
View File
@@ -4,11 +4,7 @@ namespace App\Livewire\Client;
use Livewire\Component;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Inspection;
use App\Models\Feature;
use App\Models\ChangeOrder;
use Carbon\Carbon;
class ClientProjects extends Component
{
@@ -25,7 +21,6 @@ class ClientProjects extends Component
public function loadProjects()
{
// Get projects where the user has the 'client' role
$user = auth()->user();
$this->projects = $user->projects()
->wherePivot('role_in_project', 'client')
@@ -36,9 +31,23 @@ class ClientProjects extends Component
->toArray();
}
/**
* Return only project IDs the current user can access as client.
*/
private function accessibleProjectIds(): \Illuminate\Support\Collection
{
return auth()->user()->projects()
->wherePivot('role_in_project', 'client')
->pluck('projects.id');
}
public function selectProject($projectId)
{
$this->selectedProject = $projectId;
// Verify the project is one the user is a client on
if (!$this->accessibleProjectIds()->contains((int) $projectId)) {
abort(403);
}
$this->selectedProject = (int) $projectId;
$this->loadProjectDetails();
}
@@ -48,10 +57,14 @@ class ClientProjects extends Component
return;
}
// Re-verify ownership on every load
if (!$this->accessibleProjectIds()->contains($this->selectedProject)) {
abort(403);
}
$project = Project::with([
'phases.features',
'inspections.template',
'changeOrders' // Load change orders for this project
'phases',
'changeOrders',
])->find($this->selectedProject);
if (!$project) {
@@ -61,111 +74,90 @@ class ClientProjects extends Component
$this->projectDetails = [
'id' => $project->id,
'name' => $project->name,
'description' => $project->description,
'description'=> $project->description ?? '',
'start_date' => $project->start_date,
'end_date' => $project->end_date,
'end_date' => $project->end_date_estimated,
'status' => $project->status,
'progress' => $project->phases->avg('progress_percent') ?? 0,
'progress' => round($project->phases->avg('progress_percent') ?? 0),
];
// Get recent images (we can fetch from media table if needed, but for now we'll keep simulated or link to real)
// For simplicity, we'll try to get some media images for the project
$mediaImages = $project->media()
->where('category', 'image')
->latest()
->take(3)
->get()
->map(function($media) {
return [
->map(fn ($media) => [
'url' => $media->url,
'title' => $media->name,
'date' => $media->created_at->format('d/m/Y')
];
})
'date' => $media->created_at->format('d/m/Y'),
])
->toArray();
// If we don't have 3 images, we can fallback to placeholders or just use what we have
if (count($mediaImages) > 0) {
$this->galleryImages = $mediaImages;
} else {
// Fallback to placeholders
$this->galleryImages = [
[
'url' => 'https://via.placeholder.com/400x300?text=Avance+1',
'title' => 'Avance inicial',
'date' => now()->subDays(30)->format('d/m/Y')
],
[
'url' => 'https://via.placeholder.com/400x300?text=Avance+2',
'title' => 'Estructura levantada',
'date' => now()->subDays(15)->format('d/m/Y')
],
[
'url' => 'https://via.placeholder.com/400x300?text=Avance+3',
'title' => 'Instalaciones',
'date' => now()->subDays(5)->format('d/m/Y')
]
];
}
$this->galleryImages = $mediaImages ?: [];
// Get change orders for this project
$this->changeOrders = $project->changeOrders
->orderBy('requested_at', 'desc')
->get()
->map(function($order) {
return [
->sortByDesc('requested_at')
->map(fn ($order) => [
'id' => $order->id,
'title' => $order->title,
'description' => $order->description,
'status' => $order->status,
'requested_at' => $order->requested_at->format('d/m/Y'),
'amount' => $order->amount
];
})
'requested_at' => $order->requested_at?->format('d/m/Y') ?? '',
'amount' => $order->amount,
])
->values()
->toArray();
}
public function approveChangeOrder($orderId)
{
// Update the change order in the database
$changeOrder = ChangeOrder::find($orderId);
if ($changeOrder) {
// Check that the change order belongs to the selected project (security)
if ($changeOrder->project_id == $this->selectedProject) {
$changeOrder->status = 'approved';
$changeOrder->responded_at = now()->toDateString();
$changeOrder->responded_by = auth()->id();
$changeOrder->save();
$changeOrder = ChangeOrder::where('id', $orderId)
->where('project_id', $this->selectedProject)
->first();
if (!$changeOrder) {
abort(403);
}
// Verify this project is accessible by the current user
if (!$this->accessibleProjectIds()->contains($this->selectedProject)) {
abort(403);
}
$changeOrder->update([
'status' => 'approved',
'responded_at' => now()->toDateString(),
'responded_by' => auth()->id(),
]);
// Refresh the change orders list
$this->loadProjectDetails();
// Notify any listeners (optional)
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'approved']);
}
}
}
public function rejectChangeOrder($orderId)
{
// Update the change order in the database
$changeOrder = ChangeOrder::find($orderId);
if ($changeOrder) {
// Check that the change order belongs to the selected project (security)
if ($changeOrder->project_id == $this->selectedProject) {
$changeOrder->status = 'rejected';
$changeOrder->responded_at = now()->toDateString();
$changeOrder->responded_by = auth()->id();
$changeOrder->save();
$changeOrder = ChangeOrder::where('id', $orderId)
->where('project_id', $this->selectedProject)
->first();
if (!$changeOrder) {
abort(403);
}
// Verify this project is accessible by the current user
if (!$this->accessibleProjectIds()->contains($this->selectedProject)) {
abort(403);
}
$changeOrder->update([
'status' => 'rejected',
'responded_at' => now()->toDateString(),
'responded_by' => auth()->id(),
]);
// Refresh the change orders list
$this->loadProjectDetails();
// Notify any listeners (optional)
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'rejected']);
}
}
}
public function render()
{
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\Attributes\Layout;
use App\Models\Company;
use Illuminate\Support\Facades\Storage;
#[Layout('layouts.app')]
class CompanyForm extends Component
{
use WithFileUploads;
public ?Company $company = null;
// Form fields
public string $name = '';
public string $apodo = '';
public string $tax_id = '';
public string $estado = 'activo';
public string $type = 'other';
public string $address = '';
public string $phone = '';
public string $email = '';
public string $website = '';
public string $notes = '';
public $logo = null;
public function mount(?Company $company = null): void
{
if ($company && $company->exists) {
$this->company = $company;
$this->name = $company->name;
$this->apodo = $company->apodo ?? '';
$this->tax_id = $company->tax_id ?? '';
$this->estado = $company->estado ?? 'activo';
$this->type = $company->type ?? 'other';
$this->address = $company->address ?? '';
$this->phone = $company->phone ?? '';
$this->email = $company->email ?? '';
$this->website = $company->website ?? '';
$this->notes = $company->notes ?? '';
}
}
protected function rules(): array
{
$id = $this->company?->id ?? 'NULL';
return [
'name' => 'required|string|max:255',
'apodo' => 'nullable|string|max:100',
'tax_id' => "nullable|string|max:50|unique:companies,tax_id,{$id}",
'estado' => 'required|in:activo,inactivo,suspendido',
'type' => 'required|in:owner,constructor,subcontractor,consultant,supplier,other',
'address' => 'nullable|string',
'phone' => 'nullable|string|max:30',
'email' => 'nullable|email|max:255',
'website' => 'nullable|url|max:255',
'notes' => 'nullable|string',
'logo' => 'nullable|image|max:2048',
];
}
public function save(): void
{
$this->validate();
$data = [
'name' => $this->name,
'apodo' => $this->apodo ?: null,
'tax_id' => $this->tax_id ?: null,
'estado' => $this->estado,
'type' => $this->type,
'address' => $this->address ?: null,
'phone' => $this->phone ?: null,
'email' => $this->email ?: null,
'website' => $this->website ?: null,
'notes' => $this->notes ?: null,
];
if ($this->logo) {
// Delete old logo when replacing
if ($this->company?->logo_path) {
Storage::disk('public')->delete($this->company->logo_path);
}
$data['logo_path'] = $this->logo->store('company-logos', 'public');
}
if ($this->company && $this->company->exists) {
$this->company->update($data);
session()->flash('notify', 'Empresa actualizada correctamente.');
} else {
Company::create($data);
session()->flash('notify', 'Empresa creada correctamente.');
}
$this->redirect(route('companies.manage'), navigate: true);
}
public function render()
{
return view('livewire.company-form');
}
}
+10 -1
View File
@@ -3,11 +3,14 @@
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Livewire\WithFileUploads;
use App\Models\Company;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Response;
#[Layout('layouts.app')]
class CompanyManagement extends Component
{
use WithFileUploads;
@@ -51,6 +54,7 @@ class CompanyManagement extends Component
public function mount()
{
if (!Auth::user()->hasRole('Admin')) abort(403);
$this->resetForm();
}
@@ -110,6 +114,7 @@ class CompanyManagement extends Component
public function updateCompany()
{
if (!Auth::user()->hasRole('Admin')) abort(403);
$this->validate();
$company = Company::findOrFail($this->editingCompanyId);
@@ -138,6 +143,7 @@ class CompanyManagement extends Component
public function createCompany()
{
if (!Auth::user()->hasRole('Admin')) abort(403);
$this->validate();
$data = [
@@ -164,6 +170,7 @@ class CompanyManagement extends Component
public function deleteCompany(Company $company)
{
if (!Auth::user()->hasRole('Admin')) abort(403);
$company->delete(); // Soft delete
session()->flash('message', 'Empresa eliminada correctamente.');
}
@@ -231,6 +238,8 @@ class CompanyManagement extends Component
public function render()
{
return view('livewire.company-management');
return view('livewire.company-management', [
'companies' => $this->getCompaniesProperty(),
]);
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
namespace App\Livewire;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use App\Models\Company;
class CompanyTable extends DataTableComponent
{
protected $model = Company::class;
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects([
'companies.id as id',
'companies.apodo as apodo',
'companies.tax_id as tax_id',
'companies.phone as phone',
'companies.email as email',
'companies.logo_path as logo_path',
'companies.created_at as created_at',
]);
}
public function builder(): Builder
{
return Company::withCount('projects');
}
public function columns(): array
{
return [
Column::make('Empresa', 'name')
->sortable()
->searchable()
->format(function ($value, $row) {
$logoHtml = '';
if ($row->logo_path && Storage::disk('public')->exists($row->logo_path)) {
$url = Storage::disk('public')->url($row->logo_path);
$logoHtml = '<img src="'.e($url).'" class="w-9 h-9 rounded object-contain border border-base-300 shrink-0" />';
} else {
$logoHtml = '<div class="w-9 h-9 rounded bg-base-200 flex items-center justify-center shrink-0 text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-2 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
</div>';
}
$html = '<div class="flex items-center gap-3">'.$logoHtml.'<div>';
$html .= '<p class="font-semibold text-sm leading-tight">'.e($value).'</p>';
if ($row->apodo) $html .= '<p class="text-xs text-gray-500">'.e($row->apodo).'</p>';
if ($row->tax_id) $html .= '<p class="text-xs text-gray-400">NIF: '.e($row->tax_id).'</p>';
$html .= '</div></div>';
return $html;
})
->html(),
Column::make('Tipo', 'type')
->sortable()
->format(function ($value) {
$map = [
'owner' => ['badge-success', 'Promotor'],
'constructor' => ['badge-primary', 'Constructor'],
'subcontractor' => ['badge-secondary', 'Subcontratista'],
'consultant' => ['badge-info', 'Consultor'],
'supplier' => ['badge-warning', 'Proveedor'],
];
[$cls, $label] = $map[$value] ?? ['badge-ghost', 'Otro'];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make('Contacto', 'phone')
->format(function ($value, $row) {
$html = '';
if ($row->phone) {
$html .= '<div class="flex items-center gap-1 text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 opacity-50 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg>
'.e($row->phone).'</div>';
}
if ($row->email) {
$html .= '<div class="flex items-center gap-1 text-xs text-gray-500 max-w-[180px] truncate">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 opacity-50 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
'.e($row->email).'</div>';
}
return $html ?: '<span class="text-gray-300">—</span>';
})
->html(),
Column::make('Estado', 'estado')
->sortable()
->format(function ($value) {
$map = [
'activo' => ['badge-success', 'Activo'],
'inactivo' => ['badge-ghost', 'Inactivo'],
'suspendido' => ['badge-error', 'Suspendido'],
];
[$cls, $label] = $map[$value ?? 'activo'] ?? ['badge-ghost', ucfirst($value ?? 'activo')];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make('Proyectos')
->label(fn ($row) =>
'<span class="badge badge-outline badge-sm">'.(int)($row->projects_count ?? 0).'</span>'
)
->html(),
Column::make('Acciones')
->label(function ($row) {
$ver = route('companies.show', $row->id);
$editar = route('companies.edit', $row->id);
$name = addslashes($row->name);
$html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>';
$html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>';
$html .= '<button wire:click="deleteCompany('.$row->id.')"
wire:confirm="¿Eliminar \''.$name.'\'? Esta acción no se puede deshacer."
class="btn btn-xs btn-outline btn-error" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>';
$html .= '</div>';
return $html;
})
->html(),
];
}
public function filters(): array
{
return [
SelectFilter::make('Tipo', 'type')
->options([
'' => 'Tipo: todos',
'owner' => 'Promotor',
'constructor' => 'Constructor',
'subcontractor' => 'Subcontratista',
'consultant' => 'Consultor',
'supplier' => 'Proveedor',
'other' => 'Otro',
])
->filter(fn (Builder $query, string $value) => $query->where('type', $value)),
SelectFilter::make('Estado', 'estado')
->options([
'' => 'Estado: todos',
'activo' => 'Activo',
'inactivo' => 'Inactivo',
'suspendido' => 'Suspendido',
])
->filter(fn (Builder $query, string $value) => $query->where('estado', $value)),
];
}
public function deleteCompany(int $id): void
{
$company = Company::findOrFail($id);
if ($company->logo_path) {
Storage::disk('public')->delete($company->logo_path);
}
$company->delete();
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Company;
use App\Models\Project;
use App\Models\User;
use App\Models\Issue;
use Illuminate\Support\Facades\Auth;
#[Layout('layouts.app')]
class CompanyView extends Component
{
public Company $company;
public string $activeTab = 'summary';
// Projects tab
public ?int $addProjectId = null;
public string $addProjectRole = '';
public $availableProjects;
// People tab
public ?int $assignUserId = null;
public $assignableUsers;
// Notes tab
public string $notes = '';
public bool $editingNotes = false;
// Stats (computed once in mount, refreshed on mutations)
public int $usersCount = 0;
public int $projectsCount = 0;
public float $avgProgress = 0.0;
public int $openIssues = 0;
public function mount(Company $company): void
{
if (!Auth::user()->hasRole('Admin')) abort(403);
$this->company = $company->load(['users.roles', 'projects.phases']);
$this->notes = $company->notes ?? '';
$this->loadAvailableProjects();
$this->loadAssignableUsers();
$this->computeStats();
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function loadAvailableProjects(): void
{
$assignedIds = $this->company->projects->pluck('id');
$this->availableProjects = Project::whereNotIn('id', $assignedIds)
->orderBy('name')->get();
}
private function loadAssignableUsers(): void
{
$this->assignableUsers = User::where(function ($q) {
$q->where('company_id', '!=', $this->company->id)
->orWhereNull('company_id');
})->orderBy('name')->get();
}
private function computeStats(): void
{
$this->usersCount = $this->company->users->count();
$this->projectsCount = $this->company->projects->count();
$this->avgProgress = round(
$this->company->projects->flatMap(fn($p) => $p->phases)->avg('progress_percent') ?? 0
);
$userIds = $this->company->users->pluck('id');
$this->openIssues = $userIds->isNotEmpty()
? Issue::whereIn('reported_by', $userIds)->where('status', 'open')->count()
: 0;
}
// ── Tabs ─────────────────────────────────────────────────────────────────
public function setTab(string $tab): void
{
$this->activeTab = $tab;
}
// ── Projects ──────────────────────────────────────────────────────────────
public function assignProject(): void
{
$this->validate([
'addProjectId' => 'required|exists:projects,id',
'addProjectRole' => 'required|string|max:150',
], [], ['addProjectId' => 'proyecto', 'addProjectRole' => 'rol en proyecto']);
$this->company->projects()->attach($this->addProjectId, [
'role_in_project' => $this->addProjectRole,
]);
$this->company->load('projects.phases');
$this->addProjectId = null;
$this->addProjectRole = '';
$this->loadAvailableProjects();
$this->computeStats();
$this->dispatch('notify', 'Proyecto asignado correctamente.');
}
public function removeProject(int $projectId): void
{
$this->company->projects()->detach($projectId);
$this->company->load('projects.phases');
$this->loadAvailableProjects();
$this->computeStats();
$this->dispatch('notify', 'Proyecto desasignado.');
}
// ── People ────────────────────────────────────────────────────────────────
public function assignUser(): void
{
$this->validate([
'assignUserId' => 'required|exists:users,id',
], [], ['assignUserId' => 'usuario']);
User::find($this->assignUserId)?->update(['company_id' => $this->company->id]);
$this->company->load('users.roles');
$this->assignUserId = null;
$this->loadAssignableUsers();
$this->computeStats();
$this->dispatch('notify', 'Usuario vinculado a la empresa.');
}
public function removeUser(int $userId): void
{
User::find($userId)?->update(['company_id' => null]);
$this->company->load('users.roles');
$this->loadAssignableUsers();
$this->computeStats();
$this->dispatch('notify', 'Usuario desvinculado de la empresa.');
}
// ── Notes ─────────────────────────────────────────────────────────────────
public function saveNotes(): void
{
$this->validate(['notes' => 'nullable|string']);
$this->company->update(['notes' => $this->notes ?: null]);
$this->editingNotes = false;
$this->dispatch('notify', 'Notas guardadas.');
}
public function render()
{
return view('livewire.company-view');
}
}
+135
View File
@@ -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');
}
}
+7 -6
View File
@@ -9,20 +9,19 @@ use Illuminate\Support\Facades\Session;
class LanguageSwitcher extends Component
{
public $currentLocale;
public string $currentLocale;
public function mount()
public function mount(): void
{
$this->currentLocale = App::getLocale();
}
public function switchLanguage($locale)
public function switchLanguage(string $locale): void
{
if (!in_array($locale, ['en', 'es'])) {
return;
}
App::setLocale($locale);
Session::put('locale', $locale);
if (Auth::check()) {
@@ -31,8 +30,10 @@ class LanguageSwitcher extends Component
$user->save();
}
$this->currentLocale = $locale;
$this->dispatch('localeChanged', $locale);
// Dispatch a browser event — JavaScript reloads the page.
// PHP-side redirects break because $this->redirect() runs inside
// /livewire/update (the AJAX endpoint), not on the real page URL.
$this->dispatch('locale-changed');
}
public function render()
+10 -2
View File
@@ -40,10 +40,17 @@ class LayerManager extends Component
{
$this->project = $project;
$this->phase = $phase;
$this->loadLayers();
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);
}
$this->loadLayers();
// Por defecto todas visibles
$this->visibleLayers = $this->layers->pluck('id')->toArray();
$this->emitInitialLayersData();
@@ -286,7 +293,8 @@ class LayerManager extends Component
{
$user = Auth::user();
if (!$user->can('delete layers') && !$user->hasRole('Admin')) abort(403);
$layer = Layer::find($layerId);
// Verify layer 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
+62 -20
View File
@@ -4,7 +4,6 @@ namespace App\Livewire;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\Attributes\On;
use App\Models\Media;
use App\Models\Project;
use App\Models\Phase;
@@ -12,44 +11,60 @@ use App\Models\Layer;
use App\Models\Feature;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class MediaManager extends Component
{
use WithFileUploads;
// Polimórfico: a qué entidad pertenece
/**
* Whitelist of allowed mediable types (prevents arbitrary class instantiation).
* Keys are the public string accepted in mount(); values are FQCN.
*/
private const ALLOWED_TYPES = [
'App\\Models\\Project' => \App\Models\Project::class,
'App\\Models\\Phase' => \App\Models\Phase::class,
'App\\Models\\Layer' => \App\Models\Layer::class,
'App\\Models\\Feature' => \App\Models\Feature::class,
'App\\Models\\Inspection' => \App\Models\Inspection::class,
'App\\Models\\Issue' => \App\Models\Issue::class,
];
public $mediableType;
public $mediableId;
public $entity; // instancia cargada
public $entity;
public $mediaItems = [];
// Subida
public $uploadFiles = [];
public $uploadDescription = '';
public $uploadCategory = 'image';
// Modal visor
public $showViewer = false;
public $viewingMedia = null;
protected $rules = [
'uploadFiles.*' => 'required|file|max:102400', // 100MB total
'uploadFiles.*' => 'required|file|max:51200', // 50 MB per file
'uploadDescription' => 'nullable|string|max:500',
'uploadCategory' => 'required|in:image,document,other',
];
protected $messages = [
'uploadFiles.*.max' => 'Cada archivo debe pesar menos de 100MB.',
'uploadFiles.*.max' => 'Cada archivo debe pesar menos de 50 MB.',
];
public function mount($mediableType, $mediableId)
{
$this->mediableType = $mediableType;
$this->mediableId = $mediableId;
// Validate type against whitelist to prevent RCE via class instantiation
if (!array_key_exists($mediableType, self::ALLOWED_TYPES)) {
abort(400, 'Invalid mediable type.');
}
$this->mediableType = $mediableType;
$this->mediableId = (int) $mediableId;
$modelClass = self::ALLOWED_TYPES[$mediableType];
$this->entity = $modelClass::findOrFail($this->mediableId);
$this->entity = $mediableType::findOrFail($mediableId);
$this->loadMedia();
}
@@ -77,22 +92,43 @@ class MediaManager extends Component
return;
}
// Allowed MIME types (server-side validation)
$allowedMimes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain', 'text/csv',
'application/zip', 'application/x-zip-compressed',
];
$uploaded = 0;
foreach ($this->uploadFiles as $file) {
$mime = $file->getMimeType();
if (!in_array($mime, $allowedMimes, true)) {
session()->flash('error', "Tipo de archivo no permitido: {$mime}");
continue;
}
$ext = $file->getClientOriginalExtension();
$size = $file->getSize();
$name = $file->getClientOriginalName();
$name = substr($file->getClientOriginalName(), 0, 255);
// Determinar categoría automática
$category = $this->uploadCategory;
if (str_starts_with($mime, 'image/')) {
$category = 'image';
} elseif (in_array($mime, ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])) {
} elseif (in_array($mime, [
'application/pdf', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
], true)) {
$category = 'document';
}
// Guardar en disco
$entityType = class_basename($this->entity);
$dir = "uploads/{$entityType}s/{$this->mediableId}/media";
$path = $file->store($dir, 'public');
@@ -116,18 +152,21 @@ class MediaManager extends Component
$this->reset(['uploadFiles', 'uploadDescription']);
$this->loadMedia();
// Notificar al mapa si corresponde
$this->dispatch('mediaUploaded', [
'mediableType' => $this->mediableType,
'mediableId' => $this->mediableId,
]);
session()->flash('message', "$uploaded archivo(s) subido(s) correctamente.");
session()->flash('message', "{$uploaded} archivo(s) subido(s) correctamente.");
}
public function deleteMedia($mediaId)
{
$media = Media::findOrFail($mediaId);
// Ensure the media belongs to the entity this component manages (IDOR prevention)
$media = Media::where('id', $mediaId)
->where('mediable_type', $this->mediableType)
->where('mediable_id', $this->mediableId)
->firstOrFail();
$user = Auth::user();
if (!$user->hasRole('Admin') && $media->uploaded_by !== $user->id) {
@@ -142,9 +181,12 @@ class MediaManager extends Component
public function viewMedia($mediaId)
{
$media = Media::findOrFail($mediaId);
$media = Media::where('id', $mediaId)
->where('mediable_type', $this->mediableType)
->where('mediable_id', $this->mediableId)
->firstOrFail();
if (!$media->is_image) {
// Si no es imagen, abrir en nueva pestaña
$this->dispatch('openUrl', $media->url);
return;
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Illuminate\Support\Facades\Auth;
class NotificationBell extends Component
{
public $notifications = [];
public $unreadCount = 0;
public $showDropdown = false;
public function mount()
{
$this->loadNotifications();
}
public function loadNotifications()
{
$user = Auth::user();
$this->notifications = $user->notifications()->latest()->take(10)->get()->toArray();
$this->unreadCount = $user->unreadNotifications()->count();
}
public function markAsRead($id)
{
Auth::user()->notifications()->where('id', $id)->update(['read_at' => now()]);
$this->loadNotifications();
}
public function markAllAsRead()
{
Auth::user()->unreadNotifications->markAsRead();
$this->loadNotifications();
}
public function render()
{
return view('livewire.notification-bell');
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
#[Layout('layouts.app')]
class PhaseGantt extends Component
{
public Project $project;
public $ganttData = [];
public function mount(Project $project)
{
$user = Auth::user();
if (!$user->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(),
]);
}
}
+15 -2
View File
@@ -5,6 +5,8 @@ namespace App\Livewire;
use Livewire\Component;
use App\Models\Project;
use App\Models\Phase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
class PhaseList extends Component
{
@@ -13,16 +15,19 @@ class PhaseList extends Component
public function mount(Project $project)
{
Gate::authorize('edit projects', $project);
$this->project = $project;
$this->phases = $project->phases;
}
public function addPhase()
{
Gate::authorize('edit projects', $this->project);
$this->project->phases()->create([
'name' => 'Nueva fase',
'order' => $this->phases->count() + 1,
'color' => '#'.substr(md5(rand()), 0, 6)
'color' => '#' . substr(md5(random_int(0, PHP_INT_MAX)), 0, 6),
]);
$this->phases = $this->project->phases()->get();
session()->flash('message', 'Fase agregada');
@@ -30,8 +35,16 @@ class PhaseList extends Component
public function deletePhase($phaseId)
{
Phase::find($phaseId)->delete();
Gate::authorize('edit projects', $this->project);
// Scope to this project to prevent IDOR deletion of another project's phase
Phase::where('id', $phaseId)
->where('project_id', $this->project->id)
->firstOrFail()
->delete();
$this->phases = $this->project->phases()->get();
session()->flash('message', 'Fase eliminada');
}
public function render()
+12
View File
@@ -3,8 +3,11 @@
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Phase;
use Illuminate\Support\Facades\Auth;
#[Layout('layouts.app')]
class PhaseProgress extends Component
{
public Phase $phase;
@@ -13,12 +16,21 @@ class PhaseProgress extends Component
public function mount(Phase $phase)
{
$user = Auth::user();
if (!$user->hasRole('Admin') && !$phase->project->users()->where('user_id', $user->id)->exists()) {
abort(403);
}
$this->phase = $phase->load('progressUpdates');
$this->progress = $phase->progress_percent;
}
public function updateProgressManual()
{
$user = Auth::user();
if (!$user->can('update progress') && !$user->hasRole('Admin')) {
session()->flash('error', 'Sin permisos para actualizar el progreso.');
return;
}
$this->validate(['progress' => 'required|integer|min:0|max:100']);
$this->phase->progress_percent = $this->progress;
$this->phase->save();
+9
View File
@@ -17,6 +17,10 @@ class ProjectCompanies extends Component
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->loadCompanies();
}
@@ -65,6 +69,11 @@ class ProjectCompanies extends Component
public function changeRole($companyId, $role)
{
$user = Auth::user();
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
session()->flash('error', 'Sin permisos.');
return;
}
if (!in_array($role, ['owner', 'constructor', 'subcontractor', 'consultant', 'supplier', 'other'])) return;
$this->project->companies()->updateExistingPivot($companyId, [
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Feature;
use App\Models\Inspection;
use App\Models\Issue;
use Illuminate\Support\Facades\Auth;
#[Layout('layouts.app')]
class ProjectDashboard extends Component
{
public Project $project;
// Computed stats (cached as properties after mount)
public array $stats = [];
public $phases;
public $recentInspections;
public $recentIssues;
public $teamMembers;
public $companies;
public function mount(Project $project): void
{
$this->project = $project;
$this->checkAccess();
$this->loadData();
}
private function checkAccess(): void
{
$user = Auth::user();
if ($user->hasRole('Admin')) return;
if (!$this->project->users()->where('user_id', $user->id)->exists()) abort(403);
}
private function loadData(): void
{
$pid = $this->project->id;
$this->phases = Phase::where('project_id', $pid)
->withCount('layers')
->with(['layers' => fn($q) => $q->withCount('features')])
->orderBy('order')
->get();
$totalFeatures = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $pid))->count();
$completedFeatures = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $pid))
->where('status', 'completed')->count();
$verifiedFeatures = Feature::whereHas('layer.phase', fn($q) => $q->where('project_id', $pid))
->where('status', 'verified')->count();
$openIssues = Issue::where('project_id', $pid)->where('status', 'open')->count();
$closedIssues = Issue::where('project_id', $pid)->where('status', 'closed')->count();
$criticalIssues = Issue::where('project_id', $pid)->where('status', 'open')->where('priority', 'critical')->count();
$totalInspections = Inspection::where('project_id', $pid)->count();
$passedInspections = Inspection::where('project_id', $pid)->where('result', 'pass')->count();
$failedInspections = Inspection::where('project_id', $pid)->where('result', 'fail')->count();
$globalProgress = $this->phases->avg('progress_percent') ?? 0;
$delayedPhases = $this->phases->filter(fn($p) =>
$p->planned_end && $p->planned_end < now() && $p->progress_percent < 100
)->count();
$this->stats = [
'global_progress' => round($globalProgress),
'total_phases' => $this->phases->count(),
'delayed_phases' => $delayedPhases,
'total_features' => $totalFeatures,
'completed_features' => $completedFeatures,
'verified_features' => $verifiedFeatures,
'open_issues' => $openIssues,
'closed_issues' => $closedIssues,
'critical_issues' => $criticalIssues,
'total_inspections' => $totalInspections,
'passed_inspections' => $passedInspections,
'failed_inspections' => $failedInspections,
];
$this->recentInspections = Inspection::where('project_id', $pid)
->with(['feature', 'template', 'user'])
->latest()->take(6)->get();
$this->recentIssues = Issue::where('project_id', $pid)
->with(['feature', 'reporter'])
->where('status', '!=', 'closed')
->orderByRaw("FIELD(priority,'critical','high','medium','low')")
->take(6)->get();
$this->teamMembers = $this->project->users()->with('roles')->get();
$this->companies = $this->project->companies()->get();
}
public function render()
{
return view('livewire.projects.project-dashboard', [
'project' => $this->project,
]);
}
}
+3
View File
@@ -4,6 +4,7 @@ namespace App\Livewire;
use Livewire\Component;
use App\Models\Project;
use Illuminate\Support\Facades\Gate;
class ProjectEditTabs extends Component
{
@@ -12,6 +13,7 @@ class ProjectEditTabs extends Component
public function mount(Project $project)
{
Gate::authorize('edit projects', $project);
$this->project = $project;
}
@@ -29,6 +31,7 @@ class ProjectEditTabs extends Component
public function updateProject()
{
Gate::authorize('edit projects', $this->project);
$this->project->save();
session()->flash('message', __('Project updated successfully.'));
+84 -50
View File
@@ -3,79 +3,113 @@
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);
if ($project && $project->exists) {
Gate::authorize('edit projects', $project);
$this->project = $project;
$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->reference = $project->reference ?? '';
$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.
$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()
+9 -3
View File
@@ -3,10 +3,12 @@
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Livewire\WithPagination;
use App\Models\Project;
use Illuminate\Support\Facades\Auth;
#[Layout('layouts.app')]
class ProjectList extends Component
{
use WithPagination;
@@ -16,12 +18,16 @@ class ProjectList extends Component
public function deleteProject($id)
{
$project = Project::findOrFail($id);
if (Auth::user()->can('delete projects')) {
$user = Auth::user();
if (!$user->can('delete projects')) {
session()->flash('error', 'Sin permisos para eliminar proyectos.');
return;
}
// Scope to accessible projects to prevent IDOR (deleting another user's project by ID)
$project = Project::accessibleBy($user)->findOrFail($id);
$project->delete();
session()->flash('message', 'Proyecto eliminado');
}
}
public function render()
{
+33 -22
View File
@@ -42,17 +42,17 @@ class ProjectMap extends Component
public $showFeatureImages = false;
public $featureImageMarkers = [];
// Tab management
public $activeTab = 'edit'; // edit or list
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;
// 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->loadTemplates();
}
@@ -87,19 +87,19 @@ class ProjectMap extends Component
*/
public function updateProgress($featureId, $newProgress, $comment = null)
{
$feature = Feature::findOrFail($featureId);
$user = Auth::user();
$feature = Feature::with('layer.phase')->findOrFail($featureId);
// Verify feature belongs to this project (IDOR prevention)
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
$user = Auth::user();
if (!$user->can('update progress') && !$user->hasRole('Admin')) {
$this->dispatch('notify', 'Sin permisos');
return;
}
$oldProgress = $feature->progress;
$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->progress_percent = $phase->features()->avg('progress') ?: 0;
$phase->save();
@@ -127,8 +127,10 @@ class ProjectMap extends Component
public function selectFeature($featureId)
{
$this->selectedFeature = null;
$feature = Feature::with('template')->find($featureId);
$feature = Feature::with(['template', 'layer.phase'])->find($featureId);
if (!$feature) return;
// Verify feature belongs to this project
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
$this->selectedFeature = $feature;
$this->selectedPhaseId = $feature->layer->phase_id;
@@ -184,11 +186,10 @@ class ProjectMap extends Component
return;
}
$this->validate([
'selectedTemplateId' => 'required|exists:inspection_templates,id',
]);
$template = InspectionTemplate::find($this->selectedTemplateId);
// Verify the template belongs to this project
$template = InspectionTemplate::where('id', $this->selectedTemplateId)
->where('project_id', $this->project->id)
->firstOrFail();
foreach ($template->fields as $field) {
if (($field['required'] ?? false) && empty($this->inspectionFormData[$field['name']])) {
$this->dispatch('notify', "El campo {$field['label']} es obligatorio.");
@@ -221,10 +222,17 @@ class ProjectMap extends Component
public function assignTemplateToFeature($templateId)
{
if (!$this->selectedFeature) return;
// Verify template belongs to this project (IDOR prevention)
$template = InspectionTemplate::where('id', $templateId)
->where('project_id', $this->project->id)
->firstOrFail();
$this->selectedFeature->template_id = $templateId;
$this->selectedFeature->save();
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
$feature->template_id = $templateId;
$feature->save();
$this->selectedFeature = $feature;
$this->selectedTemplateId = $templateId;
$this->resetInspectionForm();
$this->dispatch('notify', 'Template asignado al elemento');
@@ -237,12 +245,15 @@ class ProjectMap extends Component
{
if (!$this->selectedFeature) return;
$this->selectedFeature->progress = min(100, max(0, (int)$this->editProgress));
$this->selectedFeature->responsible = $this->editResponsible;
$this->selectedFeature->save();
$feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id);
if ($feature->layer->phase->project_id !== $this->project->id) abort(403);
// Recalcular progreso de la fase
$phase = Phase::find($this->selectedFeature->layer->phase_id);
$feature->progress = min(100, max(0, (int) $this->editProgress));
$feature->responsible = $this->editResponsible;
$feature->save();
$this->selectedFeature = $feature;
$phase = Phase::find($feature->layer->phase_id);
$phase->progress_percent = $phase->features()->avg('progress') ?: 0;
$phase->save();
+74 -59
View File
@@ -4,9 +4,8 @@ namespace App\Livewire;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Columns\{BooleanColumn, ButtonGroupColumn, LinkColumn, ImageColumn};
use Rappasoft\LaravelLivewireTables\Views\Filters\{DateFilter, MultiSelectFilter, SelectFilter};
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use App\Models\Project;
class ProjectTable extends DataTableComponent
@@ -17,86 +16,102 @@ class ProjectTable extends DataTableComponent
{
$this->setPrimaryKey('id')
->setDefaultSort('created_at', 'desc')
->setTableAttributes(['class' => 'table-auto w-full']);
->setSortingPillsEnabled(false)
->setAdditionalSelects(['projects.id as id', 'projects.created_at as created_at']);
}
$this->setThAttributes(function(Column $column) {
return ['class' => 'px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'];
});
$this->setTdAttributes(function(Column $column) {
return ['class' => 'px-4 py-2 whitespace-nowrap text-sm text-gray-900'];
});
public function builder(): Builder
{
return Project::accessibleBy(Auth::user())
->with('phases');
}
public function columns(): array
{
return [
Column::make(__('ID'), 'id')
Column::make('Referencia', 'reference')
->sortable()
->searchable(),
->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(__('Project Name'), 'name')
Column::make(__('Name'), 'name')
->sortable()
->searchable(),
Column::make(__('Address'), 'address')
->sortable()
->searchable(),
->searchable()
->format(fn ($value) => $value
? '<span class="truncate block max-w-xs" title="'.e($value).'">'.e($value).'</span>'
: '<span class="text-gray-400">—</span>')
->html(),
Column::make(__('Status'), 'status')
->sortable(),
Column::make(__('Start Date'), 'start_date')
->sortable()
->format(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : ''),
->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(__('Estimated End Date'), 'end_date_estimated')
->sortable()
->format(fn ($value, $row, $column) => $value ? $value->format('Y-m-d') : ''),
Column::make(__('Actions'))
Column::make(__('Progress'))
->label(function ($row) {
$confirm = __('Are you sure you want to delete this project?');
$avg = $row->phases->avg('progress_percent') ?? 0;
$pct = round($avg);
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 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(),
ButtonGroupColumn::make(__('Actions'))
->attributes(function($row) {
return [
'class' => 'space-x-2',
];
Column::make(__('Start Date'), 'start_date')
->sortable()
->format(fn ($value) => $value ? $value->format('d/m/Y') : '—'),
Column::make(__('Est. End'), 'end_date_estimated')
->sortable()
->format(fn ($value) => $value ? $value->format('d/m/Y') : '—'),
Column::make(__('Actions'))
->label(function ($row) {
$dashboard = route('projects.dashboard', $row->id);
$map = route('projects.map', $row->id);
$edit = route('projects.edit', $row->id);
$canEdit = Auth::user()->can('edit projects');
$html = '<div class="flex items-center gap-1">';
$html .= '<a href="'.$dashboard.'" class="btn btn-xs btn-outline" title="Dashboard" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
</a>';
$html .= '<a href="'.$map.'" class="btn btn-xs btn-outline" title="Mapa" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
</a>';
if ($canEdit) {
$html .= '<a href="'.$edit.'" class="btn btn-xs btn-warning" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>';
}
$html .= '</div>';
return $html;
})
->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',
];
}),
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(),
];
}
+9
View File
@@ -17,6 +17,10 @@ class ProjectUsers extends Component
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->loadUsers();
}
@@ -65,6 +69,11 @@ class ProjectUsers extends Component
public function changeRole($userId, $role)
{
$user = Auth::user();
if (!$user->can('assign users') && !$user->hasRole('Admin')) {
session()->flash('error', 'Sin permisos.');
return;
}
if (!in_array($role, ['supervisor', 'consultant', 'client', 'viewer'])) return;
$this->project->users()->updateExistingPivot($userId, [
@@ -7,6 +7,7 @@ use App\Models\Project;
use App\Models\Phase;
use App\Models\Inspection;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
class ReportsDashboard extends Component
{
@@ -15,6 +16,7 @@ class ReportsDashboard extends Component
public function mount()
{
if (!Auth::user()->hasRole('Admin')) abort(403);
$this->loadChartData();
}
+21 -4
View File
@@ -6,6 +6,7 @@ use Livewire\Component;
use App\Models\InspectionTemplate;
use App\Models\Project;
use App\Models\Phase;
use Illuminate\Support\Facades\Auth;
class TemplateManager extends Component
{
@@ -35,6 +36,10 @@ class TemplateManager extends Component
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->loadPhases();
$this->loadTemplates();
@@ -59,7 +64,9 @@ class TemplateManager extends Component
public function editTemplate($id)
{
$template = InspectionTemplate::find($id);
$template = InspectionTemplate::where('id', $id)
->where('project_id', $this->project->id)
->firstOrFail();
$this->form = $template->only(['name', 'description', 'phase_id', 'fields']);
$this->editingTemplate = $id;
$this->showForm = true;
@@ -111,8 +118,15 @@ class TemplateManager extends Component
]);
if ($this->editingTemplate) {
$template = InspectionTemplate::find($this->editingTemplate);
$template->update($this->form);
$template = InspectionTemplate::where('id', $this->editingTemplate)
->where('project_id', $this->project->id)
->firstOrFail();
$template->update([
'name' => $this->form['name'],
'description' => $this->form['description'],
'phase_id' => $this->form['phase_id'],
'fields' => $this->form['fields'],
]);
session()->flash('message', 'Template actualizado');
} else {
InspectionTemplate::create([
@@ -131,7 +145,10 @@ class TemplateManager extends Component
public function deleteTemplate($id)
{
InspectionTemplate::find($id)->delete();
InspectionTemplate::where('id', $id)
->where('project_id', $this->project->id)
->firstOrFail()
->delete();
$this->loadTemplates();
session()->flash('message', 'Template eliminado');
}
+165
View File
@@ -0,0 +1,165 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\User;
use App\Models\Company;
use Spatie\Permission\Models\Role;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
#[Layout('layouts.app')]
class UserForm extends Component
{
public ?User $user = null;
// Información personal
public string $title = '';
public string $lastName = '';
public string $firstName = '';
// Validación
public string $userStatus = 'active';
public string $validFrom = '';
public string $validUntil = '';
public string $formPassword = '';
// Contacto
public ?int $companyId = null;
public string $address = '';
public string $phone = '';
public string $email = '';
// Permisos
public string $formRole = '';
// Notas
public string $notes = '';
// Catálogos
public $roles;
public $companies;
public function mount(?User $user = null): void
{
if (!Auth::user()->hasRole('Admin')) abort(403);
$this->roles = Role::orderBy('name')->get();
$this->companies = Company::where('estado', 'activo')->orderBy('name')->get();
$this->formRole = $this->roles->first()?->name ?? '';
if ($user && $user->exists) {
$this->user = $user;
$this->title = $user->title ?? '';
$this->lastName = $user->last_name ?? '';
$this->firstName = $user->first_name ?? '';
$this->userStatus = $user->status ?? 'active';
$this->validFrom = $user->valid_from?->format('Y-m-d') ?? '';
$this->validUntil = $user->valid_until?->format('Y-m-d') ?? '';
$this->companyId = $user->company_id;
$this->address = $user->address ?? '';
$this->phone = $user->phone ?? '';
$this->email = $user->email;
$this->notes = $user->notes ?? '';
$this->formRole = $user->roles->first()?->name ?? $this->formRole;
}
}
protected function rules(): array
{
$id = $this->user?->id ?? 'NULL';
$rules = [
'lastName' => 'required|string|max:100',
'firstName' => 'required|string|max:100',
'title' => 'nullable|string|max:20',
'userStatus' => 'required|in:active,inactive,suspended',
'validFrom' => 'nullable|date',
'validUntil' => 'nullable|date|after_or_equal:validFrom',
'companyId' => 'required|exists:companies,id',
'address' => 'nullable|string',
'phone' => 'nullable|string|max:30',
'email' => "required|email|max:255|unique:users,email,{$id}",
'formRole' => 'required|exists:roles,name',
];
if (!$this->user) {
$rules['formPassword'] = ['required', Password::min(8)->letters()->mixedCase()->numbers()];
} elseif ($this->formPassword !== '') {
$rules['formPassword'] = [Password::min(8)->letters()->mixedCase()->numbers()];
}
return $rules;
}
protected $validationAttributes = [
'lastName' => 'apellidos',
'firstName' => 'nombre',
'userStatus' => 'estado',
'validFrom' => 'fecha de inicio',
'validUntil' => 'fecha de fin',
'companyId' => 'empresa',
'formPassword'=> 'contraseña',
'formRole' => 'rol',
];
public function copyCompanyAddress(): void
{
if (!$this->companyId) return;
$company = Company::find($this->companyId);
if ($company?->address) {
$this->address = $company->address;
}
}
public function save(): void
{
$this->validate();
if ($this->user && $this->user->id === Auth::id()
&& $this->user->hasRole('Admin') && $this->formRole !== 'Admin') {
$this->addError('formRole', 'No puedes quitarte el rol Admin a ti mismo.');
return;
}
$fullName = trim($this->firstName . ' ' . $this->lastName);
$data = [
'name' => $fullName,
'title' => $this->title ?: null,
'first_name' => $this->firstName,
'last_name' => $this->lastName,
'status' => $this->userStatus,
'valid_from' => $this->validFrom ?: null,
'valid_until'=> $this->validUntil ?: null,
'company_id' => $this->companyId,
'address' => $this->address ?: null,
'phone' => $this->phone ?: null,
'email' => $this->email,
'notes' => $this->notes ?: null,
];
if ($this->formPassword !== '') {
$data['password'] = Hash::make($this->formPassword);
}
if ($this->user && $this->user->exists) {
$this->user->update($data);
$this->user->syncRoles([$this->formRole]);
session()->flash('notify', 'Usuario actualizado correctamente.');
} else {
$user = User::create($data);
$user->assignRole($this->formRole);
session()->flash('notify', 'Usuario creado correctamente.');
}
$this->redirect(route('admin.users'), navigate: true);
}
public function render()
{
return view('livewire.user-form');
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
namespace App\Livewire;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Spatie\Permission\Models\Role;
use App\Models\User;
class UserTable extends DataTableComponent
{
protected $model = User::class;
public function configure(): void
{
$this->setPrimaryKey('id')
->setDefaultSort('name', 'asc')
->setSortingPillsEnabled(false)
->setAdditionalSelects([
'users.id as id',
'users.email as email',
'users.email_verified_at as email_verified_at',
'users.status as status',
'users.phone as phone',
'users.company_id as company_id',
'users.created_at as created_at',
]);
}
public function builder(): Builder
{
return User::with(['roles', 'company']);
}
public function columns(): array
{
return [
Column::make('Usuario', 'name')
->sortable()
->searchable()
->format(function ($value, $row) {
$initial = strtoupper(mb_substr($value, 0, 1));
$html = '<div class="flex items-center gap-3">';
$html .= '<div class="avatar placeholder shrink-0">
<div class="bg-neutral text-neutral-content rounded-full w-8">
<span class="text-xs font-semibold">'.$initial.'</span>
</div>
</div>';
$html .= '<div>';
$html .= '<p class="font-semibold text-sm leading-tight">'.e($value).'</p>';
$html .= '<p class="text-xs text-gray-500">'.e($row->email).'</p>';
$html .= '</div></div>';
return $html;
})
->html(),
Column::make('Empresa')
->label(fn ($row) =>
$row->company
? '<span class="text-sm">'.e($row->company->name).'</span>'
: '<span class="text-gray-300 text-sm">—</span>'
)
->html(),
Column::make('Rol')
->label(function ($row) {
if ($row->roles->isEmpty()) {
return '<span class="badge badge-sm badge-ghost">Sin rol</span>';
}
return $row->roles->map(fn ($role) =>
'<span class="badge badge-sm '.($role->name === 'Admin' ? 'badge-error' : 'badge-primary').'">'.e($role->name).'</span>'
)->implode(' ');
})
->html(),
Column::make('Estado', 'status')
->sortable()
->format(function ($value) {
$map = [
'active' => ['badge-success', 'Activo'],
'inactive' => ['badge-ghost', 'Inactivo'],
'suspended' => ['badge-error', 'Suspendido'],
];
[$cls, $label] = $map[$value ?? 'active'] ?? ['badge-ghost', ucfirst($value ?? '')];
return '<span class="badge badge-sm '.$cls.'">'.$label.'</span>';
})
->html(),
Column::make('Verificado', 'email_verified_at')
->sortable()
->format(fn ($value) =>
$value
? '<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
)
->html(),
Column::make('Acciones')
->label(function ($row) {
$ver = route('admin.users.show', $row->id);
$editar = route('admin.users.edit', $row->id);
$name = addslashes($row->name);
$isSelf = $row->id === Auth::id();
$html = '<div class="flex items-center justify-end gap-1">';
$html .= '<a href="'.$ver.'" class="btn btn-xs btn-outline" title="Ver" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>';
$html .= '<a href="'.$editar.'" class="btn btn-xs btn-outline btn-info" title="Editar" wire:navigate>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>';
if (! $isSelf) {
$html .= '<button wire:click="deleteUser('.$row->id.')"
wire:confirm="¿Eliminar a \''.$name.'\'? Se perderán todos sus datos."
class="btn btn-xs btn-outline btn-error" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>';
}
$html .= '</div>';
return $html;
})
->html(),
];
}
public function filters(): array
{
$roleOptions = [''] + Role::orderBy('name')->pluck('name', 'name')->prepend('Rol: todos', '')->toArray();
return [
SelectFilter::make('Rol')
->options($roleOptions)
->filter(fn (Builder $query, string $value) =>
$query->whereHas('roles', fn ($q) => $q->where('name', $value))
),
SelectFilter::make('Estado', 'status')
->options([
'' => 'Estado: todos',
'active' => 'Activo',
'inactive' => 'Inactivo',
'suspended' => 'Suspendido',
])
->filter(fn (Builder $query, string $value) => $query->where('status', $value)),
];
}
public function deleteUser(int $id): void
{
if ($id === Auth::id()) return;
User::findOrFail($id)->delete();
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\Attributes\Layout;
use App\Models\User;
use App\Models\Project;
use App\Models\Inspection;
use App\Models\Issue;
use Illuminate\Support\Facades\Auth;
#[Layout('layouts.app')]
class UserView extends Component
{
public User $user;
public string $activeTab = 'permissions';
// Projects tab
public ?int $addProjectId = null;
public string $addProjectRole = '';
public $availableProjects;
// Notes tab
public string $notes = '';
public bool $editingNotes = false;
// Recent activity (loaded once)
public $recentInspections;
public $recentIssues;
public function mount(User $user): void
{
if (!Auth::user()->hasRole('Admin')) abort(403);
$this->user = $user->load(['roles', 'company', 'projects.phases']);
$this->notes = $user->notes ?? '';
$this->loadAvailableProjects();
$this->loadActivity();
}
private function loadAvailableProjects(): void
{
$assignedIds = $this->user->projects->pluck('id');
$this->availableProjects = Project::whereNotIn('id', $assignedIds)
->orderBy('name')->get();
}
private function loadActivity(): void
{
$this->recentInspections = Inspection::where('user_id', $this->user->id)
->with(['feature.layer.phase.project', 'template'])
->latest()->take(8)->get();
$this->recentIssues = Issue::where('reported_by', $this->user->id)
->with(['feature', 'project'])
->latest()->take(8)->get();
}
// ── Tabs ─────────────────────────────────────────────────────────────────
public function setTab(string $tab): void
{
$this->activeTab = $tab;
}
// ── Projects ──────────────────────────────────────────────────────────────
public function assignProject(): void
{
$this->validate([
'addProjectId' => 'required|exists:projects,id',
'addProjectRole' => 'nullable|string|max:100',
], [], ['addProjectId' => 'proyecto', 'addProjectRole' => 'rol en proyecto']);
$this->user->projects()->attach($this->addProjectId, [
'role_in_project' => $this->addProjectRole ?: null,
]);
$this->user->load('projects.phases');
$this->addProjectId = null;
$this->addProjectRole = '';
$this->loadAvailableProjects();
$this->dispatch('notify', 'Proyecto asignado.');
}
public function removeProject(int $projectId): void
{
$this->user->projects()->detach($projectId);
$this->user->load('projects.phases');
$this->loadAvailableProjects();
$this->dispatch('notify', 'Proyecto desasignado.');
}
// ── Notes ─────────────────────────────────────────────────────────────────
public function saveNotes(): void
{
$this->validate(['notes' => 'nullable|string']);
$this->user->update(['notes' => $this->notes ?: null]);
$this->editingNotes = false;
$this->dispatch('notify', 'Notas guardadas.');
}
public function render()
{
return view('livewire.user-view');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
class ActivityLog extends Model
{
public $timestamps = false;
protected $fillable = ['action', 'model_type', 'model_id', 'user_id', 'changes', 'created_at'];
protected $casts = [
'changes' => 'array',
'created_at' => 'datetime',
];
public static function record(string $action, Model $model, array $changes = []): void
{
static::create([
'action' => $action,
'model_type' => class_basename($model),
'model_id' => $model->getKey(),
'user_id' => Auth::id(),
'changes' => empty($changes) ? null : $changes,
'created_at' => now(),
]);
}
public function user()
{
return $this->belongsTo(User::class);
}
}
+6
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -26,6 +27,11 @@ class Company extends Model
protected $dates = ['deleted_at'];
// Relationships
public function users()
{
return $this->hasMany(User::class);
}
public function projects()
{
return $this->belongsToMany(Project::class, 'company_project')
+30 -1
View File
@@ -3,11 +3,18 @@
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 = [
@@ -30,6 +37,16 @@ class Feature extends Model
return $this->hasMany(Inspection::class, 'feature_id');
}
public function issues()
{
return $this->hasMany(Issue::class);
}
public function responsibleUser()
{
return $this->belongsTo(User::class, 'responsible_user_id');
}
public function media()
{
return $this->morphMany(Media::class, 'mediable');
@@ -39,4 +56,16 @@ class Feature extends Model
{
return $this->morphMany(Media::class, 'mediable')->where('category', 'image');
}
public function getStatusColorAttribute(): string
{
return match($this->status) {
'planned' => '#6b7280',
'started' => '#3b82f6',
'in_progress' => '#f59e0b',
'completed' => '#10b981',
'verified' => '#8b5cf6',
default => '#6b7280',
};
}
}
+29 -2
View File
@@ -3,12 +3,25 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\LogsActivity;
class Inspection extends Model
{
protected $fillable = ['project_id', 'layer_id', 'feature_id', 'template_id', 'user_id', 'data'];
use SoftDeletes, LogsActivity;
protected $casts = ['data' => 'array'];
const STATUSES = ['pending', 'in_progress', 'completed', 'approved', 'rejected'];
const RESULTS = ['pass', 'fail', 'conditional'];
protected $fillable = [
'project_id', 'layer_id', 'feature_id', 'template_id', 'user_id',
'data', 'status', 'inspector_user_id', 'completed_at', 'result', 'notes',
];
protected $casts = [
'data' => 'array',
'completed_at' => 'datetime',
];
public function project()
{
@@ -30,8 +43,22 @@ class Inspection extends Model
return $this->belongsTo(User::class);
}
public function inspector()
{
return $this->belongsTo(User::class, 'inspector_user_id');
}
public function feature()
{
return $this->belongsTo(Feature::class, 'feature_id');
}
public function issues()
{
return $this->hasMany(Issue::class);
}
public function scopePending($q) { return $q->where('status', 'pending'); }
public function scopeCompleted($q) { return $q->where('status', 'completed'); }
public function scopeRejected($q) { return $q->where('status', 'rejected'); }
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\LogsActivity;
class Issue extends Model
{
use SoftDeletes, LogsActivity;
const STATUSES = ['open', 'in_review', 'resolved', 'closed'];
const PRIORITIES = ['low', 'medium', 'high', 'critical'];
protected $fillable = [
'project_id', 'feature_id', 'inspection_id',
'title', 'description', 'status', 'priority',
'reported_by', 'assigned_to', 'resolved_at', 'resolution_notes'
];
protected $casts = ['resolved_at' => 'datetime'];
public function project() { return $this->belongsTo(Project::class); }
public function feature() { return $this->belongsTo(Feature::class); }
public function inspection() { return $this->belongsTo(Inspection::class); }
public function reporter() { return $this->belongsTo(User::class, 'reported_by'); }
public function assignee() { return $this->belongsTo(User::class, 'assigned_to'); }
public function media() { return $this->morphMany(Media::class, 'mediable'); }
public function scopeOpen($q) { return $q->where('status', 'open'); }
public function scopeCritical($q) { return $q->where('priority', 'critical'); }
public function getPriorityColorAttribute(): string
{
return match($this->priority) {
'low' => '#6b7280',
'medium' => '#f59e0b',
'high' => '#ef4444',
'critical' => '#7c3aed',
default => '#6b7280',
};
}
public function getStatusColorAttribute(): string
{
return match($this->status) {
'open' => '#ef4444',
'in_review' => '#f59e0b',
'resolved' => '#10b981',
'closed' => '#6b7280',
default => '#6b7280',
};
}
}
+8
View File
@@ -3,10 +3,13 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Layer extends Model
{
use SoftDeletes;
protected $fillable = [
'project_id', 'phase_id', 'name', 'color', 'geojson_data', 'original_file', 'uploaded_by'
];
@@ -34,6 +37,11 @@ class Layer extends Model
return $this->hasMany(Feature::class);
}
public function issues()
{
return $this->hasMany(Issue::class);
}
public function media()
{
return $this->morphMany(Media::class, 'mediable');
+22 -37
View File
@@ -1,51 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Phase extends Model
{
use SoftDeletes;
protected $fillable = [
'project_id', 'name', 'description', 'order', 'color', 'progress_percent'
'project_id', 'name', 'description', 'order', 'color', 'progress_percent',
'planned_start', 'planned_end', 'actual_start', 'actual_end'
];
public function project()
{
return $this->belongsTo(Project::class);
}
protected $casts = [
'planned_start' => 'date',
'planned_end' => 'date',
'actual_start' => 'date',
'actual_end' => 'date',
];
public function layers()
{
return $this->hasMany(Layer::class);
}
public function project() { return $this->belongsTo(Project::class); }
public function layers() { return $this->hasMany(Layer::class); }
public function progressUpdates() { return $this->hasMany(ProgressUpdate::class); }
public function currentLayer() { return $this->hasOne(Layer::class)->latestOfMany(); }
public function features() { return $this->hasManyThrough(Feature::class, Layer::class); }
public function media() { return $this->morphMany(Media::class, 'mediable'); }
public function images() { return $this->morphMany(Media::class, 'mediable')->where('category', 'image'); }
public function progressUpdates()
public function getDeviationDaysAttribute(): ?int
{
return $this->hasMany(ProgressUpdate::class);
}
// 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');
if (!$this->planned_end) return null;
$end = $this->actual_end ?? now();
return $this->planned_end->diffInDays($end, false);
}
}
+4 -3
View File
@@ -4,14 +4,15 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Project extends Model
{
use HasFactory;
use HasFactory;
use HasFactory, SoftDeletes;
protected $fillable = [
'name', 'address', 'lat', 'lng', 'start_date', 'end_date_estimated', 'status', 'created_by'
'name', 'reference', 'address', 'country', 'lat', 'lng',
'start_date', 'end_date_estimated', 'status', 'created_by',
];
protected $casts = [
+8 -1
View File
@@ -22,7 +22,9 @@ class User extends Authenticatable
protected $fillable = [
'name',
'email',
'password',
'password', // Intentionally kept: required for registration factory and seeding.
// Sensitive — never pass unvalidated user input directly.
// email_verified_at and remember_token are intentionally excluded.
];
/**
@@ -47,6 +49,11 @@ class User extends Authenticatable
'password' => 'hashed',
];
}
public function company()
{
return $this->belongsTo(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})",
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Traits;
use Illuminate\Support\Facades\Auth;
use App\Models\ActivityLog;
trait LogsActivity
{
public static function bootLogsActivity(): void
{
static::created(function ($model) {
ActivityLog::record('created', $model);
});
static::updated(function ($model) {
ActivityLog::record('updated', $model, $model->getDirty());
});
static::deleted(function ($model) {
ActivityLog::record('deleted', $model);
});
}
}
+1 -1
View File
@@ -169,7 +169,7 @@ return [
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
'secure' => env('SESSION_SECURE_COOKIE', env('APP_ENV', 'production') === 'production'),
/*
|--------------------------------------------------------------------------
@@ -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();
});
}
};
+254 -2
View File
@@ -128,7 +128,7 @@
"Longitude": "Longitude",
"Register inspection": "Register inspection",
"Files of element": "Files of element",
"Fases and layers": "Phases and layers",
"Phases and layers": "Phases and layers",
"Elements": "Elements",
"optional": "optional",
"each": "each",
@@ -145,5 +145,257 @@
"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...",
"View all": "View all",
"View on map": "View on map"
}
+254 -3
View File
@@ -128,9 +128,8 @@
"Longitude": "Longitud",
"Register inspection": "Registrar inspección",
"Files of element": "Archivos del elemento",
"Fases and layers": "Fases y capas",
"Phases and layers": "Fases y capas",
"Elements": "Elementos",
"Log Out": "Cerrar sesión",
"optional": "opcional",
"each": "cada",
"Image": "Imagen",
@@ -146,5 +145,257 @@
"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...",
"View all": "Ver todos",
"View on map": "Ver en mapa"
}
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
'failed' => 'Las credenciales introducidas no son válidas.',
'password' => 'La contraseña indicada es incorrecta.',
'throttle' => 'Demasiados intentos de acceso. Por favor, inténtalo de nuevo en :seconds segundos.',
];
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'previous' => '&laquo; Anterior',
'next' => 'Siguiente &raquo;',
];
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
'reset' => 'Tu contraseña ha sido restablecida.',
'sent' => 'Te hemos enviado un enlace para restablecer tu contraseña.',
'throttled' => 'Por favor, espera antes de volver a intentarlo.',
'token' => 'Este token de restablecimiento de contraseña no es válido.',
'user' => 'No encontramos ningún usuario con esa dirección de correo.',
];
+194
View File
@@ -0,0 +1,194 @@
<?php
return [
'accepted' => 'El campo :attribute debe ser aceptado.',
'accepted_if' => 'El campo :attribute debe ser aceptado cuando :other es :value.',
'active_url' => 'El campo :attribute debe ser una URL válida.',
'after' => 'El campo :attribute debe ser una fecha posterior a :date.',
'after_or_equal' => 'El campo :attribute debe ser una fecha posterior o igual a :date.',
'alpha' => 'El campo :attribute solo debe contener letras.',
'alpha_dash' => 'El campo :attribute solo debe contener letras, números, guiones y guiones bajos.',
'alpha_num' => 'El campo :attribute solo debe contener letras y números.',
'any_of' => 'El campo :attribute no es válido.',
'array' => 'El campo :attribute debe ser un array.',
'ascii' => 'El campo :attribute solo debe contener caracteres alfanuméricos de un solo byte y símbolos.',
'before' => 'El campo :attribute debe ser una fecha anterior a :date.',
'before_or_equal' => 'El campo :attribute debe ser una fecha anterior o igual a :date.',
'between' => [
'array' => 'El campo :attribute debe tener entre :min y :max elementos.',
'file' => 'El campo :attribute debe estar entre :min y :max kilobytes.',
'numeric' => 'El campo :attribute debe estar entre :min y :max.',
'string' => 'El campo :attribute debe tener entre :min y :max caracteres.',
],
'boolean' => 'El campo :attribute debe ser verdadero o falso.',
'can' => 'El campo :attribute contiene un valor no autorizado.',
'confirmed' => 'La confirmación del campo :attribute no coincide.',
'contains' => 'Al campo :attribute le falta un valor obligatorio.',
'current_password' => 'La contraseña es incorrecta.',
'date' => 'El campo :attribute debe ser una fecha válida.',
'date_equals' => 'El campo :attribute debe ser una fecha igual a :date.',
'date_format' => 'El campo :attribute debe coincidir con el formato :format.',
'decimal' => 'El campo :attribute debe tener :decimal decimales.',
'declined' => 'El campo :attribute debe ser rechazado.',
'declined_if' => 'El campo :attribute debe ser rechazado cuando :other es :value.',
'different' => 'El campo :attribute y :other deben ser diferentes.',
'digits' => 'El campo :attribute debe tener :digits dígitos.',
'digits_between' => 'El campo :attribute debe tener entre :min y :max dígitos.',
'dimensions' => 'El campo :attribute tiene dimensiones de imagen inválidas.',
'distinct' => 'El campo :attribute tiene un valor duplicado.',
'doesnt_contain' => 'El campo :attribute no debe contener ninguno de los siguientes valores: :values.',
'doesnt_end_with' => 'El campo :attribute no debe terminar con uno de los siguientes valores: :values.',
'doesnt_start_with' => 'El campo :attribute no debe comenzar con uno de los siguientes valores: :values.',
'email' => 'El campo :attribute debe ser una dirección de correo válida.',
'encoding' => 'El campo :attribute debe estar codificado en :encoding.',
'ends_with' => 'El campo :attribute debe terminar con uno de los siguientes valores: :values.',
'enum' => 'El :attribute seleccionado no es válido.',
'exists' => 'El :attribute seleccionado no es válido.',
'extensions' => 'El campo :attribute debe tener una de las siguientes extensiones: :values.',
'file' => 'El campo :attribute debe ser un archivo.',
'filled' => 'El campo :attribute debe tener un valor.',
'gt' => [
'array' => 'El campo :attribute debe tener más de :value elementos.',
'file' => 'El campo :attribute debe ser mayor que :value kilobytes.',
'numeric' => 'El campo :attribute debe ser mayor que :value.',
'string' => 'El campo :attribute debe tener más de :value caracteres.',
],
'gte' => [
'array' => 'El campo :attribute debe tener :value elementos o más.',
'file' => 'El campo :attribute debe ser mayor o igual a :value kilobytes.',
'numeric' => 'El campo :attribute debe ser mayor o igual a :value.',
'string' => 'El campo :attribute debe tener :value caracteres o más.',
],
'hex_color' => 'El campo :attribute debe ser un color hexadecimal válido.',
'image' => 'El campo :attribute debe ser una imagen.',
'in' => 'El :attribute seleccionado no es válido.',
'in_array' => 'El campo :attribute debe existir en :other.',
'in_array_keys' => 'El campo :attribute debe contener al menos una de las siguientes claves: :values.',
'integer' => 'El campo :attribute debe ser un número entero.',
'ip' => 'El campo :attribute debe ser una dirección IP válida.',
'ipv4' => 'El campo :attribute debe ser una dirección IPv4 válida.',
'ipv6' => 'El campo :attribute debe ser una dirección IPv6 válida.',
'json' => 'El campo :attribute debe ser una cadena JSON válida.',
'list' => 'El campo :attribute debe ser una lista.',
'lowercase' => 'El campo :attribute debe estar en minúsculas.',
'lt' => [
'array' => 'El campo :attribute debe tener menos de :value elementos.',
'file' => 'El campo :attribute debe ser menor que :value kilobytes.',
'numeric' => 'El campo :attribute debe ser menor que :value.',
'string' => 'El campo :attribute debe tener menos de :value caracteres.',
],
'lte' => [
'array' => 'El campo :attribute no debe tener más de :value elementos.',
'file' => 'El campo :attribute debe ser menor o igual a :value kilobytes.',
'numeric' => 'El campo :attribute debe ser menor o igual a :value.',
'string' => 'El campo :attribute debe tener :value caracteres o menos.',
],
'mac_address' => 'El campo :attribute debe ser una dirección MAC válida.',
'max' => [
'array' => 'El campo :attribute no debe tener más de :max elementos.',
'file' => 'El campo :attribute no debe ser mayor que :max kilobytes.',
'numeric' => 'El campo :attribute no debe ser mayor que :max.',
'string' => 'El campo :attribute no debe tener más de :max caracteres.',
],
'max_digits' => 'El campo :attribute no debe tener más de :max dígitos.',
'mimes' => 'El campo :attribute debe ser un archivo de tipo: :values.',
'mimetypes' => 'El campo :attribute debe ser un archivo de tipo: :values.',
'min' => [
'array' => 'El campo :attribute debe tener al menos :min elementos.',
'file' => 'El campo :attribute debe tener al menos :min kilobytes.',
'numeric' => 'El campo :attribute debe ser al menos :min.',
'string' => 'El campo :attribute debe tener al menos :min caracteres.',
],
'min_digits' => 'El campo :attribute debe tener al menos :min dígitos.',
'missing' => 'El campo :attribute debe estar ausente.',
'missing_if' => 'El campo :attribute debe estar ausente cuando :other es :value.',
'missing_unless' => 'El campo :attribute debe estar ausente a menos que :other sea :value.',
'missing_with' => 'El campo :attribute debe estar ausente cuando :values está presente.',
'missing_with_all' => 'El campo :attribute debe estar ausente cuando :values están presentes.',
'multiple_of' => 'El campo :attribute debe ser un múltiplo de :value.',
'not_in' => 'El :attribute seleccionado no es válido.',
'not_regex' => 'El formato del campo :attribute no es válido.',
'numeric' => 'El campo :attribute debe ser un número.',
'password' => [
'letters' => 'El campo :attribute debe contener al menos una letra.',
'mixed' => 'El campo :attribute debe contener al menos una letra mayúscula y una minúscula.',
'numbers' => 'El campo :attribute debe contener al menos un número.',
'symbols' => 'El campo :attribute debe contener al menos un símbolo.',
'uncompromised' => 'El :attribute proporcionado ha aparecido en una filtración de datos. Elige un :attribute diferente.',
],
'present' => 'El campo :attribute debe estar presente.',
'present_if' => 'El campo :attribute debe estar presente cuando :other es :value.',
'present_unless' => 'El campo :attribute debe estar presente a menos que :other sea :value.',
'present_with' => 'El campo :attribute debe estar presente cuando :values está presente.',
'present_with_all' => 'El campo :attribute debe estar presente cuando :values están presentes.',
'prohibited' => 'El campo :attribute está prohibido.',
'prohibited_if' => 'El campo :attribute está prohibido cuando :other es :value.',
'prohibited_if_accepted' => 'El campo :attribute está prohibido cuando :other es aceptado.',
'prohibited_if_declined' => 'El campo :attribute está prohibido cuando :other es rechazado.',
'prohibited_unless' => 'El campo :attribute está prohibido a menos que :other esté en :values.',
'prohibits' => 'El campo :attribute prohíbe que :other esté presente.',
'regex' => 'El formato del campo :attribute no es válido.',
'required' => 'El campo :attribute es obligatorio.',
'required_array_keys' => 'El campo :attribute debe contener entradas para: :values.',
'required_if' => 'El campo :attribute es obligatorio cuando :other es :value.',
'required_if_accepted' => 'El campo :attribute es obligatorio cuando :other es aceptado.',
'required_if_declined' => 'El campo :attribute es obligatorio cuando :other es rechazado.',
'required_unless' => 'El campo :attribute es obligatorio a menos que :other esté en :values.',
'required_with' => 'El campo :attribute es obligatorio cuando :values está presente.',
'required_with_all' => 'El campo :attribute es obligatorio cuando :values están presentes.',
'required_without' => 'El campo :attribute es obligatorio cuando :values no está presente.',
'required_without_all' => 'El campo :attribute es obligatorio cuando ninguno de :values está presente.',
'same' => 'El campo :attribute debe coincidir con :other.',
'size' => [
'array' => 'El campo :attribute debe contener :size elementos.',
'file' => 'El campo :attribute debe pesar :size kilobytes.',
'numeric' => 'El campo :attribute debe ser :size.',
'string' => 'El campo :attribute debe tener :size caracteres.',
],
'starts_with' => 'El campo :attribute debe comenzar con uno de los siguientes valores: :values.',
'string' => 'El campo :attribute debe ser una cadena de texto.',
'timezone' => 'El campo :attribute debe ser una zona horaria válida.',
'unique' => 'El :attribute ya está en uso.',
'uploaded' => 'El campo :attribute no se pudo subir.',
'uppercase' => 'El campo :attribute debe estar en mayúsculas.',
'url' => 'El campo :attribute debe ser una URL válida.',
'ulid' => 'El campo :attribute debe ser un ULID válido.',
'uuid' => 'El campo :attribute debe ser un UUID válido.',
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
'attributes' => [
'name' => 'nombre',
'email' => 'correo electrónico',
'password' => 'contraseña',
'address' => 'dirección',
'phone' => 'teléfono',
'description' => 'descripción',
'start_date' => 'fecha de inicio',
'end_date' => 'fecha de fin',
'end_date_estimated' => 'fecha estimada de fin',
'reference' => 'referencia',
'status' => 'estado',
'type' => 'tipo',
'color' => 'color',
'progress_percent' => 'porcentaje de progreso',
'tax_id' => 'NIF/CIF',
'country' => 'país',
'city' => 'ciudad',
'latitude' => 'latitud',
'longitude' => 'longitud',
'logo' => 'logo',
'avatar' => 'avatar',
'role' => 'rol',
'company_id' => 'empresa',
'current_password' => 'contraseña actual',
'new_password' => 'nueva contraseña',
'new_password_confirmation' => 'confirmación de nueva contraseña',
],
];
+39
View File
@@ -0,0 +1,39 @@
<?php
return [
'All' => 'Todos',
'All Columns' => 'Todas las columnas',
'Applied Filters' => 'Filtros aplicados',
'Applied Sorting' => 'Ordenación aplicada',
'Bulk Actions' => 'Acciones masivas',
'Bulk Actions Confirm' => '¿Estás seguro?',
'Clear' => 'Limpiar',
'Columns' => 'Columnas',
'Debugging Values' => 'Valores de depuración',
'Deselect All' => 'Deseleccionar todo',
'Done Reordering' => 'Reordenación finalizada',
'Filters' => 'Filtros',
'not_applicable' => 'N/A',
'No' => 'No',
'No items found, try to broaden your search' => 'Sin resultados. Intenta ampliar la búsqueda.',
'of' => 'de',
'Remove filter option' => 'Quitar filtro',
'Remove sort option' => 'Quitar ordenación',
'Reorder' => 'Reordenar',
'results' => 'resultados',
'row' => 'fila',
'rows' => 'filas',
'rows, do you want to select all' => 'filas, ¿deseas seleccionarlas todas?',
'Search' => 'Buscar',
'Select All' => 'Seleccionar todo',
'Select All On Page' => 'Seleccionar todo en la página',
'Showing' => 'Mostrando',
'to' => 'a',
'Yes' => 'Sí',
'You are currently selecting all' => 'Actualmente estás seleccionando todo',
'You are not connected to the internet' => 'No tienes conexión a internet',
'You have selected' => 'Has seleccionado',
'Per Page' => 'Por página',
'Export' => 'Exportar',
'Loading' => 'Cargando',
];
+3 -3
View File
@@ -7,15 +7,15 @@
<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')
@livewire('user-table')
</div>
</div>
</div>
+325 -79
View File
@@ -5,109 +5,355 @@
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
{{-- Stats cards --}}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm text-gray-500 uppercase tracking-wide">{{ __('Active projects') }}</div>
<div class="text-3xl font-bold mt-1">{{ $stats['active_projects'] }}</div>
<div class="py-8">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
{{-- ============================================================
ROW 1: Project stats (4 columns)
============================================================ --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{{-- 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>
<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 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 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>
{{-- 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>
{{-- 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>
<p class="text-right text-sm text-gray-500 mt-1">{{ $stats['global_progress'] }}%</p>
</div>
{{-- Recent projects --}}
<div class="bg-white rounded-lg shadow p-6 mb-8">
{{-- 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">{{ __('Recent projects') }}</h3>
<a href="{{ route('projects.list') }}" class="text-sm text-primary hover:underline">{{ __('View Map') }}</a>
<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>
<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>
@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
$badgeClass = match($project->status) {
'planning' => 'badge-ghost',
'in_progress' => 'badge-primary',
'paused' => 'badge-warning',
'completed' => 'badge-success',
default => 'badge-ghost'
$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
<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 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>
<span class="text-xs">{{ round($avg) }}%</span>
<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>
</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 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>
{{-- 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 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>
<span class="text-xs text-gray-400">{{ $inspection->created_at->diffForHumans() }}</span>
</div>
@endforeach
</div>
</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>
+4 -4
View File
@@ -74,8 +74,8 @@
<img src="{{ asset('logo.png') }}" alt="Avante" class="h-8 w-auto" onerror="this.onerror=null;this.src='https://via.placeholder.com/150x40?text=Avante'; this.alt='Avante Logo'">
</div>
<div class="hidden md:ml-6 md:flex md:space-x-4">
<a href="{{ url('/client/projects') }}" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-50">Mis Proyectos</a>
<a href="{{ url('/client/profile') }}" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-50">Perfil</a>
<a href="{{ url('/client/projects') }}" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-50">{{ __('My Projects') }}</a>
<a href="{{ url('/client/profile') }}" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-50">{{ __('Profile') }}</a>
</div>
</div>
<div class="hidden md:block">
@@ -93,8 +93,8 @@
<!-- Mobile menu -->
<nav class="md:hidden" id="mobile-menu">
<div class="px-2 pt-2 pb-3 space-y-1">
<a href="{{ url('/client/projects') }}" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:bg-gray-50">Mis Proyectos</a>
<a href="{{ url('/client/profile') }}" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:bg-gray-50">Perfil</a>
<a href="{{ url('/client/projects') }}" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:bg-gray-50">{{ __('My Projects') }}</a>
<a href="{{ url('/client/profile') }}" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:bg-gray-50">{{ __('Profile') }}</a>
</div>
</nav>
+80 -32
View File
@@ -1,53 +1,101 @@
<div>
@if(session()->has('message'))
<div class="alert alert-success mb-2 text-sm">{{ session('message') }}</div>
@endif
@if(session()->has('error'))
<div class="alert alert-error mb-2 text-sm">{{ session('error') }}</div>
@if(session('notify'))
<div class="alert alert-success mb-4">
<x-heroicon-o-check-circle class="w-5 h-5" />
{{ session('notify') }}
</div>
@endif
{{-- ── Cabecera ─────────────────────────────────────────────────────────── --}}
<div class="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
<label class="input input-bordered input-sm flex items-center gap-2 flex-1 max-w-sm">
<x-heroicon-o-magnifying-glass class="w-4 h-4 opacity-50" />
<input type="text" wire:model.live.debounce.300ms="search"
class="grow" placeholder="Buscar por nombre o email…" />
</label>
<a href="{{ route('admin.users.create') }}" class="btn btn-primary btn-sm gap-1 shrink-0" wire:navigate>
<x-heroicon-o-plus class="w-4 h-4" />
Nuevo usuario
</a>
</div>
{{-- ── Tabla ────────────────────────────────────────────────────────────── --}}
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>{{ __('Name') }}</th>
<th>{{ __('Email') }}</th>
<th>{{ __('Role') }}</th>
<th>{{ __('Language') }}</th>
<th>{{ __('Actions') }}</th>
<th>Usuario</th>
<th>Rol</th>
<th>Verificado</th>
<th class="w-24"></th>
</tr>
</thead>
<tbody>
@foreach($users as $user)
<tr>
<td class="font-medium">{{ $user->name }}</td>
<td class="text-sm">{{ $user->email }}</td>
@forelse($this->users as $u)
<tr wire:key="user-{{ $u->id }}">
<td>
<div class="flex flex-wrap gap-1">
@foreach($user->roles as $role)
<span class="badge badge-sm {{ $role->name === 'Admin' ? 'badge-error' : 'badge-primary' }}">
{{ __($role->name) }}
</span>
@endforeach
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-8">
<span class="text-xs">{{ strtoupper(substr($u->name, 0, 1)) }}</span>
</div>
</div>
<div>
<p class="font-semibold text-sm">{{ $u->name }}</p>
<p class="text-xs text-gray-500">{{ $u->email }}</p>
</div>
</div>
</td>
<td class="text-sm">{{ strtoupper($user->locale ?? 'en') }}</td>
<td>
@can('assign users')
<select wire:change="updateRole({{ $user->id }}, $event.target.value)"
class="select select-bordered select-xs"
@if(Auth::id() === $user->id && $user->hasRole('Admin')) disabled @endif>
@foreach($roles as $role)
<option value="{{ $role->name }}" @selected($user->hasRole($role->name))>
{{ __($role->name) }}
</option>
<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
</select>
@endcan
@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>
@@ -2,7 +2,7 @@
@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)
@@ -11,7 +11,7 @@
<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>
@@ -36,57 +36,48 @@
<h2 class="text-xl font-bold">{{ $projectDetails['name'] }}</h2>
<button wire:click="selectedProject = null"
class="px-3 py-1 bg-gray-200 text-gray-700 text-sm rounded hover:bg-gray-300">
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>
@@ -98,14 +89,14 @@
</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);
@@ -119,7 +110,7 @@
<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>
@@ -129,19 +120,19 @@
</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>
<span class="font-medium">{{ $feature->completion_status ?? __('Pending') }}</span>
</span>
</div>
@endforeach
@@ -153,14 +144,14 @@
</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)
@@ -179,7 +170,7 @@
<!-- 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">
@@ -200,10 +191,10 @@
<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>
@@ -212,11 +203,11 @@
<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 class="flex items-center justify-between mb-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">
<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>
<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>
</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 }} &ndash; {{ $user->email }}</option>
@endforeach
</select>
@error('assignedTo')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
{{-- Notas de resolución (visible when status = resolved or closed) --}}
@if(in_array($status, ['resolved', 'closed']))
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Notas de resolución</span>
<span class="label-text-alt text-base-content/50">Opcional</span>
</label>
<textarea
wire:model="resolutionNotes"
class="textarea textarea-bordered w-full h-20 resize-y @error('resolutionNotes') textarea-error @enderror"
placeholder="Describe cómo se resolvió el problema..."
></textarea>
@error('resolutionNotes')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
@endif
{{-- Modal footer --}}
<div class="flex items-center justify-end gap-3 pt-2 border-t border-base-300">
<button
type="button"
wire:click="closeForm()"
class="btn btn-ghost"
>
Cancelar
</button>
<button
type="submit"
wire:loading.attr="disabled"
wire:target="save"
class="btn btn-primary gap-2"
>
<span wire:loading.remove wire:target="save">
<x-heroicon-o-check class="w-4 h-4" />
</span>
<span wire:loading wire:target="save" class="loading loading-spinner loading-sm"></span>
{{ $editingIssue ? 'Actualizar Issue' : 'Crear Issue' }}
</button>
</div>
</form>
</div>
</div>
@endif
</div>
@@ -1,4 +1,5 @@
<div class="flex items-center gap-1">
<div class="flex items-center gap-1"
x-on:locale-changed.window="window.location.reload()">
@foreach(['en' => '🇬🇧 EN', 'es' => '🇪🇸 ES'] as $code => $label)
<button wire:click="switchLanguage('{{ $code }}')"
class="btn btn-xs {{ $currentLocale === $code ? 'btn-primary' : 'btn-ghost' }}"
@@ -39,7 +39,7 @@
<div class="form-control">
<label class="label">{{ __("Color") }}</label>
<input type="color" wire:model="layer{{ __("Color") }}" class="input input-bordered w-20" />
<input type="color" wire:model="layerColor" class="input input-bordered w-20" />
</div>
<div class="form-control">
@@ -88,6 +88,17 @@
let allLayersData = {}; // id -> {geojson, color}
let visibleLayerIds = [];
// XSS-safe HTML escaping for user-supplied data rendered in Leaflet popups
function escapeHtml(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Inicialización del mapa
function initMap() {
if (map) return;
@@ -137,9 +148,9 @@
style: { color: data.color, weight: 3, opacity: 0.8, fillOpacity: 0.3 },
onEachFeature: (feature, layer) => {
const props = feature.properties;
const content = `<b>${props.name || 'Elemento'}</b><br>
Progreso: ${props.progress || 0}%<br>
Responsable: ${props.responsible || '-'}`;
const content = `<b>${escapeHtml(props.name) || 'Elemento'}</b><br>
Progreso: ${escapeHtml(props.progress) || 0}%<br>
Responsable: ${escapeHtml(props.responsible) || '-'}`;
layer.bindPopup(content);
}
}).addTo(displayGroup);
@@ -158,9 +169,9 @@
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>
const content = `<b>${escapeHtml(props.name) || 'Elemento'}</b><br>
Progreso: ${escapeHtml(props.progress) || 0}%<br>
Responsable: ${escapeHtml(props.responsible) || '-'}<br>
<em>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>
@@ -0,0 +1,270 @@
<div class="card bg-base-100 shadow">
<div class="card-body p-8">
<form wire:submit.prevent="save">
@if($errors->any())
<div class="alert alert-error text-sm mb-6">
<x-heroicon-o-exclamation-circle class="w-5 h-5 shrink-0" />
<ul class="list-disc pl-3 space-y-0.5">
@foreach($errors->all() as $e) <li>{{ $e }}</li> @endforeach
</ul>
</div>
@endif
{{-- ══════════════════════════════════════════════════════════════════
1. IDENTIFICACIÓN
══════════════════════════════════════════════════════════════════ --}}
<div class="pb-6 mb-6 border-b border-base-200">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">Identificación</h3>
<div class="space-y-4">
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Nombre <span class="text-error">*</span>
</label>
<div class="flex-1">
<input type="text" wire:model="name"
class="input input-bordered w-full"
placeholder="Edificio Residencial Las Palmas"
autofocus />
@error('name') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Referencia
<p class="text-xs text-gray-400 font-normal mt-0.5">Código interno o expediente</p>
</label>
<div class="flex-1">
<input type="text" wire:model="reference"
class="input input-bordered w-full max-w-xs"
placeholder="OBR-2026-001" />
@error('reference') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
@if($project)
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">Estado</label>
<div class="flex-1">
<select wire:model="status" class="select select-bordered w-full max-w-xs">
<option value="planning">Planificación</option>
<option value="in_progress">En progreso</option>
<option value="paused">Pausado</option>
<option value="completed">Completado</option>
</select>
@error('status') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
@endif
</div>
</div>
{{-- ══════════════════════════════════════════════════════════════════
2. UBICACIÓN
══════════════════════════════════════════════════════════════════ --}}
<div class="pb-6 mb-6 border-b border-base-200">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">Ubicación</h3>
{{-- Search box --}}
<div class="flex gap-2 mb-3">
<label class="input input-bordered input-sm flex items-center gap-2 flex-1">
<x-heroicon-o-magnifying-glass class="w-4 h-4 opacity-40 shrink-0" />
<input type="text" id="map-search-input" class="grow"
placeholder="Buscar dirección, ciudad, lugar…"
autocomplete="off" />
</label>
<button type="button" id="map-search-btn"
class="btn btn-outline btn-sm gap-1 shrink-0">
<x-heroicon-o-magnifying-glass class="w-4 h-4" />
Buscar
</button>
</div>
{{-- Geocode status message --}}
<p id="geocode-status" class="text-xs text-gray-400 mb-2 min-h-[1rem]"></p>
{{-- Map (wire:ignore prevents Livewire morphing from destroying Leaflet) --}}
<div wire:ignore
id="project-location-map"
data-lat="{{ $lat }}"
data-lng="{{ $lng }}"
style="height: 380px; border-radius: 0.5rem; overflow: hidden; z-index: 1;"
class="border border-base-300 shadow-sm mb-4">
</div>
<p class="text-xs text-gray-400 mb-4 flex items-center gap-1">
<x-heroicon-o-cursor-arrow-rays class="w-3.5 h-3.5 opacity-60" />
Pulsa en el mapa o arrastra el marcador para actualizar la ubicación.
</p>
<div class="space-y-4">
{{-- Lat/Lng (read-only, filled by map) --}}
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Coordenadas
<p class="text-xs text-gray-400 font-normal mt-0.5">Auto al pulsar el mapa</p>
</label>
<div class="flex-1 flex items-center gap-3">
<div class="flex-1">
<label class="label-text text-xs mb-0.5">Latitud</label>
<input type="text" wire:model="lat" readonly
id="input-lat"
class="input input-bordered input-sm w-full bg-base-200 font-mono"
placeholder="40.41680000" />
@error('lat') <p class="text-error text-xs mt-0.5">{{ $message }}</p> @enderror
</div>
<span class="text-gray-300 mt-5">/</span>
<div class="flex-1">
<label class="label-text text-xs mb-0.5">Longitud</label>
<input type="text" wire:model="lng" readonly
id="input-lng"
class="input input-bordered input-sm w-full bg-base-200 font-mono"
placeholder="-3.70380000" />
@error('lng') <p class="text-error text-xs mt-0.5">{{ $message }}</p> @enderror
</div>
</div>
</div>
{{-- Dirección --}}
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Dirección <span class="text-error">*</span>
</label>
<div class="flex-1">
<textarea wire:model="address" rows="2"
class="textarea textarea-bordered w-full"
placeholder="Calle Gran Vía 28, 28013 Madrid, España"></textarea>
@error('address') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
{{-- País custom dropdown with flag images (native <select> can't render emoji on Windows) --}}
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">País</label>
<div class="flex-1 max-w-xs">
<div x-data="{ open: false, q: '' }"
@click.outside="open = false; q = ''"
class="relative">
{{-- Trigger button --}}
<button type="button"
@click="open = !open; if(open) $nextTick(() => $refs.qs?.focus())"
class="btn btn-outline w-full justify-start gap-2 font-normal h-12">
@if($country && isset($countryList[$country]))
<img src="https://flagcdn.com/w20/{{ $country }}.png"
class="w-6 h-4 object-cover rounded-sm shrink-0"
onerror="this.style.display='none'" />
<span>{{ $countryList[$country] }}</span>
@else
<span class="text-gray-400"> Sin especificar </span>
@endif
<x-heroicon-o-chevron-up-down class="w-4 h-4 ml-auto opacity-40 shrink-0" />
</button>
{{-- Dropdown panel --}}
<div x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
class="absolute z-50 mt-1 w-full bg-base-100 border border-base-300 rounded-xl shadow-xl overflow-hidden"
style="display:none">
{{-- Search --}}
<div class="p-2 border-b border-base-200">
<input x-ref="qs" x-model="q" type="text"
placeholder="Buscar país…"
class="input input-sm input-bordered w-full"
@keydown.escape="open = false; q = ''" />
</div>
{{-- Clear option --}}
<button type="button"
@click="$wire.set('country', ''); open = false; q = ''"
class="flex items-center gap-2 w-full px-3 py-2 hover:bg-base-200 text-sm text-gray-400 border-b border-base-200">
Sin especificar
</button>
{{-- Country list --}}
<ul class="overflow-y-auto max-h-52 py-1">
@foreach($countryList as $code => $cName)
<li>
<button type="button"
x-show="q === '' || '{{ strtolower(addslashes($cName)) }}'.includes(q.toLowerCase())"
@click="$wire.set('country', '{{ $code }}'); open = false; q = ''"
class="flex items-center gap-2.5 w-full px-3 py-1.5 hover:bg-base-200 text-sm text-left {{ $country === $code ? 'bg-primary/10 font-semibold text-primary' : '' }}">
<img src="https://flagcdn.com/w20/{{ $code }}.png"
class="w-6 h-4 object-cover rounded-sm shrink-0"
loading="lazy"
onerror="this.style.display='none'" />
{{ $cName }}
@if($country === $code)
<x-heroicon-o-check class="w-3.5 h-3.5 ml-auto shrink-0" />
@endif
</button>
</li>
@endforeach
</ul>
</div>
</div>
@error('country') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
</div>
</div>
{{-- ══════════════════════════════════════════════════════════════════
3. PLANIFICACIÓN
══════════════════════════════════════════════════════════════════ --}}
<div class="pb-6 mb-6 border-b border-base-200">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">Planificación</h3>
<div class="space-y-4">
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Fecha inicio <span class="text-error">*</span>
</label>
<div class="flex-1">
<input type="date" wire:model="startDate"
class="input input-bordered w-full max-w-xs" />
@error('startDate') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Fecha fin estimada
<p class="text-xs text-gray-400 font-normal mt-0.5">Vacío = sin fecha límite</p>
</label>
<div class="flex-1">
<input type="date" wire:model="endDateEstimated"
class="input input-bordered w-full max-w-xs" />
@error('endDateEstimated') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
</div>
</div>
{{-- ── Botones ─────────────────────────────────────────────────────── --}}
<div class="flex items-center justify-between pt-2">
<a href="{{ route('projects.index') }}" class="btn btn-outline gap-1" wire:navigate>
<x-heroicon-o-x-mark class="w-4 h-4" />
Cancelar
</a>
<button type="submit" class="btn btn-primary gap-2"
wire:loading.attr="disabled" wire:target="save">
<span wire:loading wire:target="save" class="loading loading-spinner loading-sm"></span>
<x-heroicon-o-check class="w-4 h-4" />
{{ $project ? 'Guardar cambios' : 'Crear proyecto' }}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,275 @@
<div class="p-4 space-y-4">
{{-- Page header --}}
<div class="flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-3">
<a href="{{ route('projects.map', $project) }}"
class="btn btn-sm btn-ghost gap-1">
<x-heroicon-o-arrow-left class="w-4 h-4" />
{{ __('Back to Map') }}
</a>
<h1 class="text-xl font-bold">{{ __('Cronograma') }}: {{ $project->name }}</h1>
</div>
<div class="flex items-center gap-3">
<a href="{{ route('projects.report', $project) }}"
target="_blank"
class="btn btn-sm btn-outline gap-1">
<x-heroicon-o-document-text class="w-4 h-4" />
{{ __('Report PDF') }}
</a>
<span class="text-sm text-base-content/60">
{{ $project->start_date?->format('d/m/Y') ?? __('N/A') }}
&mdash;
{{ $project->end_date_estimated?->format('d/m/Y') ?? __('N/A') }}
</span>
</div>
</div>
{{-- Legend --}}
<div class="flex items-center gap-4 text-sm flex-wrap">
<span class="flex items-center gap-1.5">
<span class="inline-block w-5 h-3 rounded" style="background:#3b82f6"></span>
{{ __('Planificado') }}
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block w-5 h-3 rounded" style="background:#22c55e"></span>
{{ __('Real') }}
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block w-5 h-3 rounded border-2" style="background:#fee2e2;border-color:#ef4444"></span>
{{ __('Retrasado') }}
</span>
</div>
{{-- Editor de fechas por fase (siempre visible) --}}
<div class="bg-base-100 rounded-box border border-base-300 p-4 mb-4">
<h3 class="font-semibold text-sm mb-3">Fechas planificadas y reales por fase</h3>
<div class="space-y-3">
@foreach($phases as $phase)
<div x-data="{
ps: '{{ $phase->planned_start?->format('Y-m-d') ?? '' }}',
pe: '{{ $phase->planned_end?->format('Y-m-d') ?? '' }}',
as_: '{{ $phase->actual_start?->format('Y-m-d') ?? '' }}',
ae: '{{ $phase->actual_end?->format('Y-m-d') ?? '' }}'
}" class="grid grid-cols-2 md:grid-cols-5 gap-2 items-center text-sm border-b pb-3 last:border-0">
<div class="font-medium truncate flex items-center gap-1">
<span class="w-2 h-2 rounded-full inline-block flex-shrink-0" style="background:{{ $phase->color ?? '#3b82f6' }}"></span>
{{ $phase->name }}
</div>
<div class="form-control">
<label class="label-text text-xs text-gray-500">Plan. inicio</label>
<input type="date" x-model="ps" class="input input-xs input-bordered" />
</div>
<div class="form-control">
<label class="label-text text-xs text-gray-500">Plan. fin</label>
<input type="date" x-model="pe" class="input input-xs input-bordered" />
</div>
<div class="form-control">
<label class="label-text text-xs text-gray-500">Real inicio</label>
<input type="date" x-model="as_" class="input input-xs input-bordered" />
</div>
<div class="flex flex-col gap-1">
<label class="label-text text-xs text-gray-500">Real fin</label>
<div class="flex gap-1">
<input type="date" x-model="ae" class="input input-xs input-bordered flex-1" />
<button @click="$wire.updatePhaseDates({{ $phase->id }}, ps, pe, as_, ae)"
class="btn btn-xs btn-primary">
</button>
</div>
</div>
</div>
@endforeach
</div>
</div>
@if(empty($ganttData))
<div class="alert alert-info">
<x-heroicon-o-information-circle class="w-5 h-5" />
<span>Define fechas planificadas arriba para ver el diagrama.</span>
</div>
@else
{{-- Gantt table --}}
<div class="bg-base-100 rounded-box border border-base-300 overflow-x-auto">
<table class="w-full text-sm" style="min-width:900px;">
<thead>
<tr class="border-b border-base-300">
{{-- Phase name column --}}
<th class="text-left px-3 py-2 font-semibold bg-base-200" style="width:200px;min-width:200px;">
{{ __('Fase') }}
</th>
{{-- Month header row --}}
<th class="px-0 py-0 bg-base-200" style="min-width:400px;">
@php
$projectStart = $project->start_date ?? now()->startOfMonth();
$projectEnd = $project->end_date_estimated ?? now()->addMonths(6);
$totalDays = max(1, $projectStart->diffInDays($projectEnd));
// Build month segments
$months = [];
$cursor = $projectStart->copy()->startOfMonth();
while ($cursor->lte($projectEnd)) {
$mStart = $cursor->copy()->max($projectStart);
$mEnd = $cursor->copy()->endOfMonth()->min($projectEnd);
$days = max(1, $mStart->diffInDays($mEnd) + 1);
$widthPct = round(($days / $totalDays) * 100, 2);
$months[] = [
'label' => $cursor->translatedFormat('M Y'),
'width_pct' => $widthPct,
];
$cursor->addMonthNoOverflow();
}
@endphp
<div class="flex w-full border-b border-base-300">
@foreach($months as $month)
<div class="text-center text-xs py-1 font-medium border-r border-base-300 last:border-r-0 truncate"
style="width:{{ $month['width_pct'] }}%;flex-shrink:0;">
{{ $month['label'] }}
</div>
@endforeach
</div>
</th>
{{-- Dates column --}}
<th class="text-left px-3 py-2 font-semibold bg-base-200 whitespace-nowrap" style="width:160px;min-width:160px;">
{{ __('Fechas') }}
</th>
{{-- Status column --}}
<th class="text-center px-3 py-2 font-semibold bg-base-200" style="width:110px;min-width:110px;">
{{ __('Estado') }}
</th>
</tr>
</thead>
<tbody>
@foreach($ganttData as $phase)
<tr class="border-b border-base-300 hover:bg-base-50 transition-colors {{ $phase['is_delayed'] ? 'bg-red-50' : '' }}">
{{-- Phase name --}}
<td class="px-3 py-3" style="width:200px;min-width:200px;vertical-align:middle;">
<div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 rounded-full flex-shrink-0"
style="background:{{ $phase['color'] }}"></span>
<span class="font-medium truncate" title="{{ $phase['name'] }}">
{{ $phase['name'] }}
</span>
</div>
@if($phase['features_count'] > 0)
<div class="ml-5 text-xs text-base-content/50 mt-0.5">
{{ $phase['features_count'] }} {{ __('elementos') }}
</div>
@endif
</td>
{{-- Gantt bar cell --}}
<td class="px-0 py-3" style="vertical-align:middle;">
<div class="relative w-full" style="height:36px;">
{{-- Month grid lines --}}
@php $offset = 0; @endphp
@foreach($months as $i => $month)
@if($i > 0)
<div class="absolute top-0 bottom-0 border-l border-base-300/50"
style="left:{{ $offset }}%;"></div>
@endif
@php $offset += $month['width_pct']; @endphp
@endforeach
{{-- Planned bar --}}
<div class="absolute rounded"
style="
top: 4px;
height: 13px;
left: {{ $phase['p_start_pct'] }}%;
width: {{ max(0.5, $phase['p_width_pct']) }}%;
background: {{ $phase['is_delayed'] ? '#fca5a5' : $phase['color'] }};
border: {{ $phase['is_delayed'] ? '2px solid #ef4444' : 'none' }};
opacity: 0.85;
"
title="{{ __('Planificado') }}: {{ $phase['planned_start'] }} - {{ $phase['planned_end'] }}">
</div>
{{-- Actual bar (if exists) --}}
@if($phase['a_start_pct'] !== null && $phase['a_width_pct'] !== null)
<div class="absolute rounded"
style="
top: 19px;
height: 13px;
left: {{ $phase['a_start_pct'] }}%;
width: {{ max(0.5, $phase['a_width_pct']) }}%;
background: #22c55e;
opacity: 0.85;
"
title="{{ __('Real') }}: {{ $phase['actual_start'] }} - {{ $phase['actual_end'] ?? __('En curso') }}">
</div>
@endif
{{-- Progress label --}}
<div class="absolute inset-0 flex items-center"
style="left: {{ $phase['p_start_pct'] }}%; width: {{ max(0.5, $phase['p_width_pct']) }}%;">
<span class="text-xs font-bold text-white drop-shadow px-1 truncate"
style="font-size:10px; line-height:13px; position:absolute; top:4px; left:2px;">
{{ $phase['progress'] }}%
</span>
</div>
</div>
</td>
{{-- Dates column --}}
<td class="px-3 py-3 text-xs" style="width:160px;min-width:160px;vertical-align:middle;">
<div class="space-y-0.5">
<div class="flex items-center gap-1">
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" style="background:{{ $phase['color'] }}"></span>
<span class="text-base-content/70">{{ $phase['planned_start'] }} {{ $phase['planned_end'] }}</span>
</div>
@if($phase['actual_start'])
<div class="flex items-center gap-1">
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" style="background:#22c55e"></span>
<span class="text-base-content/70">
{{ $phase['actual_start'] }} {{ $phase['actual_end'] ?? __('En curso') }}
</span>
</div>
@endif
</div>
</td>
{{-- Status badge --}}
<td class="px-3 py-3 text-center" style="width:110px;min-width:110px;vertical-align:middle;">
@if($phase['is_delayed'])
<span class="badge badge-error badge-sm gap-1">
<x-heroicon-o-exclamation-triangle class="w-3 h-3" />
{{ __('En retraso') }}
</span>
@elseif($phase['progress'] >= 100)
<span class="badge badge-success badge-sm gap-1">
<x-heroicon-o-check-circle class="w-3 h-3" />
{{ __('Completado') }}
</span>
@elseif($phase['progress'] > 0)
<span class="badge badge-info badge-sm">
{{ $phase['progress'] }}%
</span>
@else
<span class="badge badge-ghost badge-sm">
{{ __('Pendiente') }}
</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{-- Summary footer --}}
<div class="text-xs text-base-content/50 text-right">
{{ count($ganttData) }} {{ __('fases') }}
&bull;
{{ __('Actualizado') }}: {{ now()->format('d/m/Y H:i') }}
</div>
@endif
</div>
@@ -0,0 +1,400 @@
<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" />
{{ __('Map') }}
</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.report', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-document-chart-bar class="w-4 h-4" />
{{ __('Report') }}
</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">{{ __('View all') }}</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">{{ __('View on map') }}</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 --}}
@@ -2,15 +2,26 @@
<div class="max-w-2xl mx-auto p-4">
<h1 class="text-2xl font-bold mb-6">{{ $projectId ? __('Edit Project') : __('New Project') }}</h1>
@if($errors->any())
<div class="alert alert-error text-sm mb-4">
<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
<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>
<input type="text" wire:model="name" class="input input-bordered w-full {{ $errors->has('name') ? 'input-error' : '' }}" placeholder="{{ __('Project name') }}" required>
@error('name') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
</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.') }}">
@error('address') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-sm font-medium mb-2">{{ __('Country') }}</label>
@@ -18,11 +29,13 @@
</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>
<input type="date" wire:model="start_date" class="input input-bordered w-full {{ $errors->has('start_date') ? 'input-error' : '' }}" required>
@error('start_date') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
</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">
<input type="date" wire:model="end_date_estimated" class="input input-bordered w-full {{ $errors->has('end_date_estimated') ? 'input-error' : '' }}">
@error('end_date_estimated') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium mb-2">{{ __('Status') }}</label>
@@ -32,6 +45,7 @@
<option value="paused">{{ __('Paused') }}</option>
<option value="completed">{{ __('Completed') }}</option>
</select>
@error('status') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
</div>
</div>
@@ -1,44 +1,3 @@
<div>
<div class="flex justify-between mb-4">
<input type="text" wire:model.live="search" placeholder="{{ __('Search') }}..." class="input input-bordered w-64" />
<select wire:model.live="statusFilter" class="select select-bordered">
<option value="">{{ __('All') }}</option>
<option value="planning">{{ __('Planning') }}</option>
<option value="in_progress">{{ __('In progress') }}</option>
<option value="paused">{{ __('Paused') }}</option>
<option value="completed">{{ __('Completed') }}</option>
</select>
@can('create projects')
<a href="{{ route('projects.create') }}" class="btn btn-primary">+ {{ __('New Project') }}</a>
@endcan
</div>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr><th>{{ __('Name') }}</th><th>{{ __('Address') }}</th><th>{{ __('Status') }}</th><th>{{ __('Progress') }}</th><th>{{ __('Actions') }}</th></tr>
</thead>
<tbody>
@foreach($projects as $project)
<tr>
<td>{{ $project->name }}</td>
<td>{{ $project->address }}</td>
<td>{{ __(ucfirst(str_replace('_', ' ', $project->status))) }}</td>
<td>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div class="bg-primary h-2.5 rounded-full" style="width: {{ $project->phases->avg('progress_percent') }}%"></div>
</div>
</td>
<td>
<a href="{{ route('projects.map', $project) }}" class="btn btn-sm btn-outline">{{ __('Map') }}</a>
@can('edit projects')
<a href="{{ route('projects.edit', $project) }}" class="btn btn-sm btn-warning">{{ __('Edit') }}</a>
@endcan
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{ $projects->links() }}
<livewire:project-table />
</div>
@@ -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
@@ -83,11 +83,44 @@
</button>
</div>
<!-- Project navigation bar -->
<div class="flex flex-wrap gap-1 mb-3">
<a href="{{ route('projects.dashboard', $project) }}"
class="btn btn-xs {{ request()->routeIs('projects.dashboard') ? 'btn-primary' : 'btn-outline' }}">
📊 {{ __('Dashboard') }}
</a>
<a href="{{ route('projects.map', $project) }}"
class="btn btn-xs {{ request()->routeIs('projects.map') ? 'btn-primary' : 'btn-outline' }}">
🗺️ {{ __('Map') }}
</a>
<a href="{{ route('projects.gantt', $project) }}"
class="btn btn-xs {{ request()->routeIs('projects.gantt') ? 'btn-primary' : 'btn-outline' }}">
📅 {{ __('Gantt') }}
</a>
<a href="{{ route('projects.report', $project) }}"
class="btn btn-xs {{ request()->routeIs('projects.report') ? 'btn-primary' : 'btn-outline' }}">
📄 {{ __('Report') }}
</a>
<a href="{{ route('projects.issues', $project) }}"
class="btn btn-xs {{ request()->routeIs('projects.issues') ? 'btn-error' : 'btn-outline' }} gap-1">
⚠️ {{ __('Issues') }}
@if($openIssuesCount > 0)
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
@endif
</a>
</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>
<button wire:click="setActiveTab('issues')" class="tab {{ $activeTab === 'issues' ? 'tab-active' : '' }} gap-1">
{{ __('Issues') }}
@if($openIssuesCount > 0)
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
@endif
</button>
</div>
<!-- Tab Content -->
@@ -289,6 +322,9 @@
<p>{{ __("No inspections found") }}</p>
</div>
@endif
@elseif($activeTab === 'issues')
<!-- Issues tab: render embedded IssueManager component -->
@livewire('issue-manager', ['project' => $project], key('issues-tab-' . $project->id))
@endif
</div>
</div>
@@ -1,66 +1,66 @@
<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"
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() }}
@@ -69,7 +69,7 @@
<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() }}
@@ -78,7 +78,7 @@
<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
@@ -91,7 +91,7 @@
<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>
@@ -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)',
@@ -261,7 +261,7 @@
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") }} (%)'
}
}
}
@@ -21,12 +21,13 @@
{{-- 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"
class="input w-full"
class="input w-full {{ $errors->has('form.name') ? 'input-error' : '' }}"
required>
@error('form.name') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
</td>
</tr>
@@ -115,9 +116,16 @@
<button type="button" wire:click="addField" class="btn btn-sm btn-secondary mt-2">+ Agregar campo</button>
</div>
@if($errors->any())
<div class="alert alert-error text-sm mt-3">
<ul class="list-disc pl-3 space-y-0.5">
@foreach($errors->all() as $e) <li>{{ $e }}</li> @endforeach
</ul>
</div>
@endif
<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">{{ __('Save template') }}</button>
<button type="button" wire:click="cancelForm" class="btn">{{ __('Cancel') }}</button>
</div>
</form>
@endif
@@ -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>
+22 -4
View File
@@ -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>
</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>
+2 -2
View File
@@ -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', [
+2 -2
View File
@@ -2,10 +2,10 @@
<div class="mb-6">
<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 &mdash; {{ $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()">
&#128438; {{ __('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)
&nbsp;&bull;&nbsp; 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') }}
&mdash;
{{ $phase->planned_end?->format('d/m/Y') ?? 'Sin fecha fin' }}
@else
Sin fechas planificadas
@endif
&nbsp;&bull;&nbsp; {{ $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 &mdash; Sistema de Gestión de Obras</span>
<span>{{ now()->format('d/m/Y H:i') }}</span>
</div>
</div>
</body>
</html>
+69 -26
View File
@@ -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,52 +38,79 @@ 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(),
'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,
'recentInspections' => $recentInspections,
'recentIssues' => $recentIssues,
]);
})->name('dashboard');
Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboard');
Route::prefix('reports')->name('reports.')->group(function () {
// Reports — Admin only
Route::middleware(['can:manage all'])->prefix('reports')->name('reports.')->group(function () {
Route::get('/dashboard', ReportsDashboard::class)->name('dashboard');
Route::get('export/projects', [App\Http\Controllers\Reports\ExportController::class, 'exportProjects'])->name('export.projects');
Route::get('export/phases', [App\Http\Controllers\Reports\ExportController::class, 'exportPhases'])->name('export.phases');
Route::get('export/inspections', [App\Http\Controllers\Reports\ExportController::class, 'exportInspections'])->name('export.inspections');
});
// ------------------------------------------------------------
// 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 +124,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,9 +143,10 @@ 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
@@ -114,6 +154,9 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa
return view('projects.media', compact('project'));
})->name('projects.media');
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)