Feature batch: Projects, Recurrence, Group settings, Bug fixes
- Projects CRUD API + invite members - Task recurrence (daily/weekly/monthly) with auto-creation - Group time zones + GPS locations settings - i18n fallback fix (no more undefined labels) - UX: action buttons in one row - Chat/Calendar: relative API URLs - DB: task_assignments, projects tables, recurrence column Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,10 @@ import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
|
||||
interface Task {
|
||||
interface CalTask {
|
||||
id: string;
|
||||
title: string;
|
||||
scheduled_at: string | null;
|
||||
@@ -26,32 +26,61 @@ const LOCALE_MAP: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function CalendarPage() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [tasks, setTasks] = useState<CalTask[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t, locale } = useTranslation();
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_URL}/api/v1/tasks?limit=100`)
|
||||
.then(r => r.json())
|
||||
.then(d => setTasks(d.data || []));
|
||||
}, []);
|
||||
if (!token) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
fetch('/api/v1/tasks?limit=100', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then(d => setTasks(d.data || []))
|
||||
.catch(err => setError(err.message));
|
||||
}, [token, router]);
|
||||
|
||||
const events = tasks
|
||||
.filter((t): t is Task & { scheduled_at: string } | Task & { due_at: string } =>
|
||||
t.scheduled_at !== null || t.due_at !== null
|
||||
)
|
||||
.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
start: (t.scheduled_at || t.due_at) as string,
|
||||
end: (t.due_at || t.scheduled_at) as string,
|
||||
backgroundColor: t.group_color || '#3B82F6',
|
||||
borderColor: t.group_color || '#3B82F6',
|
||||
extendedProps: { status: t.status, group: t.group_name }
|
||||
.filter((tk) => tk.scheduled_at !== null || tk.due_at !== null)
|
||||
.map(tk => ({
|
||||
id: tk.id,
|
||||
title: tk.title,
|
||||
start: (tk.scheduled_at || tk.due_at) as string,
|
||||
end: (tk.due_at || tk.scheduled_at) as string,
|
||||
backgroundColor: tk.group_color || '#3B82F6',
|
||||
borderColor: tk.group_color || '#3B82F6',
|
||||
extendedProps: { status: tk.status, group: tk.group_name },
|
||||
}));
|
||||
|
||||
// Build background events from unique groups
|
||||
const groupColors = new Map<string, string>();
|
||||
tasks.forEach(tk => {
|
||||
if (tk.group_name && tk.group_color) {
|
||||
groupColors.set(tk.group_name, tk.group_color);
|
||||
}
|
||||
});
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">{t('calendar.title')}</h1>
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow">
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
@@ -59,7 +88,7 @@ export default function CalendarPage() {
|
||||
headerToolbar={{
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay'
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay',
|
||||
}}
|
||||
events={events}
|
||||
editable={true}
|
||||
|
||||
237
apps/tasks/app/projects/page.tsx
Normal file
237
apps/tasks/app/projects/page.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import {
|
||||
getProjects,
|
||||
createProject,
|
||||
deleteProject,
|
||||
Project,
|
||||
} from "@/lib/api";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const { token, user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formDesc, setFormDesc] = useState("");
|
||||
const [formColor, setFormColor] = useState("#3B82F6");
|
||||
const [formIcon, setFormIcon] = useState("\ud83d\udcc1");
|
||||
|
||||
const COLORS = ["#3B82F6", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899", "#6366F1", "#14B8A6"];
|
||||
const ICONS = ["\ud83d\udcc1", "\ud83d\ude80", "\ud83d\udca1", "\ud83c\udfaf", "\ud83d\udee0\ufe0f", "\ud83c\udf1f", "\ud83d\udcca", "\ud83d\udd25"];
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getProjects(token);
|
||||
setProjects(res.data || []);
|
||||
} catch (err) {
|
||||
console.error("Load error:", err);
|
||||
setError(t("common.error"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
loadData();
|
||||
}, [token, router, loadData]);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!token || !formName.trim()) return;
|
||||
try {
|
||||
await createProject(token, {
|
||||
name: formName.trim(),
|
||||
description: formDesc,
|
||||
color: formColor,
|
||||
icon: formIcon,
|
||||
owner_id: user?.id,
|
||||
});
|
||||
setFormName("");
|
||||
setFormDesc("");
|
||||
setFormColor("#3B82F6");
|
||||
setFormIcon("\ud83d\udcc1");
|
||||
setShowForm(false);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error("Create error:", err);
|
||||
setError(t("common.error"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!token) return;
|
||||
if (!confirm(t("tasks.confirmDelete"))) return;
|
||||
try {
|
||||
await deleteProject(token, id);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error("Delete error:", err);
|
||||
setError(t("common.error"));
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString("cs-CZ", { day: "numeric", month: "short" });
|
||||
}
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-24 sm:pb-8 px-4 sm:px-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold dark:text-white">{t("nav.projects")}</h1>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors min-h-[44px]"
|
||||
>
|
||||
{showForm ? t("tasks.form.cancel") : `+ ${t("projects.add")}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-red-700 dark:text-red-300 text-sm">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 underline">{t("tasks.close")}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create form */}
|
||||
{showForm && (
|
||||
<form onSubmit={handleCreate} className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t("tasks.form.title")}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder={t("projects.namePlaceholder")}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-h-[44px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t("tasks.form.description")}</label>
|
||||
<textarea
|
||||
value={formDesc}
|
||||
onChange={(e) => setFormDesc(e.target.value)}
|
||||
placeholder={t("projects.descPlaceholder")}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:ring-2 focus:ring-blue-500 min-h-[80px] resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t("projects.color")}</label>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setFormColor(c)}
|
||||
className={`w-8 h-8 rounded-full border-2 transition-all ${formColor === c ? "border-gray-900 dark:border-white scale-110" : "border-transparent"}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t("projects.icon")}</label>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{ICONS.map((ic) => (
|
||||
<button
|
||||
key={ic}
|
||||
type="button"
|
||||
onClick={() => setFormIcon(ic)}
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center text-lg border-2 transition-all ${formIcon === ic ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" : "border-gray-200 dark:border-gray-700"}`}
|
||||
>
|
||||
{ic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors min-h-[44px]"
|
||||
>
|
||||
{t("projects.add")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Projects list */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<div className="text-5xl mb-4 opacity-50">{"\ud83d\udcc2"}</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">{t("projects.empty")}</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">{t("projects.createFirst")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 transition-all hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center text-xl flex-shrink-0"
|
||||
style={{ backgroundColor: (project.color || "#3B82F6") + "15" }}
|
||||
>
|
||||
{project.icon || "\ud83d\udcc1"}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">{project.name}</h3>
|
||||
{project.description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 truncate mt-0.5">{project.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
<span>{t("projects.tasks")}: {project.task_count || 0}</span>
|
||||
<span>{t("projects.members")}: {project.members?.length || 0}</span>
|
||||
<span>{formatDate(project.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: project.color || "#3B82F6" }} />
|
||||
<button
|
||||
onClick={() => handleDelete(project.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-500 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
title={t("tasks.delete")}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -162,27 +162,66 @@ export default function TaskDetailPage() {
|
||||
const taskDone = isDone(task.status);
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto space-y-6">
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={() => router.push("/tasks")}
|
||||
className="text-sm text-muted hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
<div className="max-w-lg mx-auto space-y-4">
|
||||
{/* Action bar - all buttons in one compact row */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => router.push("/tasks")}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{t("common.back")}
|
||||
</button>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{t("common.back")}
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{!taskDone && (
|
||||
<button
|
||||
onClick={() => handleQuickStatus("done")}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{t("tasks.markDone")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{taskDone && (
|
||||
<button
|
||||
onClick={() => handleQuickStatus("pending")}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{t("tasks.reopen")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" 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>
|
||||
{t("tasks.edit")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" 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>
|
||||
{deleting ? t("tasks.deleting") : t("tasks.delete")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Task detail card */}
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-6">
|
||||
@@ -253,51 +292,6 @@ export default function TaskDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick status buttons */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{!taskDone && (
|
||||
<button
|
||||
onClick={() => handleQuickStatus("done")}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("tasks.markDone")}
|
||||
</button>
|
||||
)}
|
||||
{task.status === "pending" && (
|
||||
<button
|
||||
onClick={() => handleQuickStatus("in_progress")}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("tasks.start")}
|
||||
</button>
|
||||
)}
|
||||
{taskDone && (
|
||||
<button
|
||||
onClick={() => handleQuickStatus("pending")}
|
||||
className="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("tasks.reopen")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="flex-1 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors font-medium"
|
||||
>
|
||||
{t("tasks.edit")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="px-6 py-2.5 border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors font-medium disabled:opacity-50"
|
||||
>
|
||||
{deleting ? t("tasks.deleting") : t("tasks.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user