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:
Javier Braña
2026-08-03 13:44:10 +02:00
parent a65d4b12f2
commit ba614bddbc
62 changed files with 5878 additions and 382 deletions
@@ -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