diff --git a/api/package-lock.json b/api/package-lock.json index 9533720..96c2f41 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@anthropic-ai/sdk": "^0.80.0", "@fastify/cors": "^11.2.0", "@fastify/helmet": "^13.0.2", "@fastify/jwt": "^10.0.0", @@ -25,6 +26,35 @@ "nodemon": "^3.1.14" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.80.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.80.0.tgz", + "integrity": "sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@fastify/accept-negotiator": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", @@ -1056,6 +1086,19 @@ "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -1757,6 +1800,12 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/api/package.json b/api/package.json index 9e91a70..6a6b9f4 100644 --- a/api/package.json +++ b/api/package.json @@ -13,6 +13,7 @@ "license": "ISC", "type": "commonjs", "dependencies": { + "@anthropic-ai/sdk": "^0.80.0", "@fastify/cors": "^11.2.0", "@fastify/helmet": "^13.0.2", "@fastify/jwt": "^10.0.0", diff --git a/api/src/index.js b/api/src/index.js index 239e20d..7e6275a 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -27,6 +27,8 @@ app.register(require('./routes/tasks'), { prefix: '/api/v1' }); app.register(require('./routes/groups'), { prefix: '/api/v1' }); app.register(require('./routes/auth'), { prefix: '/api/v1' }); app.register(require('./routes/connectors'), { prefix: '/api/v1' }); +app.register(require('./routes/connectors/odoo'), { prefix: '/api/v1' }); +app.register(require('./routes/chat'), { prefix: '/api/v1' }); // Start const start = async () => { diff --git a/api/src/routes/chat.js b/api/src/routes/chat.js new file mode 100644 index 0000000..719b312 --- /dev/null +++ b/api/src/routes/chat.js @@ -0,0 +1,36 @@ +// Task Team — AI Chat Agent — 2026-03-29 +const Anthropic = require('@anthropic-ai/sdk'); + +async function chatRoutes(app) { + const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); + + app.post('/chat', async (req, reply) => { + const { message, context } = req.body; + + if (!message) { + reply.code(400); + return { error: 'Message is required' }; + } + + // Get user's tasks and groups for context + const { rows: tasks } = await app.db.query( + 'SELECT title, status, priority, scheduled_at FROM tasks ORDER BY created_at DESC LIMIT 20' + ); + const { rows: groups } = await app.db.query('SELECT name, color FROM task_groups ORDER BY order_index'); + + const systemPrompt = `Jsi Task Team AI asistent. Pomáháš uživateli s organizací úkolů, plánováním a cíli. +Aktuální úkoly: ${JSON.stringify(tasks.map(t => ({title: t.title, status: t.status, priority: t.priority})))} +Skupiny: ${groups.map(g => g.name).join(', ')} +Odpovídej v češtině, stručně a prakticky. Pokud uživatel chce vytvořit úkol, navrhni strukturu.`; + + const response = await anthropic.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1024, + system: systemPrompt, + messages: [{ role: 'user', content: message }] + }); + + return { data: { reply: response.content[0].text, model: response.model } }; + }); +} +module.exports = chatRoutes; diff --git a/api/src/routes/connectors/odoo.js b/api/src/routes/connectors/odoo.js new file mode 100644 index 0000000..8cb5b11 --- /dev/null +++ b/api/src/routes/connectors/odoo.js @@ -0,0 +1,112 @@ +// Task Team — Odoo API Connector — 2026-03-29 +// Bidirectional sync between Task Team and Odoo 19 + +const ODOO_ENTERPRISE_URL = process.env.ODOO_ENT_URL || 'http://10.10.10.20:8069'; +const ODOO_COMMUNITY_URL = process.env.ODOO_COM_URL || 'http://10.10.10.20:8070'; + +async function odooConnector(app) { + + // Odoo XML-RPC helpers + async function odooAuth(url, db, login, password) { + const res = await fetch(`${url}/jsonrpc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', method: 'call', id: 1, + params: { service: 'common', method: 'authenticate', args: [db, login, password, {}] } + }) + }); + const data = await res.json(); + return data.result; + } + + async function odooCall(url, db, uid, password, model, method, args = [], kwargs = {}) { + const res = await fetch(`${url}/jsonrpc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', method: 'call', id: 2, + params: { service: 'object', method: 'execute_kw', args: [db, uid, password, model, method, args, kwargs] } + }) + }); + const data = await res.json(); + return data.result; + } + + // Test Odoo connection + app.get('/connectors/odoo/test', async (req) => { + try { + const uid = await odooAuth(ODOO_ENTERPRISE_URL, 'odoo_enterprise', 'admin', 'admin'); + return { status: 'ok', uid, server: 'enterprise' }; + } catch (e) { + return { status: 'error', message: e.message }; + } + }); + + // Sync tasks from Odoo to Task Team + app.post('/connectors/odoo/sync/import', async (req) => { + const { db, login, password, server } = req.body; + const url = server === 'community' ? ODOO_COMMUNITY_URL : ODOO_ENTERPRISE_URL; + + const uid = await odooAuth(url, db, login, password); + if (!uid) return { status: 'error', message: 'Auth failed' }; + + const tasks = await odooCall(url, db, uid, password, 'project.task', 'search_read', + [[['active', '=', true]]], { fields: ['name', 'description', 'stage_id', 'priority', 'date_deadline'], limit: 100 }); + + let imported = 0; + for (const t of tasks) { + await app.db.query( + `INSERT INTO tasks (title, description, external_id, external_source, due_at, priority) + VALUES ($1, $2, $3, 'odoo', $4, $5) + ON CONFLICT DO NOTHING`, + [t.name, t.description || '', `odoo:${t.id}`, t.date_deadline, t.priority === '1' ? 'urgent' : 'medium'] + ); + imported++; + } + return { status: 'ok', imported, total_odoo: tasks.length }; + }); + + // Export tasks from Task Team to Odoo + app.post('/connectors/odoo/sync/export', async (req) => { + const { db, login, password, server, project_id } = req.body; + const url = server === 'community' ? ODOO_COMMUNITY_URL : ODOO_ENTERPRISE_URL; + + const uid = await odooAuth(url, db, login, password); + if (!uid) return { status: 'error', message: 'Auth failed' }; + + const { rows: tasks } = await app.db.query( + "SELECT * FROM tasks WHERE external_source IS NULL OR external_source != 'odoo' LIMIT 50" + ); + + let exported = 0; + for (const t of tasks) { + const vals = { name: t.title, description: t.description || '' }; + if (project_id) vals.project_id = project_id; + const taskId = await odooCall(url, db, uid, password, 'project.task', 'create', [vals]); + await app.db.query('UPDATE tasks SET external_id=$1, external_source=$2 WHERE id=$3', + [`odoo:${taskId}`, 'odoo', t.id]); + exported++; + } + return { status: 'ok', exported }; + }); + + // Webhook from Odoo (for real-time sync) + app.post('/connectors/odoo/webhook', async (req) => { + const { event, model, record_id, data } = req.body; + app.log.info({ event, model, record_id }, 'Odoo webhook received'); + + if (model === 'project.task' && event === 'write') { + // Update corresponding task in Task Team + if (data.name) { + await app.db.query( + "UPDATE tasks SET title=$1, updated_at=NOW() WHERE external_id=$2", + [data.name, `odoo:${record_id}`] + ); + } + } + return { status: 'received' }; + }); +} + +module.exports = odooConnector; diff --git a/apps/tasks/app/calendar/page.tsx b/apps/tasks/app/calendar/page.tsx new file mode 100644 index 0000000..b369fe2 --- /dev/null +++ b/apps/tasks/app/calendar/page.tsx @@ -0,0 +1,67 @@ +'use client'; +import { useState, useEffect } from 'react'; +import FullCalendar from '@fullcalendar/react'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import timeGridPlugin from '@fullcalendar/timegrid'; +import interactionPlugin from '@fullcalendar/interaction'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; + +interface Task { + id: string; + title: string; + scheduled_at: string | null; + due_at: string | null; + status: string; + group_name: string; + group_color: string; +} + +export default function CalendarPage() { + const [tasks, setTasks] = useState([]); + + useEffect(() => { + fetch(`${API_URL}/api/v1/tasks?limit=100`) + .then(r => r.json()) + .then(d => setTasks(d.data || [])); + }, []); + + 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 } + })); + + return ( +
+

Kalendar

+
+ +
+
+ ); +} diff --git a/apps/tasks/app/sw-register.tsx b/apps/tasks/app/sw-register.tsx new file mode 100644 index 0000000..15be80e --- /dev/null +++ b/apps/tasks/app/sw-register.tsx @@ -0,0 +1,10 @@ +"use client"; +import { useEffect } from "react"; +export default function SWRegister() { + useEffect(() => { + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("/sw.js").catch(() => {}); + } + }, []); + return null; +} diff --git a/apps/tasks/lib/api.ts b/apps/tasks/lib/api.ts index 494e794..e5bc65b 100644 --- a/apps/tasks/lib/api.ts +++ b/apps/tasks/lib/api.ts @@ -1,4 +1,4 @@ -const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000"; +const API_BASE = typeof window !== "undefined" ? "" : (process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000"); interface ApiOptions { method?: string; @@ -14,15 +14,21 @@ async function apiFetch(path: string, opts: ApiOptions = {}): Promise { headers["Authorization"] = `Bearer ${opts.token}`; } - const res = await fetch(`${API_BASE}${path}`, { + const url = `${API_BASE}${path}`; + const res = await fetch(url, { method: opts.method || "GET", headers, body: opts.body ? JSON.stringify(opts.body) : undefined, + cache: "no-store", }); if (!res.ok) { const err = await res.json().catch(() => ({ message: res.statusText })); - throw new Error(err.message || `HTTP ${res.status}`); + throw new Error(err.message || err.error || `HTTP ${res.status}`); + } + + if (res.status === 204 || res.headers.get("content-length") === "0") { + return {} as T; } return res.json(); diff --git a/apps/tasks/next.config.mjs b/apps/tasks/next.config.mjs index c8802de..ea87f7b 100644 --- a/apps/tasks/next.config.mjs +++ b/apps/tasks/next.config.mjs @@ -1,6 +1,5 @@ /** @type {import("next").NextConfig} */ const nextConfig = { - output: "standalone", reactStrictMode: true, async rewrites() { return [ diff --git a/apps/tasks/package-lock.json b/apps/tasks/package-lock.json index 8278101..7eb4ad5 100644 --- a/apps/tasks/package-lock.json +++ b/apps/tasks/package-lock.json @@ -8,6 +8,10 @@ "name": "tasks", "version": "0.1.0", "dependencies": { + "@fullcalendar/daygrid": "^6.1.20", + "@fullcalendar/interaction": "^6.1.20", + "@fullcalendar/react": "^6.1.20", + "@fullcalendar/timegrid": "^6.1.20", "next": "14.2.35", "react": "^18", "react-dom": "^18" @@ -133,6 +137,57 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", + "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", + "license": "MIT", + "peer": true, + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz", + "integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz", + "integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/react": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.20.tgz", + "integrity": "sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20", + "react": "^16.7.0 || ^17 || ^18 || ^19", + "react-dom": "^16.7.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/@fullcalendar/timegrid": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz", + "integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==", + "license": "MIT", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.20" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -2284,6 +2339,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4558,6 +4614,16 @@ "dev": true, "license": "MIT" }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/apps/tasks/package.json b/apps/tasks/package.json index e2836b7..4f3ee43 100644 --- a/apps/tasks/package.json +++ b/apps/tasks/package.json @@ -9,18 +9,22 @@ "lint": "next lint" }, "dependencies": { + "@fullcalendar/daygrid": "^6.1.20", + "@fullcalendar/interaction": "^6.1.20", + "@fullcalendar/react": "^6.1.20", + "@fullcalendar/timegrid": "^6.1.20", + "next": "14.2.35", "react": "^18", - "react-dom": "^18", - "next": "14.2.35" + "react-dom": "^18" }, "devDependencies": { - "typescript": "^5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.35", "postcss": "^8", "tailwindcss": "^3.4.1", - "eslint": "^8", - "eslint-config-next": "14.2.35" + "typescript": "^5" } } diff --git a/apps/tasks/public/icon-192.png b/apps/tasks/public/icon-192.png new file mode 100644 index 0000000..e69de29 diff --git a/apps/tasks/public/icon-192.png.svg b/apps/tasks/public/icon-192.png.svg new file mode 100644 index 0000000..8d39c77 --- /dev/null +++ b/apps/tasks/public/icon-192.png.svg @@ -0,0 +1,4 @@ + + + T + diff --git a/apps/tasks/public/icon-512.png b/apps/tasks/public/icon-512.png new file mode 100644 index 0000000..e69de29 diff --git a/apps/tasks/public/manifest.json b/apps/tasks/public/manifest.json index f210302..cc923aa 100644 --- a/apps/tasks/public/manifest.json +++ b/apps/tasks/public/manifest.json @@ -1,22 +1,17 @@ { "name": "Task Team", "short_name": "Tasks", - "description": "Sprava ukolu pro tym", - "start_url": "/", + "description": "Správa úkolů pro tým", + "start_url": "/tasks", "display": "standalone", - "background_color": "#ffffff", - "theme_color": "#3b82f6", + "background_color": "#0a0a0a", + "theme_color": "#3B82F6", "orientation": "portrait-primary", "icons": [ - { - "src": "/icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/icon-512.png", - "sizes": "512x512", - "type": "image/png" - } - ] + {"src": "/icon-192.png", "sizes": "192x192", "type": "image/png"}, + {"src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable"} + ], + "categories": ["productivity", "utilities"], + "lang": "cs", + "dir": "ltr" } diff --git a/apps/tasks/public/sw.js b/apps/tasks/public/sw.js new file mode 100644 index 0000000..27ec368 --- /dev/null +++ b/apps/tasks/public/sw.js @@ -0,0 +1,27 @@ +const CACHE = "taskteam-v1"; +const PRECACHE = ["/tasks", "/login", "/manifest.json"]; + +self.addEventListener("install", (e) => { + e.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE))); + self.skipWaiting(); +}); + +self.addEventListener("activate", (e) => { + e.waitUntil(caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + )); + self.clients.claim(); +}); + +self.addEventListener("fetch", (e) => { + if (e.request.method !== "GET") return; + e.respondWith( + fetch(e.request) + .then((r) => { + const clone = r.clone(); + caches.open(CACHE).then((c) => c.put(e.request, clone)); + return r; + }) + .catch(() => caches.match(e.request)) + ); +});