dummybot/resources/views/pages/app.blade.php

399 lines
21 KiB
PHP

@extends('layouts.base')
@section('content')
<div x-data="dummybotApp()" x-init="init()" class="min-h-screen flex flex-col">
{{-- NAV --}}
<nav class="border-b border-white/5 backdrop-blur-md sticky top-0 z-40">
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between gap-3">
<a href="{{ route('app') }}" class="flex items-center gap-2 text-lg font-bold shrink-0">
<span class="text-2xl">🤖</span>
<span class="bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">DummyBot</span>
</a>
{{-- Buscador --}}
<div class="hidden sm:flex flex-1 max-w-md mx-4">
<div class="relative w-full">
<input x-model="search" type="search" placeholder="Buscar... (pulsa /)"
class="input pl-9">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">🔍</span>
</div>
</div>
<div class="flex items-center gap-2">
<button @click="newTaskOpen = true" class="btn btn-primary text-sm" title="Nueva (n)">
<span>+</span> <span class="hidden sm:inline">Nueva</span>
</button>
<a href="{{ route('settings') }}" class="btn btn-ghost text-sm" title="Ajustes">⚙️</a>
<form method="POST" action="{{ route('logout') }}" class="inline">
@csrf
<button class="btn btn-ghost text-sm" title="Salir"></button>
</form>
</div>
</div>
</nav>
{{-- STATS --}}
<div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 grid grid-cols-2 sm:grid-cols-4 gap-3">
<template x-for="(s, i) in stats" :key="i">
<div class="glass rounded-xl p-4">
<div class="text-xs text-zinc-500 uppercase tracking-wide" x-text="s.label"></div>
<div class="mt-1 text-2xl font-extrabold" :class="s.color" x-text="s.value"></div>
</div>
</template>
</div>
{{-- FILTROS --}}
<div class="max-w-6xl mx-auto px-4 sm:px-6 pt-4 flex flex-wrap items-center gap-2">
<button @click="filter = 'all'"
:class="filter === 'all' ? 'bg-indigo-500/20 text-indigo-300 border-indigo-500/40' : 'btn-ghost'"
class="btn text-sm border border-white/5">Todas</button>
<button @click="filter = 'pending'"
:class="filter === 'pending' ? 'bg-yellow-500/20 text-yellow-300 border-yellow-500/40' : 'btn-ghost'"
class="btn text-sm border border-white/5">Pendientes</button>
<button @click="filter = 'today'"
:class="filter === 'today' ? 'bg-purple-500/20 text-purple-300 border-purple-500/40' : 'btn-ghost'"
class="btn text-sm border border-white/5">Hoy</button>
<button @click="filter = 'overdue'"
:class="filter === 'overdue' ? 'bg-red-500/20 text-red-300 border-red-500/40' : 'btn-ghost'"
class="btn text-sm border border-white/5">Vencidas</button>
<button @click="filter = 'done'"
:class="filter === 'done' ? 'bg-green-500/20 text-green-300 border-green-500/40' : 'btn-ghost'"
class="btn text-sm border border-white/5">Hechas</button>
<div class="ml-auto flex items-center gap-2">
<select x-model="projectFilter" class="input text-sm py-1 w-auto">
<option value="">Todos los proyectos</option>
<template x-for="p in projects" :key="p.id">
<option :value="p.id" x-text="p.name"></option>
</template>
</select>
</div>
</div>
{{-- LISTA DE TAREAS --}}
<main class="max-w-6xl mx-auto px-4 sm:px-6 py-6 flex-1 w-full">
<template x-if="loading">
<div class="text-center text-zinc-500 py-12">Cargando...</div>
</template>
<template x-if="!loading && filteredTasks.length === 0">
<div class="text-center py-16 anim-in">
<div class="text-6xl mb-3">🎉</div>
<h2 class="text-xl font-bold mb-1">¡Nada que hacer!</h2>
<p class="text-zinc-500">Ponte a procrastinar con la conciencia tranquila.</p>
</div>
</template>
<div class="space-y-2">
<template x-for="t in filteredTasks" :key="t.id">
<div class="task-card anim-in flex items-start gap-3"
:class="t.done ? 'task-done' : ''">
<button @click="toggle(t)" class="shrink-0 mt-1 w-6 h-6 rounded-full border-2 transition flex items-center justify-center"
:class="t.done ? 'bg-green-500/80 border-green-500 text-white' : 'border-zinc-600 hover:border-indigo-400'">
<span x-show="t.done" class="text-xs"></span>
</button>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold" x-text="t.title"></span>
<span class="chip" :class="'prio-' + t.priority" x-text="t.priority"></span>
<template x-if="t.project">
<span class="chip" :style="'background:' + t.project.color + '20; color:' + t.project.color"
x-text="t.project.name"></span>
</template>
<template x-for="tag in t.tags" :key="tag.id">
<span class="chip text-xs" :style="'background:' + tag.color + '20; color:' + tag.color"
x-text="'#' + tag.name"></span>
</template>
</div>
<p x-show="t.description" class="text-sm text-zinc-400 mt-1" x-text="t.description"></p>
<div class="flex items-center gap-3 text-xs text-zinc-500 mt-2">
<template x-if="t.estimate_min">
<span> <span x-text="t.estimate_min"></span> min</span>
</template>
<template x-if="t.deadline">
<span :class="isOverdue(t) ? 'text-red-400' : ''">📅 <span x-text="formatDate(t.deadline)"></span></span>
</template>
<template x-if="t.recurrence">
<span>🔁 <span x-text="t.recurrence"></span></span>
</template>
<span x-show="t.done_at" class="text-green-400"> 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)" class="btn btn-ghost text-xs" title="Snooze 5 min (s)">💤</button>
<button @click="edit(t)" class="btn btn-ghost text-xs" title="Editar"></button>
<button @click="del(t)" class="btn btn-danger text-xs" title="Borrar">🗑</button>
</div>
</div>
</template>
</div>
</main>
{{-- MODAL NUEVA / EDITAR TAREA --}}
<div x-show="newTaskOpen || editing" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
@keydown.escape.window="closeModal()">
<div class="glass rounded-2xl p-6 w-full max-w-lg anim-in" @click.outside="closeModal()">
<h2 class="text-xl font-bold mb-4" x-text="editing ? 'Editar tarea' : 'Nueva tarea'"></h2>
<form @submit.prevent="editing ? saveEdit() : create()" class="space-y-3">
<input x-model="form.title" type="text" placeholder="¿Qué tienes que hacer?" required maxlength="120" class="input" autofocus>
<textarea x-model="form.description" placeholder="Descripción (opcional)" class="input min-h-[80px]"></textarea>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-zinc-500">Prioridad</label>
<select x-model="form.priority" class="input">
<option value="low">Baja</option>
<option value="med" selected>Media</option>
<option value="high">Alta</option>
</select>
</div>
<div>
<label class="text-xs text-zinc-500">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="text-xs text-zinc-500">Fecha límite</label>
<input x-model="form.deadline" type="datetime-local" class="input">
</div>
<div>
<label class="text-xs text-zinc-500">Repetir</label>
<select x-model="form.recurrence" class="input">
<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="text-xs text-zinc-500">Proyecto</label>
<select x-model="form.project_id" class="input">
<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="text-xs text-zinc-500">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-2">
<button type="submit" class="btn btn-primary flex-1 justify-center">
<span x-text="editing ? 'Guardar' : 'Crear'"></span>
</button>
<button type="button" @click="closeModal()" class="btn btn-ghost">Cancelar</button>
</div>
</form>
</div>
</div>
{{-- TOAST --}}
<div x-show="toast" x-cloak class="toast" x-text="toast"></div>
</div>
<style>[x-cloak]{display:none!important}</style>
<script>
function dummybotApp() {
return {
tasks: [],
projects: [],
search: '',
filter: 'all',
projectFilter: '',
loading: true,
newTaskOpen: false,
editing: null,
form: {},
toast: '',
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();
// Polling cada 15s
setInterval(() => this.loadAll(true), 15000);
},
bindKeys() {
document.addEventListener('keydown', (e) => {
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
if (e.key === 'n') { this.newTaskOpen = true; this.resetForm(); }
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 = '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;
},
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(); },
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`;
},
resetForm() {
this.form = {
title: '', description: '', priority: 'med',
estimate_min: null, deadline: '', project_id: '',
recurrence: '', tagsInput: '',
};
this.editing = null;
},
closeModal() { this.newTaskOpen = 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(', '),
};
},
async create() {
try {
await this.api('/api/tasks', 'POST', this.payload());
this.flash('✓ Tarea creada');
this.closeModal();
this.resetForm();
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); }
},
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 toggle(t) {
if (t.done) {
await this.api(`/api/tasks/${t.id}/restore`, 'POST');
} 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