Some checks are pending
Deploy DummyBot / Build & Deploy to ${{ env.DEPLOY_HOST }} (push) Waiting to run
Frontend: - Layout base con design system (Inter+JetBrains Mono, glass, mesh, animaciones) - Landing con hero, mockup OLED, 6 features, 3 pasos - Auth unificado (login+register) con split-screen - App con sidebar fijo, filtros con contadores, stats, modal rediseñado - Settings con 4 tabs (devices/projects/tags/API) - Iconos SVG inline (sin emojis excepto el logo) - Skeletons, pulse-dot, toggle switch, toasts - Unificada vista auth.blade.php (elimina login/register separados) Deploy: - Workflow de Gitea Actions: build + push a /root/dummybot en 157.180.77.232 - Trigger: push a main (también workflow_dispatch) - SSH key dedicada ed25519 dummybot_deploy - /root/dummybot.env preserva .env real (no se commitea) - Healthcheck en /up tras migrar - Rollback state en .last_deploy Backend: - LoginController usa nueva vista auth con mode=login|register
567 lines
30 KiB
PHP
567 lines
30 KiB
PHP
@extends('layouts.base')
|
|
@section('title', 'Tareas · DummyBot')
|
|
@section('content')
|
|
<div x-data="dummybotApp()" x-init="init()" class="min-h-screen flex" :class="{ 'overflow-hidden': modalOpen }">
|
|
|
|
{{-- SIDEBAR --}}
|
|
<aside class="hidden lg:flex w-60 border-r border-white/5 flex-col p-3 sticky top-0 h-screen">
|
|
{{-- Logo --}}
|
|
<a href="{{ route('app') }}" class="flex items-center gap-2.5 px-3 py-2.5 mb-3">
|
|
<div class="w-7 h-7 rounded-lg bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center shadow-glow">
|
|
<svg class="w-3.5 h-3.5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<rect x="4" y="8" width="16" height="12" rx="2"></rect>
|
|
<circle cx="9" cy="14" r="1.5" fill="currentColor"></circle>
|
|
<circle cx="15" cy="14" r="1.5" fill="currentColor"></circle>
|
|
<path d="M12 4v4"></path>
|
|
</svg>
|
|
</div>
|
|
<span class="font-bold text-sm">DummyBot</span>
|
|
</a>
|
|
|
|
{{-- New task button --}}
|
|
<button @click="openNew()" class="btn btn-primary w-full justify-center mb-4">
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12h14"/></svg>
|
|
Nueva tarea
|
|
</button>
|
|
|
|
{{-- Nav --}}
|
|
<nav class="space-y-0.5 flex-1">
|
|
@php
|
|
$filters = [
|
|
['key' => 'all', 'label' => 'Todas', 'icon' => '<path d=\"M3 12h18M3 6h18M3 18h18\"/>'],
|
|
['key' => 'pending', 'label' => 'Pendientes', 'icon' => '<circle cx=\"12\" cy=\"12\" r=\"10\"/><polyline points=\"12 6 12 12 16 14\"/>'],
|
|
['key' => 'today', 'label' => 'Hoy', 'icon' => '<rect x=\"3\" y=\"4\" width=\"18\" height=\"18\" rx=\"2\"/><path d=\"M16 2v4M8 2v4M3 10h18\"/>'],
|
|
['key' => 'overdue', 'label' => 'Vencidas', 'icon' => '<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 6v6l4 2\"/>'],
|
|
['key' => 'done', 'label' => 'Hechas', 'icon' => '<path d=\"M22 11.08V12a10 10 0 1 1-5.93-9.14\"/><path d=\"M22 4L12 14.01l-3-3\"/>'],
|
|
];
|
|
@endphp
|
|
@foreach($filters as $f)
|
|
<button @click="filter = '{{ $f['key'] }}'"
|
|
:class="filter === '{{ $f['key'] }}' ? 'active' : ''"
|
|
class="sidebar-item w-full">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{!! $f['icon'] !!}</svg>
|
|
<span class="flex-1 text-left">{{ $f['label'] }}</span>
|
|
<span class="text-xs text-zinc-500" x-show="filterCounts.{{ $f['key'] }} !== undefined" x-text="filterCounts.{{ $f['key'] }} || ''"></span>
|
|
</button>
|
|
@endforeach
|
|
|
|
<div class="pt-4 pb-2 px-3 text-[10px] font-semibold text-zinc-500 uppercase tracking-wider">Proyectos</div>
|
|
|
|
<button @click="projectFilter = ''" :class="!projectFilter ? 'active' : ''" class="sidebar-item w-full">
|
|
<span class="w-2 h-2 rounded-full bg-zinc-600"></span>
|
|
<span class="flex-1 text-left">Todos</span>
|
|
</button>
|
|
<template x-for="p in projects" :key="p.id">
|
|
<button @click="projectFilter = p.id" :class="projectFilter == p.id ? 'active' : ''" class="sidebar-item w-full">
|
|
<span class="w-2 h-2 rounded-full" :style="'background:' + p.color"></span>
|
|
<span class="flex-1 text-left truncate" x-text="p.name"></span>
|
|
</button>
|
|
</template>
|
|
</nav>
|
|
|
|
{{-- Bottom: settings + user --}}
|
|
<div class="border-t border-white/5 pt-2 mt-2 space-y-0.5">
|
|
<a href="{{ route('settings') }}" class="sidebar-item">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="12" r="3"/>
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
</svg>
|
|
<span class="flex-1">Ajustes</span>
|
|
</a>
|
|
<form method="POST" action="{{ route('logout') }}" class="contents">
|
|
@csrf
|
|
<button class="sidebar-item w-full text-left">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
|
<polyline points="16 17 21 12 16 7"/>
|
|
<line x1="21" y1="12" x2="9" y2="12"/>
|
|
</svg>
|
|
<span class="flex-1 text-left">Cerrar sesión</span>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</aside>
|
|
|
|
{{-- MAIN --}}
|
|
<main class="flex-1 min-w-0 flex flex-col">
|
|
|
|
{{-- TOPBAR --}}
|
|
<header class="border-b border-white/5 sticky top-0 z-30 glass-strong">
|
|
<div class="px-4 sm:px-6 h-16 flex items-center gap-3">
|
|
<button @click="mobileMenu = !mobileMenu" class="lg:hidden btn btn-icon btn-ghost">
|
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
|
|
</button>
|
|
|
|
<div class="flex-1 max-w-xl relative">
|
|
<input x-model="search" type="search" placeholder="Buscar tareas... (pulsa /)"
|
|
class="input pl-10">
|
|
<svg class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
|
</div>
|
|
|
|
<kbd class="hidden sm:inline-flex kbd">/</kbd>
|
|
|
|
<button @click="openNew()" class="lg:hidden btn btn-primary btn-sm">
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12h14"/></svg>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{{-- STATS --}}
|
|
<div class="px-4 sm:px-6 py-6 grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
<template x-for="(s, i) in stats" :key="i">
|
|
<div class="task-card">
|
|
<div class="text-[10px] font-semibold text-zinc-500 uppercase tracking-wider" x-text="s.label"></div>
|
|
<div class="mt-1.5 flex items-baseline gap-1.5">
|
|
<div class="text-2xl font-extrabold tabular-nums" :class="s.color" x-text="s.value"></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
{{-- ACTIVE FILTER --}}
|
|
<div class="px-4 sm:px-6 pb-3 flex items-center gap-2 text-sm">
|
|
<span class="text-zinc-500">Mostrando</span>
|
|
<span class="font-semibold" x-text="filterLabel"></span>
|
|
<template x-if="projectFilter">
|
|
<span class="chip" :style="'background:' + (currentProject?.color || '#52525b') + '20; color:' + (currentProject?.color || '#a1a1aa')">
|
|
<span class="w-1.5 h-1.5 rounded-full" :style="'background:' + (currentProject?.color || '#a1a1aa')"></span>
|
|
<span x-text="currentProject?.name"></span>
|
|
</span>
|
|
</template>
|
|
<span class="text-zinc-500 ml-auto text-xs">
|
|
<span x-text="filteredTasks.length"></span> resultado<span x-show="filteredTasks.length !== 1">s</span>
|
|
</span>
|
|
</div>
|
|
|
|
{{-- LIST --}}
|
|
<div class="px-4 sm:px-6 pb-12 flex-1">
|
|
|
|
{{-- Loading --}}
|
|
<template x-if="loading">
|
|
<div class="space-y-2">
|
|
<template x-for="i in 4">
|
|
<div class="task-card">
|
|
<div class="flex items-center gap-3">
|
|
<div class="skeleton w-5 h-5 rounded"></div>
|
|
<div class="flex-1 space-y-2">
|
|
<div class="skeleton h-4 w-2/3"></div>
|
|
<div class="skeleton h-3 w-1/3"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
{{-- Empty --}}
|
|
<template x-if="!loading && filteredTasks.length === 0">
|
|
<div class="text-center py-20 anim-up">
|
|
<div class="inline-flex w-16 h-16 rounded-2xl bg-gradient-to-br from-indigo-500/20 to-purple-500/20 items-center justify-center mb-4">
|
|
<svg class="w-8 h-8 text-indigo-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 12l2 2 4-4M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"/></svg>
|
|
</div>
|
|
<h2 class="text-xl font-bold mb-1" x-text="emptyTitle"></h2>
|
|
<p class="text-zinc-500 text-sm mb-6" x-text="emptyDesc"></p>
|
|
<button @click="openNew()" class="btn btn-primary">Crear primera tarea</button>
|
|
</div>
|
|
</template>
|
|
|
|
{{-- Tasks --}}
|
|
<div class="space-y-2" x-show="!loading && filteredTasks.length > 0">
|
|
<template x-for="t in filteredTasks" :key="t.id">
|
|
<div class="task-card anim-up" :class="t.done ? 'task-done' : ''">
|
|
<div class="flex items-start gap-3">
|
|
<button @click="toggle(t)" class="check mt-0.5" :class="t.done ? 'checked' : ''">
|
|
<svg x-show="t.done" class="w-3 h-3 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
|
</button>
|
|
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<span class="task-title font-semibold" x-text="t.title"></span>
|
|
<span class="chip" :class="'prio-' + t.priority" x-text="priorityLabel(t.priority)"></span>
|
|
<template x-if="t.project">
|
|
<span class="chip" :style="'background:' + t.project.color + '18; color:' + t.project.color">
|
|
<span class="w-1.5 h-1.5 rounded-full" :style="'background:' + t.project.color"></span>
|
|
<span x-text="t.project.name"></span>
|
|
</span>
|
|
</template>
|
|
<template x-for="tag in (t.tags || [])" :key="tag.id">
|
|
<span class="chip text-[10px]" :style="'background:' + tag.color + '18; color:' + tag.color">
|
|
#<span x-text="tag.name"></span>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
|
|
<p x-show="t.description" class="text-sm text-zinc-400 mt-1.5 line-clamp-2" x-text="t.description"></p>
|
|
|
|
<div class="flex items-center gap-3 text-xs text-zinc-500 mt-2 flex-wrap">
|
|
<template x-if="t.estimate_min">
|
|
<span class="inline-flex items-center gap-1">
|
|
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
|
<span x-text="t.estimate_min"></span> min
|
|
</span>
|
|
</template>
|
|
<template x-if="t.deadline">
|
|
<span class="inline-flex items-center gap-1" :class="isOverdue(t) ? 'text-red-400' : ''">
|
|
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
|
|
<span x-text="formatDate(t.deadline)"></span>
|
|
</span>
|
|
</template>
|
|
<template x-if="t.recurrence">
|
|
<span class="inline-flex items-center gap-1">
|
|
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 1l4 4-4 4M3 11V9a4 4 0 0 1 4-4h14M7 23l-4-4 4-4M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
|
<span x-text="recurrenceLabel(t.recurrence)"></span>
|
|
</span>
|
|
</template>
|
|
<template x-if="t.snooze_until && new Date(t.snooze_until) > new Date()">
|
|
<span class="inline-flex items-center gap-1 text-yellow-400">
|
|
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
pospuesta
|
|
</span>
|
|
</template>
|
|
<span x-show="t.done_at" class="inline-flex items-center gap-1 text-green-400">
|
|
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
|
hecha <span x-text="ago(t.done_at)"></span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-1 shrink-0">
|
|
<button @click="snooze(t)" x-show="!t.done" class="btn btn-ghost btn-icon" title="Snooze 5 min (s)">
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
</button>
|
|
<button @click="edit(t)" class="btn btn-ghost btn-icon" title="Editar">
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
</button>
|
|
<button @click="del(t)" class="btn btn-ghost btn-icon hover:text-red-400" title="Borrar">
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
{{-- MODAL --}}
|
|
<div x-show="modalOpen" x-cloak class="modal-backdrop" @keydown.escape.window="closeModal()">
|
|
<div class="modal-card" @click.outside="closeModal()">
|
|
<div class="flex items-center justify-between mb-5">
|
|
<h2 class="text-lg font-bold" x-text="editing ? 'Editar tarea' : 'Nueva tarea'"></h2>
|
|
<button @click="closeModal()" class="btn btn-icon btn-ghost -mr-2">
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<form @submit.prevent="editing ? saveEdit() : create()" class="space-y-4">
|
|
<div>
|
|
<input x-model="form.title" type="text" placeholder="¿Qué tienes que hacer?" required maxlength="120" class="input text-base" autofocus>
|
|
</div>
|
|
|
|
<div>
|
|
<textarea x-model="form.description" placeholder="Descripción (opcional)" class="textarea min-h-[80px]"></textarea>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label class="label">Prioridad</label>
|
|
<select x-model="form.priority" class="select">
|
|
<option value="low">Baja</option>
|
|
<option value="med" selected>Media</option>
|
|
<option value="high">Alta</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="label">Estimación (min)</label>
|
|
<input x-model.number="form.estimate_min" type="number" min="1" max="9999" class="input" placeholder="30">
|
|
</div>
|
|
<div>
|
|
<label class="label">Fecha límite</label>
|
|
<input x-model="form.deadline" type="datetime-local" class="input">
|
|
</div>
|
|
<div>
|
|
<label class="label">Repetir</label>
|
|
<select x-model="form.recurrence" class="select">
|
|
<option value="">Nunca</option>
|
|
<option value="daily">Diario</option>
|
|
<option value="weekly">Semanal</option>
|
|
<option value="monthly">Mensual</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="label">Proyecto</label>
|
|
<select x-model="form.project_id" class="select">
|
|
<option value="">Sin proyecto</option>
|
|
<template x-for="p in projects" :key="p.id">
|
|
<option :value="p.id" x-text="p.name"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="label">Tags (separados por coma)</label>
|
|
<input x-model="form.tagsInput" type="text" class="input" placeholder="cliente-x, urgente">
|
|
</div>
|
|
|
|
<div class="flex gap-2 pt-3 border-t border-white/5">
|
|
<button type="submit" class="btn btn-primary flex-1 justify-center">
|
|
<span x-text="editing ? 'Guardar cambios' : 'Crear tarea'"></span>
|
|
</button>
|
|
<button type="button" @click="closeModal()" class="btn btn-ghost">Cancelar</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- TOAST --}}
|
|
<div x-show="toast" x-cloak x-transition.opacity class="toast" x-text="toast"></div>
|
|
</div>
|
|
|
|
<script>
|
|
function dummybotApp() {
|
|
return {
|
|
tasks: [],
|
|
projects: [],
|
|
search: '',
|
|
filter: 'all',
|
|
projectFilter: '',
|
|
loading: true,
|
|
modalOpen: false,
|
|
editing: null,
|
|
form: {},
|
|
toast: '',
|
|
mobileMenu: false,
|
|
csrf: document.querySelector('meta[name=csrf-token]').content,
|
|
stats: [
|
|
{label: 'Pendientes', value: '—', color: 'text-yellow-300'},
|
|
{label: 'Hoy', value: '—', color: 'text-indigo-300'},
|
|
{label: 'Hechas hoy', value: '—', color: 'text-green-300'},
|
|
{label: 'Vencidas', value: '—', color: 'text-red-300'},
|
|
],
|
|
|
|
async init() {
|
|
await this.loadAll();
|
|
this.bindKeys();
|
|
setInterval(() => this.loadAll(true), 15000);
|
|
},
|
|
|
|
bindKeys() {
|
|
document.addEventListener('keydown', (e) => {
|
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
|
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
if (e.key === 'n') { e.preventDefault(); this.openNew(); }
|
|
if (e.key === '/') { e.preventDefault(); document.querySelector('input[type=search]')?.focus(); }
|
|
if (e.key === 'Escape') this.closeModal();
|
|
if (e.key === '1') this.filter = 'all';
|
|
if (e.key === '2') this.filter = 'pending';
|
|
if (e.key === '3') this.filter = 'today';
|
|
if (e.key === '4') this.filter = 'overdue';
|
|
if (e.key === '5') this.filter = 'done';
|
|
});
|
|
},
|
|
|
|
async loadAll(silent = false) {
|
|
if (!silent) this.loading = true;
|
|
try {
|
|
const [t, p, s] = await Promise.all([
|
|
this.api('/api/tasks'),
|
|
this.api('/api/projects'),
|
|
this.api('/api/tasks/stats'),
|
|
]);
|
|
this.tasks = t;
|
|
this.projects = p;
|
|
this.stats = [
|
|
{label: 'Pendientes', value: s.pending, color: 'text-yellow-300'},
|
|
{label: 'Hoy', value: s.today, color: 'text-indigo-300'},
|
|
{label: 'Hechas hoy', value: s.done_today, color: 'text-green-300'},
|
|
{label: 'Vencidas', value: s.overdue, color: 'text-red-300'},
|
|
];
|
|
} catch (err) {
|
|
this.flash('Error cargando: ' + err.message);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
get filteredTasks() {
|
|
let t = this.tasks;
|
|
if (this.search) {
|
|
const q = this.search.toLowerCase();
|
|
t = t.filter(x =>
|
|
x.title.toLowerCase().includes(q) ||
|
|
(x.description || '').toLowerCase().includes(q) ||
|
|
(x.tags || []).some(tag => tag.name.toLowerCase().includes(q))
|
|
);
|
|
}
|
|
if (this.filter === 'pending') t = t.filter(x => !x.done && !this.isSnoozed(x));
|
|
if (this.filter === 'today') t = t.filter(x => !x.done && this.isToday(x));
|
|
if (this.filter === 'overdue') t = t.filter(x => !x.done && this.isOverdue(x));
|
|
if (this.filter === 'done') t = t.filter(x => x.done);
|
|
if (this.projectFilter) t = t.filter(x => x.project_id == this.projectFilter);
|
|
return t;
|
|
},
|
|
|
|
get filterCounts() {
|
|
return {
|
|
all: this.tasks.length,
|
|
pending: this.tasks.filter(x => !x.done && !this.isSnoozed(x)).length,
|
|
today: this.tasks.filter(x => !x.done && this.isToday(x)).length,
|
|
overdue: this.tasks.filter(x => !x.done && this.isOverdue(x)).length,
|
|
done: this.tasks.filter(x => x.done).length,
|
|
};
|
|
},
|
|
|
|
get currentProject() {
|
|
return this.projects.find(p => p.id == this.projectFilter);
|
|
},
|
|
|
|
get filterLabel() {
|
|
return {
|
|
all: 'todas las tareas',
|
|
pending: 'pendientes',
|
|
today: 'para hoy',
|
|
overdue: 'vencidas',
|
|
done: 'completadas',
|
|
}[this.filter] || '';
|
|
},
|
|
|
|
get emptyTitle() {
|
|
if (this.filter === 'done') return 'Aún no has hecho nada';
|
|
if (this.filter === 'today') return 'Nada para hoy';
|
|
if (this.filter === 'overdue') return 'Sin tareas vencidas 🎉';
|
|
if (this.filter === 'pending') return '¡Buen trabajo!';
|
|
return this.search ? 'Sin resultados' : 'Lista vacía';
|
|
},
|
|
|
|
get emptyDesc() {
|
|
if (this.search) return `No hay tareas que coincidan con "${this.search}"`;
|
|
if (this.filter === 'pending') return 'Ponte a procrastinar con la conciencia tranquila.';
|
|
if (this.filter === 'done') return 'Las tareas que completes aparecerán aquí.';
|
|
return 'Crea tu primera tarea con el botón + o pulsa N.';
|
|
},
|
|
|
|
isOverdue(t) { return t.deadline && new Date(t.deadline) < new Date() && !t.done; },
|
|
isSnoozed(t) { return t.snooze_until && new Date(t.snooze_until) > new Date(); },
|
|
isToday(t) { return t.deadline && new Date(t.deadline).toDateString() === new Date().toDateString(); },
|
|
|
|
priorityLabel(p) { return { high: 'Alta', med: 'Media', low: 'Baja' }[p] || p; },
|
|
recurrenceLabel(r) { return { daily: 'Diario', weekly: 'Semanal', monthly: 'Mensual' }[r] || r; },
|
|
formatDate(s) { return new Date(s).toLocaleString('es-ES', {day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit'}); },
|
|
ago(s) {
|
|
const d = (Date.now() - new Date(s).getTime()) / 1000;
|
|
if (d < 60) return 'hace un momento';
|
|
if (d < 3600) return `hace ${Math.floor(d/60)} min`;
|
|
if (d < 86400) return `hace ${Math.floor(d/3600)} h`;
|
|
return `hace ${Math.floor(d/86400)} d`;
|
|
},
|
|
|
|
openNew() {
|
|
this.editing = null;
|
|
this.form = {
|
|
title: '', description: '', priority: 'med',
|
|
estimate_min: null, deadline: '', project_id: '',
|
|
recurrence: '', tagsInput: '',
|
|
};
|
|
this.modalOpen = true;
|
|
},
|
|
closeModal() { this.modalOpen = false; this.editing = null; },
|
|
|
|
edit(t) {
|
|
this.editing = t;
|
|
this.form = {
|
|
title: t.title,
|
|
description: t.description || '',
|
|
priority: t.priority,
|
|
estimate_min: t.estimate_min,
|
|
deadline: t.deadline ? t.deadline.substring(0, 16) : '',
|
|
project_id: t.project_id || '',
|
|
recurrence: t.recurrence || '',
|
|
tagsInput: (t.tags || []).map(x => x.name).join(', '),
|
|
};
|
|
this.modalOpen = true;
|
|
},
|
|
|
|
payload() {
|
|
return {
|
|
title: this.form.title,
|
|
description: this.form.description || null,
|
|
priority: this.form.priority,
|
|
estimate_min: this.form.estimate_min || null,
|
|
deadline: this.form.deadline ? new Date(this.form.deadline).toISOString() : null,
|
|
project_id: this.form.project_id || null,
|
|
recurrence: this.form.recurrence || null,
|
|
tags: this.form.tagsInput.split(',').map(s => s.trim()).filter(Boolean),
|
|
};
|
|
},
|
|
|
|
async create() {
|
|
try {
|
|
await this.api('/api/tasks', 'POST', this.payload());
|
|
this.flash('✓ Tarea creada');
|
|
this.closeModal();
|
|
await this.loadAll();
|
|
} catch (err) { this.flash('Error: ' + err.message); }
|
|
},
|
|
async saveEdit() {
|
|
try {
|
|
await this.api(`/api/tasks/${this.editing.id}`, 'PUT', this.payload());
|
|
this.flash('✓ Tarea actualizada');
|
|
this.closeModal();
|
|
await this.loadAll();
|
|
} catch (err) { this.flash('Error: ' + err.message); }
|
|
},
|
|
|
|
async toggle(t) {
|
|
if (t.done) {
|
|
await this.api(`/api/tasks/${t.id}/restore`, 'POST');
|
|
this.flash('Reabierta');
|
|
} else {
|
|
await this.api(`/api/tasks/${t.id}/done`, 'POST');
|
|
this.flash('✓ ¡Hecha!');
|
|
}
|
|
await this.loadAll();
|
|
},
|
|
async snooze(t) {
|
|
await this.api(`/api/tasks/${t.id}/snooze`, 'POST', {minutes: 5});
|
|
this.flash('💤 Pospuesta 5 min');
|
|
await this.loadAll();
|
|
},
|
|
async del(t) {
|
|
if (!confirm(`¿Borrar "${t.title}"?`)) return;
|
|
await this.api(`/api/tasks/${t.id}`, 'DELETE');
|
|
this.flash('🗑 Borrada');
|
|
await this.loadAll();
|
|
},
|
|
|
|
async api(url, method = 'GET', body = null) {
|
|
const opts = {
|
|
method,
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': this.csrf,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
credentials: 'same-origin',
|
|
};
|
|
if (body) {
|
|
opts.headers['Content-Type'] = 'application/json';
|
|
opts.body = JSON.stringify(body);
|
|
}
|
|
const r = await fetch(url, opts);
|
|
if (!r.ok) {
|
|
const txt = await r.text();
|
|
throw new Error(`${r.status}: ${txt.substring(0, 100)}`);
|
|
}
|
|
return r.json();
|
|
},
|
|
flash(msg) {
|
|
this.toast = msg;
|
|
setTimeout(() => this.toast = '', 2500);
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
@endsection
|