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:
2026-03-29 13:49:25 +00:00
parent fc39029ce3
commit b4b8439f80
14 changed files with 1173 additions and 136 deletions

View File

@@ -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" });

View File

@@ -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;

114
api/src/routes/projects.js Normal file
View File

@@ -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;

View File

@@ -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;

View File

@@ -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}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -27,6 +27,18 @@ export default function BottomNav() {
</svg>
),
},
{
href: "/projects",
label: t("nav.projects"),
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2 12.5V3.5C2 2.67 2.67 2 3.5 2H8.5C9.33 2 10 2.67 10 3.5V12.5C10 13.33 9.33 14 8.5 14H3.5C2.67 14 2 13.33 2 12.5Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M14 20.5V11.5C14 10.67 14.67 10 15.5 10H20.5C21.33 10 22 10.67 22 11.5V20.5C22 21.33 21.33 22 20.5 22H15.5C14.67 22 14 21.33 14 20.5Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M14 5.5V3.5C14 2.67 14.67 2 15.5 2H20.5C21.33 2 22 2.67 22 3.5V5.5C22 6.33 21.33 7 20.5 7H15.5C14.67 7 14 6.33 14 5.5Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2 20.5V18.5C2 17.67 2.67 17 3.5 17H8.5C9.33 17 10 17.67 10 18.5V20.5C10 21.33 9.33 22 8.5 22H3.5C2.67 22 2 21.33 2 20.5Z" />
</svg>
),
},
{
href: "/goals",
label: t("nav.goals"),
@@ -45,16 +57,6 @@ export default function BottomNav() {
</svg>
),
},
{
href: "/settings",
label: t("nav.settings"),
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
];
return (
@@ -66,7 +68,7 @@ export default function BottomNav() {
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center justify-center gap-0.5 flex-1 h-full min-w-[64px] min-h-[48px] rounded-lg transition-colors ${
className={`flex flex-col items-center justify-center gap-0.5 flex-1 h-full min-w-[56px] min-h-[48px] rounded-lg transition-colors ${
isActive
? "text-blue-600 dark:text-blue-400"
: "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"

View File

@@ -204,3 +204,44 @@ export function generateGoalPlan(token: string, id: string) {
export function getGoalReport(token: string, id: string) {
return apiFetch<{ data: GoalReport }>(`/api/v1/goals/${id}/report`, { token });
}
// Projects
export interface Project {
id: string;
owner_id: string | null;
name: string;
description: string;
color: string;
icon: string;
members: string[];
task_count?: number;
done_count?: number;
created_at: string;
updated_at: string;
}
export function getProjects(token: string, userId?: string) {
const qs = userId ? "?user_id=" + userId : "";
return apiFetch<{ data: Project[] }>("/api/v1/projects" + qs, { token });
}
export function getProject(token: string, id: string) {
return apiFetch<{ data: Project }>("/api/v1/projects/" + id, { token });
}
export function createProject(token: string, data: Partial<Project>) {
return apiFetch<{ data: Project }>("/api/v1/projects", { method: "POST", body: data, token });
}
export function updateProject(token: string, id: string, data: Partial<Project>) {
return apiFetch<{ data: Project }>("/api/v1/projects/" + id, { method: "PUT", body: data, token });
}
export function deleteProject(token: string, id: string) {
return apiFetch<void>("/api/v1/projects/" + id, { method: "DELETE", token });
}
export function inviteToProject(token: string, id: string, userId: string) {
return apiFetch<{ data: Project; status: string }>("/api/v1/projects/" + id + "/invite", { method: "POST", body: { user_id: userId }, token });
}

View File

@@ -22,14 +22,14 @@ const MESSAGES: Record<Locale, Messages> = { cs, he, ru, ua };
const STORAGE_KEY = "taskteam_language";
function getNestedValue(obj: unknown, path: string): string {
function getNestedValue(obj: unknown, path: string): string | undefined {
const keys = path.split(".");
let current: unknown = obj;
for (const key of keys) {
if (current == null || typeof current !== "object") return path;
if (current == null || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[key];
}
return typeof current === "string" ? current : path;
return typeof current === "string" ? current : undefined;
}
interface I18nContextType {
@@ -75,7 +75,10 @@ export function I18nProvider({ children }: { children: ReactNode }) {
const t = useCallback(
(key: string): string => {
return getNestedValue(MESSAGES[locale], key);
const value = getNestedValue(MESSAGES[locale], key);
if (value !== undefined) return value;
// Fallback: return last segment of the key (the raw value) instead of "undefined"
return key.split(".").pop() || key;
},
[locale]
);

View File

@@ -1,10 +1,133 @@
{
"nav": { "tasks": "Úkoly", "calendar": "Kalendář", "chat": "Chat", "settings": "Nastavení", "goals": "Cíle" },
"auth": { "login": "Přihlášení", "register": "Registrace", "email": "Email", "name": "Jméno", "phone": "Telefon", "submit": "Přihlásit se", "registerBtn": "Registrovat se", "noAccount": "Nemáte účet?", "hasAccount": "Máte účet?", "logout": "Odhlásit se" },
"tasks": { "title": "Úkoly", "add": "Nový úkol", "edit": "Upravit", "delete": "Smazat", "noTasks": "Žádné úkoly", "all": "Vše", "status": { "pending": "Čeká", "in_progress": "Probíhá", "done": "Hotovo", "completed": "Hotovo", "cancelled": "Zrušeno" }, "priority": { "urgent": "Urgentní", "high": "Vysoká", "medium": "Střední", "low": "Nízká" }, "form": { "title": "Název", "description": "Popis", "group": "Skupina", "priority": "Priorita", "status": "Status", "dueDate": "Termín", "save": "Uložit", "cancel": "Zrušit", "titleRequired": "Název je povinný", "saveError": "Chyba při ukládání", "saving": "Ukládám...", "noGroup": "-- Bez skupiny --", "placeholder": "Co je třeba udělat...", "descPlaceholder": "Podrobnosti..." }, "noDue": "Bez termínu", "createFirst": "Vytvořte první úkol pomocí tlačítka +", "newTask": "Nový úkol", "close": "Zavřít", "markDone": "Označit jako hotové", "start": "Zahájit", "reopen": "Znovu otevřít", "confirmDelete": "Opravdu smazat tento úkol?", "editTask": "Upravit úkol", "saveChanges": "Uložit změny", "deleting": "Mažu...", "created": "Vytvořeno", "completed": "Dokončeno", "loadError": "Chyba při načítání úkolu", "notFound": "Úkol nenalezen", "backToTasks": "Zpět na úkoly" },
"chat": { "title": "AI Asistent", "placeholder": "Napište zprávu...", "send": "Odeslat", "empty": "Zeptejte se na cokoliv...", "subtitle": "Zeptejte se na cokoliv ohledně vašich úkolů", "startConversation": "Začněte konverzaci", "helpText": "Napište zprávu a AI asistent vám pomůže s úkoly", "unavailable": "Chat asistent je momentálně nedostupný. Zkuste to prosím později.", "processError": "Omlouvám se, nemohl jsem zpracovat vaši zprávu." },
"settings": { "title": "Nastavení", "language": "Jazyk", "theme": "Motiv", "dark": "Tmavý režim", "light": "Světlý režim", "notifications": "Oznámení", "push": "Push oznámení", "email": "E-mailová oznámení", "taskReminders": "Připomenutí úkolů", "dailySummary": "Denní souhrn", "save": "Uložit nastavení", "saved": "Uloženo!", "profile": "Profil", "appearance": "Vzhled", "user": "Uživatel" },
"goals": { "title": "Cíle", "add": "Nový cíl", "progress": "Progres", "plan": "Generovat plán", "report": "AI Report" },
"common": { "back": "Zpět", "loading": "Načítání...", "error": "Chyba", "confirm": "Potvrdit", "menu": "Menu", "closeMenu": "Zavřít menu", "toggleTheme": "Přepnout téma" },
"calendar": { "title": "Kalendář" }
}
"nav": {
"tasks": "Úkoly",
"calendar": "Kalendář",
"chat": "Chat",
"settings": "Nastavení",
"goals": "Cíle",
"projects": "Projekty"
},
"auth": {
"login": "Přihlášení",
"register": "Registrace",
"email": "Email",
"name": "Jméno",
"phone": "Telefon",
"submit": "Přihlásit se",
"registerBtn": "Registrovat se",
"noAccount": "Nemáte účet?",
"hasAccount": "Máte účet?",
"logout": "Odhlásit se"
},
"tasks": {
"title": "Úkoly",
"add": "Nový úkol",
"edit": "Upravit",
"delete": "Smazat",
"noTasks": "Žádné úkoly",
"all": "Vše",
"status": {
"pending": "Čeká",
"in_progress": "Probíhá",
"done": "Hotovo",
"completed": "Hotovo",
"cancelled": "Zrušeno"
},
"priority": {
"urgent": "Urgentní",
"high": "Vysoká",
"medium": "Střední",
"low": "Nízká"
},
"form": {
"title": "Název",
"description": "Popis",
"group": "Skupina",
"priority": "Priorita",
"status": "Status",
"dueDate": "Termín",
"save": "Uložit",
"cancel": "Zrušit",
"titleRequired": "Název je povinný",
"saveError": "Chyba při ukládání",
"saving": "Ukládám...",
"noGroup": "-- Bez skupiny --",
"placeholder": "Co je třeba udělat...",
"descPlaceholder": "Podrobnosti..."
},
"noDue": "Bez termínu",
"createFirst": "Vytvořte první úkol pomocí tlačítka +",
"newTask": "Nový úkol",
"close": "Zavřít",
"markDone": "Označit jako hotové",
"start": "Zahájit",
"reopen": "Znovu otevřít",
"confirmDelete": "Opravdu smazat tento úkol?",
"editTask": "Upravit úkol",
"saveChanges": "Uložit změny",
"deleting": "Mažu...",
"created": "Vytvořeno",
"completed": "Dokončeno",
"loadError": "Chyba při načítání úkolu",
"notFound": "Úkol nenalezen",
"backToTasks": "Zpět na úkoly"
},
"chat": {
"title": "AI Asistent",
"placeholder": "Napište zprávu...",
"send": "Odeslat",
"empty": "Zeptejte se na cokoliv...",
"subtitle": "Zeptejte se na cokoliv ohledně vašich úkolů",
"startConversation": "Začněte konverzaci",
"helpText": "Napište zprávu a AI asistent vám pomůže s úkoly",
"unavailable": "Chat asistent je momentálně nedostupný. Zkuste to prosím později.",
"processError": "Omlouvám se, nemohl jsem zpracovat vaši zprávu."
},
"settings": {
"title": "Nastavení",
"language": "Jazyk",
"theme": "Motiv",
"dark": "Tmavý režim",
"light": "Světlý režim",
"notifications": "Oznámení",
"push": "Push oznámení",
"email": "E-mailová oznámení",
"taskReminders": "Připomenutí úkolů",
"dailySummary": "Denní souhrn",
"save": "Uložit nastavení",
"saved": "Uloženo!",
"profile": "Profil",
"appearance": "Vzhled",
"user": "Uživatel"
},
"goals": {
"title": "Cíle",
"add": "Nový cíl",
"progress": "Progres",
"plan": "Generovat plán",
"report": "AI Report"
},
"common": {
"back": "Zpět",
"loading": "Načítání...",
"error": "Chyba",
"confirm": "Potvrdit",
"menu": "Menu",
"closeMenu": "Zavřít menu",
"toggleTheme": "Přepnout téma"
},
"calendar": {
"title": "Kalendář"
},
"projects": {
"add": "Novy projekt",
"empty": "Zadne projekty",
"createFirst": "Vytvorte prvni projekt tlacitkem +",
"namePlaceholder": "Nazev projektu...",
"descPlaceholder": "Popis projektu...",
"color": "Barva",
"icon": "Ikona",
"tasks": "Ukoly",
"members": "Clenove"
}
}

View File

@@ -1,10 +1,133 @@
{
"nav": { "tasks": "משימות", "calendar": "לוח שנה", "chat": "צ׳אט", "settings": "הגדרות", "goals": "מטרות" },
"auth": { "login": "התחברות", "register": "הרשמה", "email": "אימייל", "name": "שם", "phone": "טלפון", "submit": "התחבר", "registerBtn": "הירשם", "noAccount": "אין לך חשבון?", "hasAccount": "יש לך חשבון?", "logout": "התנתק" },
"tasks": { "title": "משימות", "add": "משימה חדשה", "edit": "ערוך", "delete": "מחק", "noTasks": "אין משימות", "all": "הכל", "status": { "pending": "ממתין", "in_progress": "בתהליך", "done": "הושלם", "completed": "הושלם", "cancelled": "בוטל" }, "priority": { "urgent": "דחוף", "high": "גבוה", "medium": "בינוני", "low": "נמוך" }, "form": { "title": "כותרת", "description": "תיאור", "group": "קבוצה", "priority": "עדיפות", "status": "סטטוס", "dueDate": "תאריך יעד", "save": "שמור", "cancel": "ביטול", "titleRequired": "כותרת חובה", "saveError": "שגיאה בשמירה", "saving": "שומר...", "noGroup": "-- ללא קבוצה --", "placeholder": "מה צריך לעשות...", "descPlaceholder": "פרטים..." }, "noDue": "ללא תאריך", "createFirst": "צור משימה ראשונה בעזרת הכפתור +", "newTask": "משימה חדשה", "close": "סגור", "markDone": "סמן כהושלם", "start": "התחל", "reopen": "פתח מחדש", "confirmDelete": "למחוק משימה זו?", "editTask": "ערוך משימה", "saveChanges": "שמור שינויים", "deleting": "מוחק...", "created": "נוצר", "completed": "הושלם", "loadError": "שגיאה בטעינת המשימה", "notFound": "משימה לא נמצאה", "backToTasks": "חזרה למשימות" },
"chat": { "title": "עוזר AI", "placeholder": "כתוב הודעה...", "send": "שלח", "empty": "שאל כל דבר...", "subtitle": "שאל כל שאלה לגבי המשימות שלך", "startConversation": "התחל שיחה", "helpText": "כתוב הודעה ועוזר ה-AI יעזור לך עם משימות", "unavailable": "עוזר הצ׳אט אינו זמין כרגע. נסה שוב מאוחר יותר.", "processError": "מצטער, לא הצלחתי לעבד את ההודעה שלך." },
"settings": { "title": "הגדרות", "language": "שפה", "theme": "ערכת נושא", "dark": "מצב כהה", "light": "מצב בהיר", "notifications": "התראות", "push": "התראות פוש", "email": "התראות אימייל", "taskReminders": "תזכורות משימות", "dailySummary": "סיכום יומי", "save": "שמור הגדרות", "saved": "נשמר!", "profile": "פרופיל", "appearance": "מראה", "user": "משתמש" },
"goals": { "title": "מטרות", "add": "מטרה חדשה", "progress": "התקדמות", "plan": "צור תוכנית", "report": "דוח AI" },
"common": { "back": "חזרה", "loading": "טוען...", "error": "שגיאה", "confirm": "אישור", "menu": "תפריט", "closeMenu": "סגור תפריט", "toggleTheme": "החלף ערכת נושא" },
"calendar": { "title": "לוח שנה" }
}
"nav": {
"tasks": "משימות",
"calendar": "לוח שנה",
"chat": "צ׳אט",
"settings": "הגדרות",
"goals": "מטרות",
"projects": "פרויקטים"
},
"auth": {
"login": "התחברות",
"register": "הרשמה",
"email": "אימייל",
"name": "שם",
"phone": "טלפון",
"submit": "התחבר",
"registerBtn": "הירשם",
"noAccount": "אין לך חשבון?",
"hasAccount": "יש לך חשבון?",
"logout": "התנתק"
},
"tasks": {
"title": "משימות",
"add": "משימה חדשה",
"edit": "ערוך",
"delete": "מחק",
"noTasks": "אין משימות",
"all": "הכל",
"status": {
"pending": "ממתין",
"in_progress": "בתהליך",
"done": "הושלם",
"completed": "הושלם",
"cancelled": "בוטל"
},
"priority": {
"urgent": "דחוף",
"high": "גבוה",
"medium": "בינוני",
"low": "נמוך"
},
"form": {
"title": "כותרת",
"description": "תיאור",
"group": "קבוצה",
"priority": "עדיפות",
"status": "סטטוס",
"dueDate": "תאריך יעד",
"save": "שמור",
"cancel": "ביטול",
"titleRequired": "כותרת חובה",
"saveError": "שגיאה בשמירה",
"saving": "שומר...",
"noGroup": "-- ללא קבוצה --",
"placeholder": "מה צריך לעשות...",
"descPlaceholder": "פרטים..."
},
"noDue": "ללא תאריך",
"createFirst": "צור משימה ראשונה בעזרת הכפתור +",
"newTask": "משימה חדשה",
"close": "סגור",
"markDone": "סמן כהושלם",
"start": "התחל",
"reopen": "פתח מחדש",
"confirmDelete": "למחוק משימה זו?",
"editTask": "ערוך משימה",
"saveChanges": "שמור שינויים",
"deleting": "מוחק...",
"created": "נוצר",
"completed": "הושלם",
"loadError": "שגיאה בטעינת המשימה",
"notFound": "משימה לא נמצאה",
"backToTasks": "חזרה למשימות"
},
"chat": {
"title": "עוזר AI",
"placeholder": "כתוב הודעה...",
"send": "שלח",
"empty": "שאל כל דבר...",
"subtitle": "שאל כל שאלה לגבי המשימות שלך",
"startConversation": "התחל שיחה",
"helpText": "כתוב הודעה ועוזר ה-AI יעזור לך עם משימות",
"unavailable": "עוזר הצ׳אט אינו זמין כרגע. נסה שוב מאוחר יותר.",
"processError": "מצטער, לא הצלחתי לעבד את ההודעה שלך."
},
"settings": {
"title": "הגדרות",
"language": "שפה",
"theme": "ערכת נושא",
"dark": "מצב כהה",
"light": "מצב בהיר",
"notifications": "התראות",
"push": "התראות פוש",
"email": "התראות אימייל",
"taskReminders": "תזכורות משימות",
"dailySummary": "סיכום יומי",
"save": "שמור הגדרות",
"saved": "נשמר!",
"profile": "פרופיל",
"appearance": "מראה",
"user": "משתמש"
},
"goals": {
"title": "מטרות",
"add": "מטרה חדשה",
"progress": "התקדמות",
"plan": "צור תוכנית",
"report": "דוח AI"
},
"common": {
"back": "חזרה",
"loading": "טוען...",
"error": "שגיאה",
"confirm": "אישור",
"menu": "תפריט",
"closeMenu": "סגור תפריט",
"toggleTheme": "החלף ערכת נושא"
},
"calendar": {
"title": "לוח שנה"
},
"projects": {
"add": "פרויקט חדש",
"empty": "אין פרויקטים",
"createFirst": "צור פרויקט ראשון +",
"namePlaceholder": "שם פרויקט...",
"descPlaceholder": "תיאור פרויקט...",
"color": "צבע",
"icon": "איקון",
"tasks": "משימות",
"members": "חברים"
}
}

View File

@@ -1,10 +1,133 @@
{
"nav": { "tasks": "Задачи", "calendar": "Календарь", "chat": "Чат", "settings": "Настройки", "goals": "Цели" },
"auth": { "login": "Вход", "register": "Регистрация", "email": "Email", "name": "Имя", "phone": "Телефон", "submit": "Войти", "registerBtn": "Зарегистрироваться", "noAccount": "Нет аккаунта?", "hasAccount": "Есть аккаунт?", "logout": "Выйти" },
"tasks": { "title": "Задачи", "add": "Новая задача", "edit": "Редактировать", "delete": "Удалить", "noTasks": "Нет задач", "all": "Все", "status": { "pending": "Ожидает", "in_progress": "В работе", "done": "Готово", "completed": "Готово", "cancelled": "Отменено" }, "priority": { "urgent": "Срочно", "high": "Высокий", "medium": "Средний", "low": "Низкий" }, "form": { "title": "Название", "description": "Описание", "group": "Группа", "priority": "Приоритет", "status": "Статус", "dueDate": "Срок", "save": "Сохранить", "cancel": "Отмена", "titleRequired": "Название обязательно", "saveError": "Ошибка при сохранении", "saving": "Сохраняю...", "noGroup": "-- Без группы --", "placeholder": "Что нужно сделать...", "descPlaceholder": "Подробности..." }, "noDue": "Без срока", "createFirst": "Создайте первую задачу кнопкой +", "newTask": "Новая задача", "close": "Закрыть", "markDone": "Отметить готовой", "start": "Начать", "reopen": "Открыть заново", "confirmDelete": "Удалить эту задачу?", "editTask": "Редактировать задачу", "saveChanges": "Сохранить изменения", "deleting": "Удаляю...", "created": "Создано", "completed": "Завершено", "loadError": "Ошибка при загрузке задачи", "notFound": "Задача не найдена", "backToTasks": "Назад к задачам" },
"chat": { "title": "AI Ассистент", "placeholder": "Напишите сообщение...", "send": "Отправить", "empty": "Спросите что угодно...", "subtitle": "Задайте любой вопрос о ваших задачах", "startConversation": "Начните разговор", "helpText": "Напишите сообщение, и AI ассистент поможет вам с задачами", "unavailable": "Чат ассистент сейчас недоступен. Попробуйте позже.", "processError": "Извините, не удалось обработать ваше сообщение." },
"settings": { "title": "Настройки", "language": "Язык", "theme": "Тема", "dark": "Тёмный режим", "light": "Светлый режим", "notifications": "Уведомления", "push": "Push уведомления", "email": "E-mail уведомления", "taskReminders": "Напоминания о задачах", "dailySummary": "Ежедневная сводка", "save": "Сохранить настройки", "saved": "Сохранено!", "profile": "Профиль", "appearance": "Внешний вид", "user": "Пользователь" },
"goals": { "title": "Цели", "add": "Новая цель", "progress": "Прогресс", "plan": "Создать план", "report": "AI Отчёт" },
"common": { "back": "Назад", "loading": "Загрузка...", "error": "Ошибка", "confirm": "Подтвердить", "menu": "Меню", "closeMenu": "Закрыть меню", "toggleTheme": "Переключить тему" },
"calendar": { "title": "Календарь" }
}
"nav": {
"tasks": "Задачи",
"calendar": "Календарь",
"chat": "Чат",
"settings": "Настройки",
"goals": "Цели",
"projects": "Проекты"
},
"auth": {
"login": "Вход",
"register": "Регистрация",
"email": "Email",
"name": "Имя",
"phone": "Телефон",
"submit": "Войти",
"registerBtn": "Зарегистрироваться",
"noAccount": "Нет аккаунта?",
"hasAccount": "Есть аккаунт?",
"logout": "Выйти"
},
"tasks": {
"title": "Задачи",
"add": "Новая задача",
"edit": "Редактировать",
"delete": "Удалить",
"noTasks": "Нет задач",
"all": "Все",
"status": {
"pending": "Ожидает",
"in_progress": "В работе",
"done": "Готово",
"completed": "Готово",
"cancelled": "Отменено"
},
"priority": {
"urgent": "Срочно",
"high": "Высокий",
"medium": "Средний",
"low": "Низкий"
},
"form": {
"title": "Название",
"description": "Описание",
"group": "Группа",
"priority": "Приоритет",
"status": "Статус",
"dueDate": "Срок",
"save": "Сохранить",
"cancel": "Отмена",
"titleRequired": "Название обязательно",
"saveError": "Ошибка при сохранении",
"saving": "Сохраняю...",
"noGroup": "-- Без группы --",
"placeholder": "Что нужно сделать...",
"descPlaceholder": "Подробности..."
},
"noDue": "Без срока",
"createFirst": "Создайте первую задачу кнопкой +",
"newTask": "Новая задача",
"close": "Закрыть",
"markDone": "Отметить готовой",
"start": "Начать",
"reopen": "Открыть заново",
"confirmDelete": "Удалить эту задачу?",
"editTask": "Редактировать задачу",
"saveChanges": "Сохранить изменения",
"deleting": "Удаляю...",
"created": "Создано",
"completed": "Завершено",
"loadError": "Ошибка при загрузке задачи",
"notFound": "Задача не найдена",
"backToTasks": "Назад к задачам"
},
"chat": {
"title": "AI Ассистент",
"placeholder": "Напишите сообщение...",
"send": "Отправить",
"empty": "Спросите что угодно...",
"subtitle": "Задайте любой вопрос о ваших задачах",
"startConversation": "Начните разговор",
"helpText": "Напишите сообщение, и AI ассистент поможет вам с задачами",
"unavailable": "Чат ассистент сейчас недоступен. Попробуйте позже.",
"processError": "Извините, не удалось обработать ваше сообщение."
},
"settings": {
"title": "Настройки",
"language": "Язык",
"theme": "Тема",
"dark": "Тёмный режим",
"light": "Светлый режим",
"notifications": "Уведомления",
"push": "Push уведомления",
"email": "E-mail уведомления",
"taskReminders": "Напоминания о задачах",
"dailySummary": "Ежедневная сводка",
"save": "Сохранить настройки",
"saved": "Сохранено!",
"profile": "Профиль",
"appearance": "Внешний вид",
"user": "Пользователь"
},
"goals": {
"title": "Цели",
"add": "Новая цель",
"progress": "Прогресс",
"plan": "Создать план",
"report": "AI Отчёт"
},
"common": {
"back": "Назад",
"loading": "Загрузка...",
"error": "Ошибка",
"confirm": "Подтвердить",
"menu": "Меню",
"closeMenu": "Закрыть меню",
"toggleTheme": "Переключить тему"
},
"calendar": {
"title": "Календарь"
},
"projects": {
"add": "Новый проект",
"empty": "Нет проектов",
"createFirst": "Создайте первый проект кнопкой +",
"namePlaceholder": "Название проекта...",
"descPlaceholder": "Описание проекта...",
"color": "Цвет",
"icon": "Иконка",
"tasks": "Задачи",
"members": "Участники"
}
}

View File

@@ -1,10 +1,133 @@
{
"nav": { "tasks": "Завдання", "calendar": "Календар", "chat": "Чат", "settings": "Налаштування", "goals": "Цілі" },
"auth": { "login": "Вхід", "register": "Реєстрація", "email": "Email", "name": "Ім'я", "phone": "Телефон", "submit": "Увійти", "registerBtn": "Зареєструватися", "noAccount": "Немає акаунту?", "hasAccount": "Є акаунт?", "logout": "Вийти" },
"tasks": { "title": "Завдання", "add": "Нове завдання", "edit": "Редагувати", "delete": "Видалити", "noTasks": "Немає завдань", "all": "Усі", "status": { "pending": "Очікує", "in_progress": "В роботі", "done": "Готово", "completed": "Готово", "cancelled": "Скасовано" }, "priority": { "urgent": "Терміново", "high": "Високий", "medium": "Середній", "low": "Низький" }, "form": { "title": "Назва", "description": "Опис", "group": "Група", "priority": "Пріоритет", "status": "Статус", "dueDate": "Термін", "save": "Зберегти", "cancel": "Скасувати", "titleRequired": "Назва обов'язкова", "saveError": "Помилка при збереженні", "saving": "Зберігаю...", "noGroup": "-- Без групи --", "placeholder": "Що треба зробити...", "descPlaceholder": "Подробиці..." }, "noDue": "Без терміну", "createFirst": "Створіть перше завдання кнопкою +", "newTask": "Нове завдання", "close": "Закрити", "markDone": "Позначити готовим", "start": "Розпочати", "reopen": "Відкрити знову", "confirmDelete": "Видалити це завдання?", "editTask": "Редагувати завдання", "saveChanges": "Зберегти зміни", "deleting": "Видаляю...", "created": "Створено", "completed": "Завершено", "loadError": "Помилка при завантаженні завдання", "notFound": "Завдання не знайдено", "backToTasks": "Назад до завдань" },
"chat": { "title": "AI Асистент", "placeholder": "Напишіть повідомлення...", "send": "Надіслати", "empty": "Запитайте будь-що...", "subtitle": "Задайте будь-яке питання щодо ваших завдань", "startConversation": "Почніть розмову", "helpText": "Напишіть повідомлення, і AI асистент допоможе вам із завданнями", "unavailable": "Чат асистент зараз недоступний. Спробуйте пізніше.", "processError": "Вибачте, не вдалося обробити ваше повідомлення." },
"settings": { "title": "Налаштування", "language": "Мова", "theme": "Тема", "dark": "Темний режим", "light": "Світлий режим", "notifications": "Сповіщення", "push": "Push сповіщення", "email": "E-mail сповіщення", "taskReminders": "Нагадування про завдання", "dailySummary": "Щоденний підсумок", "save": "Зберегти налаштування", "saved": "Збережено!", "profile": "Профіль", "appearance": "Зовнішній вигляд", "user": "Користувач" },
"goals": { "title": "Цілі", "add": "Нова ціль", "progress": "Прогрес", "plan": "Створити план", "report": "AI Звіт" },
"common": { "back": "Назад", "loading": "Завантаження...", "error": "Помилка", "confirm": "Підтвердити", "menu": "Меню", "closeMenu": "Закрити меню", "toggleTheme": "Перемкнути тему" },
"calendar": { "title": "Календар" }
}
"nav": {
"tasks": "Завдання",
"calendar": "Календар",
"chat": "Чат",
"settings": "Налаштування",
"goals": "Цілі",
"projects": "Проекти"
},
"auth": {
"login": "Вхід",
"register": "Реєстрація",
"email": "Email",
"name": "Ім'я",
"phone": "Телефон",
"submit": "Увійти",
"registerBtn": "Зареєструватися",
"noAccount": "Немає акаунту?",
"hasAccount": "Є акаунт?",
"logout": "Вийти"
},
"tasks": {
"title": "Завдання",
"add": "Нове завдання",
"edit": "Редагувати",
"delete": "Видалити",
"noTasks": "Немає завдань",
"all": "Усі",
"status": {
"pending": "Очікує",
"in_progress": "В роботі",
"done": "Готово",
"completed": "Готово",
"cancelled": "Скасовано"
},
"priority": {
"urgent": "Терміново",
"high": "Високий",
"medium": "Середній",
"low": "Низький"
},
"form": {
"title": "Назва",
"description": "Опис",
"group": "Група",
"priority": "Пріоритет",
"status": "Статус",
"dueDate": "Термін",
"save": "Зберегти",
"cancel": "Скасувати",
"titleRequired": "Назва обов'язкова",
"saveError": "Помилка при збереженні",
"saving": "Зберігаю...",
"noGroup": "-- Без групи --",
"placeholder": "Що треба зробити...",
"descPlaceholder": "Подробиці..."
},
"noDue": "Без терміну",
"createFirst": "Створіть перше завдання кнопкою +",
"newTask": "Нове завдання",
"close": "Закрити",
"markDone": "Позначити готовим",
"start": "Розпочати",
"reopen": "Відкрити знову",
"confirmDelete": "Видалити це завдання?",
"editTask": "Редагувати завдання",
"saveChanges": "Зберегти зміни",
"deleting": "Видаляю...",
"created": "Створено",
"completed": "Завершено",
"loadError": "Помилка при завантаженні завдання",
"notFound": "Завдання не знайдено",
"backToTasks": "Назад до завдань"
},
"chat": {
"title": "AI Асистент",
"placeholder": "Напишіть повідомлення...",
"send": "Надіслати",
"empty": "Запитайте будь-що...",
"subtitle": "Задайте будь-яке питання щодо ваших завдань",
"startConversation": "Почніть розмову",
"helpText": "Напишіть повідомлення, і AI асистент допоможе вам із завданнями",
"unavailable": "Чат асистент зараз недоступний. Спробуйте пізніше.",
"processError": "Вибачте, не вдалося обробити ваше повідомлення."
},
"settings": {
"title": "Налаштування",
"language": "Мова",
"theme": "Тема",
"dark": "Темний режим",
"light": "Світлий режим",
"notifications": "Сповіщення",
"push": "Push сповіщення",
"email": "E-mail сповіщення",
"taskReminders": "Нагадування про завдання",
"dailySummary": "Щоденний підсумок",
"save": "Зберегти налаштування",
"saved": "Збережено!",
"profile": "Профіль",
"appearance": "Зовнішній вигляд",
"user": "Користувач"
},
"goals": {
"title": "Цілі",
"add": "Нова ціль",
"progress": "Прогрес",
"plan": "Створити план",
"report": "AI Звіт"
},
"common": {
"back": "Назад",
"loading": "Завантаження...",
"error": "Помилка",
"confirm": "Підтвердити",
"menu": "Меню",
"closeMenu": "Закрити меню",
"toggleTheme": "Перемкнути тему"
},
"calendar": {
"title": "Календар"
},
"projects": {
"add": "Новий проект",
"empty": "Немає проектів",
"createFirst": "Створіть перший проект кнопкою +",
"namePlaceholder": "Назва проекту...",
"descPlaceholder": "Опис проекту...",
"color": "Колір",
"icon": "Іконка",
"tasks": "Завдання",
"members": "Учасники"
}
}