diff --git a/apps/tasks/.eslintrc.json b/apps/tasks/.eslintrc.json new file mode 100644 index 0000000..13015d6 --- /dev/null +++ b/apps/tasks/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": "next/core-web-vitals", + "rules": { + "@typescript-eslint/no-explicit-any": "off" + } +} diff --git a/apps/tasks/.gitignore b/apps/tasks/.gitignore new file mode 100644 index 0000000..fd3dbb5 --- /dev/null +++ b/apps/tasks/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/tasks/README.md b/apps/tasks/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/apps/tasks/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/tasks/app/favicon.ico b/apps/tasks/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/apps/tasks/app/favicon.ico differ diff --git a/apps/tasks/app/fonts/GeistMonoVF.woff b/apps/tasks/app/fonts/GeistMonoVF.woff new file mode 100644 index 0000000..f2ae185 Binary files /dev/null and b/apps/tasks/app/fonts/GeistMonoVF.woff differ diff --git a/apps/tasks/app/fonts/GeistVF.woff b/apps/tasks/app/fonts/GeistVF.woff new file mode 100644 index 0000000..1b62daa Binary files /dev/null and b/apps/tasks/app/fonts/GeistVF.woff differ diff --git a/apps/tasks/app/globals.css b/apps/tasks/app/globals.css new file mode 100644 index 0000000..bb982a3 --- /dev/null +++ b/apps/tasks/app/globals.css @@ -0,0 +1,37 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: #ffffff; + --foreground: #171717; + --card: #f9fafb; + --card-border: #e5e7eb; + --muted: #6b7280; + --primary: #3b82f6; +} + +.dark { + --background: #0a0a0a; + --foreground: #ededed; + --card: #1a1a2e; + --card-border: #2d2d44; + --muted: #9ca3af; + --primary: #60a5fa; +} + +body { + background: var(--background); + color: var(--foreground); + font-family: system-ui, -apple-system, sans-serif; +} + +@layer utilities { + .scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; + } + .scrollbar-hide::-webkit-scrollbar { + display: none; + } +} diff --git a/apps/tasks/app/layout.tsx b/apps/tasks/app/layout.tsx new file mode 100644 index 0000000..f38c182 --- /dev/null +++ b/apps/tasks/app/layout.tsx @@ -0,0 +1,47 @@ +import type { Metadata, Viewport } from "next"; +import "./globals.css"; +import ThemeProvider from "@/components/ThemeProvider"; +import AuthProvider from "@/components/AuthProvider"; +import Header from "@/components/Header"; + +export const metadata: Metadata = { + title: "Task Team", + description: "Sprava ukolu pro tym", + manifest: "/manifest.json", + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "Task Team", + }, +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#ffffff" }, + { media: "(prefers-color-scheme: dark)", color: "#0a0a0a" }, + ], +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + +
+
+ {children} +
+ + + + + ); +} diff --git a/apps/tasks/app/login/page.tsx b/apps/tasks/app/login/page.tsx new file mode 100644 index 0000000..c13cf3a --- /dev/null +++ b/apps/tasks/app/login/page.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { login } from "@/lib/api"; +import { useAuth } from "@/lib/auth"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const { setAuth } = useAuth(); + const router = useRouter(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!email.trim()) { + setError("Zadejte email"); + return; + } + setLoading(true); + setError(""); + try { + const result = await login({ email: email.trim() }); + setAuth(result.token, result.user); + router.push("/tasks"); + } catch (err) { + setError(err instanceof Error ? err.message : "Chyba prihlaseni"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

Prihlaseni

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" + placeholder="vas@email.cz" + autoFocus + autoComplete="email" + /> +
+ + +
+ +

+ Nemate ucet?{" "} + + Registrovat se + +

+
+
+
+ ); +} diff --git a/apps/tasks/app/page.tsx b/apps/tasks/app/page.tsx new file mode 100644 index 0000000..5d47943 --- /dev/null +++ b/apps/tasks/app/page.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/auth"; + +export default function Home() { + const { token } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (token) { + router.replace("/tasks"); + } else { + router.replace("/login"); + } + }, [token, router]); + + return ( +
+
+
+ ); +} diff --git a/apps/tasks/app/register/page.tsx b/apps/tasks/app/register/page.tsx new file mode 100644 index 0000000..e583947 --- /dev/null +++ b/apps/tasks/app/register/page.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { register } from "@/lib/api"; +import { useAuth } from "@/lib/auth"; + +export default function RegisterPage() { + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [phone, setPhone] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const { setAuth } = useAuth(); + const router = useRouter(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!email.trim() || !name.trim()) { + setError("Email a jmeno jsou povinne"); + return; + } + setLoading(true); + setError(""); + try { + const result = await register({ + email: email.trim(), + name: name.trim(), + phone: phone.trim() || undefined, + }); + setAuth(result.token, result.user); + router.push("/tasks"); + } catch (err) { + setError(err instanceof Error ? err.message : "Chyba registrace"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

Registrace

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setName(e.target.value)} + className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" + placeholder="Vase jmeno" + autoFocus + /> +
+ +
+ + setEmail(e.target.value)} + className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" + placeholder="vas@email.cz" + autoComplete="email" + /> +
+ +
+ + setPhone(e.target.value)} + className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" + placeholder="+420 123 456 789" + /> +
+ + +
+ +

+ Jiz mate ucet?{" "} + + Prihlasit se + +

+
+
+
+ ); +} diff --git a/apps/tasks/app/tasks/[id]/page.tsx b/apps/tasks/app/tasks/[id]/page.tsx new file mode 100644 index 0000000..2009531 --- /dev/null +++ b/apps/tasks/app/tasks/[id]/page.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { useAuth } from "@/lib/auth"; +import { getTask, getGroups, updateTask, deleteTask, Task, Group } from "@/lib/api"; +import TaskForm from "@/components/TaskForm"; +import StatusBadge from "@/components/StatusBadge"; + +export default function TaskDetailPage() { + const { token } = useAuth(); + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + const [task, setTask] = useState(null); + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [editing, setEditing] = useState(false); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(""); + + const loadTask = useCallback(async () => { + if (!token || !id) return; + setLoading(true); + try { + const [taskData, groupsData] = await Promise.all([ + getTask(token, id), + getGroups(token), + ]); + setTask(taskData); + setGroups(groupsData.data || []); + } catch (err) { + setError(err instanceof Error ? err.message : "Chyba pri nacitani ukolu"); + } finally { + setLoading(false); + } + }, [token, id]); + + useEffect(() => { + if (!token) { + router.replace("/login"); + return; + } + loadTask(); + }, [token, router, loadTask]); + + async function handleUpdate(data: Partial) { + if (!token || !id) return; + await updateTask(token, id, data); + setEditing(false); + loadTask(); + } + + async function handleDelete() { + if (!token || !id) return; + if (!confirm("Opravdu smazat tento ukol?")) return; + setDeleting(true); + try { + await deleteTask(token, id); + router.push("/tasks"); + } catch (err) { + setError(err instanceof Error ? err.message : "Chyba pri mazani"); + setDeleting(false); + } + } + + async function handleQuickStatus(newStatus: Task["status"]) { + if (!token || !id) return; + try { + await updateTask(token, id, { status: newStatus }); + loadTask(); + } catch (err) { + setError(err instanceof Error ? err.message : "Chyba pri zmene stavu"); + } + } + + if (!token) return null; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error || !task) { + return ( +
+

{error || "Ukol nenalezen"}

+ +
+ ); + } + + if (editing) { + return ( +
+

Upravit ukol

+ setEditing(false)} + submitLabel="Ulozit zmeny" + /> +
+ ); + } + + const PRIORITY_LABELS: Record = { + low: "Nizka", + medium: "Stredni", + high: "Vysoka", + urgent: "Urgentni", + }; + + return ( +
+ {/* Back button */} + + + {/* Task detail card */} +
+
+

+ {task.title} +

+ +
+ + {task.description && ( +

{task.description}

+ )} + +
+
+ Priorita: + {PRIORITY_LABELS[task.priority]} +
+ {task.group_name && ( +
+ Skupina: + + {task.group_name} + +
+ )} + {task.due_at && ( +
+ Termin: + {new Date(task.due_at).toLocaleString("cs-CZ")} +
+ )} +
+ Vytvoreno: + {new Date(task.created_at).toLocaleString("cs-CZ")} +
+ {task.completed_at && ( +
+ Dokonceno: + {new Date(task.completed_at).toLocaleString("cs-CZ")} +
+ )} +
+
+ + {/* Quick status buttons */} +
+ {task.status !== "done" && ( + + )} + {task.status === "pending" && ( + + )} + {task.status === "done" && ( + + )} +
+ + {/* Action buttons */} +
+ + +
+
+ ); +} diff --git a/apps/tasks/app/tasks/page.tsx b/apps/tasks/app/tasks/page.tsx new file mode 100644 index 0000000..bbf04ce --- /dev/null +++ b/apps/tasks/app/tasks/page.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/auth"; +import { getTasks, getGroups, createTask, Task, Group } from "@/lib/api"; +import TaskCard from "@/components/TaskCard"; +import GroupSelector from "@/components/GroupSelector"; +import TaskForm from "@/components/TaskForm"; + +type StatusFilter = "all" | "pending" | "in_progress" | "done" | "cancelled"; + +export default function TasksPage() { + const { token } = useAuth(); + const router = useRouter(); + const [tasks, setTasks] = useState([]); + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedGroup, setSelectedGroup] = useState(null); + const [statusFilter, setStatusFilter] = useState("all"); + const [showForm, setShowForm] = useState(false); + + const loadData = useCallback(async () => { + if (!token) return; + setLoading(true); + try { + const params: Record = {}; + if (selectedGroup) params.group_id = selectedGroup; + if (statusFilter !== "all") params.status = statusFilter; + + const [tasksRes, groupsRes] = await Promise.all([ + getTasks(token, Object.keys(params).length > 0 ? params : undefined), + getGroups(token), + ]); + setTasks(tasksRes.data || []); + setGroups(groupsRes.data || []); + } catch (err) { + console.error("Chyba pri nacitani:", err); + } finally { + setLoading(false); + } + }, [token, selectedGroup, statusFilter]); + + useEffect(() => { + if (!token) { + router.replace("/login"); + return; + } + loadData(); + }, [token, router, loadData]); + + async function handleCreateTask(data: Partial) { + if (!token) return; + await createTask(token, data); + setShowForm(false); + loadData(); + } + + const statusOptions: { value: StatusFilter; label: string }[] = [ + { value: "all", label: "Vse" }, + { value: "pending", label: "Ceka" }, + { value: "in_progress", label: "Probiha" }, + { value: "done", label: "Hotovo" }, + { value: "cancelled", label: "Zruseno" }, + ]; + + if (!token) return null; + + return ( +
+ {/* Group tabs */} + + + {/* Status filter */} +
+ {statusOptions.map((opt) => ( + + ))} +
+ + {/* Task creation form */} + {showForm && ( +
+

Novy ukol

+ setShowForm(false)} + submitLabel="Vytvorit" + /> +
+ )} + + {/* Task list */} + {loading ? ( +
+
+
+ ) : tasks.length === 0 ? ( +
+
+

Zadne ukoly

+

Vytvorte prvni ukol pomoci tlacitka +

+
+ ) : ( +
+ {tasks.map((task) => ( + + ))} +
+ )} + + {/* Floating add button */} + {!showForm && ( + + )} +
+ ); +} diff --git a/apps/tasks/components/AuthProvider.tsx b/apps/tasks/components/AuthProvider.tsx new file mode 100644 index 0000000..95949e8 --- /dev/null +++ b/apps/tasks/components/AuthProvider.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useState, useEffect, ReactNode } from "react"; +import { AuthContext, getStoredToken, getStoredUser, setStoredToken, setStoredUser } from "@/lib/auth"; +import { User } from "@/lib/api"; + +export default function AuthProvider({ children }: { children: ReactNode }) { + const [token, setToken] = useState(null); + const [user, setUser] = useState(null); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + setToken(getStoredToken()); + setUser(getStoredUser()); + setLoaded(true); + }, []); + + function setAuth(newToken: string | null, newUser: User | null) { + setToken(newToken); + setUser(newUser); + setStoredToken(newToken); + setStoredUser(newUser); + } + + function logout() { + setAuth(null, null); + } + + if (!loaded) { + return ( +
+
+
+ ); + } + + return ( + + {children} + + ); +} diff --git a/apps/tasks/components/GroupSelector.tsx b/apps/tasks/components/GroupSelector.tsx new file mode 100644 index 0000000..f49e8a2 --- /dev/null +++ b/apps/tasks/components/GroupSelector.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Group } from "@/lib/api"; + +interface GroupSelectorProps { + groups: Group[]; + selected: string | null; + onSelect: (id: string | null) => void; +} + +export default function GroupSelector({ groups, selected, onSelect }: GroupSelectorProps) { + return ( +
+ + {groups.map((g) => ( + + ))} +
+ ); +} diff --git a/apps/tasks/components/Header.tsx b/apps/tasks/components/Header.tsx new file mode 100644 index 0000000..7cd3ebe --- /dev/null +++ b/apps/tasks/components/Header.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useAuth } from "@/lib/auth"; +import { useTheme } from "./ThemeProvider"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +export default function Header() { + const { user, logout, token } = useAuth(); + const { theme, toggleTheme } = useTheme(); + const router = useRouter(); + + function handleLogout() { + logout(); + router.push("/login"); + } + + return ( +
+
+ + Task Team + + +
+ + + {token && user ? ( +
+ {user.name || user.email} + +
+ ) : ( + + Prihlasit + + )} +
+
+
+ ); +} diff --git a/apps/tasks/components/StatusBadge.tsx b/apps/tasks/components/StatusBadge.tsx new file mode 100644 index 0000000..a78061b --- /dev/null +++ b/apps/tasks/components/StatusBadge.tsx @@ -0,0 +1,24 @@ +"use client"; + +interface StatusBadgeProps { + status: string; + size?: "sm" | "md"; +} + +const STATUS_MAP: Record = { + pending: { label: "Ceka", bg: "bg-yellow-100 dark:bg-yellow-900/30", text: "text-yellow-800 dark:text-yellow-300" }, + in_progress: { label: "Probiha", bg: "bg-blue-100 dark:bg-blue-900/30", text: "text-blue-800 dark:text-blue-300" }, + done: { label: "Hotovo", bg: "bg-green-100 dark:bg-green-900/30", text: "text-green-800 dark:text-green-300" }, + cancelled: { label: "Zruseno", bg: "bg-gray-100 dark:bg-gray-800/30", text: "text-gray-600 dark:text-gray-400" }, +}; + +export default function StatusBadge({ status, size = "sm" }: StatusBadgeProps) { + const s = STATUS_MAP[status] || STATUS_MAP.pending; + const sizeClass = size === "sm" ? "px-2 py-0.5 text-xs" : "px-3 py-1 text-sm"; + + return ( + + {s.label} + + ); +} diff --git a/apps/tasks/components/TaskCard.tsx b/apps/tasks/components/TaskCard.tsx new file mode 100644 index 0000000..7d47836 --- /dev/null +++ b/apps/tasks/components/TaskCard.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { Task } from "@/lib/api"; +import StatusBadge from "./StatusBadge"; +import Link from "next/link"; + +interface TaskCardProps { + task: Task; +} + +const PRIORITY_INDICATOR: Record = { + urgent: "border-l-red-500", + high: "border-l-orange-500", + medium: "border-l-yellow-500", + low: "border-l-green-500", +}; + +export default function TaskCard({ task }: TaskCardProps) { + const priorityClass = PRIORITY_INDICATOR[task.priority] || PRIORITY_INDICATOR.medium; + + return ( + +
+
+
+

+ {task.title} +

+ {task.description && ( +

{task.description}

+ )} +
+ + {task.group_name && ( + + {task.group_name} + + )} + {task.due_at && ( + + {new Date(task.due_at).toLocaleDateString("cs-CZ")} + + )} +
+
+
+ + {task.priority === "urgent" ? "!!!" : task.priority === "high" ? "!!" : task.priority === "medium" ? "!" : ""} + +
+
+
+ + ); +} diff --git a/apps/tasks/components/TaskForm.tsx b/apps/tasks/components/TaskForm.tsx new file mode 100644 index 0000000..82f79d6 --- /dev/null +++ b/apps/tasks/components/TaskForm.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useState } from "react"; +import { Group, Task } from "@/lib/api"; + +interface TaskFormProps { + groups: Group[]; + initial?: Partial; + onSubmit: (data: Partial) => Promise; + onCancel: () => void; + submitLabel?: string; +} + +const STATUSES = [ + { value: "pending", label: "Ceka" }, + { value: "in_progress", label: "Probiha" }, + { value: "done", label: "Hotovo" }, + { value: "cancelled", label: "Zruseno" }, +]; + +const PRIORITIES = [ + { value: "low", label: "Nizka" }, + { value: "medium", label: "Stredni" }, + { value: "high", label: "Vysoka" }, + { value: "urgent", label: "Urgentni" }, +]; + +export default function TaskForm({ groups, initial, onSubmit, onCancel, submitLabel = "Ulozit" }: TaskFormProps) { + const [title, setTitle] = useState(initial?.title || ""); + const [description, setDescription] = useState(initial?.description || ""); + const [status, setStatus] = useState(initial?.status || "pending"); + const [priority, setPriority] = useState(initial?.priority || "medium"); + const [groupId, setGroupId] = useState(initial?.group_id || ""); + const [dueAt, setDueAt] = useState(initial?.due_at ? initial.due_at.slice(0, 16) : ""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!title.trim()) { + setError("Nazev je povinny"); + return; + } + setLoading(true); + setError(""); + try { + await onSubmit({ + title: title.trim(), + description: description.trim(), + status: status as Task["status"], + priority: priority as Task["priority"], + group_id: groupId || null, + due_at: dueAt ? new Date(dueAt).toISOString() : null, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Chyba pri ukladani"); + } finally { + setLoading(false); + } + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + setTitle(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" + placeholder="Co je treba udelat..." + autoFocus + /> +
+ +
+ +