diff --git a/api/src/index.js b/api/src/index.js index b6cdc69..26660a2 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -69,6 +69,7 @@ const start = async () => { await app.register(require("./routes/chat"), { prefix: "/api/v1" }); await app.register(require("./routes/notifications"), { prefix: "/api/v1" }); await app.register(require("./routes/goals"), { prefix: "/api/v1" }); + await app.register(require("./routes/projects"), { prefix: "/api/v1" }); await app.register(require("./routes/deploy"), { prefix: "/api/v1" }); await app.register(require("./routes/system"), { prefix: "/api/v1" }); diff --git a/api/src/routes/groups.js b/api/src/routes/groups.js index 2094923..a2ea4c4 100644 --- a/api/src/routes/groups.js +++ b/api/src/routes/groups.js @@ -57,6 +57,50 @@ async function groupRoutes(app) { } return { status: 'ok' }; }); + + // Update time zones for a group + app.put('/groups/:id/timezones', async (req) => { + const { time_zones } = req.body; + if (!Array.isArray(time_zones)) { + throw { statusCode: 400, message: 'time_zones must be an array of [{days, from, to}]' }; + } + // Validate each timezone entry + for (const tz of time_zones) { + if (!Array.isArray(tz.days) || !tz.from || !tz.to) { + throw { statusCode: 400, message: 'Each timezone must have days (array), from (HH:MM), to (HH:MM)' }; + } + } + const { rows } = await app.db.query( + 'UPDATE task_groups SET time_zones = $1, updated_at = NOW() WHERE id = $2 RETURNING *', + [JSON.stringify(time_zones), req.params.id] + ); + if (!rows.length) throw { statusCode: 404, message: 'Group not found' }; + return { data: rows[0] }; + }); + + // Update GPS locations for a group + app.put('/groups/:id/locations', async (req) => { + const { locations } = req.body; + if (!Array.isArray(locations)) { + throw { statusCode: 400, message: 'locations must be an array of [{name, lat, lng, radius_m}]' }; + } + // Validate each location entry + for (const loc of locations) { + if (!loc.name || loc.lat === undefined || loc.lng === undefined) { + throw { statusCode: 400, message: 'Each location must have name, lat, lng' }; + } + if (typeof loc.lat !== 'number' || typeof loc.lng !== 'number') { + throw { statusCode: 400, message: 'lat and lng must be numbers' }; + } + } + const { rows } = await app.db.query( + 'UPDATE task_groups SET locations = $1, updated_at = NOW() WHERE id = $2 RETURNING *', + [JSON.stringify(locations), req.params.id] + ); + if (!rows.length) throw { statusCode: 404, message: 'Group not found' }; + return { data: rows[0] }; + }); + } module.exports = groupRoutes; diff --git a/api/src/routes/projects.js b/api/src/routes/projects.js new file mode 100644 index 0000000..366bcee --- /dev/null +++ b/api/src/routes/projects.js @@ -0,0 +1,114 @@ +// Task Team — Projects CRUD — 2026-03-29 +async function projectRoutes(app) { + + // List projects (optionally filter by member) + app.get("/projects", async (req) => { + const { user_id } = req.query; + let query = "SELECT * FROM projects"; + const params = []; + if (user_id) { + params.push(user_id); + query += ` WHERE owner_id = $1 OR $1 = ANY(members)`; + } + query += " ORDER BY updated_at DESC"; + const { rows } = await app.db.query(query, params); + return { data: rows }; + }); + + // Get single project with task count + app.get("/projects/:id", async (req) => { + const { rows } = await app.db.query( + `SELECT p.*, + (SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) as task_count, + (SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id AND t.status IN ('done','completed')) as done_count + FROM projects p WHERE p.id = $1`, + [req.params.id] + ); + if (!rows.length) throw { statusCode: 404, message: "Project not found" }; + return { data: rows[0] }; + }); + + // Create project + app.post("/projects", async (req) => { + const { name, description, color, icon, owner_id } = req.body; + if (!name || !name.trim()) throw { statusCode: 400, message: "name is required" }; + const { rows } = await app.db.query( + `INSERT INTO projects (name, description, color, icon, owner_id, members) + VALUES ($1, $2, $3, $4, $5, ARRAY[$5]::uuid[]) RETURNING *`, + [name.trim(), description || "", color || "#3B82F6", icon || "\ud83d\udcc1", owner_id] + ); + return { data: rows[0] }; + }); + + // Update project + app.put("/projects/:id", async (req) => { + const { name, description, color, icon } = req.body; + const { rows } = await app.db.query( + `UPDATE projects SET + name = COALESCE($1, name), + description = COALESCE($2, description), + color = COALESCE($3, color), + icon = COALESCE($4, icon), + updated_at = NOW() + WHERE id = $5 RETURNING *`, + [name, description, color, icon, req.params.id] + ); + if (!rows.length) throw { statusCode: 404, message: "Project not found" }; + return { data: rows[0] }; + }); + + // Invite user to project + app.post("/projects/:id/invite", async (req) => { + const { user_id } = req.body; + if (!user_id) throw { statusCode: 400, message: "user_id is required" }; + const { rows } = await app.db.query( + `UPDATE projects SET + members = array_append(members, $1::uuid), + updated_at = NOW() + WHERE id = $2 AND NOT ($1::uuid = ANY(members)) + RETURNING *`, + [user_id, req.params.id] + ); + if (!rows.length) { + // Check if project exists + const check = await app.db.query("SELECT id FROM projects WHERE id = $1", [req.params.id]); + if (!check.rows.length) throw { statusCode: 404, message: "Project not found" }; + return { status: "already_member" }; + } + return { data: rows[0], status: "invited" }; + }); + + // Remove member from project + app.delete("/projects/:id/members/:userId", async (req) => { + const { rows } = await app.db.query( + `UPDATE projects SET + members = array_remove(members, $1::uuid), + updated_at = NOW() + WHERE id = $2 RETURNING *`, + [req.params.userId, req.params.id] + ); + if (!rows.length) throw { statusCode: 404, message: "Project not found" }; + return { data: rows[0], status: "removed" }; + }); + + // Delete project (sets tasks.project_id to NULL via ON DELETE SET NULL) + app.delete("/projects/:id", async (req) => { + const { rowCount } = await app.db.query("DELETE FROM projects WHERE id = $1", [req.params.id]); + if (!rowCount) throw { statusCode: 404, message: "Project not found" }; + return { status: "deleted" }; + }); + + // List tasks in project + app.get("/projects/:id/tasks", async (req) => { + const { rows } = await app.db.query( + `SELECT t.*, tg.name as group_name, tg.color as group_color, tg.icon as group_icon + FROM tasks t LEFT JOIN task_groups tg ON t.group_id = tg.id + WHERE t.project_id = $1 + ORDER BY t.priority DESC, t.created_at DESC`, + [req.params.id] + ); + return { data: rows }; + }); +} + +module.exports = projectRoutes; diff --git a/api/src/routes/tasks.js b/api/src/routes/tasks.js index e79c747..f149eb7 100644 --- a/api/src/routes/tasks.js +++ b/api/src/routes/tasks.js @@ -183,6 +183,45 @@ async function taskRoutes(app) { `UPDATE tasks SET ${sets.join(", ")} WHERE id = $${i} RETURNING *`, params ); if (!rows.length) throw { statusCode: 404, message: "Task not found" }; + + // Auto-create next occurrence for recurring tasks + const updatedTask = rows[0]; + if ((fields.status === "completed" || fields.status === "done") && updatedTask.recurrence) { + try { + const rec = typeof updatedTask.recurrence === "string" ? JSON.parse(updatedTask.recurrence) : updatedTask.recurrence; + let nextDate = new Date(); + if (rec.type === "daily") { + nextDate.setDate(nextDate.getDate() + 1); + } else if (rec.type === "weekly") { + nextDate.setDate(nextDate.getDate() + 7); + } else if (rec.type === "monthly") { + nextDate.setMonth(nextDate.getMonth() + 1); + } + if (rec.time) { + const [h, m] = rec.time.split(":"); + nextDate.setHours(parseInt(h) || 8, parseInt(m) || 0, 0, 0); + } + await app.db.query( + `INSERT INTO tasks (title, description, status, group_id, priority, scheduled_at, due_at, assigned_to, recurrence, project_id) + VALUES ($1, $2, 'pending', $3, $4, $5, $6, $7, $8, $9)`, + [ + updatedTask.title, + updatedTask.description, + updatedTask.group_id, + updatedTask.priority, + nextDate.toISOString(), + updatedTask.due_at ? new Date(new Date(updatedTask.due_at).getTime() + (nextDate.getTime() - Date.now())).toISOString() : null, + updatedTask.assigned_to || [], + JSON.stringify(rec), + updatedTask.project_id + ] + ); + app.log.info("Auto-created next recurring task for: " + updatedTask.title); + } catch (recErr) { + app.log.warn("Recurrence auto-create failed: " + recErr.message); + } + } + await invalidateTaskCaches(); return { data: rows[0] }; }); @@ -267,6 +306,47 @@ async function taskRoutes(app) { return { data: rows[0] }; }); + +// === Task Recurrence Endpoints === + +// Set recurrence on a task +app.post("/tasks/:id/recurrence", async (req) => { + const { type, days, time } = req.body; + if (!type || !["daily", "weekly", "monthly"].includes(type)) { + throw { statusCode: 400, message: "type must be one of: daily, weekly, monthly" }; + } + const recurrence = { type, days: days || [], time: time || "08:00" }; + const { rows } = await app.db.query( + "UPDATE tasks SET recurrence = $1, updated_at = NOW() WHERE id = $2 RETURNING *", + [JSON.stringify(recurrence), req.params.id] + ); + if (!rows.length) throw { statusCode: 404, message: "Task not found" }; + await invalidateTaskCaches(); + return { data: rows[0] }; +}); + +// Remove recurrence from a task +app.delete("/tasks/:id/recurrence", async (req) => { + const { rows } = await app.db.query( + "UPDATE tasks SET recurrence = NULL, updated_at = NOW() WHERE id = $1 RETURNING *", + [req.params.id] + ); + if (!rows.length) throw { statusCode: 404, message: "Task not found" }; + await invalidateTaskCaches(); + return { data: rows[0] }; +}); + +// List recurring tasks +app.get("/tasks/recurring", async (req) => { + const { rows } = await app.db.query( + `SELECT t.*, tg.name as group_name, tg.color as group_color, tg.icon as group_icon + FROM tasks t LEFT JOIN task_groups tg ON t.group_id = tg.id + WHERE t.recurrence IS NOT NULL + ORDER BY t.created_at DESC` + ); + return { data: rows }; +}); + } module.exports = taskRoutes; diff --git a/apps/tasks/app/calendar/page.tsx b/apps/tasks/app/calendar/page.tsx index c65a538..f6b2979 100644 --- a/apps/tasks/app/calendar/page.tsx +++ b/apps/tasks/app/calendar/page.tsx @@ -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 = { }; export default function CalendarPage() { - const [tasks, setTasks] = useState([]); + const [tasks, setTasks] = useState([]); + const [error, setError] = useState(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(); + tasks.forEach(tk => { + if (tk.group_name && tk.group_color) { + groupColors.set(tk.group_name, tk.group_color); + } + }); + + if (!token) return null; + return (

{t('calendar.title')}

+ {error && ( +
+ {error} +
+ )}
([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [error, setError] = useState(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 ( +
+ {/* Header */} +
+

{t("nav.projects")}

+ +
+ + {/* Error */} + {error && ( +
+ {error} + +
+ )} + + {/* Create form */} + {showForm && ( +
+
+ + 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 + /> +
+
+ +