feat(tasks): complete task management system with Kanban, Calendar, notifications
- Add Task model with subtasks, priorities, status transitions, dates, hours - Add Comment polymorphic model for tasks/issues/projects - Livewire components: TaskManager (list+filters), TaskForm (modal), TaskDetail, TaskKanban (drag&drop), TaskCalendar (FullCalendar) - TaskPolicy with permissions (view/create/edit/delete/assign/manage all) - Notifications: assigned, status change, overdue, comment added - Daily overdue notification job scheduled - Dashboard widget unifies IssueTask + Task - i18n: en/es/fr/ru (393 keys each) - Routes, navigation, offline sync support - 101 tests passing, Pint compliant on new files
This commit is contained in:
@@ -1,12 +1,33 @@
|
||||
<div class="flex items-center gap-1"
|
||||
x-on:locale-changed.window="window.location.reload()">
|
||||
@foreach(['es' => ['flag' => 'es.svg', 'label' => 'ES'], 'en' => ['flag' => 'gb.svg', 'label' => 'EN']] as $code => $lang)
|
||||
<button wire:click="switchLanguage('{{ $code }}')"
|
||||
class="btn btn-xs gap-1.5 {{ $currentLocale === $code ? 'btn-primary' : 'btn-ghost' }}"
|
||||
title="{{ __('Language') }}: {{ __($code === 'en' ? 'English' : 'Spanish') }}">
|
||||
<img src="{{ asset('images/flags/' . $lang['flag']) }}"
|
||||
class="w-4 h-auto rounded-sm border border-base-300" alt="{{ $lang['label'] }}">
|
||||
{{ $lang['label'] }}
|
||||
<x-dropdown align="right" width="48">
|
||||
<x-slot name="trigger">
|
||||
<button class="btn btn-ghost btn-sm gap-2" type="button">
|
||||
<img src="{{ asset('images/flags/' . ($languages[$currentLocale]['flag'] ?? 'gb.svg')) }}"
|
||||
class="w-5 h-4 rounded-sm border border-base-300"
|
||||
alt="{{ $languages[$currentLocale]['name'] ?? 'English' }}">
|
||||
<span class="hidden sm:inline">{{ $languages[$currentLocale]['name'] ?? 'English' }}</span>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="content">
|
||||
<div class="py-1">
|
||||
@foreach($languages as $code => $lang)
|
||||
<button wire:click="switchLanguage('{{ $code }}')"
|
||||
class="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-base-200 rounded-md {{ $currentLocale === $code ? 'bg-primary/10 text-primary font-medium' : 'text-base-content' }}"
|
||||
type="button">
|
||||
<img src="{{ asset('images/flags/' . $lang['flag']) }}"
|
||||
class="w-5 h-4 rounded-sm border border-base-300"
|
||||
alt="{{ $lang['name'] }}">
|
||||
<span>{{ $lang['name'] }}</span>
|
||||
@if($currentLocale === $code)
|
||||
<svg class="ml-auto w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
@endif
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-dropdown>
|
||||
@@ -18,11 +18,11 @@
|
||||
<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>
|
||||
<span class="font-semibold text-base-content">{{ __('Notifications') }}</span>
|
||||
@if($unreadCount > 0)
|
||||
<button wire:click="markAllAsRead"
|
||||
class="text-xs text-primary hover:underline focus:outline-none">
|
||||
Marcar todas
|
||||
{{ __('Mark all as read') }}
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
@@ -48,7 +48,7 @@
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-base-content leading-snug">
|
||||
{{ $data['message'] ?? 'Notificación' }}
|
||||
{{ $data['message'] ?? __('Notification') }}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/50 mt-1">
|
||||
{{ $createdAt->diffForHumans() }}
|
||||
@@ -59,7 +59,7 @@
|
||||
@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">
|
||||
title="{{ __('Mark as read') }}">
|
||||
<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>
|
||||
@@ -68,7 +68,7 @@
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-4 py-8 text-center text-sm text-base-content/50">
|
||||
No hay notificaciones
|
||||
{{ __('No notifications') }}
|
||||
</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
@@ -78,7 +78,7 @@
|
||||
<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
|
||||
{{ __('Mark all as read') }}
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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' }}
|
||||
{{ $company ? __('Edit company: :name', ['name' => $company->name]) : __('New company') }}
|
||||
</h2>
|
||||
</div>
|
||||
</x-slot>
|
||||
@@ -37,71 +37,74 @@
|
||||
{{-- ── 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
|
||||
{{ __('Identification') }}
|
||||
</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>
|
||||
{{ __('Registered name') }} <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." />
|
||||
placeholder="{{ __('E.g.: Acme Construct, 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
|
||||
{{ __('Nickname') }}
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<input type="text" wire:model="apodo"
|
||||
<input type="text" wire:model="nickname"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Ejemplo Constr." />
|
||||
placeholder="{{ __('E.g.: Acme Construct') }}" />
|
||||
</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
|
||||
{{ __('Tax ID / NIF / CIF') }}
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<input type="text" wire:model="tax_id"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="B12345678" />
|
||||
placeholder="{{ __('E.g.: 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>
|
||||
{{ __('Company type') }} <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>
|
||||
<option value="">{{ __('Select a type') }}</option>
|
||||
<option value="owner">{{ __('Owner / Developer') }}</option>
|
||||
<option value="constructor">{{ __('Constructor') }}</option>
|
||||
<option value="subcontractor">{{ __('Subcontractor') }}</option>
|
||||
<option value="supplier">{{ __('Supplier') }}</option>
|
||||
<option value="consultant">{{ __('Consultant / Engineering') }}</option>
|
||||
</select>
|
||||
@error('type') <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">
|
||||
Estado <span class="text-error">*</span>
|
||||
{{ __('Status') }} <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 wire:model="status" class="select select-bordered w-full">
|
||||
<option value="">{{ __('Select a status') }}</option>
|
||||
<option value="active">{{ __('Active') }}</option>
|
||||
<option value="inactive">{{ __('Inactive') }}</option>
|
||||
<option value="suspended">{{ __('Suspended') }}</option>
|
||||
</select>
|
||||
@error('status') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,24 +114,24 @@
|
||||
{{-- ── 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
|
||||
{{ __('Contact') }}
|
||||
</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
|
||||
{{ __('Address') }}
|
||||
</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>
|
||||
class="textarea textarea-bordered w-full"
|
||||
placeholder="{{ __('Street, number, city, ZIP, country') }}"></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
|
||||
{{ __('Phone') }}
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
@@ -141,7 +144,7 @@
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Email
|
||||
{{ __('Email') }}
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
@@ -155,7 +158,7 @@
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||
Sitio web
|
||||
{{ __('Website') }}
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
@@ -173,12 +176,12 @@
|
||||
{{-- ── 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
|
||||
{{ __('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>
|
||||
{{ $company?->logo_path ? __('Replace logo') : __('Upload logo') }}
|
||||
<p class="text-xs text-gray-400 font-normal mt-0.5">{{ __('PNG / JPG, max. 2 MB') }}</p>
|
||||
</label>
|
||||
<div class="flex-1 flex items-start gap-4">
|
||||
{{-- Preview --}}
|
||||
@@ -187,7 +190,7 @@
|
||||
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"
|
||||
alt="{{ __('Current logo') }}"
|
||||
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">
|
||||
@@ -197,7 +200,7 @@
|
||||
<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>
|
||||
<div wire:loading wire:target="logo" class="text-xs text-info mt-1">{{ __('Uploading…') }}</div>
|
||||
@error('logo') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,17 +210,17 @@
|
||||
{{-- ── 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
|
||||
{{ __('Internal notes') }}
|
||||
</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>
|
||||
{{ __('Observations') }}
|
||||
<p class="text-xs text-gray-400 font-normal mt-0.5">{{ __('Internal information') }}</p>
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<textarea wire:model="notes" rows="3"
|
||||
class="textarea textarea-bordered w-full"
|
||||
placeholder="Condiciones especiales, observaciones…"></textarea>
|
||||
placeholder="{{ __('Special conditions, observations…') }}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,13 +229,13 @@
|
||||
<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
|
||||
{{ __('Cancel') }}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary gap-2"
|
||||
wire:loading.attr="disabled" wire:target="save">
|
||||
<span wire:loading wire:target="save" class="loading loading-spinner loading-sm"></span>
|
||||
<x-heroicon-o-check class="w-4 h-4" />
|
||||
{{ $company ? 'Guardar cambios' : 'Crear empresa' }}
|
||||
{{ $company ? __('Save changes') : __('Create company') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -243,4 +246,4 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,12 +28,12 @@
|
||||
{{-- 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'],
|
||||
'owner' => ['badge-success', __('Owner')],
|
||||
'constructor' => ['badge-primary', __('Constructor')],
|
||||
'subcontractor' => ['badge-secondary',__('Subcontractor')],
|
||||
'consultant' => ['badge-info', __('Consultant')],
|
||||
'supplier' => ['badge-warning', __('Supplier')],
|
||||
default => ['badge-ghost', __('Other')],
|
||||
};
|
||||
@endphp
|
||||
<span class="badge {{ $typeBadge[0] }}">{{ $typeBadge[1] }}</span>
|
||||
@@ -80,9 +80,9 @@
|
||||
{{-- Estado --}}
|
||||
@php
|
||||
$estadoBadge = match($company->estado ?? 'activo') {
|
||||
'activo' => ['badge-success', 'Activo'],
|
||||
'inactivo' => ['badge-ghost', 'Inactivo'],
|
||||
'suspendido' => ['badge-error', 'Suspendido'],
|
||||
'activo' => ['badge-success', __('Active')],
|
||||
'inactivo' => ['badge-ghost', __('Inactive')],
|
||||
'suspendido' => ['badge-error', __('Suspended')],
|
||||
default => ['badge-ghost', ucfirst($company->estado ?? '')],
|
||||
};
|
||||
@endphp
|
||||
@@ -93,12 +93,12 @@
|
||||
<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
|
||||
{{ __('Edit') }}
|
||||
</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
|
||||
{{ __('Back') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,170 +111,170 @@
|
||||
<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>
|
||||
<!-- 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" />
|
||||
{{ __('Summary') }}
|
||||
</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" />
|
||||
{{ __('People') }}
|
||||
<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" />
|
||||
{{ __('Projects') }}
|
||||
<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" />
|
||||
{{ __('Notes') }}
|
||||
@if($company->notes)
|
||||
<span class="badge badge-sm badge-primary">•</span>
|
||||
@endif
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- ════════════════════════════════════════════════════════════════════
|
||||
TAB: RESUMEN
|
||||
TAB: SUMMARY
|
||||
════════════════════════════════════════════════════════════════════ --}}
|
||||
@if($activeTab === 'summary')
|
||||
<div class="space-y-4">
|
||||
@if($activeTab === 'summary')
|
||||
<div class="space-y-4">
|
||||
|
||||
{{-- KPIs --}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-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 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>
|
||||
@endforeach
|
||||
<p class="text-3xl font-bold">{{ $usersCount }}</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">{{ __('People') }}</p>
|
||||
</div>
|
||||
</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>
|
||||
<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">{{ __('Projects') }}</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">{{ __('Average progress') }}</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">{{ __('Open issues') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- Projects with progress --}}
|
||||
@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" />
|
||||
{{ __('Projects status') }}
|
||||
</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', __('In progress')],
|
||||
'completed' => ['badge-success', __('Completed')],
|
||||
'paused' => ['badge-warning', __('Paused')],
|
||||
'planning' => ['badge-ghost', __('Planning')],
|
||||
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>
|
||||
@endif
|
||||
@endforeach
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@endif
|
||||
{{-- Company card --}}
|
||||
<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" />
|
||||
{{ __('Company details') }}
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2 text-sm">
|
||||
@foreach([
|
||||
[__('Tax ID'), $company->tax_id],
|
||||
[__('Type'), $typeBadge[1]],
|
||||
[__('Status'), $estadoBadge[1]],
|
||||
[__('Phone'), $company->phone],
|
||||
[__('Email'), $company->email],
|
||||
[__('Address'), $company->address],
|
||||
[__('Website'), $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 === __('Website'))
|
||||
<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
|
||||
|
||||
@@ -49,6 +49,14 @@ new class extends Component
|
||||
</x-nav-link>
|
||||
</div>
|
||||
|
||||
@can('view tasks')
|
||||
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
|
||||
<x-nav-link :href="route('projects.list')" :active="request()->routeIs('projects.tasks*')" wire:navigate>
|
||||
{{ __('Tasks') }}
|
||||
</x-nav-link>
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
@can('view users')
|
||||
<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>
|
||||
|
||||
@@ -90,6 +90,16 @@
|
||||
<x-heroicon-o-pencil class="w-4 h-4" />
|
||||
</button>
|
||||
@endcan
|
||||
@can('delete inspections')
|
||||
<button
|
||||
wire:click="$dispatch('delete-inspection', { id: {{ $inspection->id } }})"
|
||||
class="btn btn-xs btn-ghost btn-circle btn-error"
|
||||
title="{{ __('Delete') }}"
|
||||
onclick="event.stopPropagation(); return confirm('{{ __('Delete this inspection? This cannot be undone.') }}');"
|
||||
>
|
||||
<x-heroicon-o-trash class="w-4 h-4" />
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -418,6 +418,16 @@
|
||||
<x-heroicon-o-pencil class="w-4 h-4 mr-1" /> {{ __('Edit') }}
|
||||
</button>
|
||||
@endcan
|
||||
@can('delete inspections')
|
||||
<button
|
||||
wire:click="$dispatch('delete-inspection', { id: {{ $viewingInspection['id'] }} })"
|
||||
wire:loading.attr="disabled"
|
||||
class="btn btn-error btn-sm"
|
||||
onclick="return confirm('{{ __('Delete this inspection? This cannot be undone.') }}');"
|
||||
>
|
||||
<x-heroicon-o-trash class="w-4 h-4 mr-1" /> {{ __('Delete') }}
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop bg-black/40" wire:click="closeViewInspection"></div>
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ $project ? 'Calendario: ' . $project->name : 'Calendario - Todas las tareas' }}
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Vista de calendario de tareas
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@can('create tasks', $project ?? \App\Models\Project::first())
|
||||
<a href="{{ $project ? route('projects.tasks.create', $project) : route('tasks.create') }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Nueva Tarea
|
||||
</a>
|
||||
@endcan
|
||||
<a href="{{ $project ? route('projects.tasks', $project) : route('tasks.index') }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
Vista Tabla
|
||||
</a>
|
||||
<a href="{{ $project ? route('projects.tasks.kanban', $project) : route('tasks.kanban') }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"/>
|
||||
</svg>
|
||||
Kanban
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<div class="flex-1 min-w-[250px]">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Buscar</label>
|
||||
<input type="text" wire:model.debounce.300ms="search" placeholder="Título, descripción..."
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div class="min-w-[160px]">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Vista</label>
|
||||
<select wire:model="viewMode" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="dayGridMonth">Mes</option>
|
||||
<option value="timeGridWeek">Semana</option>
|
||||
<option value="timeGridDay">Día</option>
|
||||
<option value="listWeek">Lista</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="min-w-[180px]">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Asignado</label>
|
||||
<select wire:model="assigneeFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Todos</option>
|
||||
@foreach($assignees as $assignee)
|
||||
<option value="{{ $assignee->id }}">{{ $assignee->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if($project)
|
||||
<div class="min-w-[180px]">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Fase</label>
|
||||
<select wire:model="phaseFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Todas</option>
|
||||
@foreach($phases as $phase)
|
||||
<option value="{{ $phase->id }}">{{ $phase->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="min-w-[160px]">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Prioridad</label>
|
||||
<select wire:model="priorityFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Todas</option>
|
||||
@foreach($priorityOptions as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="min-w-[160px]">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estado</label>
|
||||
<select wire:model="statusFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Todos</option>
|
||||
@foreach($statusOptions as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Container -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div id="calendar" class="min-h-[600px]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h4 class="font-medium text-gray-900 dark:text-white mb-3">Leyenda</h4>
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
@foreach(['low' => '#6b7280', 'medium' => '#f59e0b', 'high' => '#ef4444', 'critical' => '#7c3aed'] as $key => $color)
|
||||
<span class="flex items-center gap-2" style="color: {{ $color }}">
|
||||
<span class="w-3 h-3 rounded" style="background-color: {{ $color }}"></span>
|
||||
{{ \App\Models\Task::priorityOptions()[$key] ?? ucfirst($key) }}
|
||||
</span>
|
||||
@endforeach
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded border-2 border-dashed border-gray-400"></span>
|
||||
Tareas vencidas
|
||||
</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded border-2 border-amber-500 bg-amber-500/20"></span>
|
||||
Vence hoy
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// FullCalendar initialization
|
||||
document.addEventListener('livewire:load', () => {
|
||||
// Load FullCalendar dynamically if not already loaded
|
||||
if (typeof FullCalendar === 'undefined') {
|
||||
// Load CSS
|
||||
const css = document.createElement('link');
|
||||
css.rel = 'stylesheet';
|
||||
css.href = 'https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.11/index.global.min.css';
|
||||
document.head.appendChild(css);
|
||||
|
||||
// Load JS
|
||||
const js = document.createElement('script');
|
||||
js.src = 'https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.11/index.global.min.js';
|
||||
js.onload = () => {
|
||||
initCalendar();
|
||||
};
|
||||
document.head.appendChild(js);
|
||||
} else {
|
||||
initCalendar();
|
||||
}
|
||||
});
|
||||
|
||||
let calendarInstance = null;
|
||||
|
||||
function initCalendar() {
|
||||
const events = @json($events);
|
||||
const initialView = '{{ $viewMode }}';
|
||||
|
||||
const calendarEl = document.getElementById('calendar');
|
||||
if (!calendarEl) return;
|
||||
|
||||
// Destroy existing instance
|
||||
if (calendarInstance) {
|
||||
calendarInstance.destroy();
|
||||
}
|
||||
|
||||
calendarInstance = new FullCalendar.Calendar(calendarEl, {
|
||||
initialView: initialView,
|
||||
locale: '{{ app()->getLocale() }}',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
|
||||
},
|
||||
events: events,
|
||||
eventTimeFormat: {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
meridiem: false
|
||||
},
|
||||
eventDidMount: function(info) {
|
||||
// Add tooltip
|
||||
const props = info.event.extendedProps;
|
||||
let tooltip = `<strong>${info.event.title}</strong>`;
|
||||
if (props.project) tooltip += `<br>Proyecto: ${props.project}`;
|
||||
if (props.phase) tooltip += `<br>Fase: ${props.phase}`;
|
||||
if (props.assignee) tooltip += `<br>Asignado: ${props.assignee}`;
|
||||
if (props.dueDate) tooltip += `<br>Vence: ${props.dueDate}`;
|
||||
if (props.statusLabel) tooltip += `<br>Estado: ${props.statusLabel}`;
|
||||
if (props.priorityLabel) tooltip += `<br>Prioridad: ${props.priorityLabel}`;
|
||||
if (props.subtasksCount > 0) tooltip += `<br>Subtareas: ${props.completedSubtasksCount}/${props.subtasksCount}`;
|
||||
|
||||
tippy(info.el, {
|
||||
content: tooltip,
|
||||
allowHTML: true,
|
||||
theme: 'light',
|
||||
placement: 'top'
|
||||
});
|
||||
},
|
||||
eventClick: function(info) {
|
||||
// Navigate to task detail
|
||||
if (info.event.url) {
|
||||
window.location.href = info.event.url;
|
||||
}
|
||||
},
|
||||
datesSet: function(info) {
|
||||
// Optional: notify Livewire of view change
|
||||
}
|
||||
});
|
||||
|
||||
calendarInstance.render();
|
||||
}
|
||||
|
||||
// Listen for Livewire updates
|
||||
document.addEventListener('livewire:render', () => {
|
||||
// Re-initialize calendar when data changes
|
||||
setTimeout(() => {
|
||||
if (calendarInstance) {
|
||||
calendarInstance.destroy();
|
||||
}
|
||||
initCalendar();
|
||||
}, 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Load Tippy.js for tooltips -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/tippy.js@6.3.7/dist/tippy-bundle.umd.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tippy.js@6.3.7/dist/tippy.css">
|
||||
@endpush
|
||||
@@ -0,0 +1,319 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ route('projects.tasks', $project) }}" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ $task->title }}</h1>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 mt-2">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
|
||||
style="background-color: {{ $task->status_color }}20; color: {{ $task->status_color }}">
|
||||
{{ $task->status_label }}
|
||||
</span>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
|
||||
style="background-color: {{ $task->priority_color }}20; color: {{ $task->priority_color }}">
|
||||
{{ $task->priority_label }}
|
||||
</span>
|
||||
@if($task->phase)
|
||||
<span class="px-3 py-1 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 rounded-full">
|
||||
{{ $task->phase->name }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@can('edit tasks', $task)
|
||||
<a href="{{ route('projects.tasks.edit', [$project, $task]) }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
Editar
|
||||
</a>
|
||||
@endcan
|
||||
<div class="flex items-center gap-2" x-data="{ open: false }">
|
||||
<button @click="open = !open" class="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
Acciones
|
||||
</button>
|
||||
<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" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95" @click.outside="open = false" class="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50">
|
||||
@foreach($statusOptions as $value => $label)
|
||||
<button wire:click="changeStatus('{{ $value }}')"
|
||||
@click="open = false"
|
||||
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 {{ $task->status === $value ? 'font-medium text-blue-600 dark:text-blue-400' : '' }}">
|
||||
@if($task->status === $value)
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><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>
|
||||
@endif
|
||||
{{ $label }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main Content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Description -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Descripción</h3>
|
||||
@if($task->description)
|
||||
<div class="prose dark:prose-invert max-w-none">{{ $task->description }}</div>
|
||||
@else
|
||||
<p class="text-gray-500 dark:text-gray-400 italic">Sin descripción</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Subtasks -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Subtareas ({{ $task->subtasks->count() }})
|
||||
</h3>
|
||||
@can('create tasks', $task)
|
||||
<div class="flex gap-2">
|
||||
<input type="text" x-model="newSubtaskTitle" @keydown.enter="addSubtask" placeholder="Añadir subtarea..." class="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white text-sm w-64">
|
||||
<button @click="addSubtask" class="px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm transition-colors">Añadir</button>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
@if($task->subtasks->isEmpty())
|
||||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No hay subtareas. {{ auth()->user()->can('create tasks', $task) ? 'Crea la primera arriba.' : '' }}
|
||||
</div>
|
||||
@else
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach($task->subtasks as $subtask)
|
||||
<div class="p-4 flex items-center gap-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<div class="flex items-center gap-3">
|
||||
<select wire:model="subtaskStatuses.{{ $subtask->id }}" wire:change="updateSubtaskStatus({{ $subtask->id }}, $event.target.value)" class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-2 py-1 dark:bg-gray-700 dark:text-white" style="width: 140px;">
|
||||
@foreach($statusOptions as $value => $label)
|
||||
<option value="{{ $value }}" {{ $subtask->status === $value ? 'selected' : '' }}>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ $subtask->title }}</span>
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
@can('delete tasks', $subtask)
|
||||
<button wire:click="deleteSubtask({{ $subtask->id }})" wire:confirm="¿Eliminar subtarea \"{{ $subtask->title }}\"?" class="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 p-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
|
||||
@endcan
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Comentarios</h3>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<textarea wire:model="commentBody" rows="2" placeholder="Escribe un comentario..." class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"></textarea>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button wire:click="addComment" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" :disabled="!commentBody.trim()">
|
||||
Comentar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@forelse($task->comments->where('parent_id', null) as $comment)
|
||||
<div class="p-4" x-data="{ editing: false }">
|
||||
<div class="flex gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-sm font-medium text-blue-700 dark:text-blue-300 flex-shrink-0">
|
||||
{{ strtoupper($comment->user->name[0]) }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ $comment->user->name }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ $comment->created_at->diffForHumans() }}</span>
|
||||
@if($comment->user_id === auth()->id() || auth()->user()->can('manage all tasks'))
|
||||
<button @click="editing = !editing" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-sm">Editar</button>
|
||||
<button wire:click="deleteComment({{ $comment->id }})" wire:confirm="¿Eliminar comentario?" class="text-gray-400 hover:text-red-600 dark:hover:text-red-400 text-sm">Eliminar</button>
|
||||
@endif
|
||||
</div>
|
||||
@if($editing)
|
||||
<textarea wire:model="editingCommentBody" x-model="editingCommentBody" rows="2" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white mb-2"></textarea>
|
||||
<div class="flex gap-2">
|
||||
<button wire:click="saveEditComment" class="px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm transition-colors">Guardar</button>
|
||||
<button wire:click="cancelEditComment" @click="editing = false" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 text-sm transition-colors">Cancelar</button>
|
||||
</div>
|
||||
@else
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">{{ $comment->body }}</div>
|
||||
@endif
|
||||
|
||||
<!-- Replies -->
|
||||
@if($comment->replies->isNotEmpty())
|
||||
<div class="ml-8 mt-3 border-l-2 border-gray-200 dark:border-gray-700 pl-4 space-y-3">
|
||||
@foreach($comment->replies as $reply)
|
||||
<div class="flex gap-3">
|
||||
<div class="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-xs font-medium text-gray-700 dark:text-gray-300 flex-shrink-0">
|
||||
{{ strtoupper($reply->user->name[0]) }}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium text-sm text-gray-900 dark:text-white">{{ $reply->user->name }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ $reply->created_at->diffForHumans() }}</span>
|
||||
</div>
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">{{ $reply->body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">No hay comentarios aún</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="lg:col-span-1 space-y-6">
|
||||
<!-- Info Card -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Información</h3>
|
||||
<dl class="space-y-4 text-sm">
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Proyecto</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ $task->project->name }}</dd>
|
||||
</div>
|
||||
@if($task->phase)
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Fase</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ $task->phase->name }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
@if($task->parent_task_id)
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Tarea padre</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">
|
||||
<a href="{{ route('projects.tasks.show', [$task->project, $task->parent]) }}" class="hover:text-blue-600 dark:hover:text-blue-400">
|
||||
{{ $task->parent->title }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Creada por</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ $task->creator->name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Fecha creación</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ $task->created_at->format('d/m/Y H:i') }}</dd>
|
||||
</div>
|
||||
@if($task->due_date)
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Vence</dt>
|
||||
<dd class="font-medium {{ $task->is_overdue ? 'text-red-600 dark:text-red-400' : ($task->is_due_today ? 'text-amber-600 dark:text-amber-400' : 'text-gray-900 dark:text-white') }}">
|
||||
{{ $task->due_date->format('d/m/Y') }}
|
||||
@if($task->is_overdue) <span class="text-xs ml-1">(Vencida)</span> @endif
|
||||
@if($task->is_due_today) <span class="text-xs ml-1">(Hoy)</span> @endif
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
@if($task->start_date)
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Inicio</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ $task->start_date->format('d/m/Y') }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
@if($task->completed_at)
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Completada</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ $task->completed_at->format('d/m/Y H:i') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Completada por</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ $task->completer->name ?? '—' }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
@if($task->estimated_hours || $task->actual_hours)
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Horas</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">
|
||||
{{ $task->estimated_hours ? $task->estimated_hours . 'h estimadas' : '' }}
|
||||
@if($task->estimated_hours && $task->actual_hours) / @endif
|
||||
{{ $task->actual_hours ? $task->actual_hours . 'h reales' : '' }}
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Assignee Card -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Asignación</h3>
|
||||
@if($task->assignee)
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-lg font-medium text-blue-700 dark:text-blue-300">
|
||||
{{ strtoupper($task->assignee->name[0]) }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">{{ $task->assignee->name }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $task->assignee->email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">Sin asignar</p>
|
||||
@endif
|
||||
|
||||
@can('assign tasks', $task)
|
||||
<form wire:submit.prevent="updateAssignee">
|
||||
<select wire:model="newAssigneeId" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white mb-2">
|
||||
<option value="">Sin asignar</option>
|
||||
@foreach($assignees as $assignee)
|
||||
<option value="{{ $assignee->id }}" {{ $task->assigned_to === $assignee->id ? 'selected' : '' }}>{{ $assignee->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="submit" class="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">Asignar</button>
|
||||
</form>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
<!-- Progress Card -->
|
||||
@if($task->subtasks->count() > 0)
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Progreso</h3>
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">Completado</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ $task->progress }}%</span>
|
||||
</div>
|
||||
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-blue-500 rounded-full transition-all duration-300" style="width: {{ $task->progress }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $task->subtasks->where('status', 'completed')->count() }} de {{ $task->subtasks->count() }} subtareas completadas
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('livewire:load', () => {
|
||||
// Initialize subtask statuses
|
||||
@js($task->subtasks->pluck('status', 'id')->toArray())
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,177 @@
|
||||
<div x-show="showModal" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95" class="fixed inset-0 z-50 overflow-y-auto" @keydown.escape.window="closeModal">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" @click="closeModal" aria-hidden="true"></div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="relative w-full max-w-2xl bg-white dark:bg-gray-800 rounded-xl shadow-xl">
|
||||
<form wire:submit.prevent="save" class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ $mode === 'create' ? 'Nueva Tarea' : 'Editar Tarea' }}
|
||||
</h3>
|
||||
<button type="button" wire:click="closeModal" class="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if($errors->any())
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<ul class="ml-3 text-sm text-red-700 dark:text-red-300">
|
||||
@foreach($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Título <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="title" wire:model="title"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Título de la tarea" required autofocus>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Descripción</label>
|
||||
<textarea id="description" wire:model="description" rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Detalles de la tarea..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Row: Project/Phase + Parent Task -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="phase_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Fase</label>
|
||||
<select id="phase_id" wire:model="phase_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Sin fase</option>
|
||||
@foreach($phases as $id => $name)
|
||||
<option value="{{ $id }}">{{ $name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="parent_task_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tarea Padre (subtarea de)</label>
|
||||
<select id="parent_task_id" wire:model="parent_task_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Ninguna (tarea principal)</option>
|
||||
@foreach($parentTasks as $pt)
|
||||
<option value="{{ $pt->id }}">{{ $pt->title }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row: Status + Priority -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estado <span class="text-red-500">*</span></label>
|
||||
<select id="status" wire:model="status"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" required>
|
||||
@foreach($statusOptions as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="priority" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Prioridad <span class="text-red-500">*</span></label>
|
||||
<select id="priority" wire:model="priority"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" required>
|
||||
@foreach($priorityOptions as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row: Assignee + Order -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="assigned_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Asignado a</label>
|
||||
<select id="assigned_to" wire:model="assigned_to"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Sin asignar</option>
|
||||
@foreach($assignees as $assignee)
|
||||
<option value="{{ $assignee->id }}">{{ $assignee->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="order" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Orden</label>
|
||||
<input type="number" id="order" wire:model="order" min="0"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row: Dates -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Fecha inicio</label>
|
||||
<input type="date" id="start_date" wire:model="start_date"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="due_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Fecha vencimiento</label>
|
||||
<input type="date" id="due_date" wire:model="due_date"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row: Hours -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="estimated_hours" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Horas estimadas</label>
|
||||
<input type="number" id="estimated_hours" wire:model="estimated_hours" min="0" max="10000"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
|
||||
placeholder="0">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="actual_hours" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Horas reales</label>
|
||||
<input type="number" id="actual_hours" wire:model="actual_hours" min="0" max="10000"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
|
||||
placeholder="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="button" wire:click="closeModal"
|
||||
class="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
{{ $mode === 'create' ? 'Crear' : 'Guardar' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// Close modal on Escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
@this.call('closeModal');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,243 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ $project ? 'Kanban: ' . $project->name : 'Kanban - Todas las tareas' }}
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Arrastra y suelta para cambiar estado
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@can('create tasks', $project ?? \App\Models\Project::first())
|
||||
<a href="{{ $project ? route('projects.tasks.create', $project) : route('tasks.create') }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Nueva Tarea
|
||||
</a>
|
||||
@endcan
|
||||
<a href="{{ $project ? route('projects.tasks', $project) : route('tasks.index') }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
Vista Tabla
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<div class="flex-1 min-w-[250px]">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Buscar</label>
|
||||
<input type="text" wire:model.debounce.300ms="search" placeholder="Título, descripción..."
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div class="min-w-[180px]">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Asignado</label>
|
||||
<select wire:model="assigneeFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Todos</option>
|
||||
@foreach($assignees as $assignee)
|
||||
<option value="{{ $assignee->id }}">{{ $assignee->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if($project)
|
||||
<div class="min-w-[180px]">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Fase</label>
|
||||
<select wire:model="phaseFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Todas</option>
|
||||
@foreach($phases as $phase)
|
||||
<option value="{{ $phase->id }}">{{ $phase->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="min-w-[160px]">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Prioridad</label>
|
||||
<select wire:model="priorityFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Todas</option>
|
||||
@foreach($priorityOptions as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" wire:model="showOverdueOnly" class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Solo vencidas</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button wire:click="resetFilters" class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm font-medium">
|
||||
Limpiar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban Board -->
|
||||
<div class="flex gap-4 overflow-x-auto pb-4" id="kanban-board" x-data="kanbanBoard()">
|
||||
@foreach($columns as $status => $column)
|
||||
<div class="flex-shrink-0 w-80 min-h-[500px]"
|
||||
style="background-color: {{ $column['color'] }}15; border: 1px solid {{ $column['color'] }}40;"
|
||||
x-ref="column-{{ $status }}">
|
||||
<!-- Column Header -->
|
||||
<div class="p-3 sticky top-0 z-10 flex items-center justify-between"
|
||||
style="background-color: {{ $column['color'] }}; color: white; border-radius: 0.5rem 0.5rem 0 0;">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold text-sm">{{ $column['label'] }}</h3>
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-white/20 rounded-full">
|
||||
{{ $column['count'] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column Content / Drop Zone -->
|
||||
<div class="p-3 space-y-3 min-h-[400px] kanban-column"
|
||||
x-data="{ columnStatus: '{{ $status }}' }"
|
||||
@dragover.prevent="onDragOver($event)"
|
||||
@drop="onDrop($event)"
|
||||
data-status="{{ $status }}"
|
||||
style="min-height: 400px;">
|
||||
@if($column['tasks']->isEmpty())
|
||||
<div class="text-center py-8 text-gray-400 dark:text-gray-500 text-sm border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg"
|
||||
style="border-color: {{ $column['color'] }}80;">
|
||||
<svg class="mx-auto h-8 w-8 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
<p>Sin tareas</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@foreach($column['tasks'] as $task)
|
||||
<div class="kanban-card bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-3 hover:shadow-md transition-shadow cursor-pointer"
|
||||
draggable="true"
|
||||
wire:click="$dispatch('open-task-detail', { taskId: {{ $task->id }}, projectId: {{ $task->project_id }} })"
|
||||
@dragstart="onDragStart($event, {{ $task->id }}, '{{ $status }}')"
|
||||
@dragend="onDragEnd($event)"
|
||||
data-task-id="{{ $task->id }}"
|
||||
data-status="{{ $status }}">
|
||||
<!-- Priority indicator -->
|
||||
<div class="w-full h-1 rounded-t-lg mb-2"
|
||||
style="background-color: {{ $task->priority_color }};"></div>
|
||||
|
||||
<h4 class="font-medium text-gray-900 dark:text-white text-sm mb-1 line-clamp-2">
|
||||
{{ $task->title }}
|
||||
</h4>
|
||||
|
||||
@if($task->description)
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2 line-clamp-2">{{ $task->description }}</p>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
@if($task->phase)
|
||||
<span class="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-700 rounded text-gray-700 dark:text-gray-300">
|
||||
{{ $task->phase->name }}
|
||||
</span>
|
||||
@endif
|
||||
@if($task->due_date)
|
||||
<span class="px-2 py-0.5 text-xs rounded
|
||||
{{ $task->is_overdue ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300' : ($task->is_due_today ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300') }}">
|
||||
{{ $task->due_date->format('d/m') }}
|
||||
@if($task->is_overdue)
|
||||
<svg class="inline w-3 h-3 ml-0.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-6a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 4zm0 10a.75.75 0 01.75-.75.75.75 0 01.75.75v.75a.75.75 0 01-1.5 0v-.75z" clip-rule="evenodd"/></svg>
|
||||
@elseif($task->is_due_today)
|
||||
<svg class="inline w-3 h-3 ml-0.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
|
||||
@endif
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||
@if($task->assignee)
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-xs font-medium text-blue-700 dark:text-blue-300">
|
||||
{{ strtoupper($task->assignee->name[0]) }}
|
||||
</div>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">{{ $task->assignee->name }}</span>
|
||||
</div>
|
||||
@else
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">Sin asignar</span>
|
||||
@endif
|
||||
|
||||
@if($task->subtasks->count() > 0)
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $task->subtasks->where('status', 'completed')->count() }}/{{ $task->subtasks->count() }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<!-- Add Task Button -->
|
||||
@can('create tasks', $project ?? \App\Models\Project::first())
|
||||
<div class="p-3">
|
||||
<button wire:click="$dispatch('open-task-modal', { mode: 'create', projectId: {{ $project->id ?? 'null'}}, status: '{{ $status }}' })"
|
||||
class="w-full py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg transition-colors">
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Añadir tarea
|
||||
</button>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// Simple Alpine-based Kanban drag & drop
|
||||
function kanbanBoard() {
|
||||
return {
|
||||
draggedTask: null,
|
||||
draggedFromStatus: null,
|
||||
|
||||
onDragStart(event, taskId, status) {
|
||||
this.draggedTask = taskId;
|
||||
this.draggedFromStatus = status;
|
||||
event.target.classList.add('opacity-50', 'rotate-1');
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', taskId);
|
||||
},
|
||||
|
||||
onDragEnd(event) {
|
||||
event.target.classList.remove('opacity-50', 'rotate-1');
|
||||
this.draggedTask = null;
|
||||
this.draggedFromStatus = null;
|
||||
},
|
||||
|
||||
onDragOver(event) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
event.currentTarget.classList.add('bg-blue-50', 'dark:bg-blue-900/20');
|
||||
},
|
||||
|
||||
onDrop(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.remove('bg-blue-50', 'dark:bg-blue-900/20');
|
||||
|
||||
const taskId = event.dataTransfer.getData('text/plain');
|
||||
const targetStatus = event.currentTarget.dataset.status;
|
||||
|
||||
if (taskId && this.draggedFromStatus !== targetStatus) {
|
||||
// Call Livewire to update status
|
||||
@this.call('updateTaskStatus', taskId, targetStatus);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,258 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ $project ? 'Tareas del proyecto: ' . $project->name : 'Tareas' }}
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ $project ? 'Gestiona las tareas de este proyecto' : 'Todas las tareas accesibles' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@can('create tasks', $project ?? \App\Models\Project::first())
|
||||
<a href="{{ $project ? route('projects.tasks.create', $project) : route('tasks.create') }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Nueva Tarea
|
||||
</a>
|
||||
@endcan
|
||||
<button wire:click="toggleViewMode"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
:class="{'hidden': viewMode === 'table', 'inline': viewMode === 'kanban'}">
|
||||
<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-2V6zM14 6a2 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-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
:class="{'inline': viewMode === 'table', 'hidden': viewMode === 'kanban'}">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
{{ $viewMode === 'table' ? 'Vista Kanban' : 'Vista Tabla' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Buscar</label>
|
||||
<input type="text" wire:model.debounce.300ms="search" placeholder="Título, descripción..."
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div class="min-w-[160px]">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estado</label>
|
||||
<select wire:model="statusFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Todos</option>
|
||||
@foreach($statusOptions as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="min-w-[160px]">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Prioridad</label>
|
||||
<select wire:model="priorityFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Todas</option>
|
||||
@foreach($priorityOptions as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="min-w-[180px]">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Asignado</label>
|
||||
<select wire:model="assigneeFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Todos</option>
|
||||
@foreach($assignees as $assignee)
|
||||
<option value="{{ $assignee->id }}">{{ $assignee->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if($project)
|
||||
<div class="min-w-[180px]">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Fase</label>
|
||||
<select wire:model="phaseFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Todas</option>
|
||||
@foreach($phases as $phase)
|
||||
<option value="{{ $phase->id }}">{{ $phase->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" wire:model="showOverdueOnly" class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Solo vencidas</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button wire:click="resetFilters" class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm font-medium">
|
||||
Limpiar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table View -->
|
||||
<div x-show="viewMode === 'table'" x-cloak class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
@if($tasks->isEmpty())
|
||||
<div class="p-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No hay tareas</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $project ? 'Comienza creando la primera tarea para este proyecto.' : 'No se encontraron tareas con los filtros actuales.' }}
|
||||
</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
@foreach([
|
||||
'title' => 'Tarea',
|
||||
'project.name' => 'Proyecto',
|
||||
'phase.name' => 'Fase',
|
||||
'status' => 'Estado',
|
||||
'priority' => 'Prioridad',
|
||||
'assignee.name' => 'Asignado',
|
||||
'due_date' => 'Vence',
|
||||
'actions' => '',
|
||||
] as $key => $label)
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700/50"
|
||||
@if(in_array($key, ['title', 'status', 'priority', 'due_date']))
|
||||
wire:click="sortBy('{{ $key }}')"
|
||||
@endif>
|
||||
<div class="flex items-center gap-1">
|
||||
{{ $label }}
|
||||
@if($sortField === $key)
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ $sortDirection === 'asc' ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7' }}"/>
|
||||
</svg>
|
||||
@else
|
||||
<svg class="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l4-4m0 0l4 4m-4-4v12"/>
|
||||
</svg>
|
||||
@endif
|
||||
</div>
|
||||
</th>
|
||||
@endforeach
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach($tasks as $task)
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td class="px-4 py-4">
|
||||
<div>
|
||||
<a href="{{ route('projects.tasks.show', [$task->project, $task]) }}"
|
||||
class="font-medium text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400">
|
||||
{{ $task->title }}
|
||||
</a>
|
||||
@if($task->description)
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs mt-1">{{ $task->description }}</p>
|
||||
@endif
|
||||
@if($task->subtasks->count() > 0)
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $task->subtasks->where('status', 'completed')->count() }} / {{ $task->subtasks->count() }} subtareas
|
||||
</span>
|
||||
<div class="w-24 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-blue-500" style="width: {{ $task->progress }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ $task->project->name ?? '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ $task->phase->name ?? '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
style="background-color: {{ $task->status_color }}20; color: {{ $task->status_color }}">
|
||||
{{ $task->status_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
style="background-color: {{ $task->priority_color }}20; color: {{ $task->priority_color }}">
|
||||
{{ $task->priority_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
@if($task->assignee)
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-xs font-medium text-blue-700 dark:text-blue-300">
|
||||
{{ strtoupper($task->assignee->name[0]) }}
|
||||
</div>
|
||||
{{ $task->assignee->name }}
|
||||
</div>
|
||||
@else
|
||||
<span class="text-gray-400 dark:text-gray-500">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-4 text-sm">
|
||||
@if($task->due_date)
|
||||
<span class="{{ $task->is_overdue ? 'text-red-600 dark:text-red-400 font-medium' : ($task->is_due_today ? 'text-amber-600 dark:text-amber-400 font-medium' : 'text-gray-700 dark:text-gray-300') }}">
|
||||
{{ $task->due_date->format('d/m/Y') }}
|
||||
@if($task->is_overdue)
|
||||
<svg class="inline w-3.5 h-3.5 ml-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-6a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 4zm0 10a.75.75 0 01.75-.75.75.75 0 01.75.75v.75a.75.75 0 01-1.5 0v-.75z" clip-rule="evenodd"/></svg>
|
||||
@elseif($task->is_due_today)
|
||||
<svg class="inline w-3.5 h-3.5 ml-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
|
||||
@endif
|
||||
</span>
|
||||
@else
|
||||
<span class="text-gray-400 dark:text-gray-500">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
@can('edit tasks', $task)
|
||||
<a href="{{ route('projects.tasks.edit', [$task->project, $task]) }}"
|
||||
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium">
|
||||
Editar
|
||||
</a>
|
||||
@endcan
|
||||
@can('delete tasks', $task)
|
||||
<button wire:click="deleteTask({{ $task->id }})"
|
||||
wire:confirm="¿Eliminar la tarea \"{{ $task->title }}\"?"
|
||||
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 text-sm font-medium">
|
||||
Eliminar
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700">
|
||||
{{ $tasks->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Kanban View (placeholder) -->
|
||||
<div x-show="viewMode === 'kanban'" x-cloak class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">Vista Kanban</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">En desarrollo - Próximamente</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user