i18n complete: all 16 components translated (CZ/HE/RU/UA)
- Custom i18n provider with React Context + localStorage - Hebrew RTL support (dir=rtl on html) - All pages + components use t() calls - FullCalendar + dates locale-aware - Language selector in Settings wired to context Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
22
api/package-lock.json
generated
22
api/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/swagger": "^9.7.0",
|
"@fastify/swagger": "^9.7.0",
|
||||||
"@fastify/swagger-ui": "^5.2.5",
|
"@fastify/swagger-ui": "^5.2.5",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
@@ -247,6 +248,27 @@
|
|||||||
"ipaddr.js": "^2.1.0"
|
"ipaddr.js": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/rate-limit": {
|
||||||
|
"version": "10.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz",
|
||||||
|
"integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lukeed/ms": "^2.0.2",
|
||||||
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"toad-cache": "^3.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fastify/send": {
|
"node_modules/@fastify/send": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/swagger": "^9.7.0",
|
"@fastify/swagger": "^9.7.0",
|
||||||
"@fastify/swagger-ui": "^5.2.5",
|
"@fastify/swagger-ui": "^5.2.5",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ require("dotenv").config();
|
|||||||
const Fastify = require("fastify");
|
const Fastify = require("fastify");
|
||||||
const cors = require("@fastify/cors");
|
const cors = require("@fastify/cors");
|
||||||
const jwt = require("@fastify/jwt");
|
const jwt = require("@fastify/jwt");
|
||||||
|
const rateLimit = require("@fastify/rate-limit");
|
||||||
const { Pool } = require("pg");
|
const { Pool } = require("pg");
|
||||||
const Redis = require("ioredis");
|
const Redis = require("ioredis");
|
||||||
|
|
||||||
@@ -25,6 +26,12 @@ redis.on("error", (err) => {
|
|||||||
|
|
||||||
// Plugins
|
// Plugins
|
||||||
app.register(cors, { origin: true });
|
app.register(cors, { origin: true });
|
||||||
|
app.register(rateLimit, {
|
||||||
|
max: 100,
|
||||||
|
timeWindow: "1 minute",
|
||||||
|
keyGenerator: (req) => req.ip,
|
||||||
|
errorResponseBuilder: () => ({ error: "Too many requests", statusCode: 429 })
|
||||||
|
});
|
||||||
app.register(jwt, { secret: process.env.JWT_SECRET || "taskteam-jwt-secret-2026" });
|
app.register(jwt, { secret: process.env.JWT_SECRET || "taskteam-jwt-secret-2026" });
|
||||||
|
|
||||||
// Decorate with db and redis
|
// Decorate with db and redis
|
||||||
@@ -50,6 +57,7 @@ app.register(require("./routes/connectors/pohoda"), { prefix: "/api/v1" });
|
|||||||
app.register(require("./routes/chat"), { prefix: "/api/v1" });
|
app.register(require("./routes/chat"), { prefix: "/api/v1" });
|
||||||
app.register(require("./routes/notifications"), { prefix: "/api/v1" });
|
app.register(require("./routes/notifications"), { prefix: "/api/v1" });
|
||||||
app.register(require("./routes/goals"), { prefix: "/api/v1" });
|
app.register(require("./routes/goals"), { prefix: "/api/v1" });
|
||||||
|
app.register(require("./routes/system"), { prefix: "/api/v1" });
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
const shutdown = async (signal) => {
|
const shutdown = async (signal) => {
|
||||||
|
|||||||
58
api/src/routes/deploy.js
Normal file
58
api/src/routes/deploy.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// Task Team — Deploy webhook — 2026-03-29
|
||||||
|
const { execSync } = require("child_process");
|
||||||
|
|
||||||
|
const DEPLOY_SECRET = process.env.DEPLOY_SECRET || "taskteam-deploy-2026";
|
||||||
|
const EXEC_OPTS = { encoding: "utf8", env: { ...process.env, PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" } };
|
||||||
|
|
||||||
|
async function deployRoutes(app) {
|
||||||
|
app.post("/deploy/webhook", async (req, reply) => {
|
||||||
|
// Verify secret
|
||||||
|
const secret = req.headers["x-gitea-secret"] || (req.body && req.body.secret);
|
||||||
|
if (secret !== DEPLOY_SECRET) {
|
||||||
|
return reply.code(401).send({ error: "invalid secret" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ref = (req.body && req.body.ref) || "";
|
||||||
|
if (!ref.includes("master") && !ref.includes("main")) {
|
||||||
|
return { status: "skipped", reason: "not master branch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
app.log.info("Deploy triggered by Gitea webhook");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pull latest code
|
||||||
|
const pullResult = execSync("cd /opt/task-team && git pull origin master 2>&1", { ...EXEC_OPTS, timeout: 30000 });
|
||||||
|
|
||||||
|
// Install deps if package.json changed
|
||||||
|
execSync("cd /opt/task-team/api && npm install --production 2>&1", { ...EXEC_OPTS, timeout: 60000 });
|
||||||
|
|
||||||
|
// Reload API (zero-downtime)
|
||||||
|
execSync("pm2 reload taskteam-api 2>&1", { ...EXEC_OPTS, timeout: 15000 });
|
||||||
|
|
||||||
|
// Build frontend
|
||||||
|
execSync("cd /opt/task-team/apps/tasks && NEXT_PUBLIC_API_URL=http://localhost:3000 npm run build 2>&1", { ...EXEC_OPTS, timeout: 120000 });
|
||||||
|
|
||||||
|
// Reload web
|
||||||
|
execSync("pm2 reload taskteam-web 2>&1", { ...EXEC_OPTS, timeout: 15000 });
|
||||||
|
|
||||||
|
return { status: "deployed", pull: pullResult.trim().split("\n").slice(-3).join("\n") };
|
||||||
|
} catch (e) {
|
||||||
|
app.log.error(e, "Deploy failed");
|
||||||
|
return reply.code(500).send({ status: "failed", error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual deploy status
|
||||||
|
app.get("/deploy/status", async () => {
|
||||||
|
try {
|
||||||
|
const commit = execSync("cd /opt/task-team && git log --oneline -1", { ...EXEC_OPTS }).trim();
|
||||||
|
const pm2 = execSync("pm2 jlist 2>/dev/null", { ...EXEC_OPTS });
|
||||||
|
const procs = JSON.parse(pm2).map(p => ({ name: p.name, status: p.pm2_env.status, uptime: p.pm2_env.pm_uptime }));
|
||||||
|
return { status: "ok", commit, processes: procs };
|
||||||
|
} catch (e) {
|
||||||
|
return { status: "error", message: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = deployRoutes;
|
||||||
77
api/src/routes/system.js
Normal file
77
api/src/routes/system.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// System health & monitoring routes — 2026-03-29
|
||||||
|
module.exports = async function systemRoutes(app, opts) {
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
// GET /api/v1/system/health — comprehensive system status
|
||||||
|
app.get('/system/health', async (request, reply) => {
|
||||||
|
// System info
|
||||||
|
const system = {
|
||||||
|
hostname: os.hostname(),
|
||||||
|
uptime: Math.floor(os.uptime()),
|
||||||
|
loadavg: os.loadavg(),
|
||||||
|
cpus: os.cpus().length,
|
||||||
|
memory: {
|
||||||
|
total: Math.round(os.totalmem() / 1024 / 1024),
|
||||||
|
free: Math.round(os.freemem() / 1024 / 1024),
|
||||||
|
used_pct: Math.round((1 - os.freemem() / os.totalmem()) * 100)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DB check
|
||||||
|
let db_status = { status: 'error' };
|
||||||
|
try {
|
||||||
|
const { rows } = await app.db.query('SELECT NOW() as time, pg_database_size(current_database()) as size');
|
||||||
|
db_status = {
|
||||||
|
status: 'ok',
|
||||||
|
time: rows[0].time,
|
||||||
|
size_mb: Math.round(rows[0].size / 1024 / 1024)
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
db_status = { status: 'error', message: e.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis check
|
||||||
|
let redis_status = { status: 'error' };
|
||||||
|
try {
|
||||||
|
const pong = await app.redis.ping();
|
||||||
|
const info = await app.redis.info('memory');
|
||||||
|
const usedMem = info.match(/used_memory_human:(.+)/)?.[1]?.trim();
|
||||||
|
redis_status = {
|
||||||
|
status: pong === 'PONG' ? 'ok' : 'error',
|
||||||
|
memory: usedMem
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
redis_status = { status: 'error', message: e.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task stats
|
||||||
|
let task_stats = {};
|
||||||
|
try {
|
||||||
|
const { rows } = await app.db.query(`
|
||||||
|
SELECT status, count(*)::int as count FROM tasks GROUP BY status
|
||||||
|
UNION ALL SELECT 'total', count(*)::int FROM tasks
|
||||||
|
UNION ALL SELECT 'users', count(*)::int FROM users
|
||||||
|
UNION ALL SELECT 'goals', count(*)::int FROM goals
|
||||||
|
`);
|
||||||
|
task_stats = Object.fromEntries(rows.map(r => [r.status, r.count]));
|
||||||
|
} catch (e) {
|
||||||
|
task_stats = { error: e.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine overall status
|
||||||
|
const overall = (db_status.status === 'ok' && redis_status.status === 'ok') ? 'ok' : 'degraded';
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: overall,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
system,
|
||||||
|
database: db_status,
|
||||||
|
redis: redis_status,
|
||||||
|
tasks: task_stats,
|
||||||
|
version: require('../../package.json').version || '1.0.0'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/system/ping — lightweight liveness check
|
||||||
|
app.get('/system/ping', async () => ({ pong: true, ts: Date.now() }));
|
||||||
|
};
|
||||||
@@ -1,7 +1,55 @@
|
|||||||
// Task Team — Tasks CRUD with Redis Caching — 2026-03-29
|
// Task Team — Tasks CRUD with Redis Caching + Input Validation — 2026-03-29
|
||||||
const CACHE_TTL = 30; // seconds
|
const CACHE_TTL = 30; // seconds
|
||||||
const CACHE_PREFIX = "taskteam:tasks:";
|
const CACHE_PREFIX = "taskteam:tasks:";
|
||||||
|
|
||||||
|
// Validation constants
|
||||||
|
const VALID_STATUSES = ["pending", "in_progress", "done", "completed", "cancelled"];
|
||||||
|
const VALID_PRIORITIES = ["urgent", "high", "medium", "low"];
|
||||||
|
const MAX_TITLE_LENGTH = 500;
|
||||||
|
const MAX_DESCRIPTION_LENGTH = 5000;
|
||||||
|
|
||||||
|
// Input validation helper
|
||||||
|
function validateTaskInput(body, isUpdate = false) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!isUpdate) {
|
||||||
|
// title is required for create
|
||||||
|
if (!body.title || typeof body.title !== "string" || body.title.trim().length === 0) {
|
||||||
|
errors.push("title is required and must be a non-empty string");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.title !== undefined) {
|
||||||
|
if (typeof body.title !== "string") {
|
||||||
|
errors.push("title must be a string");
|
||||||
|
} else if (body.title.length > MAX_TITLE_LENGTH) {
|
||||||
|
errors.push("title must not exceed " + MAX_TITLE_LENGTH + " characters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.description !== undefined && body.description !== null) {
|
||||||
|
if (typeof body.description !== "string") {
|
||||||
|
errors.push("description must be a string");
|
||||||
|
} else if (body.description.length > MAX_DESCRIPTION_LENGTH) {
|
||||||
|
errors.push("description must not exceed " + MAX_DESCRIPTION_LENGTH + " characters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.status !== undefined && body.status !== null) {
|
||||||
|
if (!VALID_STATUSES.includes(body.status)) {
|
||||||
|
errors.push("status must be one of: " + VALID_STATUSES.join(", "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.priority !== undefined && body.priority !== null) {
|
||||||
|
if (!VALID_PRIORITIES.includes(body.priority)) {
|
||||||
|
errors.push("priority must be one of: " + VALID_PRIORITIES.join(", "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
async function taskRoutes(app) {
|
async function taskRoutes(app) {
|
||||||
|
|
||||||
// Helper: build cache key from query params
|
// Helper: build cache key from query params
|
||||||
@@ -91,20 +139,30 @@ async function taskRoutes(app) {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create task (invalidates cache)
|
// Create task (with validation, invalidates cache)
|
||||||
app.post("/tasks", async (req) => {
|
app.post("/tasks", async (req, reply) => {
|
||||||
|
const errors = validateTaskInput(req.body, false);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return reply.status(400).send({ error: "Validation failed", details: errors, statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const { title, description, status, group_id, priority, scheduled_at, due_at, assigned_to } = req.body;
|
const { title, description, status, group_id, priority, scheduled_at, due_at, assigned_to } = req.body;
|
||||||
const { rows } = await app.db.query(
|
const { rows } = await app.db.query(
|
||||||
`INSERT INTO tasks (title, description, status, group_id, priority, scheduled_at, due_at, assigned_to)
|
`INSERT INTO tasks (title, description, status, group_id, priority, scheduled_at, due_at, assigned_to)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||||
[title, description || "", status || "pending", group_id, priority || "medium", scheduled_at, due_at, assigned_to || []]
|
[title.trim(), description || "", status || "pending", group_id, priority || "medium", scheduled_at, due_at, assigned_to || []]
|
||||||
);
|
);
|
||||||
await invalidateTaskCaches();
|
await invalidateTaskCaches();
|
||||||
return { data: rows[0] };
|
return { data: rows[0] };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update task (invalidates cache)
|
// Update task (with validation, invalidates cache)
|
||||||
app.put("/tasks/:id", async (req) => {
|
app.put("/tasks/:id", async (req, reply) => {
|
||||||
|
const errors = validateTaskInput(req.body, true);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return reply.status(400).send({ error: "Validation failed", details: errors, statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const fields = req.body;
|
const fields = req.body;
|
||||||
const sets = [];
|
const sets = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
@@ -112,7 +170,7 @@ async function taskRoutes(app) {
|
|||||||
for (const [key, value] of Object.entries(fields)) {
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
if (["title","description","status","group_id","priority","scheduled_at","due_at","assigned_to","completed_at"].includes(key)) {
|
if (["title","description","status","group_id","priority","scheduled_at","due_at","assigned_to","completed_at"].includes(key)) {
|
||||||
sets.push(`${key} = $${i}`);
|
sets.push(`${key} = $${i}`);
|
||||||
params.push(value);
|
params.push(key === "title" && typeof value === "string" ? value.trim() : value);
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import FullCalendar from '@fullcalendar/react';
|
|||||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||||
import interactionPlugin from '@fullcalendar/interaction';
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
@@ -17,8 +18,16 @@ interface Task {
|
|||||||
group_color: string;
|
group_color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOCALE_MAP: Record<string, string> = {
|
||||||
|
cs: 'cs',
|
||||||
|
he: 'he',
|
||||||
|
ru: 'ru',
|
||||||
|
ua: 'uk',
|
||||||
|
};
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_URL}/api/v1/tasks?limit=100`)
|
fetch(`${API_URL}/api/v1/tasks?limit=100`)
|
||||||
@@ -42,7 +51,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h1 className="text-2xl font-bold mb-4">Kalendar</h1>
|
<h1 className="text-2xl font-bold mb-4">{t('calendar.title')}</h1>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow">
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow">
|
||||||
<FullCalendar
|
<FullCalendar
|
||||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||||
@@ -55,7 +64,8 @@ export default function CalendarPage() {
|
|||||||
events={events}
|
events={events}
|
||||||
editable={true}
|
editable={true}
|
||||||
selectable={true}
|
selectable={true}
|
||||||
locale="cs"
|
locale={LOCALE_MAP[locale] || 'cs'}
|
||||||
|
direction={locale === 'he' ? 'rtl' : 'ltr'}
|
||||||
firstDay={1}
|
firstDay={1}
|
||||||
height="auto"
|
height="auto"
|
||||||
slotMinTime="06:00:00"
|
slotMinTime="06:00:00"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,6 +14,7 @@ interface ChatMessage {
|
|||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
@@ -20,6 +22,8 @@ export default function ChatPage() {
|
|||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const timeLocale = locale === "ua" ? "uk-UA" : locale === "cs" ? "cs-CZ" : locale === "he" ? "he-IL" : "ru-RU";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
@@ -63,7 +67,7 @@ export default function ChatPage() {
|
|||||||
const assistantMsg: ChatMessage = {
|
const assistantMsg: ChatMessage = {
|
||||||
id: (Date.now() + 1).toString(),
|
id: (Date.now() + 1).toString(),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: data.reply || data.message || "Omlouvám se, nemohl jsem zpracovat vaši zprávu.",
|
content: data.reply || data.message || t("chat.processError"),
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
setMessages((prev) => [...prev, assistantMsg]);
|
setMessages((prev) => [...prev, assistantMsg]);
|
||||||
@@ -71,7 +75,7 @@ export default function ChatPage() {
|
|||||||
const errorMsg: ChatMessage = {
|
const errorMsg: ChatMessage = {
|
||||||
id: (Date.now() + 1).toString(),
|
id: (Date.now() + 1).toString(),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "Chat asistent je momentálně nedostupný. Zkuste to prosím později.",
|
content: t("chat.unavailable"),
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
setMessages((prev) => [...prev, errorMsg]);
|
setMessages((prev) => [...prev, errorMsg]);
|
||||||
@@ -100,8 +104,8 @@ export default function ChatPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-semibold">AI Asistent</h1>
|
<h1 className="font-semibold">{t("chat.title")}</h1>
|
||||||
<p className="text-xs text-muted">Zeptejte se na cokoliv ohledně vašich úkolů</p>
|
<p className="text-xs text-muted">{t("chat.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -114,9 +118,9 @@ export default function ChatPage() {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted text-lg font-medium">Začněte konverzaci</p>
|
<p className="text-muted text-lg font-medium">{t("chat.startConversation")}</p>
|
||||||
<p className="text-muted text-sm mt-1">
|
<p className="text-muted text-sm mt-1">
|
||||||
Napište zprávu a AI asistent vám pomůže s úkoly
|
{t("chat.helpText")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -139,7 +143,7 @@ export default function ChatPage() {
|
|||||||
msg.role === "user" ? "text-blue-200" : "text-muted"
|
msg.role === "user" ? "text-blue-200" : "text-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{msg.timestamp.toLocaleTimeString("cs-CZ", { hour: "2-digit", minute: "2-digit" })}
|
{msg.timestamp.toLocaleTimeString(timeLocale, { hour: "2-digit", minute: "2-digit" })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,7 +172,7 @@ export default function ChatPage() {
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Napište zprávu..."
|
placeholder={t("chat.placeholder")}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-2xl bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none resize-none text-sm"
|
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-2xl bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none resize-none text-sm"
|
||||||
style={{ maxHeight: "120px" }}
|
style={{ maxHeight: "120px" }}
|
||||||
@@ -177,7 +181,7 @@ export default function ChatPage() {
|
|||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={loading || !input.trim()}
|
disabled={loading || !input.trim()}
|
||||||
className="p-3 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-full transition-colors flex-shrink-0"
|
className="p-3 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-full transition-colors flex-shrink-0"
|
||||||
aria-label="Odeslat"
|
aria-label={t("chat.send")}
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import {
|
import {
|
||||||
getGoals,
|
getGoals,
|
||||||
getGoal,
|
getGoal,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
|
|
||||||
export default function GoalsPage() {
|
export default function GoalsPage() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [goals, setGoals] = useState<Goal[]>([]);
|
const [goals, setGoals] = useState<Goal[]>([]);
|
||||||
const [groups, setGroups] = useState<Group[]>([]);
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
@@ -36,6 +38,8 @@ export default function GoalsPage() {
|
|||||||
const [formDate, setFormDate] = useState("");
|
const [formDate, setFormDate] = useState("");
|
||||||
const [formGroup, setFormGroup] = useState("");
|
const [formGroup, setFormGroup] = useState("");
|
||||||
|
|
||||||
|
const dateLocale = locale === "ua" ? "uk-UA" : locale === "cs" ? "cs-CZ" : locale === "he" ? "he-IL" : "ru-RU";
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -47,12 +51,12 @@ export default function GoalsPage() {
|
|||||||
setGoals(goalsRes.data || []);
|
setGoals(goalsRes.data || []);
|
||||||
setGroups(groupsRes.data || []);
|
setGroups(groupsRes.data || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Chyba pri nacitani:", err);
|
console.error("Load error:", err);
|
||||||
setError("Nepodarilo se nacist data");
|
setError(t("common.error"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [token]);
|
}, [token, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -77,8 +81,8 @@ export default function GoalsPage() {
|
|||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
loadData();
|
loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Chyba pri vytvareni:", err);
|
console.error("Create error:", err);
|
||||||
setError("Nepodarilo se vytvorit cil");
|
setError(t("common.error"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +94,7 @@ export default function GoalsPage() {
|
|||||||
setPlanResult(null);
|
setPlanResult(null);
|
||||||
setReport(null);
|
setReport(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Chyba pri nacitani cile:", err);
|
console.error("Load goal error:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,13 +105,12 @@ export default function GoalsPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await generateGoalPlan(token, goalId);
|
const res = await generateGoalPlan(token, goalId);
|
||||||
setPlanResult(res.data);
|
setPlanResult(res.data);
|
||||||
// Reload goal to get updated plan
|
|
||||||
const updated = await getGoal(token, goalId);
|
const updated = await getGoal(token, goalId);
|
||||||
setSelectedGoal(updated.data);
|
setSelectedGoal(updated.data);
|
||||||
loadData();
|
loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Chyba pri generovani planu:", err);
|
console.error("Plan error:", err);
|
||||||
setError("Nepodarilo se vygenerovat plan. Zkuste to znovu.");
|
setError(t("common.error"));
|
||||||
} finally {
|
} finally {
|
||||||
setAiLoading(null);
|
setAiLoading(null);
|
||||||
}
|
}
|
||||||
@@ -121,8 +124,8 @@ export default function GoalsPage() {
|
|||||||
const res = await getGoalReport(token, goalId);
|
const res = await getGoalReport(token, goalId);
|
||||||
setReport(res.data);
|
setReport(res.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Chyba pri ziskavani reportu:", err);
|
console.error("Report error:", err);
|
||||||
setError("Nepodarilo se ziskat report. Zkuste to znovu.");
|
setError(t("common.error"));
|
||||||
} finally {
|
} finally {
|
||||||
setAiLoading(null);
|
setAiLoading(null);
|
||||||
}
|
}
|
||||||
@@ -137,25 +140,25 @@ export default function GoalsPage() {
|
|||||||
setSelectedGoal({ ...selectedGoal, progress_pct: pct });
|
setSelectedGoal({ ...selectedGoal, progress_pct: pct });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Chyba pri aktualizaci:", err);
|
console.error("Update error:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(goalId: string) {
|
async function handleDelete(goalId: string) {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
if (!confirm("Opravdu chcete smazat tento cil?")) return;
|
if (!confirm(t("tasks.confirmDelete"))) return;
|
||||||
try {
|
try {
|
||||||
await deleteGoal(token, goalId);
|
await deleteGoal(token, goalId);
|
||||||
setSelectedGoal(null);
|
setSelectedGoal(null);
|
||||||
loadData();
|
loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Chyba pri mazani:", err);
|
console.error("Delete error:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(d: string | null) {
|
function formatDate(d: string | null) {
|
||||||
if (!d) return "Bez terminu";
|
if (!d) return t("tasks.noDue");
|
||||||
return new Date(d).toLocaleDateString("cs-CZ", { day: "numeric", month: "short", year: "numeric" });
|
return new Date(d).toLocaleDateString(dateLocale, { day: "numeric", month: "short", year: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function progressColor(pct: number) {
|
function progressColor(pct: number) {
|
||||||
@@ -171,12 +174,12 @@ export default function GoalsPage() {
|
|||||||
<div className="space-y-4 pb-24 sm:pb-8 px-4 sm:px-0">
|
<div className="space-y-4 pb-24 sm:pb-8 px-4 sm:px-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold dark:text-white">Cile</h1>
|
<h1 className="text-xl font-bold dark:text-white">{t("goals.title")}</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowForm(!showForm)}
|
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]"
|
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 ? "Zrusit" : "+ Novy cil"}
|
{showForm ? t("tasks.form.cancel") : `+ ${t("goals.add")}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -184,7 +187,7 @@ export default function GoalsPage() {
|
|||||||
{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">
|
<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}
|
{error}
|
||||||
<button onClick={() => setError(null)} className="ml-2 underline">Zavrrit</button>
|
<button onClick={() => setError(null)} className="ml-2 underline">{t("tasks.close")}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -192,19 +195,18 @@ export default function GoalsPage() {
|
|||||||
{showForm && (
|
{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">
|
<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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Nazev cile</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t("tasks.form.title")}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formTitle}
|
value={formTitle}
|
||||||
onChange={(e) => setFormTitle(e.target.value)}
|
onChange={(e) => setFormTitle(e.target.value)}
|
||||||
placeholder="Napr. Naucit se TypeScript"
|
|
||||||
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]"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Termin</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t("tasks.form.dueDate")}</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={formDate}
|
value={formDate}
|
||||||
@@ -213,13 +215,13 @@ export default function GoalsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Skupina</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t("tasks.form.group")}</label>
|
||||||
<select
|
<select
|
||||||
value={formGroup}
|
value={formGroup}
|
||||||
onChange={(e) => setFormGroup(e.target.value)}
|
onChange={(e) => setFormGroup(e.target.value)}
|
||||||
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-[44px]"
|
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-[44px]"
|
||||||
>
|
>
|
||||||
<option value="">-- Bez skupiny --</option>
|
<option value="">{t("tasks.form.noGroup")}</option>
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<option key={g.id} value={g.id}>{g.icon ? g.icon + " " : ""}{g.name}</option>
|
<option key={g.id} value={g.id}>{g.icon ? g.icon + " " : ""}{g.name}</option>
|
||||||
))}
|
))}
|
||||||
@@ -230,7 +232,7 @@ export default function GoalsPage() {
|
|||||||
type="submit"
|
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]"
|
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors min-h-[44px]"
|
||||||
>
|
>
|
||||||
Vytvorit cil
|
{t("goals.add")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -243,8 +245,7 @@ export default function GoalsPage() {
|
|||||||
) : goals.length === 0 ? (
|
) : goals.length === 0 ? (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
<div className="text-5xl mb-4 opacity-50">🎯</div>
|
<div className="text-5xl mb-4 opacity-50">🎯</div>
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">Zadne cile</p>
|
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">{t("goals.title")}</p>
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">Vytvorte svuj prvni cil</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -296,7 +297,7 @@ export default function GoalsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold dark:text-white">{selectedGoal.title}</h2>
|
<h2 className="text-lg font-bold dark:text-white">{selectedGoal.title}</h2>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{formatDate(selectedGoal.target_date)} | Progres: {selectedGoal.progress_pct}%
|
{formatDate(selectedGoal.target_date)} | {t("goals.progress")}: {selectedGoal.progress_pct}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -312,7 +313,7 @@ export default function GoalsPage() {
|
|||||||
{/* Progress slider */}
|
{/* Progress slider */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Progres: {selectedGoal.progress_pct}%
|
{t("goals.progress")}: {selectedGoal.progress_pct}%
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -334,14 +335,14 @@ export default function GoalsPage() {
|
|||||||
{aiLoading === "plan" ? (
|
{aiLoading === "plan" ? (
|
||||||
<>
|
<>
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||||
Generuji plan...
|
{t("common.loading")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
</svg>
|
</svg>
|
||||||
Generovat plan (AI)
|
{t("goals.plan")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -353,14 +354,14 @@ export default function GoalsPage() {
|
|||||||
{aiLoading === "report" ? (
|
{aiLoading === "report" ? (
|
||||||
<>
|
<>
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||||
Generuji report...
|
{t("common.loading")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
</svg>
|
</svg>
|
||||||
Report (AI)
|
{t("goals.report")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -370,22 +371,22 @@ export default function GoalsPage() {
|
|||||||
{planResult && (
|
{planResult && (
|
||||||
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-3">
|
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-3">
|
||||||
<h3 className="font-semibold text-purple-800 dark:text-purple-300 mb-2">
|
<h3 className="font-semibold text-purple-800 dark:text-purple-300 mb-2">
|
||||||
Vygenerovany plan ({planResult.tasks_created} ukolu vytvoreno)
|
{t("goals.plan")} ({planResult.tasks_created})
|
||||||
</h3>
|
</h3>
|
||||||
{planResult.plan.weeks ? (
|
{planResult.plan.weeks ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(planResult.plan.weeks as Array<{ week_number: number; focus: string; tasks: Array<{ title: string; duration_hours?: number; day_of_week?: string }> }>).map((week, i) => (
|
{(planResult.plan.weeks as Array<{ week_number: number; focus: string; tasks: Array<{ title: string; duration_hours?: number; day_of_week?: string }> }>).map((week, i) => (
|
||||||
<div key={i} className="bg-white dark:bg-gray-800 rounded-lg p-2">
|
<div key={i} className="bg-white dark:bg-gray-800 rounded-lg p-2">
|
||||||
<p className="text-sm font-medium text-purple-700 dark:text-purple-300">
|
<p className="text-sm font-medium text-purple-700 dark:text-purple-300">
|
||||||
Tyden {week.week_number}: {week.focus}
|
#{week.week_number}: {week.focus}
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-1 space-y-0.5">
|
<ul className="mt-1 space-y-0.5">
|
||||||
{(week.tasks || []).map((t, j) => (
|
{(week.tasks || []).map((wt, j) => (
|
||||||
<li key={j} className="text-xs text-gray-600 dark:text-gray-400 flex items-center gap-1">
|
<li key={j} className="text-xs text-gray-600 dark:text-gray-400 flex items-center gap-1">
|
||||||
<span className="w-1 h-1 bg-purple-400 rounded-full flex-shrink-0" />
|
<span className="w-1 h-1 bg-purple-400 rounded-full flex-shrink-0" />
|
||||||
{t.title}
|
{wt.title}
|
||||||
{t.duration_hours && <span className="text-gray-400 ml-1">({t.duration_hours}h)</span>}
|
{wt.duration_hours && <span className="text-gray-400 ml-1">({wt.duration_hours}h)</span>}
|
||||||
{t.day_of_week && <span className="text-gray-400 ml-1">[{t.day_of_week}]</span>}
|
{wt.day_of_week && <span className="text-gray-400 ml-1">[{wt.day_of_week}]</span>}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -404,9 +405,9 @@ export default function GoalsPage() {
|
|||||||
{report && (
|
{report && (
|
||||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="font-semibold text-green-800 dark:text-green-300">AI Report</h3>
|
<h3 className="font-semibold text-green-800 dark:text-green-300">{t("goals.report")}</h3>
|
||||||
<span className="text-sm text-green-600 dark:text-green-400">
|
<span className="text-sm text-green-600 dark:text-green-400">
|
||||||
{report.stats.done}/{report.stats.total} splneno ({report.stats.pct}%)
|
{report.stats.done}/{report.stats.total} ({report.stats.pct}%)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{report.report}</p>
|
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{report.report}</p>
|
||||||
@@ -416,19 +417,19 @@ export default function GoalsPage() {
|
|||||||
{/* Existing plan */}
|
{/* Existing plan */}
|
||||||
{selectedGoal.plan && Object.keys(selectedGoal.plan).length > 0 && !planResult && (
|
{selectedGoal.plan && Object.keys(selectedGoal.plan).length > 0 && !planResult && (
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3">
|
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3">
|
||||||
<h3 className="font-semibold text-gray-700 dark:text-gray-300 mb-2">Ulozeny plan</h3>
|
<h3 className="font-semibold text-gray-700 dark:text-gray-300 mb-2">{t("goals.plan")}</h3>
|
||||||
{(selectedGoal.plan as Record<string, unknown>).weeks ? (
|
{(selectedGoal.plan as Record<string, unknown>).weeks ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{((selectedGoal.plan as Record<string, unknown>).weeks as Array<{ week_number: number; focus: string; tasks: Array<{ title: string; duration_hours?: number; day_of_week?: string }> }>).map((week, i) => (
|
{((selectedGoal.plan as Record<string, unknown>).weeks as Array<{ week_number: number; focus: string; tasks: Array<{ title: string; duration_hours?: number; day_of_week?: string }> }>).map((week, i) => (
|
||||||
<div key={i} className="bg-white dark:bg-gray-900 rounded-lg p-2">
|
<div key={i} className="bg-white dark:bg-gray-900 rounded-lg p-2">
|
||||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
Tyden {week.week_number}: {week.focus}
|
#{week.week_number}: {week.focus}
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-1 space-y-0.5">
|
<ul className="mt-1 space-y-0.5">
|
||||||
{(week.tasks || []).map((t, j) => (
|
{(week.tasks || []).map((wt, j) => (
|
||||||
<li key={j} className="text-xs text-gray-600 dark:text-gray-400 flex items-center gap-1">
|
<li key={j} className="text-xs text-gray-600 dark:text-gray-400 flex items-center gap-1">
|
||||||
<span className="w-1 h-1 bg-gray-400 rounded-full flex-shrink-0" />
|
<span className="w-1 h-1 bg-gray-400 rounded-full flex-shrink-0" />
|
||||||
{t.title}
|
{wt.title}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -447,7 +448,7 @@ export default function GoalsPage() {
|
|||||||
{selectedGoal.tasks && selectedGoal.tasks.length > 0 && (
|
{selectedGoal.tasks && selectedGoal.tasks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
<h3 className="font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Souvisejici ukoly ({selectedGoal.tasks.length})
|
{t("nav.tasks")} ({selectedGoal.tasks.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||||
{(selectedGoal.tasks as Array<{ id: string; title: string; status: string }>).map((task) => (
|
{(selectedGoal.tasks as Array<{ id: string; title: string; status: string }>).map((task) => (
|
||||||
@@ -461,7 +462,7 @@ export default function GoalsPage() {
|
|||||||
"bg-gray-400"
|
"bg-gray-400"
|
||||||
}`} />
|
}`} />
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">{task.title}</span>
|
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">{task.title}</span>
|
||||||
<span className="text-xs text-gray-400 ml-auto flex-shrink-0">{task.status}</span>
|
<span className="text-xs text-gray-400 ml-auto flex-shrink-0">{t(`tasks.status.${task.status}`)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -473,7 +474,7 @@ export default function GoalsPage() {
|
|||||||
onClick={() => handleDelete(selectedGoal.id)}
|
onClick={() => handleDelete(selectedGoal.id)}
|
||||||
className="w-full py-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg text-sm font-medium transition-colors min-h-[44px]"
|
className="w-full py-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg text-sm font-medium transition-colors min-h-[44px]"
|
||||||
>
|
>
|
||||||
Smazat cil
|
{t("tasks.delete")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useRouter, useParams } from "next/navigation";
|
import { useRouter, useParams } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import {
|
import {
|
||||||
getTask,
|
getTask,
|
||||||
getGroups,
|
getGroups,
|
||||||
@@ -18,25 +19,9 @@ function isDone(status: string): boolean {
|
|||||||
return status === "done" || status === "completed";
|
return status === "done" || status === "completed";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string | null | undefined): string {
|
|
||||||
if (!dateStr) return "Bez termínu";
|
|
||||||
try {
|
|
||||||
const d = new Date(dateStr);
|
|
||||||
if (isNaN(d.getTime())) return "Bez termínu";
|
|
||||||
return d.toLocaleDateString("cs-CZ", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return "Bez termínu";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TaskDetailPage() {
|
export default function TaskDetailPage() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const id = params.id as string;
|
const id = params.id as string;
|
||||||
@@ -47,6 +32,25 @@ export default function TaskDetailPage() {
|
|||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const dateLocale = locale === "ua" ? "uk-UA" : locale === "cs" ? "cs-CZ" : locale === "he" ? "he-IL" : "ru-RU";
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null | undefined): string {
|
||||||
|
if (!dateStr) return t("tasks.noDue");
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (isNaN(d.getTime())) return t("tasks.noDue");
|
||||||
|
return d.toLocaleDateString(dateLocale, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return t("tasks.noDue");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadTask = useCallback(async () => {
|
const loadTask = useCallback(async () => {
|
||||||
if (!token || !id) return;
|
if (!token || !id) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -59,12 +63,12 @@ export default function TaskDetailPage() {
|
|||||||
setGroups(groupsData.data || []);
|
setGroups(groupsData.data || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error ? err.message : "Chyba při načítání úkolu"
|
err instanceof Error ? err.message : t("tasks.loadError")
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [token, id]);
|
}, [token, id, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -83,14 +87,14 @@ export default function TaskDetailPage() {
|
|||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!token || !id) return;
|
if (!token || !id) return;
|
||||||
if (!confirm("Opravdu smazat tento úkol?")) return;
|
if (!confirm(t("tasks.confirmDelete"))) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
await deleteTask(token, id);
|
await deleteTask(token, id);
|
||||||
router.push("/tasks");
|
router.push("/tasks");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error ? err.message : "Chyba při mazání"
|
err instanceof Error ? err.message : t("common.error")
|
||||||
);
|
);
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
@@ -103,7 +107,7 @@ export default function TaskDetailPage() {
|
|||||||
loadTask();
|
loadTask();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error ? err.message : "Chyba při změně stavu"
|
err instanceof Error ? err.message : t("common.error")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,12 +125,12 @@ export default function TaskDetailPage() {
|
|||||||
if (error || !task) {
|
if (error || !task) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-red-500">{error || "Úkol nenalezen"}</p>
|
<p className="text-red-500">{error || t("tasks.notFound")}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/tasks")}
|
onClick={() => router.push("/tasks")}
|
||||||
className="mt-4 text-blue-600 hover:underline"
|
className="mt-4 text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
Zpět na úkoly
|
{t("tasks.backToTasks")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -135,23 +139,23 @@ export default function TaskDetailPage() {
|
|||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg mx-auto">
|
<div className="max-w-lg mx-auto">
|
||||||
<h1 className="text-xl font-bold mb-4">Upravit úkol</h1>
|
<h1 className="text-xl font-bold mb-4">{t("tasks.editTask")}</h1>
|
||||||
<TaskForm
|
<TaskForm
|
||||||
groups={groups}
|
groups={groups}
|
||||||
initial={task}
|
initial={task}
|
||||||
onSubmit={handleUpdate}
|
onSubmit={handleUpdate}
|
||||||
onCancel={() => setEditing(false)}
|
onCancel={() => setEditing(false)}
|
||||||
submitLabel="Uložit změny"
|
submitLabel={t("tasks.saveChanges")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITY_LABELS: Record<string, { label: string; dot: string }> = {
|
const PRIORITY_LABELS: Record<string, { dot: string }> = {
|
||||||
low: { label: "Nízká", dot: "🟢" },
|
low: { dot: "\ud83d\udfe2" },
|
||||||
medium: { label: "Střední", dot: "🟡" },
|
medium: { dot: "\ud83d\udfe1" },
|
||||||
high: { label: "Vysoká", dot: "🟠" },
|
high: { dot: "\ud83d\udfe0" },
|
||||||
urgent: { label: "Urgentní", dot: "🔴" },
|
urgent: { dot: "\ud83d\udd34" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const pri = PRIORITY_LABELS[task.priority] || PRIORITY_LABELS.medium;
|
const pri = PRIORITY_LABELS[task.priority] || PRIORITY_LABELS.medium;
|
||||||
@@ -177,7 +181,7 @@ export default function TaskDetailPage() {
|
|||||||
d="M15 19l-7-7 7-7"
|
d="M15 19l-7-7 7-7"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Zpět
|
{t("common.back")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Task detail card */}
|
{/* Task detail card */}
|
||||||
@@ -206,14 +210,14 @@ export default function TaskDetailPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted">Priorita:</span>
|
<span className="text-muted">{t("tasks.form.priority")}:</span>
|
||||||
<span className="ml-2 font-medium">
|
<span className="ml-2 font-medium">
|
||||||
{pri.dot} {pri.label}
|
{pri.dot} {t(`tasks.priority.${task.priority}`)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{task.group_name && (
|
{task.group_name && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted">Skupina:</span>
|
<span className="text-muted">{t("tasks.form.group")}:</span>
|
||||||
<span
|
<span
|
||||||
className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium text-white"
|
className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium text-white"
|
||||||
style={{
|
style={{
|
||||||
@@ -228,20 +232,20 @@ export default function TaskDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted">Termín:</span>
|
<span className="text-muted">{t("tasks.form.dueDate")}:</span>
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
{formatDate(task.due_at)}
|
{formatDate(task.due_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted">Vytvořeno:</span>
|
<span className="text-muted">{t("tasks.created")}:</span>
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
{formatDate(task.created_at)}
|
{formatDate(task.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{task.completed_at && (
|
{task.completed_at && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted">Dokončeno:</span>
|
<span className="text-muted">{t("tasks.completed")}:</span>
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
{formatDate(task.completed_at)}
|
{formatDate(task.completed_at)}
|
||||||
</span>
|
</span>
|
||||||
@@ -257,7 +261,7 @@ export default function TaskDetailPage() {
|
|||||||
onClick={() => handleQuickStatus("done")}
|
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"
|
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Označit jako hotové
|
{t("tasks.markDone")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{task.status === "pending" && (
|
{task.status === "pending" && (
|
||||||
@@ -265,7 +269,7 @@ export default function TaskDetailPage() {
|
|||||||
onClick={() => handleQuickStatus("in_progress")}
|
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"
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Zahájit
|
{t("tasks.start")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{taskDone && (
|
{taskDone && (
|
||||||
@@ -273,7 +277,7 @@ export default function TaskDetailPage() {
|
|||||||
onClick={() => handleQuickStatus("pending")}
|
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"
|
className="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Znovu otevřít
|
{t("tasks.reopen")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -284,14 +288,14 @@ export default function TaskDetailPage() {
|
|||||||
onClick={() => setEditing(true)}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
Upravit
|
{t("tasks.edit")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={deleting}
|
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"
|
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 ? "Mažu..." : "Smazat"}
|
{deleting ? t("tasks.deleting") : t("tasks.delete")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
import { Group } from "@/lib/api";
|
import { Group } from "@/lib/api";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
interface GroupSelectorProps {
|
interface GroupSelectorProps {
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
@@ -16,6 +17,7 @@ export default function GroupSelector({
|
|||||||
}: GroupSelectorProps) {
|
}: GroupSelectorProps) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const activeRef = useRef<HTMLButtonElement>(null);
|
const activeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Scroll active button into view
|
// Scroll active button into view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -46,7 +48,7 @@ export default function GroupSelector({
|
|||||||
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 min-h-[44px]"
|
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 min-h-[44px]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Vse
|
{t("tasks.all")}
|
||||||
</button>
|
</button>
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { Task } from "@/lib/api";
|
import { Task } from "@/lib/api";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import StatusBadge from "./StatusBadge";
|
import StatusBadge from "./StatusBadge";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSwipeable } from "react-swipeable";
|
import { useSwipeable } from "react-swipeable";
|
||||||
@@ -23,6 +24,7 @@ function isDone(status: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TaskCard({ task, onComplete }: TaskCardProps) {
|
export default function TaskCard({ task, onComplete }: TaskCardProps) {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
const groupColor = task.group_color || "#6b7280";
|
const groupColor = task.group_color || "#6b7280";
|
||||||
const priorityColor = PRIORITY_COLORS[task.priority] || PRIORITY_COLORS.medium;
|
const priorityColor = PRIORITY_COLORS[task.priority] || PRIORITY_COLORS.medium;
|
||||||
const taskDone = isDone(task.status);
|
const taskDone = isDone(task.status);
|
||||||
@@ -32,6 +34,8 @@ export default function TaskCard({ task, onComplete }: TaskCardProps) {
|
|||||||
|
|
||||||
const SWIPE_THRESHOLD = 120;
|
const SWIPE_THRESHOLD = 120;
|
||||||
|
|
||||||
|
const dateLocale = locale === "ua" ? "uk-UA" : locale === "cs" ? "cs-CZ" : locale === "he" ? "he-IL" : "ru-RU";
|
||||||
|
|
||||||
const swipeHandlers = useSwipeable({
|
const swipeHandlers = useSwipeable({
|
||||||
onSwiping: (e) => {
|
onSwiping: (e) => {
|
||||||
// Only allow right swipe for complete gesture
|
// Only allow right swipe for complete gesture
|
||||||
@@ -80,7 +84,7 @@ export default function TaskCard({ task, onComplete }: TaskCardProps) {
|
|||||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm">Hotovo</span>
|
<span className="text-sm">{t("tasks.status.done")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -155,7 +159,7 @@ export default function TaskCard({ task, onComplete }: TaskCardProps) {
|
|||||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
{new Date(task.due_at).toLocaleDateString("cs-CZ")}
|
{new Date(task.due_at).toLocaleDateString(dateLocale)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -165,7 +169,7 @@ export default function TaskCard({ task, onComplete }: TaskCardProps) {
|
|||||||
<div
|
<div
|
||||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: priorityColor }}
|
style={{ backgroundColor: priorityColor }}
|
||||||
title={task.priority}
|
title={t(`tasks.priority.${task.priority}`)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
22
backup.sh
Executable file
22
backup.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
BACKUP_DIR="/opt/task-team/backups"
|
||||||
|
DATE=$(date +%Y%m%d_%H%M)
|
||||||
|
PGDUMP="/usr/lib/postgresql/18/bin/pg_dump"
|
||||||
|
mkdir -p $BACKUP_DIR
|
||||||
|
|
||||||
|
# Dump all databases
|
||||||
|
PGPASSWORD="TaskTeam2026!" $PGDUMP -h 10.10.10.10 -U taskteam -d taskteam -F c -f "$BACKUP_DIR/taskteam_$DATE.dump"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
# Compress
|
||||||
|
gzip -f "$BACKUP_DIR/taskteam_$DATE.dump" 2>/dev/null
|
||||||
|
echo "$(date '+%Y-%m-%d %H:%M:%S') OK: taskteam_$DATE.dump.gz"
|
||||||
|
else
|
||||||
|
echo "$(date '+%Y-%m-%d %H:%M:%S') FAIL: pg_dump exited with error"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Keep last 7 daily backups
|
||||||
|
find $BACKUP_DIR -name "*.dump.gz" -mtime +7 -delete
|
||||||
|
find $BACKUP_DIR -name "*.dump" -mtime +7 -delete
|
||||||
|
|
||||||
|
ls -lh $BACKUP_DIR/ | tail -5
|
||||||
BIN
backups/taskteam_20260329_1317.dump.gz
Normal file
BIN
backups/taskteam_20260329_1317.dump.gz
Normal file
Binary file not shown.
@@ -14,7 +14,8 @@ module.exports = {
|
|||||||
JWT_SECRET: "taskteam-jwt-secret-2026-secure-key",
|
JWT_SECRET: "taskteam-jwt-secret-2026-secure-key",
|
||||||
ANTHROPIC_API_KEY: "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",
|
ANTHROPIC_API_KEY: "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",
|
||||||
NOTION_API_KEY: "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",
|
NOTION_API_KEY: "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",
|
||||||
NOTION_TASKS_DB: "659a5381-564a-453a-9e2b-1345c457cca9"
|
NOTION_TASKS_DB: "659a5381-564a-453a-9e2b-1345c457cca9",
|
||||||
|
DEPLOY_SECRET: "taskteam-deploy-2026",
|
||||||
},
|
},
|
||||||
max_memory_restart: "500M",
|
max_memory_restart: "500M",
|
||||||
watch: false,
|
watch: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user