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
@@ -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>