diff --git a/api/package-lock.json b/api/package-lock.json index 5cc2883..77d7fd1 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -17,6 +17,7 @@ "@fastify/swagger": "^9.7.0", "@fastify/swagger-ui": "^5.2.5", "@fastify/websocket": "^11.2.0", + "@simplewebauthn/server": "^12.0.0", "bcrypt": "^6.0.0", "bcryptjs": "^3.0.3", "dotenv": "^17.3.1", @@ -389,12 +390,24 @@ "ws": "^8.16.0" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@ioredis/commands": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", "license": "MIT" }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@lukeed/ms": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", @@ -404,6 +417,64 @@ "node": ">=8" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz", + "integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", + "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", + "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", + "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -479,6 +550,33 @@ "@redis/client": "^5.11.0" } }, + "node_modules/@simplewebauthn/server": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-12.0.0.tgz", + "integrity": "sha512-aJdTe9GikOk40U7Q5Mm/Sqkxcq4a2oPZAcLcnyqMyFqrUaOS6vdsZW8/H3Mnsw9umcr88pcgB7kozPPt+5wOBw==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@simplewebauthn/types": "^12.0.0", + "cross-fetch": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@simplewebauthn/types": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-12.0.0.tgz", + "integrity": "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -553,6 +651,20 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -733,6 +845,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1425,6 +1546,26 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -1813,6 +1954,24 @@ "dev": true, "license": "MIT" }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -2163,12 +2322,24 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/uid2": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", @@ -2229,6 +2400,22 @@ "node": ">= 16" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/api/package.json b/api/package.json index b2bd4cc..e0ad163 100644 --- a/api/package.json +++ b/api/package.json @@ -21,6 +21,7 @@ "@fastify/swagger": "^9.7.0", "@fastify/swagger-ui": "^5.2.5", "@fastify/websocket": "^11.2.0", + "@simplewebauthn/server": "^12.0.0", "bcrypt": "^6.0.0", "bcryptjs": "^3.0.3", "dotenv": "^17.3.1", diff --git a/api/src/features/registry.js b/api/src/features/registry.js index 2a23471..545a3c7 100644 --- a/api/src/features/registry.js +++ b/api/src/features/registry.js @@ -8,6 +8,7 @@ const features = [ { name: "kanban", path: "./kanban", prefix: "/api/v1" }, { name: "ai-briefing", path: "./ai-briefing", prefix: "/api/v1" }, { name: "webhooks-outgoing", path: "./webhooks-outgoing", prefix: "/api/v1" }, + { name: "webauthn", path: "./webauthn", prefix: "/api/v1" }, ]; async function registerFeatures(app) { diff --git a/api/src/features/webauthn.js b/api/src/features/webauthn.js new file mode 100644 index 0000000..4c0bd75 --- /dev/null +++ b/api/src/features/webauthn.js @@ -0,0 +1,64 @@ +// Task Team — WebAuthn Biometric Auth +// Note: Full WebAuthn needs @simplewebauthn/server, but we create the API structure +async function webauthnFeature(app) { + await app.db.query(` + CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + credential_id TEXT UNIQUE NOT NULL, + public_key TEXT NOT NULL, + counter INTEGER DEFAULT 0, + device_name VARCHAR(100), + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `).catch(() => {}); + + // Registration options + app.post('/webauthn/register/options', async (req) => { + const { user_id } = req.body; + const { rows } = await app.db.query('SELECT id,email,name FROM users WHERE id=$1', [user_id]); + if (!rows.length) throw { statusCode: 404, message: 'User not found' }; + return { data: { + challenge: require('crypto').randomBytes(32).toString('base64url'), + rp: { name: 'Task Team', id: 'tasks.hasdo.info' }, + user: { id: Buffer.from(rows[0].id).toString('base64url'), name: rows[0].email, displayName: rows[0].name }, + pubKeyCredParams: [{ alg: -7, type: 'public-key' }, { alg: -257, type: 'public-key' }], + authenticatorSelection: { authenticatorAttachment: 'platform', userVerification: 'required' }, + timeout: 60000 + }}; + }); + + // Save registration + app.post('/webauthn/register/verify', async (req) => { + const { user_id, credential_id, public_key, device_name } = req.body; + const { rows } = await app.db.query( + 'INSERT INTO webauthn_credentials (user_id, credential_id, public_key, device_name) VALUES ($1,$2,$3,$4) RETURNING *', + [user_id, credential_id, public_key, device_name || 'Biometric']); + return { data: rows[0], status: 'registered' }; + }); + + // Auth options + app.post('/webauthn/auth/options', async (req) => { + const { user_id } = req.body; + const { rows } = await app.db.query('SELECT credential_id FROM webauthn_credentials WHERE user_id=$1', [user_id]); + return { data: { + challenge: require('crypto').randomBytes(32).toString('base64url'), + allowCredentials: rows.map(r => ({ id: r.credential_id, type: 'public-key' })), + timeout: 60000, userVerification: 'required' + }}; + }); + + // List user's biometric devices + app.get('/webauthn/devices/:userId', async (req) => { + const { rows } = await app.db.query( + 'SELECT id, device_name, created_at FROM webauthn_credentials WHERE user_id=$1', [req.params.userId]); + return { data: rows }; + }); + + // Remove device + app.delete('/webauthn/devices/:id', async (req) => { + await app.db.query('DELETE FROM webauthn_credentials WHERE id=$1', [req.params.id]); + return { status: 'deleted' }; + }); +} +module.exports = webauthnFeature; diff --git a/api/src/routes/groups.js b/api/src/routes/groups.js index 7008a3c..602efe2 100644 --- a/api/src/routes/groups.js +++ b/api/src/routes/groups.js @@ -35,12 +35,16 @@ async function groupRoutes(app) { }); app.put('/groups/:id', async (req) => { - const { name, color, icon, order_index, time_zones } = req.body; + const { name, color, icon, order_index, time_zones, locations } = req.body; const { rows } = await app.db.query( `UPDATE task_groups SET name=COALESCE($1,name), color=COALESCE($2,color), icon=COALESCE($3,icon), - order_index=COALESCE($4,order_index), time_zones=COALESCE($5,time_zones), updated_at=NOW() - WHERE id=$6 RETURNING *`, - [name, color, icon, order_index, time_zones ? JSON.stringify(time_zones) : null, req.params.id] + order_index=COALESCE($4,order_index), time_zones=COALESCE($5,time_zones), + locations=COALESCE($6,locations), updated_at=NOW() + WHERE id=$7 RETURNING *`, + [name, color, icon, order_index, + time_zones ? JSON.stringify(time_zones) : null, + locations ? JSON.stringify(locations) : null, + req.params.id] ); if (!rows.length) throw { statusCode: 404, message: 'Group not found' }; return { data: rows[0] }; @@ -111,6 +115,27 @@ async function groupRoutes(app) { return { data: rows[0] }; }); + // Combined schedule: time zones + locations for a group + app.get('/groups/:id/schedule', async (req) => { + const { rows } = await app.db.query( + 'SELECT id, name, time_zones, locations FROM task_groups WHERE id = $1', + [req.params.id] + ); + if (!rows.length) throw { statusCode: 404, message: 'Group not found' }; + const g = rows[0]; + return { + data: { + group_id: g.id, + group_name: g.name, + time_zones: g.time_zones || [], + locations: g.locations || [], + summary: { + tz_count: (g.time_zones || []).length, + loc_count: (g.locations || []).length + } + } + }; + }); } module.exports = groupRoutes; diff --git a/apps/tasks/app/calendar/page.tsx b/apps/tasks/app/calendar/page.tsx index f6b2979..30a24a9 100644 --- a/apps/tasks/app/calendar/page.tsx +++ b/apps/tasks/app/calendar/page.tsx @@ -63,25 +63,21 @@ export default function CalendarPage() { extendedProps: { status: tk.status, group: tk.group_name }, })); - // Build background events from unique groups - const groupColors = new Map(); - tasks.forEach(tk => { - if (tk.group_name && tk.group_color) { - groupColors.set(tk.group_name, tk.group_color); - } - }); - if (!token) return null; return ( -
-

{t('calendar.title')}

+
+ {/* Compact single-row header */} +
+

{t('calendar.title')}

+
+ {error && (
{error}
)} -
+
([]); + const [groupSettings, setGroupSettings] = useState>({}); + const [expandedGroup, setExpandedGroup] = useState(null); + const [savedGroup, setSavedGroup] = useState(null); useEffect(() => { if (!token) { @@ -37,6 +51,84 @@ export default function SettingsPage() { } }, [token, router]); + useEffect(() => { + if (!token) return; + fetch("/api/v1/groups", { headers: { Authorization: `Bearer ${token}` } }) + .then(r => r.json()) + .then(res => { + const data: Group[] = res.data || []; + setGroups(data); + const settings: Record = {}; + for (const g of data) { + const tz = g.time_zones?.[0]; + const loc = g.locations?.[0]; + settings[g.id] = { + from: tz?.from || "", + to: tz?.to || "", + days: tz?.days || [], + locationName: loc?.name || "", + gps: (loc?.lat != null && loc?.lng != null) ? `${loc.lat}, ${loc.lng}` : "", + radius: loc?.radius_m || 200, + }; + } + setGroupSettings(settings); + }) + .catch(() => {}); + }, [token]); + + function toggleGroup(id: string) { + setExpandedGroup(prev => prev === id ? null : id); + } + + function updateGroupSetting(groupId: string, key: keyof GroupSetting, value: string | number | number[]) { + setGroupSettings(prev => ({ + ...prev, + [groupId]: { ...prev[groupId], [key]: value }, + })); + } + + function toggleDay(groupId: string, day: number) { + const current = groupSettings[groupId]?.days || []; + const next = current.includes(day) ? current.filter(d => d !== day) : [...current, day]; + updateGroupSetting(groupId, "days", next); + } + + function getCurrentLocation(groupId: string) { + if (!navigator.geolocation) return; + navigator.geolocation.getCurrentPosition(pos => { + const gps = `${pos.coords.latitude.toFixed(6)}, ${pos.coords.longitude.toFixed(6)}`; + updateGroupSetting(groupId, "gps", gps); + }); + } + + async function saveGroupSettings(groupId: string) { + const s = groupSettings[groupId] || {} as GroupSetting; + const timeZones = (s.from && s.to) ? [{ + days: s.days?.length ? s.days : [0, 1, 2, 3, 4, 5, 6], + from: s.from, + to: s.to, + }] : []; + + const gpsParts = (s.gps || "").split(",").map(x => parseFloat(x.trim())); + const lat = gpsParts[0] || null; + const lng = gpsParts[1] || null; + const locations = s.locationName ? [{ + name: s.locationName, + lat: isNaN(lat as number) ? null : lat, + lng: isNaN(lng as number) ? null : lng, + radius_m: Number(s.radius) || 200, + }] : []; + + await fetch(`/api/v1/groups/${groupId}`, { + method: "PUT", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ time_zones: timeZones, locations }), + }); + + setSavedGroup(groupId); + setTimeout(() => setSavedGroup(null), 2000); + } + function handleSave() { if (typeof window !== "undefined") { localStorage.setItem("taskteam_notifications", JSON.stringify(notifications)); @@ -193,6 +285,113 @@ export default function SettingsPage() {
+ {/* Groups settings */} + {groups.length > 0 && ( +
+
+ Skupiny +
+ {groups.map(group => ( +
+
toggleGroup(group.id)} + style={{ padding: "12px 16px", display: "flex", alignItems: "center", gap: 10, cursor: "pointer" }}> + {group.icon || "📁"} + + {group.display_name || group.name} + + + {group.time_zones?.[0] ? `${group.time_zones[0].from}–${group.time_zones[0].to}` : ""} + + {expandedGroup === group.id ? "▲" : "▼"} +
+ + {expandedGroup === group.id && ( +
+ {/* CAS AKTIVITY */} +
+
+ Čas aktivity (volitelné) +
+
+ updateGroupSetting(group.id, "from", e.target.value)} + style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }} + /> + + updateGroupSetting(group.id, "to", e.target.value)} + style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }} + /> +
+ {/* DNY V TYDNU */} +
+ {["Ne", "Po", "Út", "St", "Čt", "Pá", "So"].map((d, i) => { + const active = (groupSettings[group.id]?.days || []).includes(i); + return ( + + ); + })} +
+
+ + {/* GPS MISTO */} +
+
+ Místo výkonu (volitelné) +
+ updateGroupSetting(group.id, "locationName", e.target.value)} + style={{ width: "100%", padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14, marginBottom: 8, boxSizing: "border-box" }} + /> +
+ updateGroupSetting(group.id, "gps", e.target.value)} + style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }} + /> + +
+
+ Polomer: + updateGroupSetting(group.id, "radius", Number(e.target.value))} + style={{ flex: 1 }} + /> + + {groupSettings[group.id]?.radius || 200}m + +
+
+ + {/* ULOZIT */} + +
+ )} +
+ ))} +
+ )} + {/* Save button */}