Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 926a584789 | |||
| 6d68b68412 | |||
| 8cf14dcf59 | |||
| f9c4ec631c | |||
| 0c3fc44440 | |||
| 83febef040 | |||
| 42881b1f5a | |||
| 524025bfe9 | |||
| e250b2124f | |||
| 317672aa08 | |||
| 703541d29a | |||
| 653805af4c | |||
| 5751ab832f | |||
| 03d7bd8de6 | |||
| 3f04637550 | |||
| 79f7e18c8c |
BIN
android/store-assets/feature-graphic.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
android/store-assets/icon-512.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
android/store-assets/phone-1-tasks.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
android/store-assets/phone-2-calendar.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
android/store-assets/phone-3-chat.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
android/store-assets/phone-4-goals.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
android/store-assets/screenshot-1.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
android/store-assets/tablet10-1-tasks.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
android/store-assets/tablet10-2-calendar.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
android/store-assets/tablet7-1-tasks.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
android/store-assets/tablet7-2-calendar.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
301
api/package-lock.json
generated
@@ -16,6 +16,8 @@
|
|||||||
"@fastify/rate-limit": "^10.3.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",
|
||||||
|
"@fastify/websocket": "^11.2.0",
|
||||||
|
"@simplewebauthn/server": "^12.0.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
@@ -367,12 +369,45 @@
|
|||||||
"yaml": "^2.4.1"
|
"yaml": "^2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/websocket": {
|
||||||
|
"version": "11.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
|
||||||
|
"integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"duplexify": "^4.1.3",
|
||||||
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"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": {
|
"node_modules/@ioredis/commands": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
|
||||||
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
|
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@lukeed/ms": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||||
@@ -382,6 +417,64 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
@@ -457,6 +550,33 @@
|
|||||||
"@redis/client": "^5.11.0"
|
"@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": {
|
"node_modules/abstract-logging": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||||
@@ -531,6 +651,20 @@
|
|||||||
"safer-buffer": "^2.1.0"
|
"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": {
|
"node_modules/atomic-sleep": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
@@ -711,6 +845,15 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -767,6 +910,18 @@
|
|||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/duplexify": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"end-of-stream": "^1.4.1",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.1.1",
|
||||||
|
"stream-shift": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ecdsa-sig-formatter": {
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
@@ -776,6 +931,15 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/end-of-stream": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -1382,6 +1546,26 @@
|
|||||||
"node": "^18 || ^20 || >= 21"
|
"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": {
|
"node_modules/node-gyp-build": {
|
||||||
"version": "4.8.4",
|
"version": "4.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
@@ -1462,6 +1646,15 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/openapi-types": {
|
"node_modules/openapi-types": {
|
||||||
"version": "12.1.3",
|
"version": "12.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
|
||||||
@@ -1761,12 +1954,44 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/quick-format-unescaped": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
@@ -2016,6 +2241,21 @@
|
|||||||
"reusify": "^1.0.0"
|
"reusify": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stream-shift": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||||
@@ -2082,12 +2322,24 @@
|
|||||||
"nodetouch": "bin/nodetouch.js"
|
"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": {
|
"node_modules/ts-algebra": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/uid2": {
|
||||||
"version": "0.0.4",
|
"version": "0.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
|
||||||
@@ -2101,6 +2353,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/utils-merge": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
@@ -2142,6 +2400,49 @@
|
|||||||
"node": ">= 16"
|
"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",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
"@fastify/rate-limit": "^10.3.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",
|
||||||
|
"@fastify/websocket": "^11.2.0",
|
||||||
|
"@simplewebauthn/server": "^12.0.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
|
|||||||
28
api/src/features/ai-briefing.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Task Team — AI Daily Briefing
|
||||||
|
const Anthropic = require("@anthropic-ai/sdk");
|
||||||
|
|
||||||
|
async function aiBriefingFeature(app) {
|
||||||
|
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
||||||
|
|
||||||
|
app.get("/briefing/:userId", async (req) => {
|
||||||
|
const { rows: tasks } = await app.db.query(
|
||||||
|
"SELECT title, status, priority, due_at, group_id FROM tasks WHERE user_id=$1 AND status NOT IN ('done','completed','cancelled') ORDER BY priority DESC, due_at ASC NULLS LAST LIMIT 20",
|
||||||
|
[req.params.userId]
|
||||||
|
);
|
||||||
|
const { rows: goals } = await app.db.query(
|
||||||
|
"SELECT title, progress_pct FROM goals WHERE user_id=$1 LIMIT 5", [req.params.userId]
|
||||||
|
);
|
||||||
|
const { rows: reviews } = await app.db.query(
|
||||||
|
"SELECT count(*) as due FROM review_items WHERE user_id=$1 AND next_review <= NOW()", [req.params.userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await anthropic.messages.create({
|
||||||
|
model: "claude-sonnet-4-20250514",
|
||||||
|
max_tokens: 512,
|
||||||
|
system: "Jsi osobni asistent. Dej strucny ranni briefing v cestine. Bud prakticky a motivujici.",
|
||||||
|
messages: [{ role: "user", content: `Ranni briefing pro uzivatele:\nUkoly (${tasks.length}): ${JSON.stringify(tasks.slice(0,5).map(t=>t.title))}\nCile: ${JSON.stringify(goals.map(g=>g.title+" "+g.progress_pct+"%"))}\nKarty k opakovani: ${reviews[0]?.due || 0}\nDnes je ${new Date().toLocaleDateString("cs-CZ",{weekday:"long",day:"numeric",month:"long"})}` }]
|
||||||
|
});
|
||||||
|
return { data: { briefing: response.content[0].text, tasks_count: tasks.length, reviews_due: parseInt(reviews[0]?.due || 0) } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
module.exports = aiBriefingFeature;
|
||||||
73
api/src/features/gamification.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Task Team — Gamification — points, streaks, badges
|
||||||
|
async function gamificationFeature(app) {
|
||||||
|
await app.db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_stats (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
points INTEGER DEFAULT 0,
|
||||||
|
level INTEGER DEFAULT 1,
|
||||||
|
streak_days INTEGER DEFAULT 0,
|
||||||
|
longest_streak INTEGER DEFAULT 0,
|
||||||
|
tasks_completed INTEGER DEFAULT 0,
|
||||||
|
last_active DATE DEFAULT CURRENT_DATE,
|
||||||
|
badges JSONB DEFAULT '[]'
|
||||||
|
);
|
||||||
|
`).catch(() => {});
|
||||||
|
|
||||||
|
// Get user stats
|
||||||
|
app.get("/gamification/stats/:userId", async (req) => {
|
||||||
|
let { rows } = await app.db.query("SELECT * FROM user_stats WHERE user_id=$1", [req.params.userId]);
|
||||||
|
if (!rows.length) {
|
||||||
|
await app.db.query("INSERT INTO user_stats (user_id) VALUES ($1) ON CONFLICT DO NOTHING", [req.params.userId]);
|
||||||
|
rows = (await app.db.query("SELECT * FROM user_stats WHERE user_id=$1", [req.params.userId])).rows;
|
||||||
|
}
|
||||||
|
const stats = rows[0];
|
||||||
|
stats.next_level_points = stats.level * 100;
|
||||||
|
stats.progress_pct = Math.min(100, Math.round((stats.points % (stats.level * 100)) / (stats.level * 100) * 100));
|
||||||
|
return { data: stats };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Award points (called internally when task completed)
|
||||||
|
app.post("/gamification/award", async (req) => {
|
||||||
|
const { user_id, points, reason } = req.body;
|
||||||
|
const { rows } = await app.db.query(`
|
||||||
|
INSERT INTO user_stats (user_id, points, tasks_completed, last_active)
|
||||||
|
VALUES ($1, $2, 1, CURRENT_DATE)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
|
points = user_stats.points + $2,
|
||||||
|
tasks_completed = user_stats.tasks_completed + 1,
|
||||||
|
streak_days = CASE
|
||||||
|
WHEN user_stats.last_active = CURRENT_DATE - 1 THEN user_stats.streak_days + 1
|
||||||
|
WHEN user_stats.last_active = CURRENT_DATE THEN user_stats.streak_days
|
||||||
|
ELSE 1
|
||||||
|
END,
|
||||||
|
longest_streak = GREATEST(user_stats.longest_streak,
|
||||||
|
CASE WHEN user_stats.last_active = CURRENT_DATE - 1 THEN user_stats.streak_days + 1 ELSE 1 END),
|
||||||
|
level = 1 + (user_stats.points + $2) / 100,
|
||||||
|
last_active = CURRENT_DATE
|
||||||
|
RETURNING *
|
||||||
|
`, [user_id, points || 10]);
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leaderboard
|
||||||
|
app.get("/gamification/leaderboard", async (req) => {
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
"SELECT us.*, u.name, u.avatar_url FROM user_stats us JOIN users u ON us.user_id=u.id ORDER BY us.points DESC LIMIT 20"
|
||||||
|
);
|
||||||
|
return { data: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Badges
|
||||||
|
app.get("/gamification/badges", async () => {
|
||||||
|
return { data: [
|
||||||
|
{ id: "first_task", name: "First Task", icon: "target", description: "Completed first task" },
|
||||||
|
{ id: "streak_7", name: "7 Day Streak", icon: "fire", description: "7 consecutive days active" },
|
||||||
|
{ id: "streak_30", name: "30 Day Streak", icon: "diamond", description: "30 consecutive days active" },
|
||||||
|
{ id: "collaborator", name: "Team Player", icon: "handshake", description: "Collaborated on 5+ tasks" },
|
||||||
|
{ id: "goal_master", name: "Goal Master", icon: "trophy", description: "Completed 3 goals" },
|
||||||
|
{ id: "speed_demon", name: "Speed Demon", icon: "lightning", description: "Completed 5 tasks in one day" },
|
||||||
|
{ id: "polyglot", name: "Polyglot", icon: "globe", description: "Used 3+ languages" }
|
||||||
|
]};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
module.exports = gamificationFeature;
|
||||||
44
api/src/features/kanban.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Task Team — Kanban Board — drag-and-drop columns
|
||||||
|
async function kanbanFeature(app) {
|
||||||
|
// Kanban uses existing tasks table, just provides board-view endpoints
|
||||||
|
|
||||||
|
app.get("/kanban/board", async (req) => {
|
||||||
|
const { group_id, project_id } = req.query;
|
||||||
|
let where = "1=1";
|
||||||
|
const params = [];
|
||||||
|
if (group_id) { params.push(group_id); where += ` AND t.group_id=$${params.length}`; }
|
||||||
|
if (project_id) { params.push(project_id); where += ` AND t.project_id=$${params.length}`; }
|
||||||
|
|
||||||
|
const columns = ["pending", "in_progress", "done", "cancelled"];
|
||||||
|
const board = {};
|
||||||
|
for (const col of columns) {
|
||||||
|
const colParams = [...params, col];
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
`SELECT t.id, t.title, t.priority, t.assigned_to, t.group_id, tg.color as group_color, tg.icon as group_icon
|
||||||
|
FROM tasks t LEFT JOIN task_groups tg ON t.group_id=tg.id
|
||||||
|
WHERE ${where} AND t.status=$${colParams.length}
|
||||||
|
ORDER BY t.created_at DESC LIMIT 50`, colParams
|
||||||
|
);
|
||||||
|
board[col] = rows;
|
||||||
|
}
|
||||||
|
return { data: board };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move task between columns (change status)
|
||||||
|
app.post("/kanban/move", async (req) => {
|
||||||
|
const { task_id, new_status } = req.body;
|
||||||
|
const valid = ["pending", "in_progress", "done", "completed", "cancelled"];
|
||||||
|
if (!valid.includes(new_status)) throw { statusCode: 400, message: "Invalid status" };
|
||||||
|
|
||||||
|
const sets = ["status=$1", "updated_at=NOW()"];
|
||||||
|
const params = [new_status];
|
||||||
|
if (new_status === "done" || new_status === "completed") sets.push("completed_at=NOW()");
|
||||||
|
params.push(task_id);
|
||||||
|
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
`UPDATE tasks SET ${sets.join(",")} WHERE id=$${params.length} RETURNING *`, params
|
||||||
|
);
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
module.exports = kanbanFeature;
|
||||||
79
api/src/features/media-input.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// Task Team — Media Input — text, audio transcription, photo, video
|
||||||
|
const { writeFileSync, mkdirSync, existsSync } = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
|
const UPLOAD_DIR = "/opt/task-team/uploads";
|
||||||
|
|
||||||
|
async function mediaInputFeature(app) {
|
||||||
|
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// Create media table
|
||||||
|
await app.db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS media_attachments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID,
|
||||||
|
type VARCHAR(20) NOT NULL,
|
||||||
|
filename VARCHAR(500),
|
||||||
|
path TEXT,
|
||||||
|
size_bytes INTEGER DEFAULT 0,
|
||||||
|
mime_type VARCHAR(100),
|
||||||
|
transcription TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`).catch(() => {});
|
||||||
|
|
||||||
|
// Upload media (base64 encoded in JSON body for simplicity)
|
||||||
|
app.post("/media/upload", async (req) => {
|
||||||
|
const { task_id, user_id, type, data, filename, mime_type } = req.body;
|
||||||
|
// type: "text", "audio", "photo", "video"
|
||||||
|
|
||||||
|
let filePath = null;
|
||||||
|
let size = 0;
|
||||||
|
let transcription = null;
|
||||||
|
|
||||||
|
if (type === "text") {
|
||||||
|
transcription = data; // plain text, no file
|
||||||
|
} else if (data) {
|
||||||
|
// Save base64 file
|
||||||
|
const buffer = Buffer.from(data, "base64");
|
||||||
|
const ext = mime_type?.split("/")[1] || type;
|
||||||
|
const fname = crypto.randomBytes(8).toString("hex") + "." + ext;
|
||||||
|
const dir = path.join(UPLOAD_DIR, type);
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
filePath = path.join(dir, fname);
|
||||||
|
writeFileSync(filePath, buffer);
|
||||||
|
size = buffer.length;
|
||||||
|
|
||||||
|
// Audio transcription via AI
|
||||||
|
if (type === "audio" && process.env.ANTHROPIC_API_KEY) {
|
||||||
|
transcription = "[Audio transcription pending — needs Whisper API]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
`INSERT INTO media_attachments (task_id, user_id, type, filename, path, size_bytes, mime_type, transcription)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||||
|
[task_id, user_id, type, filename || "", filePath || "", size, mime_type || "", transcription || ""]
|
||||||
|
);
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get media for task
|
||||||
|
app.get("/media/task/:taskId", async (req) => {
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
"SELECT id, task_id, type, filename, size_bytes, mime_type, transcription, created_at FROM media_attachments WHERE task_id=$1 ORDER BY created_at DESC",
|
||||||
|
[req.params.taskId]
|
||||||
|
);
|
||||||
|
return { data: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete media
|
||||||
|
app.delete("/media/:id", async (req) => {
|
||||||
|
await app.db.query("DELETE FROM media_attachments WHERE id=$1", [req.params.id]);
|
||||||
|
return { status: "deleted" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
module.exports = mediaInputFeature;
|
||||||
25
api/src/features/registry.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Task Team — Feature Registry
|
||||||
|
// Each feature is a separate file. Add/remove here to enable/disable.
|
||||||
|
const features = [
|
||||||
|
{ name: "media-input", path: "./media-input", prefix: "/api/v1" },
|
||||||
|
{ name: "gamification", path: "./gamification", prefix: "/api/v1" },
|
||||||
|
{ name: "templates", path: "./templates", prefix: "/api/v1" },
|
||||||
|
{ name: "time-tracking", path: "./time-tracking", prefix: "/api/v1" },
|
||||||
|
{ 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) {
|
||||||
|
for (const f of features) {
|
||||||
|
try {
|
||||||
|
await app.register(require(f.path), { prefix: f.prefix });
|
||||||
|
app.log.info(`Feature loaded: ${f.name}`);
|
||||||
|
} catch (e) {
|
||||||
|
app.log.warn(`Feature ${f.name} failed to load: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerFeatures };
|
||||||
87
api/src/features/templates.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// Task Team — Task Templates — predefined task sets
|
||||||
|
async function templatesFeature(app) {
|
||||||
|
await app.db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS task_templates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
category VARCHAR(50),
|
||||||
|
tasks JSONB NOT NULL DEFAULT '[]',
|
||||||
|
created_by UUID,
|
||||||
|
is_public BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`).catch(() => {});
|
||||||
|
|
||||||
|
// List templates
|
||||||
|
app.get("/templates", async (req) => {
|
||||||
|
const { rows } = await app.db.query("SELECT * FROM task_templates WHERE is_public=true ORDER BY name");
|
||||||
|
return { data: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create template
|
||||||
|
app.post("/templates", async (req) => {
|
||||||
|
const { name, description, category, tasks, created_by } = req.body;
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
"INSERT INTO task_templates (name, description, category, tasks, created_by) VALUES ($1,$2,$3,$4,$5) RETURNING *",
|
||||||
|
[name, description || "", category || "general", JSON.stringify(tasks || []), created_by]
|
||||||
|
);
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply template (creates tasks from template)
|
||||||
|
app.post("/templates/:id/apply", async (req) => {
|
||||||
|
const { user_id, group_id } = req.body;
|
||||||
|
const { rows: templates } = await app.db.query("SELECT * FROM task_templates WHERE id=$1", [req.params.id]);
|
||||||
|
if (!templates.length) throw { statusCode: 404, message: "Template not found" };
|
||||||
|
|
||||||
|
const taskList = templates[0].tasks;
|
||||||
|
let created = 0;
|
||||||
|
for (const t of taskList) {
|
||||||
|
await app.db.query(
|
||||||
|
"INSERT INTO tasks (title, description, user_id, group_id, priority, status) VALUES ($1,$2,$3,$4,$5,$6)",
|
||||||
|
[t.title, t.description || "", user_id, group_id, t.priority || "medium", "pending"]
|
||||||
|
);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
return { status: "ok", created, template: templates[0].name };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete template
|
||||||
|
app.delete("/templates/:id", async (req) => {
|
||||||
|
await app.db.query("DELETE FROM task_templates WHERE id=$1", [req.params.id]);
|
||||||
|
return { status: "deleted" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed default templates
|
||||||
|
const { rows: existing } = await app.db.query("SELECT count(*) as c FROM task_templates");
|
||||||
|
if (parseInt(existing[0].c) === 0) {
|
||||||
|
const defaults = [
|
||||||
|
{ name: "Weekly Sprint", category: "work", tasks: [
|
||||||
|
{ title: "Sprint planning", priority: "high" },
|
||||||
|
{ title: "Daily standup notes", priority: "medium" },
|
||||||
|
{ title: "Code review", priority: "high" },
|
||||||
|
{ title: "Sprint retrospective", priority: "medium" }
|
||||||
|
]},
|
||||||
|
{ name: "Study Plan", category: "study", tasks: [
|
||||||
|
{ title: "Read chapter", priority: "medium" },
|
||||||
|
{ title: "Take notes", priority: "medium" },
|
||||||
|
{ title: "Practice exercises", priority: "high" },
|
||||||
|
{ title: "Review flashcards", priority: "low" },
|
||||||
|
{ title: "Weekly quiz", priority: "high" }
|
||||||
|
]},
|
||||||
|
{ name: "Moving Checklist", category: "personal", tasks: [
|
||||||
|
{ title: "Pack kitchen", priority: "high" },
|
||||||
|
{ title: "Notify utilities", priority: "high" },
|
||||||
|
{ title: "Change address", priority: "medium" },
|
||||||
|
{ title: "Clean old place", priority: "medium" },
|
||||||
|
{ title: "Unpack essentials", priority: "high" }
|
||||||
|
]}
|
||||||
|
];
|
||||||
|
for (const t of defaults) {
|
||||||
|
await app.db.query("INSERT INTO task_templates (name, category, tasks) VALUES ($1,$2,$3)",
|
||||||
|
[t.name, t.category, JSON.stringify(t.tasks)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = templatesFeature;
|
||||||
72
api/src/features/time-tracking.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Task Team — Time Tracking — stopwatch on tasks
|
||||||
|
async function timeTrackingFeature(app) {
|
||||||
|
await app.db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS time_entries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
duration_seconds INTEGER,
|
||||||
|
note TEXT DEFAULT '\,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_time_task ON time_entries(task_id);
|
||||||
|
`).catch(() => {});
|
||||||
|
|
||||||
|
// Start timer
|
||||||
|
app.post("/time/start", async (req) => {
|
||||||
|
const { task_id, user_id } = req.body;
|
||||||
|
// Stop any running timer first
|
||||||
|
await app.db.query(
|
||||||
|
"UPDATE time_entries SET ended_at=NOW(), duration_seconds=EXTRACT(EPOCH FROM NOW()-started_at)::integer WHERE user_id=$1 AND ended_at IS NULL",
|
||||||
|
[user_id]
|
||||||
|
);
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
"INSERT INTO time_entries (task_id, user_id) VALUES ($1,$2) RETURNING *",
|
||||||
|
[task_id, user_id]
|
||||||
|
);
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop timer
|
||||||
|
app.post("/time/stop", async (req) => {
|
||||||
|
const { user_id, note } = req.body;
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
"UPDATE time_entries SET ended_at=NOW(), duration_seconds=EXTRACT(EPOCH FROM NOW()-started_at)::integer, note=$2 WHERE user_id=$1 AND ended_at IS NULL RETURNING *",
|
||||||
|
[user_id, note || ""]
|
||||||
|
);
|
||||||
|
if (!rows.length) throw { statusCode: 404, message: "No running timer" };
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get active timer
|
||||||
|
app.get("/time/active/:userId", async (req) => {
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
"SELECT te.*, t.title as task_title FROM time_entries te JOIN tasks t ON te.task_id=t.id WHERE te.user_id=$1 AND te.ended_at IS NULL",
|
||||||
|
[req.params.userId]
|
||||||
|
);
|
||||||
|
return { data: rows[0] || null };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Task time report
|
||||||
|
app.get("/time/task/:taskId", async (req) => {
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
"SELECT te.*, u.name as user_name FROM time_entries te JOIN users u ON te.user_id=u.id WHERE te.task_id=$1 ORDER BY te.started_at DESC",
|
||||||
|
[req.params.taskId]
|
||||||
|
);
|
||||||
|
const total = rows.reduce((s, r) => s + (r.duration_seconds || 0), 0);
|
||||||
|
return { data: rows, total_seconds: total, total_hours: Math.round(total / 36) / 100 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// User weekly report
|
||||||
|
app.get("/time/report/:userId", async (req) => {
|
||||||
|
const { rows } = await app.db.query(`
|
||||||
|
SELECT date_trunc('day', started_at)::date as day, sum(duration_seconds) as seconds, count(*) as entries
|
||||||
|
FROM time_entries WHERE user_id=$1 AND started_at > NOW() - INTERVAL '7 days' AND duration_seconds IS NOT NULL
|
||||||
|
GROUP BY 1 ORDER BY 1
|
||||||
|
`, [req.params.userId]);
|
||||||
|
return { data: rows };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
module.exports = timeTrackingFeature;
|
||||||
64
api/src/features/webauthn.js
Normal file
@@ -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;
|
||||||
57
api/src/features/webhooks-outgoing.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// Task Team — Outgoing Webhooks — notify external systems
|
||||||
|
async function webhooksFeature(app) {
|
||||||
|
await app.db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS webhook_endpoints (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
events TEXT[] DEFAULT '{"task.created","task.completed"}',
|
||||||
|
secret VARCHAR(64),
|
||||||
|
active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`).catch(() => {});
|
||||||
|
|
||||||
|
app.get("/webhooks", async (req) => {
|
||||||
|
const { user_id } = req.query;
|
||||||
|
const { rows } = await app.db.query("SELECT id,url,events,active,created_at FROM webhook_endpoints WHERE user_id=$1", [user_id]);
|
||||||
|
return { data: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/webhooks", async (req) => {
|
||||||
|
const { user_id, url, events, secret } = req.body;
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
"INSERT INTO webhook_endpoints (user_id, url, events, secret) VALUES ($1,$2,$3,$4) RETURNING *",
|
||||||
|
[user_id, url, events || ["task.created","task.completed"], secret || crypto.randomBytes(16).toString("hex")]
|
||||||
|
);
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/webhooks/:id", async (req) => {
|
||||||
|
await app.db.query("DELETE FROM webhook_endpoints WHERE id=$1", [req.params.id]);
|
||||||
|
return { status: "deleted" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire webhook (internal — called from task routes)
|
||||||
|
app.post("/webhooks/fire", async (req) => {
|
||||||
|
const { event, payload, user_id } = req.body;
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
"SELECT * FROM webhook_endpoints WHERE user_id=$1 AND active=true AND $2=ANY(events)",
|
||||||
|
[user_id, event]
|
||||||
|
);
|
||||||
|
let sent = 0;
|
||||||
|
for (const wh of rows) {
|
||||||
|
try {
|
||||||
|
await fetch(wh.url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "X-Webhook-Secret": wh.secret || "" },
|
||||||
|
body: JSON.stringify({ event, payload, timestamp: new Date().toISOString() })
|
||||||
|
});
|
||||||
|
sent++;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return { status: "ok", sent, total: rows.length };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
module.exports = webhooksFeature;
|
||||||
@@ -6,6 +6,7 @@ const jwt = require("@fastify/jwt");
|
|||||||
const rateLimit = require("@fastify/rate-limit");
|
const rateLimit = require("@fastify/rate-limit");
|
||||||
const { Pool } = require("pg");
|
const { Pool } = require("pg");
|
||||||
const Redis = require("ioredis");
|
const Redis = require("ioredis");
|
||||||
|
const websocket = require("@fastify/websocket");
|
||||||
const swagger = require("@fastify/swagger");
|
const swagger = require("@fastify/swagger");
|
||||||
const swaggerUi = require("@fastify/swagger-ui");
|
const swaggerUi = require("@fastify/swagger-ui");
|
||||||
|
|
||||||
@@ -47,7 +48,17 @@ const start = async () => {
|
|||||||
await app.register(rateLimit, {
|
await app.register(rateLimit, {
|
||||||
max: 100,
|
max: 100,
|
||||||
timeWindow: "1 minute",
|
timeWindow: "1 minute",
|
||||||
keyGenerator: (req) => req.ip,
|
keyGenerator: (req) => {
|
||||||
|
// Use user ID if authenticated, otherwise IP
|
||||||
|
try {
|
||||||
|
const token = req.headers.authorization?.split(" ")[1];
|
||||||
|
if (token) {
|
||||||
|
const decoded = app.jwt.decode(token);
|
||||||
|
return `user:${decoded.id}`;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return `ip:${req.ip}`;
|
||||||
|
},
|
||||||
errorResponseBuilder: () => ({ error: "Too many requests", statusCode: 429 })
|
errorResponseBuilder: () => ({ error: "Too many requests", statusCode: 429 })
|
||||||
});
|
});
|
||||||
await app.register(jwt, { secret: process.env.JWT_SECRET || "taskteam-jwt-secret-2026" });
|
await app.register(jwt, { secret: process.env.JWT_SECRET || "taskteam-jwt-secret-2026" });
|
||||||
@@ -59,8 +70,6 @@ const start = async () => {
|
|||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
redis: redis.status
|
redis: redis.status
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
// Swagger/OpenAPI documentation
|
// Swagger/OpenAPI documentation
|
||||||
await app.register(swagger, {
|
await app.register(swagger, {
|
||||||
openapi: {
|
openapi: {
|
||||||
@@ -85,6 +94,16 @@ const start = async () => {
|
|||||||
uiConfig: { docExpansion: "list", deepLinking: true }
|
uiConfig: { docExpansion: "list", deepLinking: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// WebSocket support
|
||||||
|
await app.register(websocket);
|
||||||
|
|
||||||
|
app.get("/ws", { websocket: true }, (socket, req) => {
|
||||||
|
socket.on("message", (msg) => {
|
||||||
|
socket.send(JSON.stringify({ type: "ack", message: msg.toString() }));
|
||||||
|
});
|
||||||
|
socket.send(JSON.stringify({ type: "connected", timestamp: new Date().toISOString() }));
|
||||||
|
});
|
||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
await app.register(require("./routes/tasks"), { prefix: "/api/v1" });
|
await app.register(require("./routes/tasks"), { prefix: "/api/v1" });
|
||||||
await app.register(require("./routes/groups"), { prefix: "/api/v1" });
|
await app.register(require("./routes/groups"), { prefix: "/api/v1" });
|
||||||
@@ -103,7 +122,17 @@ const start = async () => {
|
|||||||
await app.register(require("./routes/collaboration"), { prefix: "/api/v1" });
|
await app.register(require("./routes/collaboration"), { prefix: "/api/v1" });
|
||||||
await app.register(require("./routes/email"), { prefix: "/api/v1" });
|
await app.register(require("./routes/email"), { prefix: "/api/v1" });
|
||||||
await app.register(require("./routes/errors"), { prefix: "/api/v1" });
|
await app.register(require("./routes/errors"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/invitations"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/notification-prefs"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/odoo-modules"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/spaced-repetition"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/admin"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/activity"), { prefix: "/api/v1" });
|
||||||
|
await app.register(require("./routes/families"), { prefix: "/api/v1" });
|
||||||
|
|
||||||
|
// Register features (modular)
|
||||||
|
const { registerFeatures } = require("./features/registry");
|
||||||
|
await registerFeatures(app);
|
||||||
try {
|
try {
|
||||||
await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" });
|
await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" });
|
||||||
console.log("Task Team API listening on port " + (process.env.PORT || 3000) + " (pid: " + process.pid + ")");
|
console.log("Task Team API listening on port " + (process.env.PORT || 3000) + " (pid: " + process.pid + ")");
|
||||||
|
|||||||
85
api/src/routes/activity.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// Task Team — Activity Monitor — 2026-03-30
|
||||||
|
async function activityRoutes(app) {
|
||||||
|
await app.db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
app_name VARCHAR(255) NOT NULL,
|
||||||
|
package_name VARCHAR(255),
|
||||||
|
duration_seconds INTEGER DEFAULT 0,
|
||||||
|
category VARCHAR(50),
|
||||||
|
recorded_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
device VARCHAR(100)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_user ON activity_logs(user_id, recorded_at DESC);
|
||||||
|
`).catch(() => {});
|
||||||
|
|
||||||
|
// Report activity from phone
|
||||||
|
app.post("/activity/report", async (req) => {
|
||||||
|
const { user_id, activities } = req.body;
|
||||||
|
// activities: [{app_name, package_name, duration_seconds, category}]
|
||||||
|
let inserted = 0;
|
||||||
|
for (const a of (activities || [])) {
|
||||||
|
await app.db.query(
|
||||||
|
"INSERT INTO activity_logs (user_id, app_name, package_name, duration_seconds, category, device) VALUES ($1,$2,$3,$4,$5,$6)",
|
||||||
|
[user_id, a.app_name, a.package_name || "", a.duration_seconds || 0, a.category || "other", a.device || "android"]
|
||||||
|
);
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
return { status: "ok", inserted };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get activity summary
|
||||||
|
app.get("/activity/summary", async (req) => {
|
||||||
|
const { user_id, days } = req.query;
|
||||||
|
const d = parseInt(days) || 7;
|
||||||
|
const { rows } = await app.db.query(`
|
||||||
|
SELECT category, sum(duration_seconds) as total_seconds, count(*) as sessions
|
||||||
|
FROM activity_logs
|
||||||
|
WHERE user_id = $1 AND recorded_at > NOW() - make_interval(days => $2)
|
||||||
|
GROUP BY category ORDER BY total_seconds DESC
|
||||||
|
`, [user_id, d]);
|
||||||
|
return { data: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get daily breakdown
|
||||||
|
app.get("/activity/daily", async (req) => {
|
||||||
|
const { user_id } = req.query;
|
||||||
|
const { rows } = await app.db.query(`
|
||||||
|
SELECT date_trunc('day', recorded_at)::date as day,
|
||||||
|
sum(duration_seconds) as total_seconds,
|
||||||
|
count(DISTINCT app_name) as apps_used
|
||||||
|
FROM activity_logs WHERE user_id = $1 AND recorded_at > NOW() - INTERVAL '7 days'
|
||||||
|
GROUP BY 1 ORDER BY 1
|
||||||
|
`, [user_id]);
|
||||||
|
return { data: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
// AI analysis of activity vs planned tasks
|
||||||
|
app.get("/activity/ai-analysis", async (req) => {
|
||||||
|
const { user_id } = req.query;
|
||||||
|
const { rows: activities } = await app.db.query(
|
||||||
|
`SELECT app_name, category, sum(duration_seconds) as total FROM activity_logs
|
||||||
|
WHERE user_id=$1 AND recorded_at > NOW() - INTERVAL '7 days' GROUP BY 1,2 ORDER BY total DESC LIMIT 10`,
|
||||||
|
[user_id]
|
||||||
|
);
|
||||||
|
const { rows: tasks } = await app.db.query(
|
||||||
|
"SELECT title, status, group_id FROM tasks WHERE user_id=$1 ORDER BY created_at DESC LIMIT 10",
|
||||||
|
[user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!activities.length) return { data: { message: "No activity data yet" } };
|
||||||
|
|
||||||
|
const Anthropic = require("@anthropic-ai/sdk");
|
||||||
|
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
||||||
|
const response = await anthropic.messages.create({
|
||||||
|
model: "claude-sonnet-4-20250514",
|
||||||
|
max_tokens: 512,
|
||||||
|
system: "Analyzuj pouzivani telefonu vs planovane ukoly. Odpovez cesky, strucne.",
|
||||||
|
messages: [{ role: "user", content: `Aktivity: ${JSON.stringify(activities)}\nUkoly: ${JSON.stringify(tasks)}\nAnalyzuj: jsou aktivity v souladu s ukoly?` }]
|
||||||
|
});
|
||||||
|
return { data: { analysis: response.content[0].text, activities, tasks } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = activityRoutes;
|
||||||
67
api/src/routes/admin.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// Task Team — Admin Dashboard — 2026-03-30
|
||||||
|
async function adminRoutes(app) {
|
||||||
|
// User management
|
||||||
|
app.get("/admin/users", async (req) => {
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
`SELECT u.id, u.email, u.name, u.phone, u.language, u.auth_provider, u.created_at,
|
||||||
|
(SELECT count(*) FROM tasks WHERE user_id=u.id) as task_count,
|
||||||
|
(SELECT count(*) FROM goals WHERE user_id=u.id) as goal_count
|
||||||
|
FROM users u ORDER BY u.created_at DESC`
|
||||||
|
);
|
||||||
|
return { data: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/admin/users/:id", async (req) => {
|
||||||
|
await app.db.query("DELETE FROM users WHERE id = $1", [req.params.id]);
|
||||||
|
return { status: "deleted" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// System analytics
|
||||||
|
app.get("/admin/analytics", async (req) => {
|
||||||
|
const { rows: overview } = await app.db.query(`
|
||||||
|
SELECT
|
||||||
|
(SELECT count(*) FROM users) as total_users,
|
||||||
|
(SELECT count(*) FROM users WHERE created_at > NOW() - INTERVAL '7 days') as new_users_7d,
|
||||||
|
(SELECT count(*) FROM tasks) as total_tasks,
|
||||||
|
(SELECT count(*) FROM tasks WHERE status='completed' OR status='done') as completed_tasks,
|
||||||
|
(SELECT count(*) FROM tasks WHERE created_at > NOW() - INTERVAL '24 hours') as tasks_today,
|
||||||
|
(SELECT count(*) FROM goals) as total_goals,
|
||||||
|
(SELECT count(*) FROM invitations WHERE status='accepted') as accepted_invites,
|
||||||
|
(SELECT count(*) FROM task_comments WHERE is_ai=true) as ai_messages,
|
||||||
|
(SELECT count(*) FROM error_logs WHERE created_at > NOW() - INTERVAL '24 hours') as errors_24h,
|
||||||
|
(SELECT count(*) FROM projects) as total_projects
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Daily activity (last 7 days)
|
||||||
|
const { rows: daily } = await app.db.query(`
|
||||||
|
SELECT date_trunc('day', created_at)::date as day, count(*) as tasks_created
|
||||||
|
FROM tasks WHERE created_at > NOW() - INTERVAL '7 days'
|
||||||
|
GROUP BY 1 ORDER BY 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
return { data: { overview: overview[0], daily } };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recent activity feed
|
||||||
|
app.get("/admin/activity", async (req) => {
|
||||||
|
const { rows } = await app.db.query(`
|
||||||
|
(SELECT 'task_created' as type, title as detail, created_at FROM tasks ORDER BY created_at DESC LIMIT 5)
|
||||||
|
UNION ALL
|
||||||
|
(SELECT 'user_registered', email, created_at FROM users ORDER BY created_at DESC LIMIT 5)
|
||||||
|
UNION ALL
|
||||||
|
(SELECT 'goal_created', title, created_at FROM goals ORDER BY created_at DESC LIMIT 5)
|
||||||
|
UNION ALL
|
||||||
|
(SELECT 'invite_sent', invitee_email, created_at FROM invitations ORDER BY created_at DESC LIMIT 5)
|
||||||
|
ORDER BY created_at DESC LIMIT 20
|
||||||
|
`);
|
||||||
|
return { data: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error log management
|
||||||
|
app.delete("/admin/errors/clear", async (req) => {
|
||||||
|
const { rowCount } = await app.db.query("DELETE FROM error_logs WHERE created_at < NOW() - INTERVAL '7 days'");
|
||||||
|
return { status: "cleared", deleted: rowCount };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = adminRoutes;
|
||||||
@@ -101,8 +101,57 @@ async function authRoutes(app) {
|
|||||||
return { status: 'not_implemented', message: 'WebAuthn biometric auth coming soon.' };
|
return { status: 'not_implemented', message: 'WebAuthn biometric auth coming soon.' };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delete account (GDPR + Google Play requirement)
|
||||||
|
app.delete("/auth/account", { preHandler: [async (req) => { await req.jwtVerify(); }] }, async (req, reply) => {
|
||||||
|
const uid = req.user.id;
|
||||||
|
await app.db.query("DELETE FROM task_comments WHERE user_id=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM subtasks WHERE assigned_to=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM task_collaboration WHERE from_user_id=$1 OR to_user_id=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM task_assignments WHERE user_id=$1 OR assigned_by=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM push_subscriptions WHERE user_id=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM goals WHERE user_id=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM connectors WHERE user_id=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM tasks WHERE user_id=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM task_groups WHERE user_id=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM users WHERE id=$1", [uid]);
|
||||||
|
return reply.send({ data: { deleted: true, message: "Account and all data permanently deleted" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alias: /auth/delete-account (backward compat)
|
||||||
|
app.delete("/auth/delete-account", { preHandler: [async (req) => { await req.jwtVerify(); }] }, async (req, reply) => {
|
||||||
|
const uid = req.user.id;
|
||||||
|
await app.db.query("DELETE FROM task_comments WHERE user_id=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM subtasks WHERE assigned_to=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM task_collaboration WHERE from_user_id=$1 OR to_user_id=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM task_assignments WHERE user_id=$1 OR assigned_by=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM push_subscriptions WHERE user_id=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM goals WHERE user_id=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM connectors WHERE user_id=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM tasks WHERE user_id=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM task_groups WHERE user_id=$1", [uid]);
|
||||||
|
await app.db.query("DELETE FROM users WHERE id=$1", [uid]);
|
||||||
|
return reply.send({ data: { deleted: true, message: "Account and all data permanently deleted" } });
|
||||||
|
});
|
||||||
|
|
||||||
// OAuth initiate routes moved to ./oauth.js
|
// OAuth initiate routes moved to ./oauth.js
|
||||||
|
|
||||||
|
|
||||||
|
// Export all user data (GDPR)
|
||||||
|
app.get("/auth/export-data", { preHandler: [async (req) => { await req.jwtVerify(); }] }, async (req) => {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const { rows: user } = await app.db.query("SELECT id,email,name,phone,language,created_at FROM users WHERE id=$1", [userId]);
|
||||||
|
const { rows: tasks } = await app.db.query("SELECT * FROM tasks WHERE user_id=$1", [userId]);
|
||||||
|
const { rows: goals } = await app.db.query("SELECT * FROM goals WHERE user_id=$1", [userId]);
|
||||||
|
const { rows: groups } = await app.db.query("SELECT * FROM task_groups WHERE user_id=$1 OR user_id IS NULL", [userId]);
|
||||||
|
const { rows: comments } = await app.db.query("SELECT * FROM task_comments WHERE user_id=$1", [userId]);
|
||||||
|
return {
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
user: user[0] || {},
|
||||||
|
tasks, goals, groups, comments,
|
||||||
|
total: { tasks: tasks.length, goals: goals.length, comments: comments.length }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Search users by name or email (for collaboration)
|
// Search users by name or email (for collaboration)
|
||||||
app.get('/auth/users/search', async (req) => {
|
app.get('/auth/users/search', async (req) => {
|
||||||
const q = (req.query.q || '').trim();
|
const q = (req.query.q || '').trim();
|
||||||
@@ -117,3 +166,4 @@ async function authRoutes(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = authRoutes;
|
module.exports = authRoutes;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
// Task Team — Error Tracking — 2026-03-29
|
// Task Team — Error Tracking — 2026-03-29
|
||||||
|
|
||||||
async function errorRoutes(app) {
|
async function errorRoutes(app) {
|
||||||
|
await app.db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS error_logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
level VARCHAR(20) DEFAULT 'error',
|
||||||
|
message TEXT,
|
||||||
|
stack TEXT,
|
||||||
|
url TEXT,
|
||||||
|
method VARCHAR(10),
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_errors_created ON error_logs(created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_errors_created ON error_logs(created_at DESC);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|||||||
52
api/src/routes/families.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Task Team — Family Workspace — 2026-03-30
|
||||||
|
async function familyRoutes(app) {
|
||||||
|
await app.db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS families (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
owner_id UUID REFERENCES users(id),
|
||||||
|
members UUID[] DEFAULT '{}',
|
||||||
|
settings JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`).catch(() => {});
|
||||||
|
|
||||||
|
app.get("/families", async (req) => {
|
||||||
|
const { user_id } = req.query;
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
"SELECT * FROM families WHERE owner_id=$1 OR $1=ANY(members)", [user_id]);
|
||||||
|
return { data: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/families", async (req) => {
|
||||||
|
const { name, owner_id } = req.body;
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
"INSERT INTO families (name, owner_id, members) VALUES ($1,$2,ARRAY[$2]::uuid[]) RETURNING *",
|
||||||
|
[name, owner_id]);
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/families/:id/members", async (req) => {
|
||||||
|
const { user_id } = req.body;
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
"UPDATE families SET members=array_append(members,$1::uuid) WHERE id=$2 RETURNING *",
|
||||||
|
[user_id, req.params.id]);
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/families/:id/tasks", async (req) => {
|
||||||
|
const { rows: fam } = await app.db.query("SELECT members FROM families WHERE id=$1", [req.params.id]);
|
||||||
|
if (!fam.length) throw { statusCode: 404, message: "Family not found" };
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
"SELECT t.*, u.name as creator_name FROM tasks t LEFT JOIN users u ON t.user_id=u.id WHERE t.user_id=ANY($1) ORDER BY t.created_at DESC",
|
||||||
|
[fam[0].members]);
|
||||||
|
return { data: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/families/:id", async (req) => {
|
||||||
|
await app.db.query("DELETE FROM families WHERE id=$1", [req.params.id]);
|
||||||
|
return { status: "deleted" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = familyRoutes;
|
||||||
@@ -35,12 +35,16 @@ async function groupRoutes(app) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.put('/groups/:id', async (req) => {
|
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(
|
const { rows } = await app.db.query(
|
||||||
`UPDATE task_groups SET name=COALESCE($1,name), color=COALESCE($2,color), icon=COALESCE($3,icon),
|
`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()
|
order_index=COALESCE($4,order_index), time_zones=COALESCE($5,time_zones),
|
||||||
WHERE id=$6 RETURNING *`,
|
locations=COALESCE($6,locations), updated_at=NOW()
|
||||||
[name, color, icon, order_index, time_zones ? JSON.stringify(time_zones) : null, req.params.id]
|
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' };
|
if (!rows.length) throw { statusCode: 404, message: 'Group not found' };
|
||||||
return { data: rows[0] };
|
return { data: rows[0] };
|
||||||
@@ -111,6 +115,27 @@ async function groupRoutes(app) {
|
|||||||
return { data: rows[0] };
|
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;
|
module.exports = groupRoutes;
|
||||||
|
|||||||
140
api/src/routes/invitations.js
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// Task Team — Invitation System — 2026-03-30
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
async function invitationRoutes(app) {
|
||||||
|
|
||||||
|
// Create invitation
|
||||||
|
app.post('/invitations', async (req) => {
|
||||||
|
const { task_id, invitee_email, invitee_name, message, inviter_id } = req.body;
|
||||||
|
if (!task_id) throw { statusCode: 400, message: 'task_id is required' };
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
`INSERT INTO invitations (token, task_id, inviter_id, invitee_email, invitee_name, message)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||||
|
[token, task_id, inviter_id || null, invitee_email || null, invitee_name || '', message || '']
|
||||||
|
);
|
||||||
|
|
||||||
|
const invite = rows[0];
|
||||||
|
const link = `https://tasks.hasdo.info/invite/${token}`;
|
||||||
|
|
||||||
|
// Generate share links
|
||||||
|
const taskRes = await app.db.query('SELECT title FROM tasks WHERE id=$1', [task_id]);
|
||||||
|
const taskTitle = taskRes.rows[0]?.title || 'Task';
|
||||||
|
|
||||||
|
let inviterName = 'Someone';
|
||||||
|
if (inviter_id) {
|
||||||
|
const inviterRes = await app.db.query('SELECT name FROM users WHERE id=$1', [inviter_id]);
|
||||||
|
inviterName = inviterRes.rows[0]?.name || 'Someone';
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareText = `${inviterName} te pozval do Task Team!\n${taskTitle}\n\n${link}`;
|
||||||
|
const shareLinks = {
|
||||||
|
whatsapp: `https://wa.me/?text=${encodeURIComponent(shareText)}`,
|
||||||
|
telegram: `https://t.me/share/url?url=${encodeURIComponent(link)}&text=${encodeURIComponent(`${inviterName} te pozval: ${taskTitle}`)}`,
|
||||||
|
sms: `sms:${invitee_email || ''}?body=${encodeURIComponent(shareText)}`,
|
||||||
|
copy: link
|
||||||
|
};
|
||||||
|
|
||||||
|
return { data: { invitation: invite, link, share: shareLinks } };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get invitation by token (public - no auth needed)
|
||||||
|
app.get('/invite/:token', async (req) => {
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
`SELECT i.*, t.title as task_title, t.description as task_description, t.due_at,
|
||||||
|
u.name as inviter_name, u.avatar_url as inviter_avatar
|
||||||
|
FROM invitations i
|
||||||
|
LEFT JOIN tasks t ON i.task_id = t.id
|
||||||
|
LEFT JOIN users u ON i.inviter_id = u.id
|
||||||
|
WHERE i.token = $1`,
|
||||||
|
[req.params.token]
|
||||||
|
);
|
||||||
|
if (!rows.length) throw { statusCode: 404, message: 'Invitation not found' };
|
||||||
|
|
||||||
|
const invite = rows[0];
|
||||||
|
if (invite.status !== 'pending') throw { statusCode: 410, message: 'Invitation already used' };
|
||||||
|
if (new Date(invite.expires_at) < new Date()) throw { statusCode: 410, message: 'Invitation expired' };
|
||||||
|
|
||||||
|
return { data: invite };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accept invitation (registers + assigns to task)
|
||||||
|
app.post('/invite/:token/accept', async (req) => {
|
||||||
|
const { name, password, email } = req.body;
|
||||||
|
|
||||||
|
// Get invitation
|
||||||
|
const { rows: invites } = await app.db.query('SELECT * FROM invitations WHERE token=$1', [req.params.token]);
|
||||||
|
if (!invites.length) throw { statusCode: 404, message: 'Invitation not found' };
|
||||||
|
const invite = invites[0];
|
||||||
|
if (invite.status !== 'pending') throw { statusCode: 410, message: 'Already accepted' };
|
||||||
|
if (new Date(invite.expires_at) < new Date()) throw { statusCode: 410, message: 'Invitation expired' };
|
||||||
|
|
||||||
|
const userEmail = email || invite.invitee_email;
|
||||||
|
if (!userEmail) throw { statusCode: 400, message: 'Email is required' };
|
||||||
|
|
||||||
|
// Find or create user
|
||||||
|
let { rows: users } = await app.db.query('SELECT * FROM users WHERE email=$1', [userEmail]);
|
||||||
|
let userId;
|
||||||
|
|
||||||
|
if (users.length) {
|
||||||
|
userId = users[0].id;
|
||||||
|
} else {
|
||||||
|
// Register new user
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const hash = password ? await bcrypt.hash(password, 12) : null;
|
||||||
|
const settings = hash ? { password_hash: hash } : {};
|
||||||
|
const { rows: newUser } = await app.db.query(
|
||||||
|
'INSERT INTO users (email, name, auth_provider, settings) VALUES ($1, $2, $3, $4) RETURNING id, email, name',
|
||||||
|
[userEmail, name || invite.invitee_name || 'New User', 'email', JSON.stringify(settings)]
|
||||||
|
);
|
||||||
|
userId = newUser[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign to task (avoid duplicates)
|
||||||
|
await app.db.query(
|
||||||
|
`UPDATE tasks SET assigned_to = array_append(COALESCE(assigned_to, ARRAY[]::uuid[]), $1::uuid)
|
||||||
|
WHERE id = $2 AND NOT ($1::uuid = ANY(COALESCE(assigned_to, ARRAY[]::uuid[])))`,
|
||||||
|
[userId, invite.task_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark invitation accepted
|
||||||
|
await app.db.query(
|
||||||
|
"UPDATE invitations SET status='accepted', accepted_at=NOW() WHERE id=$1",
|
||||||
|
[invite.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
const token = app.jwt.sign({ id: userId, email: userEmail }, { expiresIn: '7d' });
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
user: { id: userId, email: userEmail, name: name || invite.invitee_name },
|
||||||
|
token,
|
||||||
|
task_id: invite.task_id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// List invitations for a task
|
||||||
|
app.get('/tasks/:taskId/invitations', async (req) => {
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
'SELECT * FROM invitations WHERE task_id=$1 ORDER BY created_at DESC',
|
||||||
|
[req.params.taskId]
|
||||||
|
);
|
||||||
|
return { data: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revoke invitation
|
||||||
|
app.delete('/invitations/:id', async (req) => {
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
"UPDATE invitations SET status='revoked' WHERE id=$1 AND status='pending' RETURNING *",
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
if (!rows.length) throw { statusCode: 404, message: 'Invitation not found or already used' };
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = invitationRoutes;
|
||||||
39
api/src/routes/notification-prefs.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Task Team — Notification Preferences — 2026-03-30
|
||||||
|
async function notifPrefRoutes(app) {
|
||||||
|
app.db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_prefs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
remind_before_minutes INTEGER DEFAULT 15,
|
||||||
|
remind_on_due BOOLEAN DEFAULT true,
|
||||||
|
remind_daily BOOLEAN DEFAULT false,
|
||||||
|
channels JSONB DEFAULT '["push"]',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, task_id)
|
||||||
|
)
|
||||||
|
`).catch(() => {});
|
||||||
|
|
||||||
|
app.get("/notification-prefs/:taskId", async (req) => {
|
||||||
|
const { user_id } = req.query;
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
"SELECT * FROM notification_prefs WHERE task_id=$1 AND user_id=$2", [req.params.taskId, user_id]);
|
||||||
|
return { data: rows[0] || { remind_before_minutes: 15, remind_on_due: true, remind_daily: false, channels: ["push"] } };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/notification-prefs/:taskId", async (req) => {
|
||||||
|
const { user_id, remind_before_minutes, remind_on_due, remind_daily, channels } = req.body;
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
`INSERT INTO notification_prefs (user_id, task_id, remind_before_minutes, remind_on_due, remind_daily, channels)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6)
|
||||||
|
ON CONFLICT (user_id, task_id) DO UPDATE SET
|
||||||
|
remind_before_minutes=EXCLUDED.remind_before_minutes,
|
||||||
|
remind_on_due=EXCLUDED.remind_on_due,
|
||||||
|
remind_daily=EXCLUDED.remind_daily,
|
||||||
|
channels=EXCLUDED.channels
|
||||||
|
RETURNING *`,
|
||||||
|
[user_id, req.params.taskId, remind_before_minutes || 15, remind_on_due !== false, remind_daily || false, JSON.stringify(channels || ["push"])]);
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
module.exports = notifPrefRoutes;
|
||||||
47
api/src/routes/odoo-modules.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Task Team — Odoo Module Management — 2026-03-30
|
||||||
|
async function odooModuleRoutes(app) {
|
||||||
|
const ODOO_ENT = "http://10.10.10.20:8069";
|
||||||
|
const ODOO_COM = "http://10.10.10.20:8070";
|
||||||
|
|
||||||
|
app.get("/odoo/modules", async (req) => {
|
||||||
|
return { data: {
|
||||||
|
available: ["task_team_connector"],
|
||||||
|
location: "/opt/task-team/odoo_modules/",
|
||||||
|
enterprise_url: ODOO_ENT,
|
||||||
|
community_url: ODOO_COM
|
||||||
|
}};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/odoo/modules/install", async (req) => {
|
||||||
|
const { module_name, server } = req.body;
|
||||||
|
const url = server === "community" ? ODOO_COM : ODOO_ENT;
|
||||||
|
// Trigger module install via Odoo JSON-RPC
|
||||||
|
try {
|
||||||
|
const authRes = 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:[server==="community"?"odoo_community":"odoo_enterprise","admin","admin",{}]}})
|
||||||
|
});
|
||||||
|
const uid = (await authRes.json()).result;
|
||||||
|
if (!uid) return { status: "error", message: "Odoo auth failed" };
|
||||||
|
|
||||||
|
const installRes = 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:[server==="community"?"odoo_community":"odoo_enterprise",uid,"admin",
|
||||||
|
"ir.module.module","button_immediate_install",[[["name","=",module_name]]],{}]}})
|
||||||
|
});
|
||||||
|
return { status: "ok", result: (await installRes.json()).result };
|
||||||
|
} catch(e) { return { status: "error", message: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/odoo/status", async (req) => {
|
||||||
|
let ent = false, com = false;
|
||||||
|
try { const r = await fetch("http://10.10.10.20:8069/web/login"); ent = r.status === 200; } catch {}
|
||||||
|
try { const r = await fetch("http://10.10.10.20:8070/web/login"); com = r.status === 200; } catch {}
|
||||||
|
return { data: { enterprise: ent, community: com } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
module.exports = odooModuleRoutes;
|
||||||
106
api/src/routes/spaced-repetition.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Task Team — Spaced Repetition Engine — 2026-03-30
|
||||||
|
// SM-2 algorithm for study goals
|
||||||
|
|
||||||
|
function calculateNextReview(quality, repetitions, easeFactor, interval) {
|
||||||
|
// quality: 0-5 (0=complete fail, 5=perfect)
|
||||||
|
if (quality < 3) {
|
||||||
|
return { repetitions: 0, interval: 1, easeFactor };
|
||||||
|
}
|
||||||
|
let newInterval;
|
||||||
|
if (repetitions === 0) newInterval = 1;
|
||||||
|
else if (repetitions === 1) newInterval = 6;
|
||||||
|
else newInterval = Math.round(interval * easeFactor);
|
||||||
|
|
||||||
|
const newEase = Math.max(1.3, easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)));
|
||||||
|
return { repetitions: repetitions + 1, interval: newInterval, easeFactor: newEase };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spacedRepRoutes(app) {
|
||||||
|
// Create review_items table
|
||||||
|
await app.db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS review_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
goal_id UUID REFERENCES goals(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
content TEXT DEFAULT '',
|
||||||
|
repetitions INTEGER DEFAULT 0,
|
||||||
|
ease_factor NUMERIC(4,2) DEFAULT 2.5,
|
||||||
|
interval_days INTEGER DEFAULT 1,
|
||||||
|
next_review TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
last_reviewed TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`).catch(() => {});
|
||||||
|
|
||||||
|
// Get items due for review
|
||||||
|
app.get("/review/due", async (req) => {
|
||||||
|
const { user_id, goal_id, limit } = req.query;
|
||||||
|
let query = "SELECT * FROM review_items WHERE next_review <= NOW()";
|
||||||
|
const params = [];
|
||||||
|
if (user_id) { params.push(user_id); query += ` AND user_id = $${params.length}`; }
|
||||||
|
if (goal_id) { params.push(goal_id); query += ` AND goal_id = $${params.length}`; }
|
||||||
|
query += " ORDER BY next_review ASC";
|
||||||
|
if (limit) { params.push(limit); query += ` LIMIT $${params.length}`; }
|
||||||
|
const { rows } = await app.db.query(query, params);
|
||||||
|
return { data: rows, count: rows.length };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create review item
|
||||||
|
app.post("/review/items", async (req) => {
|
||||||
|
const { user_id, goal_id, title, content } = req.body;
|
||||||
|
const { rows } = await app.db.query(
|
||||||
|
"INSERT INTO review_items (user_id, goal_id, title, content) VALUES ($1,$2,$3,$4) RETURNING *",
|
||||||
|
[user_id, goal_id, title, content || ""]
|
||||||
|
);
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit review (rate quality 0-5)
|
||||||
|
app.post("/review/items/:id/review", async (req) => {
|
||||||
|
const { quality } = req.body; // 0-5
|
||||||
|
if (quality < 0 || quality > 5) throw { statusCode: 400, message: "Quality must be 0-5" };
|
||||||
|
|
||||||
|
const { rows } = await app.db.query("SELECT * FROM review_items WHERE id = $1", [req.params.id]);
|
||||||
|
if (!rows.length) throw { statusCode: 404, message: "Item not found" };
|
||||||
|
const item = rows[0];
|
||||||
|
|
||||||
|
const result = calculateNextReview(quality, item.repetitions, parseFloat(item.ease_factor), item.interval_days);
|
||||||
|
const nextReview = new Date(Date.now() + result.interval * 86400000);
|
||||||
|
|
||||||
|
const { rows: updated } = await app.db.query(
|
||||||
|
`UPDATE review_items SET repetitions=$1, ease_factor=$2, interval_days=$3,
|
||||||
|
next_review=$4, last_reviewed=NOW() WHERE id=$5 RETURNING *`,
|
||||||
|
[result.repetitions, result.easeFactor, result.interval, nextReview, req.params.id]
|
||||||
|
);
|
||||||
|
return { data: updated[0], next_review_in_days: result.interval };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single review item
|
||||||
|
app.get("/review/items/:id", async (req) => {
|
||||||
|
const { rows } = await app.db.query("SELECT * FROM review_items WHERE id = $1", [req.params.id]);
|
||||||
|
if (!rows.length) throw { statusCode: 404, message: "Item not found" };
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete review item
|
||||||
|
app.delete("/review/items/:id", async (req) => {
|
||||||
|
const { rowCount } = await app.db.query("DELETE FROM review_items WHERE id = $1", [req.params.id]);
|
||||||
|
if (!rowCount) throw { statusCode: 404, message: "Item not found" };
|
||||||
|
return { status: "deleted" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
app.get("/review/stats", async (req) => {
|
||||||
|
const { user_id } = req.query;
|
||||||
|
const { rows } = await app.db.query(`
|
||||||
|
SELECT count(*) as total,
|
||||||
|
count(*) FILTER (WHERE next_review <= NOW()) as due,
|
||||||
|
count(*) FILTER (WHERE last_reviewed > NOW() - INTERVAL '24 hours') as reviewed_today,
|
||||||
|
avg(ease_factor)::numeric(4,2) as avg_ease
|
||||||
|
FROM review_items WHERE user_id = $1`, [user_id || "00000000-0000-0000-0000-000000000000"]);
|
||||||
|
return { data: rows[0] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = spacedRepRoutes;
|
||||||
282
apps/tasks/app/admin/page.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
|
||||||
|
interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
phone: string | null;
|
||||||
|
language: string;
|
||||||
|
auth_provider: string;
|
||||||
|
created_at: string;
|
||||||
|
task_count: number;
|
||||||
|
goal_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Analytics {
|
||||||
|
overview: {
|
||||||
|
total_users: number;
|
||||||
|
new_users_7d: number;
|
||||||
|
total_tasks: number;
|
||||||
|
completed_tasks: number;
|
||||||
|
tasks_today: number;
|
||||||
|
total_goals: number;
|
||||||
|
accepted_invites: number;
|
||||||
|
ai_messages: number;
|
||||||
|
errors_24h: number;
|
||||||
|
total_projects: number;
|
||||||
|
};
|
||||||
|
daily: { day: string; tasks_created: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityItem {
|
||||||
|
type: string;
|
||||||
|
detail: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = typeof window !== "undefined" ? "" : "http://localhost:3000";
|
||||||
|
|
||||||
|
async function adminFetch<T>(path: string, opts: { method?: string; token?: string } = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (opts.token) headers["Authorization"] = `Bearer ${opts.token}`;
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, { method: opts.method || "GET", headers, cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [analytics, setAnalytics] = useState<Analytics | null>(null);
|
||||||
|
const [activity, setActivity] = useState<ActivityItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [tab, setTab] = useState<"overview" | "users" | "activity">("overview");
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [usersRes, analyticsRes, activityRes] = await Promise.all([
|
||||||
|
adminFetch<{ data: AdminUser[] }>("/api/v1/admin/users", { token }),
|
||||||
|
adminFetch<{ data: Analytics }>("/api/v1/admin/analytics", { token }),
|
||||||
|
adminFetch<{ data: ActivityItem[] }>("/api/v1/admin/activity", { token }),
|
||||||
|
]);
|
||||||
|
setUsers(usersRes.data || []);
|
||||||
|
setAnalytics(analyticsRes.data || null);
|
||||||
|
setActivity(activityRes.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Admin load error:", err);
|
||||||
|
setError("Failed to load admin data");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) { router.replace("/login"); return; }
|
||||||
|
loadData();
|
||||||
|
}, [token, router, loadData]);
|
||||||
|
|
||||||
|
async function handleDeleteUser(userId: string) {
|
||||||
|
if (!token || !confirm("Delete this user and all their data?")) return;
|
||||||
|
try {
|
||||||
|
await adminFetch("/api/v1/admin/users/" + userId, { method: "DELETE", token });
|
||||||
|
loadData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Delete error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClearErrors() {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
await adminFetch("/api/v1/admin/errors/clear", { method: "DELETE", token });
|
||||||
|
loadData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Clear errors:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const o = analytics?.overview;
|
||||||
|
const maxTasks = analytics?.daily ? Math.max(...analytics.daily.map(d => Number(d.tasks_created)), 1) : 1;
|
||||||
|
|
||||||
|
function formatDate(d: string) {
|
||||||
|
return new Date(d).toLocaleDateString("cs-CZ", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(t: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
task_created: "New task",
|
||||||
|
user_registered: "New user",
|
||||||
|
goal_created: "New goal",
|
||||||
|
invite_sent: "Invitation",
|
||||||
|
};
|
||||||
|
return map[t] || t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeColor(t: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
task_created: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
|
||||||
|
user_registered: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
|
||||||
|
goal_created: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300",
|
||||||
|
invite_sent: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300",
|
||||||
|
};
|
||||||
|
return map[t] || "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-8 text-center">
|
||||||
|
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto" />
|
||||||
|
<p className="mt-3 text-sm text-muted">Loading admin data...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-8 text-center">
|
||||||
|
<p className="text-red-500 text-sm">{error}</p>
|
||||||
|
<button onClick={loadData} className="mt-3 text-sm text-blue-500 underline">Retry</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 pb-24">
|
||||||
|
<h1 className="text-xl font-bold mb-4">Admin Dashboard</h1>
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex gap-1 mb-5 bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
||||||
|
{(["overview", "users", "activity"] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
tab === t
|
||||||
|
? "bg-white dark:bg-gray-700 shadow-sm text-blue-600 dark:text-blue-400"
|
||||||
|
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === "overview" ? "Overview" : t === "users" ? "Users" : "Activity"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview tab */}
|
||||||
|
{tab === "overview" && o && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Stat cards */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: "Users", value: o.total_users, sub: `+${o.new_users_7d} this week`, color: "text-blue-600 dark:text-blue-400" },
|
||||||
|
{ label: "Tasks", value: o.total_tasks, sub: `${o.completed_tasks} completed`, color: "text-green-600 dark:text-green-400" },
|
||||||
|
{ label: "Today", value: o.tasks_today, sub: "tasks created", color: "text-purple-600 dark:text-purple-400" },
|
||||||
|
{ label: "Goals", value: o.total_goals, sub: "total", color: "text-orange-600 dark:text-orange-400" },
|
||||||
|
{ label: "Projects", value: o.total_projects, sub: "active", color: "text-indigo-600 dark:text-indigo-400" },
|
||||||
|
{ label: "AI msgs", value: o.ai_messages, sub: "chat responses", color: "text-pink-600 dark:text-pink-400" },
|
||||||
|
{ label: "Invites", value: o.accepted_invites, sub: "accepted", color: "text-teal-600 dark:text-teal-400" },
|
||||||
|
{ label: "Errors", value: o.errors_24h, sub: "last 24h", color: o.errors_24h > 0 ? "text-red-600 dark:text-red-400" : "text-green-600 dark:text-green-400" },
|
||||||
|
].map((card) => (
|
||||||
|
<div key={card.label} className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">{card.label}</p>
|
||||||
|
<p className={`text-2xl font-bold mt-1 ${card.color}`}>{card.value}</p>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{card.sub}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Daily activity chart */}
|
||||||
|
{analytics?.daily && analytics.daily.length > 0 && (
|
||||||
|
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4">
|
||||||
|
<h3 className="text-sm font-semibold mb-3">Tasks Created (last 7 days)</h3>
|
||||||
|
<div className="flex items-end gap-2 h-32">
|
||||||
|
{analytics.daily.map((d) => (
|
||||||
|
<div key={d.day} className="flex-1 flex flex-col items-center gap-1">
|
||||||
|
<span className="text-xs text-gray-500">{Number(d.tasks_created)}</span>
|
||||||
|
<div
|
||||||
|
className="w-full bg-blue-500 dark:bg-blue-600 rounded-t-md transition-all min-h-[4px]"
|
||||||
|
style={{ height: `${(Number(d.tasks_created) / maxTasks) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-gray-400 whitespace-nowrap">
|
||||||
|
{new Date(d.day).toLocaleDateString("cs-CZ", { day: "numeric", month: "numeric" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Clear errors button */}
|
||||||
|
{o.errors_24h > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleClearErrors}
|
||||||
|
className="text-sm text-red-500 hover:text-red-600 underline"
|
||||||
|
>
|
||||||
|
Clear errors older than 7 days
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Users tab */}
|
||||||
|
{tab === "users" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-gray-500 mb-3">{users.length} registered users</p>
|
||||||
|
{users.map((u) => (
|
||||||
|
<div key={u.id} className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4 flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0">
|
||||||
|
{(u.name || u.email || "?").charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-sm truncate">{u.name || "No name"}</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{u.email}</p>
|
||||||
|
<div className="flex gap-3 mt-1">
|
||||||
|
<span className="text-xs text-gray-400">{u.task_count} tasks</span>
|
||||||
|
<span className="text-xs text-gray-400">{u.goal_count} goals</span>
|
||||||
|
<span className="text-xs text-gray-400">{u.auth_provider || "email"}</span>
|
||||||
|
<span className="text-xs text-gray-400">{u.language || "cs"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUser(u.id)}
|
||||||
|
className="p-2 text-red-400 hover:text-red-600 transition-colors flex-shrink-0"
|
||||||
|
title="Delete user"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activity tab */}
|
||||||
|
{tab === "activity" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{activity.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-8">No recent activity</p>
|
||||||
|
) : (
|
||||||
|
activity.map((a, i) => (
|
||||||
|
<div key={i} className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-3 flex items-center gap-3">
|
||||||
|
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full whitespace-nowrap ${typeColor(a.type)}`}>
|
||||||
|
{typeLabel(a.type)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm truncate flex-1">{a.detail}</span>
|
||||||
|
<span className="text-xs text-gray-400 whitespace-nowrap flex-shrink-0">{formatDate(a.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -63,25 +63,21 @@ export default function CalendarPage() {
|
|||||||
extendedProps: { status: tk.status, group: tk.group_name },
|
extendedProps: { status: tk.status, group: tk.group_name },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Build background events from unique groups
|
|
||||||
const groupColors = new Map<string, string>();
|
|
||||||
tasks.forEach(tk => {
|
|
||||||
if (tk.group_name && tk.group_color) {
|
|
||||||
groupColors.set(tk.group_name, tk.group_color);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="pb-24 sm:pb-8 px-4 sm:px-0">
|
||||||
<h1 className="text-2xl font-bold mb-4">{t('calendar.title')}</h1>
|
{/* Compact single-row header */}
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-3">
|
||||||
|
<h1 className="text-xl font-bold dark:text-white truncate">{t('calendar.title')}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
|
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow">
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-2 sm:p-4 shadow calendar-compact">
|
||||||
<FullCalendar
|
<FullCalendar
|
||||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||||
initialView="timeGridWeek"
|
initialView="timeGridWeek"
|
||||||
|
|||||||
@@ -254,20 +254,39 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============================
|
/* ============================
|
||||||
FullCalendar Mobile Layout Fix
|
FullCalendar Compact Layout
|
||||||
============================ */
|
============================ */
|
||||||
|
|
||||||
/* Base toolbar styles - compact */
|
/* Single-row toolbar: date + nav + view switcher all in one line */
|
||||||
.fc .fc-toolbar {
|
.fc .fc-toolbar {
|
||||||
flex-wrap: wrap;
|
display: flex;
|
||||||
gap: 4px 8px;
|
flex-wrap: nowrap;
|
||||||
row-gap: 6px;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 8px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Title: prevent vertical text wrapping */
|
/* Each toolbar chunk inline */
|
||||||
|
.fc .fc-toolbar-chunk {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Center chunk (title) takes remaining space, truncates */
|
||||||
|
.fc .fc-toolbar-chunk:nth-child(2) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title: compact, truncates */
|
||||||
.fc .fc-toolbar-title {
|
.fc .fc-toolbar-title {
|
||||||
font-size: 18px !important;
|
font-size: 16px !important;
|
||||||
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -275,56 +294,49 @@ main {
|
|||||||
|
|
||||||
/* Compact buttons */
|
/* Compact buttons */
|
||||||
.fc .fc-button {
|
.fc .fc-button {
|
||||||
padding: 4px 10px !important;
|
padding: 4px 8px !important;
|
||||||
font-size: 13px !important;
|
font-size: 12px !important;
|
||||||
line-height: 1.4 !important;
|
line-height: 1.3 !important;
|
||||||
|
min-height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Button group: no extra gaps */
|
/* Button group: pill shape */
|
||||||
.fc .fc-button-group {
|
.fc .fc-button-group {
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
.fc .fc-button-group .fc-button {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
.fc .fc-button-group .fc-button:first-child {
|
||||||
|
border-radius: 9999px 0 0 9999px !important;
|
||||||
|
}
|
||||||
|
.fc .fc-button-group .fc-button:last-child {
|
||||||
|
border-radius: 0 9999px 9999px 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile breakpoint: stack toolbar rows */
|
/* Mobile: tighten further but keep single row */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.fc .fc-toolbar {
|
.fc .fc-toolbar {
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc .fc-toolbar-chunk {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Title on its own row, centered, smaller */
|
|
||||||
.fc .fc-toolbar-title {
|
.fc .fc-toolbar-title {
|
||||||
font-size: 15px !important;
|
font-size: 13px !important;
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
order: -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smaller buttons on mobile */
|
|
||||||
.fc .fc-button {
|
.fc .fc-button {
|
||||||
padding: 3px 8px !important;
|
padding: 3px 6px !important;
|
||||||
font-size: 12px !important;
|
font-size: 11px !important;
|
||||||
min-height: 32px !important;
|
min-height: 32px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* View switcher as pill buttons */
|
/* Hide text labels on smallest screens - show abbreviated */
|
||||||
.fc .fc-button-group .fc-button {
|
.fc .fc-dayGridMonth-button,
|
||||||
border-radius: 0 !important;
|
.fc .fc-timeGridWeek-button,
|
||||||
}
|
.fc .fc-timeGridDay-button {
|
||||||
.fc .fc-button-group .fc-button:first-child {
|
font-size: 10px !important;
|
||||||
border-radius: 9999px 0 0 9999px !important;
|
padding: 3px 5px !important;
|
||||||
}
|
|
||||||
.fc .fc-button-group .fc-button:last-child {
|
|
||||||
border-radius: 0 9999px 9999px 0 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reduce page padding */
|
/* Reduce page padding */
|
||||||
@@ -339,20 +351,72 @@ main {
|
|||||||
|
|
||||||
/* Day header smaller */
|
/* Day header smaller */
|
||||||
.fc .fc-col-header-cell-cushion {
|
.fc .fc-col-header-cell-cushion {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
padding: 4px 2px;
|
padding: 3px 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Small mobile (< 400px) - even tighter */
|
/* Small mobile (< 400px) - even tighter */
|
||||||
@media (max-width: 400px) {
|
@media (max-width: 400px) {
|
||||||
.fc .fc-toolbar-title {
|
.fc .fc-toolbar-title {
|
||||||
font-size: 13px !important;
|
font-size: 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc .fc-button {
|
.fc .fc-button {
|
||||||
padding: 2px 6px !important;
|
padding: 2px 4px !important;
|
||||||
font-size: 11px !important;
|
font-size: 10px !important;
|
||||||
min-height: 28px !important;
|
min-height: 28px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fc .fc-today-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status dot pulse animation for in_progress tasks */
|
||||||
|
@keyframes statusPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot-active {
|
||||||
|
animation: statusPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Mobile Responsive Compact Headers (#9)
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
/* Ensure no horizontal overflow on any page */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
main {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page action bars: single row, no wrap */
|
||||||
|
.flex.items-center.justify-between {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch targets minimum 44px on mobile */
|
||||||
|
button,
|
||||||
|
a[role='button'],
|
||||||
|
[role='button'] {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Exception: status pills and small inline elements */
|
||||||
|
.scrollbar-hide button,
|
||||||
|
.fc button {
|
||||||
|
min-width: auto;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact text on mobile */
|
||||||
|
h1 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import {
|
|||||||
GoalReport,
|
GoalReport,
|
||||||
Group,
|
Group,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import PageActionBar from "@/components/features/PageActionBar";
|
||||||
|
import GoalActionButtons from "@/components/features/GoalActionButtons";
|
||||||
|
import IconButton from "@/components/features/IconButton";
|
||||||
|
|
||||||
export default function GoalsPage() {
|
export default function GoalsPage() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
@@ -33,7 +36,6 @@ export default function GoalsPage() {
|
|||||||
const [aiLoading, setAiLoading] = useState<string | null>(null);
|
const [aiLoading, setAiLoading] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [formTitle, setFormTitle] = useState("");
|
const [formTitle, setFormTitle] = useState("");
|
||||||
const [formDate, setFormDate] = useState("");
|
const [formDate, setFormDate] = useState("");
|
||||||
const [formGroup, setFormGroup] = useState("");
|
const [formGroup, setFormGroup] = useState("");
|
||||||
@@ -172,18 +174,14 @@ export default function GoalsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<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 */}
|
<PageActionBar
|
||||||
<div className="flex items-center justify-between">
|
title={t("goals.title")}
|
||||||
<h1 className="text-xl font-bold dark:text-white">{t("goals.title")}</h1>
|
showAdd
|
||||||
<button
|
onToggleAdd={() => setShowForm(!showForm)}
|
||||||
onClick={() => setShowForm(!showForm)}
|
addOpen={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]"
|
t={t}
|
||||||
>
|
/>
|
||||||
{showForm ? t("tasks.form.cancel") : `+ ${t("goals.add")}`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{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}
|
||||||
@@ -191,7 +189,6 @@ export default function GoalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create form */}
|
|
||||||
{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>
|
||||||
@@ -237,7 +234,6 @@ export default function GoalsPage() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Goals list */}
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||||
@@ -278,7 +274,6 @@ export default function GoalsPage() {
|
|||||||
{goal.progress_pct}%
|
{goal.progress_pct}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Progress bar */}
|
|
||||||
<div className="mt-3 h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
<div className="mt-3 h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-500 ${progressColor(goal.progress_pct)}`}
|
className={`h-full rounded-full transition-all duration-500 ${progressColor(goal.progress_pct)}`}
|
||||||
@@ -294,20 +289,23 @@ export default function GoalsPage() {
|
|||||||
{selectedGoal && (
|
{selectedGoal && (
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 space-y-4">
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 space-y-4">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<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)} | {t("goals.progress")}: {selectedGoal.progress_pct}%
|
{formatDate(selectedGoal.target_date)} | {t("goals.progress")}: {selectedGoal.progress_pct}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t("tasks.close")}
|
||||||
onClick={() => setSelectedGoal(null)}
|
onClick={() => setSelectedGoal(null)}
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1 min-h-[44px] min-w-[44px] flex items-center justify-center"
|
variant="default"
|
||||||
>
|
size="md"
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
/>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress slider */}
|
{/* Progress slider */}
|
||||||
@@ -325,47 +323,15 @@ export default function GoalsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Action buttons */}
|
{/* AI Action buttons - icon only */}
|
||||||
<div className="flex gap-2">
|
<GoalActionButtons
|
||||||
<button
|
onPlan={() => handleGeneratePlan(selectedGoal.id)}
|
||||||
onClick={() => handleGeneratePlan(selectedGoal.id)}
|
onReport={() => handleGetReport(selectedGoal.id)}
|
||||||
disabled={aiLoading === "plan"}
|
onDelete={() => handleDelete(selectedGoal.id)}
|
||||||
className="flex-1 py-2.5 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 text-white rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2 min-h-[44px]"
|
planLoading={aiLoading === "plan"}
|
||||||
>
|
reportLoading={aiLoading === "report"}
|
||||||
{aiLoading === "plan" ? (
|
t={t}
|
||||||
<>
|
/>
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
|
||||||
{t("common.loading")}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<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" />
|
|
||||||
</svg>
|
|
||||||
{t("goals.plan")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleGetReport(selectedGoal.id)}
|
|
||||||
disabled={aiLoading === "report"}
|
|
||||||
className="flex-1 py-2.5 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2 min-h-[44px]"
|
|
||||||
>
|
|
||||||
{aiLoading === "report" ? (
|
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
|
||||||
{t("common.loading")}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<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" />
|
|
||||||
</svg>
|
|
||||||
{t("goals.report")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plan result */}
|
{/* Plan result */}
|
||||||
{planResult && (
|
{planResult && (
|
||||||
@@ -468,14 +434,6 @@ export default function GoalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete button */}
|
|
||||||
<button
|
|
||||||
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]"
|
|
||||||
>
|
|
||||||
{t("tasks.delete")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
192
apps/tasks/app/invite/[token]/page.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
interface InviteData {
|
||||||
|
inviter_name: string | null;
|
||||||
|
inviter_avatar: string | null;
|
||||||
|
invitee_name: string | null;
|
||||||
|
invitee_email: string | null;
|
||||||
|
task_title: string | null;
|
||||||
|
task_description: string | null;
|
||||||
|
due_at: string | null;
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InvitePage() {
|
||||||
|
const { token } = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [invite, setInvite] = useState<InviteData | null>(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [accepting, setAccepting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/v1/invite/${token}`)
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) return r.json().then(d => { throw new Error(d.message || 'Not found'); });
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(d => {
|
||||||
|
setInvite(d.data);
|
||||||
|
setName(d.data?.invitee_name || '');
|
||||||
|
setEmail(d.data?.invitee_email || '');
|
||||||
|
})
|
||||||
|
.catch(e => setError(e.message || 'Pozvanka nenalezena'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
async function accept() {
|
||||||
|
if (!name || name.trim().length < 2) {
|
||||||
|
setError('Jmeno musi mit alespon 2 znaky');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAccepting(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/invite/${token}/accept`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
password: password || undefined,
|
||||||
|
email: email || invite?.invitee_email
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.message || 'Chyba pri prijimani');
|
||||||
|
}
|
||||||
|
if (data.data?.token) {
|
||||||
|
localStorage.setItem('taskteam_token', data.data.token);
|
||||||
|
localStorage.setItem('taskteam_user', JSON.stringify(data.data.user));
|
||||||
|
router.push('/tasks');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Chyba pri prijimani');
|
||||||
|
}
|
||||||
|
setAccepting(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-blue-600 to-blue-800">
|
||||||
|
<div className="animate-spin h-8 w-8 border-b-2 border-white rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !invite) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-blue-600 to-blue-800 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 max-w-sm w-full shadow-2xl text-center">
|
||||||
|
<div className="text-4xl mb-4">😕</div>
|
||||||
|
<h1 className="text-xl font-bold mb-2">Pozvanka neni platna</h1>
|
||||||
|
<p className="text-gray-500 mb-4">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/login')}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium"
|
||||||
|
>
|
||||||
|
Prihlasit se
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-blue-600 to-blue-800 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 max-w-sm w-full shadow-2xl">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="text-4xl mb-2">🎉</div>
|
||||||
|
<h1 className="text-xl font-bold">Task Team</h1>
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-3">
|
||||||
|
{invite?.inviter_avatar ? (
|
||||||
|
<img
|
||||||
|
src={invite.inviter_avatar}
|
||||||
|
alt=""
|
||||||
|
className="w-10 h-10 rounded-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-bold">
|
||||||
|
{invite?.inviter_name?.[0]?.toUpperCase() || '?'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-600 dark:text-gray-300">
|
||||||
|
{invite?.inviter_name || 'Nekdo'} te zve
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 mb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span>📋</span>
|
||||||
|
<span className="font-semibold">{invite?.task_title || 'Task'}</span>
|
||||||
|
</div>
|
||||||
|
{invite?.task_description && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
|
||||||
|
{invite.task_description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{invite?.due_at && (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
📅 {new Date(invite.due_at).toLocaleDateString('cs-CZ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invite?.message && (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2 italic border-l-2 border-blue-400 pl-2">
|
||||||
|
“{invite.message}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded-lg p-2 mb-3">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="Tvoje jmeno"
|
||||||
|
className="w-full px-4 py-3 rounded-lg border dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
|
/>
|
||||||
|
{!invite?.invitee_email && (
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
placeholder="Tvuj email"
|
||||||
|
className="w-full px-4 py-3 rounded-lg border dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
placeholder="Heslo (min 6 znaku)"
|
||||||
|
className="w-full px-4 py-3 rounded-lg border dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={accept}
|
||||||
|
disabled={accepting || !name.trim()}
|
||||||
|
className="w-full py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg font-bold text-lg disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{accepting ? 'Zpracovavam...' : 'Prijmout a zaregistrovat'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/login')}
|
||||||
|
className="w-full py-2 text-blue-600 dark:text-blue-400 text-sm hover:underline"
|
||||||
|
>
|
||||||
|
Mam ucet - prihlasit se
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
apps/tasks/app/privacy/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export default function PrivacyPage() {
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 800, margin: "0 auto", padding: "40px 24px", fontFamily: "system-ui", lineHeight: 1.8 }}>
|
||||||
|
<h1 style={{ fontSize: 28, fontWeight: "bold", marginBottom: 24 }}>Privacy Policy - Task Team</h1>
|
||||||
|
<p style={{ color: "#666", marginBottom: 16 }}>Last updated: March 30, 2026</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginTop: 32, marginBottom: 12 }}>Data We Collect</h2>
|
||||||
|
<p>Account info (name, email), task data, GPS location (optional), audio for voice-to-text (optional).</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginTop: 32, marginBottom: 12 }}>How We Use It</h2>
|
||||||
|
<p>Task management, sync across devices, reminders, GPS auto-verify task completion.</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginTop: 32, marginBottom: 12 }}>Data Storage</h2>
|
||||||
|
<p>Your data is stored on secure servers in the European Union (Hetzner Cloud, Germany). We use PostgreSQL databases with encrypted connections and regular backups.</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginTop: 32, marginBottom: 12 }}>Data Sharing</h2>
|
||||||
|
<p>We do NOT sell data. Services used: Hetzner Cloud Germany, Anthropic Claude AI.</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginTop: 32, marginBottom: 12 }}>Your Rights (GDPR)</h2>
|
||||||
|
<p>You have the right to access, correct, and delete your data. Contact: privacy@it-enterprise.cz</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginTop: 32, marginBottom: 12 }}>Permissions</h2>
|
||||||
|
<p>Location and microphone are optional, revocable anytime in your device settings.</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginTop: 32, marginBottom: 12 }}>Children</h2>
|
||||||
|
<p>Not directed to children under 13.</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginTop: 32, marginBottom: 12 }}>Contact</h2>
|
||||||
|
<p>IT Enterprise s.r.o. | privacy@it-enterprise.cz | tasks.hasdo.info</p>
|
||||||
|
|
||||||
|
<p style={{ marginTop: 32, color: "#999", fontSize: 14 }}>© 2026 IT Enterprise s.r.o. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
deleteProject,
|
deleteProject,
|
||||||
Project,
|
Project,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import PageActionBar from "@/components/features/PageActionBar";
|
||||||
|
import DeleteIconButton from "@/components/features/DeleteIconButton";
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
@@ -20,7 +22,6 @@ export default function ProjectsPage() {
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [formName, setFormName] = useState("");
|
const [formName, setFormName] = useState("");
|
||||||
const [formDesc, setFormDesc] = useState("");
|
const [formDesc, setFormDesc] = useState("");
|
||||||
const [formColor, setFormColor] = useState("#3B82F6");
|
const [formColor, setFormColor] = useState("#3B82F6");
|
||||||
@@ -96,18 +97,14 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<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 */}
|
<PageActionBar
|
||||||
<div className="flex items-center justify-between">
|
title={t("nav.projects")}
|
||||||
<h1 className="text-xl font-bold dark:text-white">{t("nav.projects")}</h1>
|
showAdd
|
||||||
<button
|
onToggleAdd={() => setShowForm(!showForm)}
|
||||||
onClick={() => setShowForm(!showForm)}
|
addOpen={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]"
|
t={t}
|
||||||
>
|
/>
|
||||||
{showForm ? t("tasks.form.cancel") : `+ ${t("projects.add")}`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{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}
|
||||||
@@ -115,7 +112,6 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create form */}
|
|
||||||
{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>
|
||||||
@@ -179,7 +175,6 @@ export default function ProjectsPage() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Projects list */}
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||||
@@ -219,15 +214,11 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: project.color || "#3B82F6" }} />
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: project.color || "#3B82F6" }} />
|
||||||
<button
|
<DeleteIconButton
|
||||||
onClick={() => handleDelete(project.id)}
|
onClick={() => handleDelete(project.id)}
|
||||||
className="p-2 text-gray-400 hover:text-red-500 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
|
label={t("tasks.delete")}
|
||||||
title={t("tasks.delete")}
|
size="sm"
|
||||||
>
|
/>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ import { useAuth } from "@/lib/auth";
|
|||||||
import { useTheme } from "@/components/ThemeProvider";
|
import { useTheme } from "@/components/ThemeProvider";
|
||||||
import { useTranslation, LOCALES } from "@/lib/i18n";
|
import { useTranslation, LOCALES } from "@/lib/i18n";
|
||||||
import type { Locale } from "@/lib/i18n";
|
import type { Locale } from "@/lib/i18n";
|
||||||
|
import type { Group } from "@/lib/api";
|
||||||
|
|
||||||
|
interface GroupSetting {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
days: number[];
|
||||||
|
locationName: string;
|
||||||
|
gps: string;
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { token, user, logout } = useAuth();
|
const { token, user, logout } = useAuth();
|
||||||
@@ -19,6 +29,10 @@ export default function SettingsPage() {
|
|||||||
dailySummary: false,
|
dailySummary: false,
|
||||||
});
|
});
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
|
const [groupSettings, setGroupSettings] = useState<Record<string, GroupSetting>>({});
|
||||||
|
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
|
||||||
|
const [savedGroup, setSavedGroup] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -37,6 +51,84 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [token, router]);
|
}, [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<string, GroupSetting> = {};
|
||||||
|
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() {
|
function handleSave() {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.setItem("taskteam_notifications", JSON.stringify(notifications));
|
localStorage.setItem("taskteam_notifications", JSON.stringify(notifications));
|
||||||
@@ -193,6 +285,113 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Groups settings */}
|
||||||
|
{groups.length > 0 && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 12, textTransform: "uppercase", letterSpacing: 1, fontWeight: 600 }}>
|
||||||
|
Skupiny
|
||||||
|
</div>
|
||||||
|
{groups.map(group => (
|
||||||
|
<div key={group.id} style={{
|
||||||
|
background: "#13131A", border: `1px solid #2A2A3A`,
|
||||||
|
borderLeft: `3px solid ${group.color || "#4F46E5"}`,
|
||||||
|
borderRadius: 12, marginBottom: 8, overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
<div onClick={() => toggleGroup(group.id)}
|
||||||
|
style={{ padding: "12px 16px", display: "flex", alignItems: "center", gap: 10, cursor: "pointer" }}>
|
||||||
|
<span style={{ fontSize: 18 }}>{group.icon || "📁"}</span>
|
||||||
|
<span style={{ flex: 1, fontWeight: 500, fontSize: 14, color: "#F0F0F5" }}>
|
||||||
|
{group.display_name || group.name}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#6B6B85", fontSize: 12 }}>
|
||||||
|
{group.time_zones?.[0] ? `${group.time_zones[0].from}–${group.time_zones[0].to}` : ""}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#6B6B85", fontSize: 12 }}>{expandedGroup === group.id ? "▲" : "▼"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedGroup === group.id && (
|
||||||
|
<div style={{ padding: "0 16px 16px", borderTop: "1px solid #2A2A3A" }}>
|
||||||
|
{/* CAS AKTIVITY */}
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }}>
|
||||||
|
Čas aktivity (volitelné)
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<input type="time" value={groupSettings[group.id]?.from || ""}
|
||||||
|
onChange={e => updateGroupSetting(group.id, "from", e.target.value)}
|
||||||
|
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
<span style={{ color: "#6B6B85" }}>–</span>
|
||||||
|
<input type="time" value={groupSettings[group.id]?.to || ""}
|
||||||
|
onChange={e => updateGroupSetting(group.id, "to", e.target.value)}
|
||||||
|
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* DNY V TYDNU */}
|
||||||
|
<div style={{ display: "flex", gap: 4, marginTop: 8 }}>
|
||||||
|
{["Ne", "Po", "Út", "St", "Čt", "Pá", "So"].map((d, i) => {
|
||||||
|
const active = (groupSettings[group.id]?.days || []).includes(i);
|
||||||
|
return (
|
||||||
|
<button key={i} onClick={() => toggleDay(group.id, i)} style={{
|
||||||
|
flex: 1, padding: "6px 0", borderRadius: 6, fontSize: 11,
|
||||||
|
border: `1px solid ${active ? (group.color || "#4F46E5") : "#2A2A3A"}`,
|
||||||
|
background: active ? `${group.color || "#4F46E5"}20` : "transparent",
|
||||||
|
color: active ? (group.color || "#4F46E5") : "#6B6B85",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}>{d}</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GPS MISTO */}
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }}>
|
||||||
|
Místo výkonu (volitelné)
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
placeholder="Název místa (např. Synagoga, Kancelář...)"
|
||||||
|
value={groupSettings[group.id]?.locationName || ""}
|
||||||
|
onChange={e => 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" }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<input
|
||||||
|
placeholder="GPS souřadnice (lat, lng)"
|
||||||
|
value={groupSettings[group.id]?.gps || ""}
|
||||||
|
onChange={e => updateGroupSetting(group.id, "gps", e.target.value)}
|
||||||
|
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
<button onClick={() => getCurrentLocation(group.id)}
|
||||||
|
style={{ padding: "8px 12px", borderRadius: 8, border: "1px solid #1D4ED8", background: "#1D4ED820", color: "#60A5FA", cursor: "pointer", fontSize: 12, whiteSpace: "nowrap" }}>
|
||||||
|
Moje GPS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8 }}>
|
||||||
|
<span style={{ fontSize: 12, color: "#6B6B85" }}>Polomer:</span>
|
||||||
|
<input type="range" min="50" max="1000" step="50"
|
||||||
|
value={groupSettings[group.id]?.radius || 200}
|
||||||
|
onChange={e => updateGroupSetting(group.id, "radius", Number(e.target.value))}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12, color: "#9999AA", minWidth: 50 }}>
|
||||||
|
{groupSettings[group.id]?.radius || 200}m
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ULOZIT */}
|
||||||
|
<button onClick={() => saveGroupSettings(group.id)}
|
||||||
|
style={{ marginTop: 12, width: "100%", padding: "10px", borderRadius: 10, background: savedGroup === group.id ? "#16A34A" : "#1D4ED8", color: "white", border: "none", cursor: "pointer", fontSize: 14, fontWeight: 500, transition: "background 0.2s" }}>
|
||||||
|
{savedGroup === group.id ? "Uloženo ✓" : "Uložit nastavení skupiny"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Save button */}
|
{/* Save button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
@@ -217,6 +416,21 @@ export default function SettingsPage() {
|
|||||||
<div className="text-center text-xs text-muted py-4">
|
<div className="text-center text-xs text-muted py-4">
|
||||||
<p>{t("common.appName")} {t("common.appVersion")}</p>
|
<p>{t("common.appName")} {t("common.appVersion")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section className="bg-red-50 dark:bg-red-900/20 rounded-xl p-4 border border-red-200 dark:border-red-800 mt-6">
|
||||||
|
<h2 className="font-semibold text-red-600 dark:text-red-400 mb-2">Smazat ucet</h2>
|
||||||
|
<p className="text-sm text-red-500 mb-3">Trvale smazat ucet a vsechna data. Tuto akci nelze vratit.</p>
|
||||||
|
<button onClick={() => {
|
||||||
|
if (confirm("Opravdu chcete trvale smazat svuj ucet a vsechna data?")) {
|
||||||
|
const token = localStorage.getItem("taskteam_token");
|
||||||
|
fetch("/api/v1/auth/delete-account", { method: "DELETE", headers: { Authorization: "Bearer " + token } })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => { localStorage.clear(); window.location.href = "/login"; });
|
||||||
|
}
|
||||||
|
}} className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium">
|
||||||
|
Smazat ucet
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import {
|
|||||||
sendCollabRequest,
|
sendCollabRequest,
|
||||||
searchUsers,
|
searchUsers,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import CollabBackButton from "@/components/features/CollabBackButton";
|
||||||
|
import CollabActionButtons from "@/components/features/CollabActionButtons";
|
||||||
|
import IconButton from "@/components/features/IconButton";
|
||||||
|
|
||||||
interface UserResult {
|
interface UserResult {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -130,7 +133,6 @@ export default function CollaboratePage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
// Forms
|
|
||||||
const [showAssignSearch, setShowAssignSearch] = useState(false);
|
const [showAssignSearch, setShowAssignSearch] = useState(false);
|
||||||
const [showTransferSearch, setShowTransferSearch] = useState(false);
|
const [showTransferSearch, setShowTransferSearch] = useState(false);
|
||||||
const [transferMessage, setTransferMessage] = useState("");
|
const [transferMessage, setTransferMessage] = useState("");
|
||||||
@@ -152,19 +154,15 @@ export default function CollaboratePage() {
|
|||||||
setSubtasks(subtasksData.data || []);
|
setSubtasks(subtasksData.data || []);
|
||||||
setHistory(historyData.data || []);
|
setHistory(historyData.data || []);
|
||||||
|
|
||||||
// Load assignee details
|
|
||||||
const assignedIds: string[] = taskData.assigned_to || [];
|
const assignedIds: string[] = taskData.assigned_to || [];
|
||||||
if (assignedIds.length > 0) {
|
if (assignedIds.length > 0) {
|
||||||
try {
|
try {
|
||||||
const usersRes = await searchUsers(token, "");
|
const usersRes = await searchUsers(token, "");
|
||||||
// Filter by IDs — the workload endpoint returns all users, filter client-side
|
|
||||||
// For now just show what we can from collaboration history
|
|
||||||
const knownUsers = new Map<string, UserResult>();
|
const knownUsers = new Map<string, UserResult>();
|
||||||
(historyData.data || []).forEach((h: CollabRequest) => {
|
(historyData.data || []).forEach((h: CollabRequest) => {
|
||||||
if (h.from_user_id && h.from_name) knownUsers.set(h.from_user_id, { id: h.from_user_id, name: h.from_name, email: "", avatar_url: null });
|
if (h.from_user_id && h.from_name) knownUsers.set(h.from_user_id, { id: h.from_user_id, name: h.from_name, email: "", avatar_url: null });
|
||||||
if (h.to_user_id && h.to_name) knownUsers.set(h.to_user_id, { id: h.to_user_id, name: h.to_name, email: "", avatar_url: null });
|
if (h.to_user_id && h.to_name) knownUsers.set(h.to_user_id, { id: h.to_user_id, name: h.to_name, email: "", avatar_url: null });
|
||||||
});
|
});
|
||||||
// Merge with any search results
|
|
||||||
(usersRes.data || []).forEach((u: UserResult) => knownUsers.set(u.id, u));
|
(usersRes.data || []).forEach((u: UserResult) => knownUsers.set(u.id, u));
|
||||||
setAssignees(assignedIds.map((uid) => knownUsers.get(uid) || { id: uid, name: uid.slice(0, 8), email: "", avatar_url: null }));
|
setAssignees(assignedIds.map((uid) => knownUsers.get(uid) || { id: uid, name: uid.slice(0, 8), email: "", avatar_url: null }));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -316,18 +314,13 @@ export default function CollaboratePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg mx-auto space-y-4 pb-20">
|
<div className="max-w-lg mx-auto space-y-4 pb-20 px-4 sm:px-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<CollabBackButton
|
||||||
onClick={() => router.push(`/tasks/${id}`)}
|
onClick={() => router.push(`/tasks/${id}`)}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
label={t("common.back")}
|
||||||
>
|
/>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
{t("common.back")}
|
|
||||||
</button>
|
|
||||||
<h1 className="text-lg font-bold flex-1 truncate">{t("collab.title")}</h1>
|
<h1 className="text-lg font-bold flex-1 truncate">{t("collab.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -367,47 +360,20 @@ export default function CollaboratePage() {
|
|||||||
<p className="text-sm text-gray-400 mb-3">{t("collab.noAssignees")}</p>
|
<p className="text-sm text-gray-400 mb-3">{t("collab.noAssignees")}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons - icon only with tooltips */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<CollabActionButtons
|
||||||
<button
|
onAssign={() => {
|
||||||
onClick={() => {
|
setShowAssignSearch(!showAssignSearch);
|
||||||
setShowAssignSearch(!showAssignSearch);
|
setShowTransferSearch(false);
|
||||||
setShowTransferSearch(false);
|
}}
|
||||||
}}
|
onTransfer={() => {
|
||||||
disabled={actionLoading}
|
setShowTransferSearch(!showTransferSearch);
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
setShowAssignSearch(false);
|
||||||
>
|
}}
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
onClaim={handleClaim}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
disabled={actionLoading}
|
||||||
</svg>
|
t={t}
|
||||||
{t("collab.assign")}
|
/>
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowTransferSearch(!showTransferSearch);
|
|
||||||
setShowAssignSearch(false);
|
|
||||||
}}
|
|
||||||
disabled={actionLoading}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-orange-600 hover:bg-orange-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
|
||||||
</svg>
|
|
||||||
{t("collab.transfer")}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleClaim}
|
|
||||||
disabled={actionLoading}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 11.5V14m0 0V14m0 0h2.5M7 14H4.5m4.5 0a6 6 0 1012 0 6 6 0 00-12 0z" />
|
|
||||||
</svg>
|
|
||||||
{t("collab.claim")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Assign search dropdown */}
|
{/* Assign search dropdown */}
|
||||||
{showAssignSearch && (
|
{showAssignSearch && (
|
||||||
@@ -452,7 +418,6 @@ export default function CollaboratePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
{subtasksTotal > 0 && (
|
{subtasksTotal > 0 && (
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-3">
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-3">
|
||||||
<div
|
<div
|
||||||
@@ -462,10 +427,9 @@ export default function CollaboratePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Subtask list */}
|
|
||||||
<div className="space-y-2 mb-3">
|
<div className="space-y-2 mb-3">
|
||||||
{subtasks.map((sub) => {
|
{subtasks.map((sub) => {
|
||||||
const isDone = sub.status === "done" || sub.status === "completed";
|
const subDone = sub.status === "done" || sub.status === "completed";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={sub.id}
|
key={sub.id}
|
||||||
@@ -474,18 +438,18 @@ export default function CollaboratePage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleToggleSubtask(sub)}
|
onClick={() => handleToggleSubtask(sub)}
|
||||||
className={`flex-shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
className={`flex-shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||||
isDone
|
subDone
|
||||||
? "bg-green-500 border-green-500 text-white"
|
? "bg-green-500 border-green-500 text-white"
|
||||||
: "border-gray-300 dark:border-gray-600 hover:border-green-400"
|
: "border-gray-300 dark:border-gray-600 hover:border-green-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isDone && (
|
{subDone && (
|
||||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<span className={`flex-1 text-sm ${isDone ? "line-through text-gray-400" : ""}`}>
|
<span className={`flex-1 text-sm ${subDone ? "line-through text-gray-400" : ""}`}>
|
||||||
{sub.title}
|
{sub.title}
|
||||||
</span>
|
</span>
|
||||||
{sub.assignee_name && (
|
{sub.assignee_name && (
|
||||||
@@ -495,7 +459,8 @@ export default function CollaboratePage() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteSubtask(sub.id)}
|
onClick={() => handleDeleteSubtask(sub.id)}
|
||||||
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 transition-opacity"
|
title={t("tasks.delete")}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 transition-opacity p-1 rounded"
|
||||||
>
|
>
|
||||||
<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="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -510,7 +475,6 @@ export default function CollaboratePage() {
|
|||||||
<p className="text-sm text-gray-400 mb-3">{t("collab.noSubtasks")}</p>
|
<p className="text-sm text-gray-400 mb-3">{t("collab.noSubtasks")}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add subtask form */}
|
|
||||||
{showSubtaskForm ? (
|
{showSubtaskForm ? (
|
||||||
<div className="space-y-2 border-t border-gray-100 dark:border-gray-700 pt-3">
|
<div className="space-y-2 border-t border-gray-100 dark:border-gray-700 pt-3">
|
||||||
<input
|
<input
|
||||||
@@ -543,35 +507,47 @@ export default function CollaboratePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t("collab.addBtn")}
|
||||||
onClick={handleAddSubtask}
|
onClick={handleAddSubtask}
|
||||||
disabled={!newSubtaskTitle.trim() || actionLoading}
|
disabled={!newSubtaskTitle.trim() || actionLoading}
|
||||||
className="px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
variant="primary"
|
||||||
>
|
size="md"
|
||||||
{t("collab.addBtn")}
|
/>
|
||||||
</button>
|
<IconButton
|
||||||
<button
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t("tasks.form.cancel")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowSubtaskForm(false);
|
setShowSubtaskForm(false);
|
||||||
setNewSubtaskTitle("");
|
setNewSubtaskTitle("");
|
||||||
setSubtaskAssignee(null);
|
setSubtaskAssignee(null);
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-800 dark:text-gray-400"
|
variant="default"
|
||||||
>
|
size="md"
|
||||||
{t("tasks.form.cancel")}
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t("collab.addSubtask")}
|
||||||
onClick={() => setShowSubtaskForm(true)}
|
onClick={() => setShowSubtaskForm(true)}
|
||||||
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
variant="primary"
|
||||||
>
|
size="md"
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
/>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
{t("collab.addSubtask")}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,17 @@ import {
|
|||||||
searchUsers,
|
searchUsers,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import TaskForm from "@/components/TaskForm";
|
import TaskForm from "@/components/TaskForm";
|
||||||
|
import InviteModal from "@/components/InviteModal";
|
||||||
import StatusBadge from "@/components/StatusBadge";
|
import StatusBadge from "@/components/StatusBadge";
|
||||||
|
import TaskDetailActions from "@/components/features/TaskDetailActions";
|
||||||
|
import InlineEditField from "@/components/features/InlineEditField";
|
||||||
|
|
||||||
function isDone(status: string): boolean {
|
function isDone(status: string): boolean {
|
||||||
return status === "done" || status === "completed";
|
return status === "done" || status === "completed";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TaskDetailPage() {
|
export default function TaskDetailPage() {
|
||||||
const { token } = useAuth();
|
const { token, user } = useAuth();
|
||||||
const { t, locale } = useTranslation();
|
const { t, locale } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -36,6 +39,7 @@ export default function TaskDetailPage() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [subtasks, setSubtasks] = useState<Subtask[]>([]);
|
const [subtasks, setSubtasks] = useState<Subtask[]>([]);
|
||||||
const [assigneeNames, setAssigneeNames] = useState<Record<string, string>>({});
|
const [assigneeNames, setAssigneeNames] = useState<Record<string, string>>({});
|
||||||
|
const [showInvite, setShowInvite] = useState(false);
|
||||||
|
|
||||||
const dateLocale = locale === "ua" ? "uk-UA" : locale === "cs" ? "cs-CZ" : locale === "he" ? "he-IL" : "ru-RU";
|
const dateLocale = locale === "ua" ? "uk-UA" : locale === "cs" ? "cs-CZ" : locale === "he" ? "he-IL" : "ru-RU";
|
||||||
|
|
||||||
@@ -68,7 +72,6 @@ export default function TaskDetailPage() {
|
|||||||
setTask(taskData);
|
setTask(taskData);
|
||||||
setGroups(groupsData.data || []);
|
setGroups(groupsData.data || []);
|
||||||
setSubtasks(subtasksData.data || []);
|
setSubtasks(subtasksData.data || []);
|
||||||
// Load assignee names
|
|
||||||
const assigned: string[] = taskData.assigned_to || [];
|
const assigned: string[] = taskData.assigned_to || [];
|
||||||
if (assigned.length > 0) {
|
if (assigned.length > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -104,6 +107,16 @@ export default function TaskDetailPage() {
|
|||||||
loadTask();
|
loadTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleInlineUpdate(field: string, value: string) {
|
||||||
|
if (!token || !id) return;
|
||||||
|
try {
|
||||||
|
await updateTask(token, id, { [field]: value });
|
||||||
|
loadTask();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("common.error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!token || !id) return;
|
if (!token || !id) return;
|
||||||
if (!confirm(t("tasks.confirmDelete"))) return;
|
if (!confirm(t("tasks.confirmDelete"))) return;
|
||||||
@@ -181,90 +194,46 @@ export default function TaskDetailPage() {
|
|||||||
const taskDone = isDone(task.status);
|
const taskDone = isDone(task.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg mx-auto space-y-4">
|
<div className="max-w-lg mx-auto space-y-4 px-4 sm:px-0">
|
||||||
{/* Action bar - all buttons in one compact row */}
|
{/* Action bar - all icon buttons in one compact row */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<TaskDetailActions
|
||||||
<button
|
taskDone={taskDone}
|
||||||
onClick={() => router.push("/tasks")}
|
deleting={deleting}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
onBack={() => router.push("/tasks")}
|
||||||
>
|
onEdit={() => setEditing(true)}
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
onDelete={handleDelete}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
onToggleStatus={() => handleQuickStatus(taskDone ? "pending" : "done")}
|
||||||
</svg>
|
onInvite={() => setShowInvite(true)}
|
||||||
{t("common.back")}
|
onCollaborate={() => router.push(`/tasks/${id}/collaborate`)}
|
||||||
</button>
|
t={t}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex-1" />
|
{/* Task detail card with inline editing */}
|
||||||
|
|
||||||
{!taskDone && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleQuickStatus("done")}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
{t("tasks.markDone")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{taskDone && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleQuickStatus("pending")}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
{t("tasks.reopen")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setEditing(true)}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
||||||
</svg>
|
|
||||||
{t("tasks.edit")}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleting}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
{deleting ? t("tasks.deleting") : t("tasks.delete")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Task detail card */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-6">
|
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-6">
|
||||||
<div className="flex items-start justify-between gap-4 mb-4">
|
<div className="flex items-start justify-between gap-4 mb-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||||
{task.group_icon && (
|
{task.group_icon && (
|
||||||
<span className="text-2xl">{task.group_icon}</span>
|
<span className="text-2xl flex-shrink-0">{task.group_icon}</span>
|
||||||
)}
|
)}
|
||||||
<h1
|
<InlineEditField
|
||||||
className={`text-xl font-bold ${
|
value={task.title}
|
||||||
taskDone ? "line-through text-muted" : ""
|
onSave={(val) => handleInlineUpdate("title", val)}
|
||||||
}`}
|
className={`text-xl font-bold ${taskDone ? "line-through text-muted" : ""}`}
|
||||||
>
|
/>
|
||||||
{task.title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={task.status} size="md" />
|
<StatusBadge status={task.status} size="md" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.description && (
|
<div className="mb-4">
|
||||||
<p className="text-muted mb-4 whitespace-pre-wrap leading-relaxed">
|
<InlineEditField
|
||||||
{task.description}
|
value={task.description || ""}
|
||||||
</p>
|
onSave={(val) => handleInlineUpdate("description", val)}
|
||||||
)}
|
as="textarea"
|
||||||
|
multiline
|
||||||
|
placeholder={t("tasks.form.descPlaceholder")}
|
||||||
|
className="text-muted whitespace-pre-wrap leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
@@ -318,15 +287,6 @@ export default function TaskDetailPage() {
|
|||||||
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
{t("collab.collaboration")}
|
{t("collab.collaboration")}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
|
||||||
onClick={() => router.push(`/tasks/${id}/collaborate`)}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
||||||
</svg>
|
|
||||||
{t("collab.collaboration")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Assigned users */}
|
{/* Assigned users */}
|
||||||
@@ -383,6 +343,16 @@ export default function TaskDetailPage() {
|
|||||||
<p className="text-sm text-gray-400">{t("collab.noAssignees")}</p>
|
<p className="text-sm text-gray-400">{t("collab.noAssignees")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Invite Modal */}
|
||||||
|
{showInvite && token && user && (
|
||||||
|
<InviteModal
|
||||||
|
taskId={id}
|
||||||
|
token={token}
|
||||||
|
userId={user.id}
|
||||||
|
onClose={() => setShowInvite(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,21 +6,33 @@ import { useAuth } from "@/lib/auth";
|
|||||||
import { getTasks, getGroups, createTask, updateTask, Task, Group } from "@/lib/api";
|
import { getTasks, getGroups, createTask, updateTask, Task, Group } from "@/lib/api";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import TaskCard from "@/components/TaskCard";
|
import TaskCard from "@/components/TaskCard";
|
||||||
import GroupSelector from "@/components/GroupSelector";
|
|
||||||
import TaskModal from "@/components/TaskModal";
|
import TaskModal from "@/components/TaskModal";
|
||||||
import { useSwipeable } from "react-swipeable";
|
import { useSwipeable } from "react-swipeable";
|
||||||
|
|
||||||
type StatusFilter = "all" | "pending" | "in_progress" | "done" | "cancelled";
|
const STATUS_VALUES = ["pending", "in_progress", "done", "cancelled"] as const;
|
||||||
|
type StatusValue = (typeof STATUS_VALUES)[number];
|
||||||
|
|
||||||
|
function statusColor(s: StatusValue | null): string {
|
||||||
|
switch (s) {
|
||||||
|
case "pending": return "#FBBF24";
|
||||||
|
case "in_progress": return "#60A5FA";
|
||||||
|
case "done": return "#34D399";
|
||||||
|
case "cancelled": return "#9CA3AF";
|
||||||
|
default: return "#7A7A9A";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function TasksPage() {
|
export default function TasksPage() {
|
||||||
const { token } = useAuth();
|
const { token, user } = useAuth();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [groups, setGroups] = useState<Group[]>([]);
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
const [selectedStatus, setSelectedStatus] = useState<StatusValue | null>(null);
|
||||||
|
const [groupOpen, setGroupOpen] = useState(false);
|
||||||
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [swipeDirection, setSwipeDirection] = useState<"left" | "right" | null>(null);
|
const [swipeDirection, setSwipeDirection] = useState<"left" | "right" | null>(null);
|
||||||
const [swipeOverlay, setSwipeOverlay] = useState<{ name: string; icon: string | null } | null>(null);
|
const [swipeOverlay, setSwipeOverlay] = useState<{ name: string; icon: string | null } | null>(null);
|
||||||
@@ -34,13 +46,18 @@ export default function TasksPage() {
|
|||||||
return groupOrder.indexOf(selectedGroup);
|
return groupOrder.indexOf(selectedGroup);
|
||||||
}, [groupOrder, selectedGroup]);
|
}, [groupOrder, selectedGroup]);
|
||||||
|
|
||||||
|
const selectedGroupObj = useMemo(
|
||||||
|
() => (selectedGroup ? groups.find((g) => g.id === selectedGroup) ?? null : null),
|
||||||
|
[selectedGroup, groups]
|
||||||
|
);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (selectedGroup) params.group_id = selectedGroup;
|
if (selectedGroup) params.group_id = selectedGroup;
|
||||||
if (statusFilter !== "all") params.status = statusFilter;
|
if (selectedStatus) params.status = selectedStatus;
|
||||||
|
|
||||||
const [tasksRes, groupsRes] = await Promise.all([
|
const [tasksRes, groupsRes] = await Promise.all([
|
||||||
getTasks(token, Object.keys(params).length > 0 ? params : undefined),
|
getTasks(token, Object.keys(params).length > 0 ? params : undefined),
|
||||||
@@ -53,7 +70,7 @@ export default function TasksPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [token, selectedGroup, statusFilter]);
|
}, [token, selectedGroup, selectedStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -118,46 +135,141 @@ export default function TasksPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusOptions: { value: StatusFilter; label: string }[] = [
|
|
||||||
{ value: "all", label: t("tasks.all") },
|
|
||||||
{ value: "pending", label: t("tasks.status.pending") },
|
|
||||||
{ value: "in_progress", label: t("tasks.status.in_progress") },
|
|
||||||
{ value: "done", label: t("tasks.status.done") },
|
|
||||||
{ value: "cancelled", label: t("tasks.status.cancelled") },
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
|
|
||||||
return (
|
const userInitial = (user?.name || user?.email || "?").charAt(0).toUpperCase();
|
||||||
<div className="space-y-2 pb-24 sm:pb-8 px-4 sm:px-0">
|
|
||||||
{/* Group dropdown + Status pills row */}
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
<GroupSelector
|
|
||||||
groups={groups}
|
|
||||||
selected={selectedGroup}
|
|
||||||
onSelect={setSelectedGroup}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status filter pills - compact */}
|
return (
|
||||||
<div className="flex gap-1 overflow-x-auto scrollbar-hide px-4">
|
<div className="pb-24 sm:pb-8">
|
||||||
{statusOptions.map((opt) => (
|
{/* Single-row sticky filter header — group dropdown left, status pills right */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center",
|
||||||
|
padding: "0 8px",
|
||||||
|
position: "sticky", top: 40, zIndex: 40,
|
||||||
|
height: 40, maxHeight: 40,
|
||||||
|
background: "var(--background, #fff)",
|
||||||
|
borderBottom: "1px solid var(--border, #e5e7eb)",
|
||||||
|
flexWrap: "nowrap", overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* Groups dropdown — compact, left side */}
|
||||||
|
<div style={{ position: "relative", flexShrink: 0 }}>
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
onClick={() => { setGroupOpen(o => !o); }}
|
||||||
onClick={() => setStatusFilter(opt.value)}
|
style={{
|
||||||
className={`flex-shrink-0 px-2.5 py-1 rounded-full text-[11px] font-medium transition-all ${
|
display: "flex", alignItems: "center", gap: 4,
|
||||||
statusFilter === opt.value
|
padding: "4px 10px", borderRadius: 16, height: 32,
|
||||||
? "bg-gray-800 text-white dark:bg-white dark:text-gray-900"
|
border: `1.5px solid ${selectedGroupObj?.color || "var(--border, #3A3A4A)"}`,
|
||||||
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
background: selectedGroupObj ? selectedGroupObj.color + "18" : "transparent",
|
||||||
}`}
|
color: selectedGroupObj?.color || "var(--foreground, #374151)",
|
||||||
|
fontSize: 12, fontWeight: 600, cursor: "pointer", whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{selectedGroupObj
|
||||||
|
? `${selectedGroupObj.icon ?? ""} ${(selectedGroupObj as any).name}`
|
||||||
|
: t("tasks.all")}
|
||||||
|
<span style={{ fontSize: 8, opacity: 0.5, marginLeft: 1 }}>{groupOpen ? "\u25b2" : "\u25bc"}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
{groupOpen && (
|
||||||
|
<>
|
||||||
|
<div onClick={() => setGroupOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 150 }} />
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", top: "110%", left: 0, zIndex: 200,
|
||||||
|
background: "var(--popover, #fff)", border: "1px solid var(--border, #e5e7eb)",
|
||||||
|
borderRadius: 12, padding: "4px 0", minWidth: 180,
|
||||||
|
boxShadow: "0 8px 24px rgba(0,0,0,0.15)",
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedGroup(null); setGroupOpen(false); }}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "9px 14px",
|
||||||
|
background: !selectedGroup ? "rgba(29,78,216,0.08)" : "transparent",
|
||||||
|
border: "none", borderLeft: `3px solid ${!selectedGroup ? "#1D4ED8" : "transparent"}`,
|
||||||
|
color: !selectedGroup ? "#2563EB" : "var(--foreground, #374151)",
|
||||||
|
textAlign: "left", fontSize: 13, cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tasks.all")}
|
||||||
|
</button>
|
||||||
|
{groups.map(g => (
|
||||||
|
<button
|
||||||
|
key={g.id}
|
||||||
|
onClick={() => { setSelectedGroup(g.id); setGroupOpen(false); }}
|
||||||
|
style={{
|
||||||
|
width: "100%", padding: "9px 14px",
|
||||||
|
background: selectedGroup === g.id ? g.color + "18" : "transparent",
|
||||||
|
border: "none", borderLeft: `3px solid ${selectedGroup === g.id ? g.color : "transparent"}`,
|
||||||
|
color: selectedGroup === g.id ? g.color : "var(--foreground, #374151)",
|
||||||
|
textAlign: "left", fontSize: 13, cursor: "pointer",
|
||||||
|
display: "flex", alignItems: "center", gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{g.icon}</span>
|
||||||
|
{g.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status pills — horizontal scroll, right side */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 4,
|
||||||
|
flex: 1, overflow: "hidden", marginLeft: 6,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
}}
|
||||||
|
className="scrollbar-hide"
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 3,
|
||||||
|
overflowX: "auto", flexShrink: 1,
|
||||||
|
}}
|
||||||
|
className="scrollbar-hide"
|
||||||
|
>
|
||||||
|
{/* "All" pill */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedStatus(null)}
|
||||||
|
style={{
|
||||||
|
padding: "3px 10px", borderRadius: 14, height: 28,
|
||||||
|
border: "none", cursor: "pointer", whiteSpace: "nowrap",
|
||||||
|
fontSize: 11, fontWeight: selectedStatus === null ? 700 : 500,
|
||||||
|
background: selectedStatus === null ? "rgba(29,78,216,0.12)" : "transparent",
|
||||||
|
color: selectedStatus === null ? "#2563EB" : "var(--muted, #6B7280)",
|
||||||
|
transition: "all 0.15s ease",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("tasks.all")}
|
||||||
|
</button>
|
||||||
|
{STATUS_VALUES.map(s => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setSelectedStatus(selectedStatus === s ? null : s)}
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 4,
|
||||||
|
padding: "3px 10px", borderRadius: 14, height: 28,
|
||||||
|
border: "none", cursor: "pointer", whiteSpace: "nowrap",
|
||||||
|
fontSize: 11, fontWeight: selectedStatus === s ? 700 : 500,
|
||||||
|
background: selectedStatus === s ? statusColor(s) + "20" : "transparent",
|
||||||
|
color: selectedStatus === s ? statusColor(s) : "var(--muted, #6B7280)",
|
||||||
|
transition: "all 0.15s ease",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
width: 6, height: 6, borderRadius: "50%",
|
||||||
|
background: statusColor(s), flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
{t(`tasks.status.${s}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Swipeable task list area */}
|
{/* Swipeable task list area */}
|
||||||
<div {...swipeHandlers} className="relative min-h-[200px]">
|
<div {...swipeHandlers} className="relative min-h-[200px] px-4 sm:px-0 pt-2">
|
||||||
{/* Swipe overlay */}
|
{/* Swipe overlay */}
|
||||||
{swipeOverlay && (
|
{swipeOverlay && (
|
||||||
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none">
|
||||||
|
|||||||
25
apps/tasks/app/widget/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export default function WidgetPage() {
|
||||||
|
const [tasks, setTasks] = useState<any[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('taskteam_token');
|
||||||
|
if (token) {
|
||||||
|
fetch('/api/v1/tasks?limit=5&status=pending', { headers: { Authorization: 'Bearer ' + token } })
|
||||||
|
.then(r => r.json()).then(d => setTasks(d.data || []));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 8, background: '#0F172A', minHeight: '100vh', color: 'white', fontSize: 14 }}>
|
||||||
|
<div style={{ fontWeight: 'bold', marginBottom: 8 }}>Task Team</div>
|
||||||
|
{tasks.map(t => (
|
||||||
|
<div key={t.id} style={{ padding: '6px 0', borderBottom: '1px solid #1E293B', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: t.status === 'in_progress' ? '#F59E0B' : '#6B7280', flexShrink: 0 }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!tasks.length && <div style={{ opacity: 0.5 }}>Zadne ukoly</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,205 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import CompactHeader from "./features/CompactHeader";
|
||||||
import { useAuth } from "@/lib/auth";
|
|
||||||
import { useTheme } from "./ThemeProvider";
|
|
||||||
import { useTranslation } from "@/lib/i18n";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const { user, logout, token } = useAuth();
|
return <CompactHeader />;
|
||||||
const { theme, toggleTheme } = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter();
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
||||||
|
|
||||||
function handleLogout() {
|
|
||||||
logout();
|
|
||||||
router.push("/login");
|
|
||||||
setDrawerOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
|
||||||
const openDrawer = useCallback(() => setDrawerOpen(true), []);
|
|
||||||
|
|
||||||
// Close drawer on escape
|
|
||||||
useEffect(() => {
|
|
||||||
if (!drawerOpen) return;
|
|
||||||
function handleKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") closeDrawer();
|
|
||||||
}
|
|
||||||
document.addEventListener("keydown", handleKey);
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", handleKey);
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
};
|
|
||||||
}, [drawerOpen, closeDrawer]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<header className="sticky top-0 z-50 bg-white/80 dark:bg-gray-950/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-800">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 h-11 flex items-center justify-end gap-2">
|
|
||||||
{/* Right side only: avatar (no name text) + hamburger */}
|
|
||||||
{token && user && (
|
|
||||||
<button
|
|
||||||
onClick={openDrawer}
|
|
||||||
className="w-7 h-7 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-xs font-bold cursor-pointer hover:ring-2 hover:ring-blue-400 transition-all flex-shrink-0"
|
|
||||||
aria-label={t("common.menu")}
|
|
||||||
>
|
|
||||||
{(user.name || user.email || "?").charAt(0).toUpperCase()}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={openDrawer}
|
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors flex items-center justify-center flex-shrink-0"
|
|
||||||
aria-label={t("common.menu")}
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Slide-out drawer */}
|
|
||||||
{drawerOpen && (
|
|
||||||
<div className="fixed inset-0 z-[60]">
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-fadeIn"
|
|
||||||
onClick={closeDrawer}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Drawer panel */}
|
|
||||||
<div className="absolute right-0 top-0 bottom-0 w-72 bg-white dark:bg-gray-900 shadow-2xl animate-slideInRight flex flex-col">
|
|
||||||
{/* Close button */}
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
|
|
||||||
<span className="font-semibold text-lg">{t("common.menu")}</span>
|
|
||||||
<button
|
|
||||||
onClick={closeDrawer}
|
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] flex items-center justify-center"
|
|
||||||
aria-label={t("common.closeMenu")}
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User info - full name + email */}
|
|
||||||
{token && user && (
|
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-sm font-bold">
|
|
||||||
{(user.name || user.email || "?").charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-medium text-sm truncate">{user.name || t("settings.user")}</p>
|
|
||||||
<p className="text-xs text-muted truncate">{user.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Menu items */}
|
|
||||||
<div className="p-2 space-y-1 flex-1 overflow-y-auto">
|
|
||||||
<Link
|
|
||||||
href="/tasks"
|
|
||||||
onClick={closeDrawer}
|
|
||||||
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-sm font-medium">{t("nav.tasks")}</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/calendar"
|
|
||||||
onClick={closeDrawer}
|
|
||||||
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" 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>
|
|
||||||
<span className="text-sm font-medium">{t("nav.calendar")}</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/chat"
|
|
||||||
onClick={closeDrawer}
|
|
||||||
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<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>
|
|
||||||
<span className="text-sm font-medium">{t("nav.chat")}</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/settings"
|
|
||||||
onClick={closeDrawer}
|
|
||||||
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-sm font-medium">{t("nav.settings")}</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Install link */}
|
|
||||||
<Link
|
|
||||||
href="/settings#install"
|
|
||||||
onClick={closeDrawer}
|
|
||||||
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-sm font-medium">{t("settings.install") || "Instalace"}</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Theme toggle */}
|
|
||||||
<button
|
|
||||||
onClick={() => { toggleTheme(); }}
|
|
||||||
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors w-full text-left min-h-[48px]"
|
|
||||||
>
|
|
||||||
{theme === "dark" ? (
|
|
||||||
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{theme === "dark" ? t("settings.light") : t("settings.dark")}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Logout icon at bottom */}
|
|
||||||
{token && (
|
|
||||||
<div className="p-4 border-t border-gray-200 dark:border-gray-800 safe-area-bottom">
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
title={t("auth.logout")}
|
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
|
|
||||||
aria-label={t("auth.logout")}
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
184
apps/tasks/components/InviteModal.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { createInvitation, InvitationResponse } from '@/lib/api';
|
||||||
|
|
||||||
|
interface InviteModalProps {
|
||||||
|
taskId: string;
|
||||||
|
token: string;
|
||||||
|
userId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InviteModal({ taskId, token, userId, onClose }: InviteModalProps) {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState<InvitationResponse | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function handleInvite() {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await createInvitation(token, {
|
||||||
|
task_id: taskId,
|
||||||
|
invitee_email: email || undefined,
|
||||||
|
invitee_name: name || undefined,
|
||||||
|
message: message || undefined,
|
||||||
|
inviter_id: userId,
|
||||||
|
});
|
||||||
|
setResult(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Chyba pri vytvareni pozvanky');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyLink() {
|
||||||
|
if (!result) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(result.link);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Fallback
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.value = result.link;
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(input);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-white dark:bg-gray-900 rounded-2xl p-6 max-w-sm w-full shadow-2xl"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-bold">Pozvat do ukolu</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!result ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="Jmeno (nepovinne)"
|
||||||
|
className="w-full px-4 py-3 rounded-lg border dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
placeholder="Email (nepovinne)"
|
||||||
|
className="w-full px-4 py-3 rounded-lg border dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={message}
|
||||||
|
onChange={e => setMessage(e.target.value)}
|
||||||
|
placeholder="Zprava (nepovinne)"
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-4 py-3 rounded-lg border dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none resize-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded-lg p-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleInvite}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-bold disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Vytvarim...' : 'Vytvorit pozvanku'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 rounded-lg p-3 text-sm text-center">
|
||||||
|
Pozvanka vytvorena!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link display */}
|
||||||
|
<div className="flex items-center gap-2 bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={result.link}
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none truncate"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={copyLink}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
copied
|
||||||
|
? 'bg-green-600 text-white'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{copied ? 'Skopirovano!' : 'Kopirovat'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Share buttons */}
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<a
|
||||||
|
href={result.share.whatsapp}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex flex-col items-center gap-1 p-3 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6 text-green-600" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs font-medium text-green-700 dark:text-green-400">WhatsApp</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={result.share.telegram}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex flex-col items-center gap-1 p-3 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6 text-blue-500" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">Telegram</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={result.share.sms}
|
||||||
|
className="flex flex-col items-center gap-1 p-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<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>
|
||||||
|
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">SMS</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full py-2 text-gray-500 text-sm hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
Zavrit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,49 +3,76 @@
|
|||||||
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 { useTranslation } from "@/lib/i18n";
|
||||||
import StatusBadge from "./StatusBadge";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSwipeable } from "react-swipeable";
|
import { useSwipeable } from "react-swipeable";
|
||||||
|
|
||||||
interface TaskCardProps {
|
interface TaskCardProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
onComplete?: (taskId: string) => void;
|
onComplete?: (taskId: string) => void;
|
||||||
|
onAssign?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITY_COLORS: Record<string, string> = {
|
function statusColor(status: string): string {
|
||||||
urgent: "#ef4444",
|
switch (status) {
|
||||||
high: "#f97316",
|
case "todo":
|
||||||
medium: "#eab308",
|
case "pending": return "#F59E0B"; // yellow
|
||||||
low: "#22c55e",
|
case "in_progress": return "#3B82F6"; // blue
|
||||||
};
|
case "done": return "#22C55E"; // green
|
||||||
|
case "cancelled": return "#6B7280"; // gray
|
||||||
function isDone(status: string): boolean {
|
default: return "#F59E0B";
|
||||||
return status === "done" || status === "completed";
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TaskCard({ task, onComplete }: TaskCardProps) {
|
function userColor(userId: string): string {
|
||||||
|
const colors = [
|
||||||
|
"#3B82F6", "#8B5CF6", "#EC4899", "#F59E0B",
|
||||||
|
"#10B981", "#06B6D4", "#F97316", "#6366F1",
|
||||||
|
];
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < userId.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash) + userId.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return colors[Math.abs(hash) % colors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDueSoon(dateStr: string): boolean {
|
||||||
|
const due = new Date(dateStr).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
return due - now <= 7 * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPast(dateStr: string): boolean {
|
||||||
|
return new Date(dateStr).getTime() < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskCard({ task, onComplete, onAssign }: TaskCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const priorityColor = PRIORITY_COLORS[task.priority] || PRIORITY_COLORS.medium;
|
const taskDone = task.status === "done";
|
||||||
const taskDone = isDone(task.status);
|
|
||||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||||
const [swiped, setSwiped] = useState(false);
|
const [swiped, setSwiped] = useState(false);
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const SWIPE_THRESHOLD = 120;
|
const SWIPE_THRESHOLD = 120;
|
||||||
|
const assignees = task.assigned_to || [];
|
||||||
|
const visibleAssignees = assignees.slice(0, 3);
|
||||||
|
const groupColor = task.group_color;
|
||||||
|
const sColor = statusColor(task.status);
|
||||||
|
|
||||||
const swipeHandlers = useSwipeable({
|
const swipeHandlers = useSwipeable({
|
||||||
onSwiping: (e) => {
|
onSwiping: (e) => {
|
||||||
if (e.dir === "Right" && !taskDone && onComplete) {
|
if (e.dir === "Right" && !taskDone && onComplete) {
|
||||||
const offset = Math.min(e.deltaX, 160);
|
setSwipeOffset(Math.min(e.deltaX, 160));
|
||||||
setSwipeOffset(offset);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSwipedRight: (e) => {
|
onSwipedRight: (e) => {
|
||||||
if (e.absX > SWIPE_THRESHOLD && !taskDone && onComplete) {
|
if (e.absX > SWIPE_THRESHOLD && !taskDone && onComplete) {
|
||||||
setSwiped(true);
|
setSwiped(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => { onComplete(task.id); }, 300);
|
||||||
onComplete(task.id);
|
|
||||||
}, 300);
|
|
||||||
} else {
|
} else {
|
||||||
setSwipeOffset(0);
|
setSwipeOffset(0);
|
||||||
}
|
}
|
||||||
@@ -62,14 +89,12 @@ export default function TaskCard({ task, onComplete }: TaskCardProps) {
|
|||||||
const showCompleteHint = swipeOffset > 40;
|
const showCompleteHint = swipeOffset > 40;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-lg" ref={cardRef}>
|
<div className="relative overflow-hidden" ref={cardRef} style={{ margin: "0 0 5px" }}>
|
||||||
{/* Swipe background - green complete indicator */}
|
{/* Swipe background */}
|
||||||
{onComplete && !taskDone && (
|
{onComplete && !taskDone && (
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 flex items-center pl-4 rounded-lg transition-colors ${
|
className={`absolute inset-0 flex items-center pl-4 rounded-xl transition-colors ${
|
||||||
swipeOffset > SWIPE_THRESHOLD
|
swipeOffset > SWIPE_THRESHOLD ? "bg-green-500" : "bg-green-400/80"
|
||||||
? "bg-green-500"
|
|
||||||
: "bg-green-400/80"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -92,39 +117,107 @@ export default function TaskCard({ task, onComplete }: TaskCardProps) {
|
|||||||
transition: swipeOffset === 0 ? "transform 0.3s ease" : "none",
|
transition: swipeOffset === 0 ? "transform 0.3s ease" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link href={`/tasks/${task.id}`} className="block group">
|
<Link href={`/tasks/${task.id}`} className="block">
|
||||||
<div className={`relative bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-md transition-all duration-150 active:scale-[0.99] ${swiped ? "opacity-0 transition-opacity duration-300" : ""}`}>
|
<div
|
||||||
{/* Priority line on left edge */}
|
style={{
|
||||||
<div
|
padding: "10px 14px",
|
||||||
className="absolute left-0 top-0 bottom-0 w-0.5"
|
borderRadius: 10,
|
||||||
style={{ backgroundColor: priorityColor }}
|
background: "#13131A",
|
||||||
/>
|
border: `1px solid ${groupColor ? groupColor + "30" : "#1E1E2E"}`,
|
||||||
|
borderLeft: `3px solid ${groupColor || "#2A2A3A"}`,
|
||||||
<div className="pl-3 pr-2.5 py-2 flex items-center gap-2">
|
cursor: "pointer",
|
||||||
{/* Group icon */}
|
display: "flex",
|
||||||
{task.group_icon && (
|
alignItems: "center",
|
||||||
<span className="text-base flex-shrink-0 leading-none">{task.group_icon}</span>
|
gap: 10,
|
||||||
)}
|
opacity: swiped ? 0 : 1,
|
||||||
|
transition: swiped ? "opacity 0.3s" : undefined,
|
||||||
{/* Title - single line, truncated */}
|
}}
|
||||||
<h3
|
>
|
||||||
className={`text-sm font-medium truncate flex-1 min-w-0 ${
|
{/* LEFT: title + optional due date */}
|
||||||
taskDone ? "line-through text-muted" : ""
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
}`}
|
<div style={{
|
||||||
>
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
color: "#E8E8F0",
|
||||||
|
textDecoration: taskDone ? "line-through" : "none",
|
||||||
|
opacity: taskDone ? 0.5 : 1,
|
||||||
|
}}>
|
||||||
{task.title}
|
{task.title}
|
||||||
</h3>
|
</div>
|
||||||
|
{task.due_at && isDueSoon(task.due_at) && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: isPast(task.due_at) ? "#EF4444" : "#F59E0B",
|
||||||
|
marginTop: 2,
|
||||||
|
}}>
|
||||||
|
{formatDate(task.due_at)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Status badge */}
|
{/* RIGHT: avatars + big status dot */}
|
||||||
<div className="flex-shrink-0">
|
<div style={{ display: "flex", alignItems: "center", gap: 6, flexShrink: 0 }}>
|
||||||
<StatusBadge status={task.status} size="sm" />
|
{/* Avatars */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
|
{visibleAssignees.map((userId, i) => (
|
||||||
|
<div
|
||||||
|
key={userId}
|
||||||
|
title={userId}
|
||||||
|
style={{
|
||||||
|
width: 26, height: 26,
|
||||||
|
borderRadius: "50%",
|
||||||
|
marginLeft: i > 0 ? -8 : 0,
|
||||||
|
border: "2px solid #13131A",
|
||||||
|
background: userColor(userId),
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: 10, fontWeight: 700, color: "white",
|
||||||
|
zIndex: 3 - i,
|
||||||
|
position: "relative",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{userId.slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* + add user button */}
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onAssign) onAssign(task.id);
|
||||||
|
}}
|
||||||
|
title="Přidat uživatele"
|
||||||
|
style={{
|
||||||
|
width: 26, height: 26,
|
||||||
|
borderRadius: "50%",
|
||||||
|
marginLeft: visibleAssignees.length > 0 ? -8 : 0,
|
||||||
|
border: "2px dashed #3A3A5A",
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: 14, color: "#4A4A6A",
|
||||||
|
position: "relative", zIndex: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Priority dot */}
|
{/* Big colored status dot */}
|
||||||
<div
|
<div
|
||||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
title={t(`tasks.status.${task.status}`)}
|
||||||
style={{ backgroundColor: priorityColor }}
|
style={{
|
||||||
title={t(`tasks.priority.${task.priority}`)}
|
width: 14, height: 14,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: sColor,
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: `0 0 6px ${sColor}80`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
55
apps/tasks/components/features/CollabActionButtons.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
import IconButton from './IconButton';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onAssign: () => void;
|
||||||
|
onTransfer: () => void;
|
||||||
|
onClaim: () => void;
|
||||||
|
disabled: boolean;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollabActionButtons({ onAssign, onTransfer, onClaim, disabled, t }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t("collab.assign")}
|
||||||
|
onClick={onAssign}
|
||||||
|
disabled={disabled}
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t("collab.transfer")}
|
||||||
|
onClick={onTransfer}
|
||||||
|
disabled={disabled}
|
||||||
|
variant="warning"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M7 11.5V14m0 0V14m0 0h2.5M7 14H4.5m4.5 0a6 6 0 1012 0 6 6 0 00-12 0z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t("collab.claim")}
|
||||||
|
onClick={onClaim}
|
||||||
|
disabled={disabled}
|
||||||
|
variant="success"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/tasks/components/features/CollabBackButton.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
'use client';
|
||||||
|
import IconButton from './IconButton';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollabBackButton({ onClick, label }: Props) {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={label}
|
||||||
|
onClick={onClick}
|
||||||
|
variant="default"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
apps/tasks/components/features/CompactHeader.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useAuth } from '@/lib/auth';
|
||||||
|
import { useTheme } from '@/components/ThemeProvider';
|
||||||
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import IconButton from './IconButton';
|
||||||
|
|
||||||
|
export default function CompactHeader() {
|
||||||
|
const { user, logout, token } = useAuth();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout();
|
||||||
|
router.push('/login');
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
||||||
|
const openDrawer = useCallback(() => setDrawerOpen(true), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!drawerOpen) return;
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') closeDrawer();
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKey);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKey);
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [drawerOpen, closeDrawer]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="sticky top-0 z-50 bg-white/80 dark:bg-gray-950/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="max-w-4xl mx-auto px-3 h-10 flex items-center justify-end gap-1.5">
|
||||||
|
{token && user && (
|
||||||
|
<button
|
||||||
|
onClick={openDrawer}
|
||||||
|
className="w-7 h-7 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-xs font-bold cursor-pointer hover:ring-2 hover:ring-blue-400 transition-all flex-shrink-0"
|
||||||
|
title={user.name || user.email || t('common.menu')}
|
||||||
|
aria-label={t('common.menu')}
|
||||||
|
>
|
||||||
|
{(user.name || user.email || '?').charAt(0).toUpperCase()}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t('common.menu')}
|
||||||
|
onClick={openDrawer}
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="!bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Slide-out drawer */}
|
||||||
|
{drawerOpen && (
|
||||||
|
<div className="fixed inset-0 z-[60]">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-fadeIn"
|
||||||
|
onClick={closeDrawer}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-72 bg-white dark:bg-gray-900 shadow-2xl animate-slideInRight flex flex-col">
|
||||||
|
{/* Close */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<span className="font-semibold text-lg">{t('common.menu')}</span>
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t('common.closeMenu')}
|
||||||
|
onClick={closeDrawer}
|
||||||
|
variant="default"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User info */}
|
||||||
|
{token && user && (
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-sm font-bold">
|
||||||
|
{(user.name || user.email || '?').charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-sm truncate">{user.name || t('settings.user')}</p>
|
||||||
|
<p className="text-xs text-muted truncate">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Menu items - icons only with labels next to them in the drawer */}
|
||||||
|
<div className="p-2 space-y-1 flex-1 overflow-y-auto">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
href: '/tasks',
|
||||||
|
icon: <svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>,
|
||||||
|
label: t('nav.tasks'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/calendar',
|
||||||
|
icon: <svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" 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>,
|
||||||
|
label: t('nav.calendar'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/chat',
|
||||||
|
icon: <svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><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>,
|
||||||
|
label: t('nav.chat'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/settings',
|
||||||
|
icon: <svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>,
|
||||||
|
label: t('nav.settings'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/settings#install',
|
||||||
|
icon: <svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>,
|
||||||
|
label: t('settings.install') || 'Instalace',
|
||||||
|
},
|
||||||
|
].map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={closeDrawer}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[48px]"
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span className="text-sm font-medium">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors w-full text-left min-h-[48px]"
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? (
|
||||||
|
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5 text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{theme === 'dark' ? t('settings.light') : t('settings.dark')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logout icon at bottom */}
|
||||||
|
{token && (
|
||||||
|
<div className="p-4 border-t border-gray-200 dark:border-gray-800 safe-area-bottom">
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t('auth.logout')}
|
||||||
|
onClick={handleLogout}
|
||||||
|
variant="danger"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/tasks/components/features/DeleteIconButton.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
'use client';
|
||||||
|
import IconButton from './IconButton';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteIconButton({ onClick, label, size = 'sm' }: Props) {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={label}
|
||||||
|
onClick={onClick}
|
||||||
|
variant="danger"
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
apps/tasks/components/features/GoalActionButtons.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
import IconButton from './IconButton';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onPlan: () => void;
|
||||||
|
onReport: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
planLoading: boolean;
|
||||||
|
reportLoading: boolean;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GoalActionButtons({ onPlan, onReport, onDelete, planLoading, reportLoading, t }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
planLoading ? (
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current" />
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" 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" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
label={t("goals.plan")}
|
||||||
|
onClick={onPlan}
|
||||||
|
disabled={planLoading}
|
||||||
|
variant="purple"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
reportLoading ? (
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current" />
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" 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" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
label={t("goals.report")}
|
||||||
|
onClick={onReport}
|
||||||
|
disabled={reportLoading}
|
||||||
|
variant="success"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t("tasks.delete")}
|
||||||
|
onClick={onDelete}
|
||||||
|
variant="danger"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/tasks/components/features/IconButton.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
variant?: 'default' | 'danger' | 'success' | 'primary' | 'warning' | 'purple';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
disabled?: boolean;
|
||||||
|
type?: 'button' | 'submit';
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants: Record<string, string> = {
|
||||||
|
default: 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300',
|
||||||
|
danger: 'bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-800/40 text-red-600 dark:text-red-400',
|
||||||
|
success: 'bg-green-100 dark:bg-green-900/30 hover:bg-green-200 dark:hover:bg-green-800/40 text-green-600 dark:text-green-400',
|
||||||
|
primary: 'bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-800/40 text-blue-600 dark:text-blue-400',
|
||||||
|
warning: 'bg-yellow-100 dark:bg-yellow-900/30 hover:bg-yellow-200 dark:hover:bg-yellow-800/40 text-yellow-600 dark:text-yellow-400',
|
||||||
|
purple: 'bg-purple-100 dark:bg-purple-900/30 hover:bg-purple-200 dark:hover:bg-purple-800/40 text-purple-600 dark:text-purple-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes: Record<string, string> = {
|
||||||
|
sm: 'w-8 h-8 text-sm',
|
||||||
|
md: 'w-10 h-10 text-base',
|
||||||
|
lg: 'w-12 h-12 text-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function IconButton({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'md',
|
||||||
|
disabled,
|
||||||
|
type = 'button',
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
className={`inline-flex items-center justify-center rounded-lg transition-colors cursor-pointer ${variants[variant] || variants.default} ${sizes[size] || sizes.md} ${className || ''} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
apps/tasks/components/features/InlineEditField.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onSave: (newValue: string) => void;
|
||||||
|
className?: string;
|
||||||
|
as?: 'input' | 'textarea';
|
||||||
|
placeholder?: string;
|
||||||
|
multiline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InlineEditField({
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
className = '',
|
||||||
|
as = 'input',
|
||||||
|
placeholder = '',
|
||||||
|
multiline = false,
|
||||||
|
}: Props) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editValue, setEditValue] = useState(value);
|
||||||
|
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditValue(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
// Select all text
|
||||||
|
if ('setSelectionRange' in inputRef.current) {
|
||||||
|
inputRef.current.setSelectionRange(0, inputRef.current.value.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [editing]);
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
setEditing(false);
|
||||||
|
const trimmed = editValue.trim();
|
||||||
|
if (trimmed !== value && trimmed !== '') {
|
||||||
|
onSave(trimmed);
|
||||||
|
} else {
|
||||||
|
setEditValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !multiline) {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.target as HTMLElement).blur();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditValue(value);
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
if (as === 'textarea' || multiline) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={3}
|
||||||
|
className={`w-full px-2 py-1 border border-blue-400 dark:border-blue-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none resize-none text-sm ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||||
|
type="text"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={`w-full px-2 py-1 border border-blue-400 dark:border-blue-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none text-sm ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className={`cursor-pointer rounded-lg px-2 py-1 -mx-2 -my-1 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${className}`}
|
||||||
|
title="Click to edit"
|
||||||
|
>
|
||||||
|
{value || <span className="text-muted italic">{placeholder || 'Click to edit'}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
apps/tasks/components/features/PageActionBar.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import IconButton from './IconButton';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
showAdd?: boolean;
|
||||||
|
onToggleAdd?: () => void;
|
||||||
|
addOpen?: boolean;
|
||||||
|
t: (key: string) => string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageActionBar({ title, showAdd, onToggleAdd, addOpen, t, children }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h1 className="text-xl font-bold dark:text-white truncate">{title}</h1>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{children}
|
||||||
|
{showAdd && onToggleAdd && (
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
addOpen ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
label={addOpen ? t("tasks.form.cancel") : t("tasks.add")}
|
||||||
|
onClick={onToggleAdd}
|
||||||
|
variant={addOpen ? "danger" : "primary"}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
apps/tasks/components/features/TaskDetailActions.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
'use client';
|
||||||
|
import IconButton from './IconButton';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
taskDone: boolean;
|
||||||
|
deleting: boolean;
|
||||||
|
onBack: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onToggleStatus: () => void;
|
||||||
|
onInvite: () => void;
|
||||||
|
onCollaborate: () => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskDetailActions({
|
||||||
|
taskDone,
|
||||||
|
deleting,
|
||||||
|
onBack,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggleStatus,
|
||||||
|
onInvite,
|
||||||
|
onCollaborate,
|
||||||
|
t,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Back */}
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t("common.back")}
|
||||||
|
onClick={onBack}
|
||||||
|
variant="default"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Toggle done/reopen */}
|
||||||
|
{!taskDone ? (
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t("tasks.markDone")}
|
||||||
|
onClick={onToggleStatus}
|
||||||
|
variant="success"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t("tasks.reopen")}
|
||||||
|
onClick={onToggleStatus}
|
||||||
|
variant="warning"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit */}
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t("tasks.edit")}
|
||||||
|
onClick={onEdit}
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={deleting ? t("tasks.deleting") : t("tasks.delete")}
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
variant="danger"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Collaborate */}
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t("collab.collaboration")}
|
||||||
|
onClick={onCollaborate}
|
||||||
|
variant="purple"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Invite */}
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label="Pozvat"
|
||||||
|
onClick={onInvite}
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -135,12 +135,28 @@ export interface Task {
|
|||||||
group_icon: string | null;
|
group_icon: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupTimeZone {
|
||||||
|
days: number[];
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupLocation {
|
||||||
|
name: string;
|
||||||
|
lat: number | null;
|
||||||
|
lng: number | null;
|
||||||
|
radius_m: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
|
display_name?: string;
|
||||||
|
time_zones: GroupTimeZone[];
|
||||||
|
locations: GroupLocation[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Connector {
|
export interface Connector {
|
||||||
@@ -303,3 +319,44 @@ export function sendCollabRequest(token: string, taskId: string, data: { to_user
|
|||||||
export function searchUsers(token: string, query: string) {
|
export function searchUsers(token: string, query: string) {
|
||||||
return apiFetch<{ data: { id: string; name: string; email: string; avatar_url: string | null }[] }>(`/api/v1/auth/users/search?q=${encodeURIComponent(query)}`, { token });
|
return apiFetch<{ data: { id: string; name: string; email: string; avatar_url: string | null }[] }>(`/api/v1/auth/users/search?q=${encodeURIComponent(query)}`, { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invitations
|
||||||
|
export interface Invitation {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
task_id: string;
|
||||||
|
inviter_id: string | null;
|
||||||
|
invitee_email: string | null;
|
||||||
|
invitee_name: string | null;
|
||||||
|
message: string;
|
||||||
|
status: string;
|
||||||
|
expires_at: string;
|
||||||
|
accepted_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvitationResponse {
|
||||||
|
invitation: Invitation;
|
||||||
|
link: string;
|
||||||
|
share: {
|
||||||
|
whatsapp: string;
|
||||||
|
telegram: string;
|
||||||
|
sms: string;
|
||||||
|
copy: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInvitation(
|
||||||
|
token: string,
|
||||||
|
data: { task_id: string; invitee_email?: string; invitee_name?: string; message?: string; inviter_id?: string }
|
||||||
|
) {
|
||||||
|
return apiFetch<{ data: InvitationResponse }>("/api/v1/invitations", { method: "POST", body: data, token });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskInvitations(token: string, taskId: string) {
|
||||||
|
return apiFetch<{ data: Invitation[] }>(`/api/v1/tasks/${taskId}/invitations`, { token });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeInvitation(token: string, invitationId: string) {
|
||||||
|
return apiFetch<{ data: Invitation }>(`/api/v1/invitations/${invitationId}`, { method: "DELETE", token });
|
||||||
|
}
|
||||||
|
|||||||
17
apps/tasks/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@fullcalendar/interaction": "^6.1.20",
|
"@fullcalendar/interaction": "^6.1.20",
|
||||||
"@fullcalendar/react": "^6.1.20",
|
"@fullcalendar/react": "^6.1.20",
|
||||||
"@fullcalendar/timegrid": "^6.1.20",
|
"@fullcalendar/timegrid": "^6.1.20",
|
||||||
|
"@simplewebauthn/browser": "^12.0.0",
|
||||||
"next": "14.2.35",
|
"next": "14.2.35",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
@@ -559,6 +560,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@simplewebauthn/browser": {
|
||||||
|
"version": "12.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-12.0.0.tgz",
|
||||||
|
"integrity": "sha512-0w6W8qkACycyaRMb2XnHfpA9kkgs5e2Aw2Ul9ObBYmvFBbtzipyWu9u2+WP1wy98chM+GIlQFnPheUbiMBQr8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@simplewebauthn/types": "^12.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/@swc/counter": {
|
"node_modules/@swc/counter": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@fullcalendar/interaction": "^6.1.20",
|
"@fullcalendar/interaction": "^6.1.20",
|
||||||
"@fullcalendar/react": "^6.1.20",
|
"@fullcalendar/react": "^6.1.20",
|
||||||
"@fullcalendar/timegrid": "^6.1.20",
|
"@fullcalendar/timegrid": "^6.1.20",
|
||||||
|
"@simplewebauthn/browser": "^12.0.0",
|
||||||
"next": "14.2.35",
|
"next": "14.2.35",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
|||||||
@@ -1 +1,46 @@
|
|||||||
{"name":"Task Team","short_name":"Tasks","start_url":"/","display":"standalone","background_color":"#0A0A0F","theme_color":"#1D4ED8","icons":[{"src":"/icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}
|
{
|
||||||
|
"name": "Task Team",
|
||||||
|
"short_name": "Tasks",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0A0A0F",
|
||||||
|
"theme_color": "#1D4ED8",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Novy ukol",
|
||||||
|
"short_name": "Pridat",
|
||||||
|
"url": "/tasks?action=new",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Widget",
|
||||||
|
"short_name": "Widget",
|
||||||
|
"url": "/widget",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
backup.sh
@@ -1,4 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
cd /opt/task-team || exit 1
|
||||||
BACKUP_DIR="/opt/task-team/backups"
|
BACKUP_DIR="/opt/task-team/backups"
|
||||||
DATE=$(date +%Y%m%d_%H%M)
|
DATE=$(date +%Y%m%d_%H%M)
|
||||||
PGDUMP="/usr/lib/postgresql/18/bin/pg_dump"
|
PGDUMP="/usr/lib/postgresql/18/bin/pg_dump"
|
||||||
@@ -10,13 +11,13 @@ PGPASSWORD="TaskTeam2026!" $PGDUMP -h 10.10.10.10 -U taskteam -d taskteam -F c -
|
|||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
# Compress
|
# Compress
|
||||||
gzip -f "$BACKUP_DIR/taskteam_$DATE.dump" 2>/dev/null
|
gzip -f "$BACKUP_DIR/taskteam_$DATE.dump" 2>/dev/null
|
||||||
echo "$(date '+%Y-%m-%d %H:%M:%S') OK: taskteam_$DATE.dump.gz"
|
echo "$(date "+%Y-%m-%d %H:%M:%S") OK: taskteam_$DATE.dump.gz"
|
||||||
else
|
else
|
||||||
echo "$(date '+%Y-%m-%d %H:%M:%S') FAIL: pg_dump exited with error"
|
echo "$(date "+%Y-%m-%d %H:%M:%S") FAIL: pg_dump exited with error"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Keep last 7 daily backups
|
# Keep last 14 daily backups
|
||||||
find $BACKUP_DIR -name "*.dump.gz" -mtime +7 -delete
|
find $BACKUP_DIR -name "*.dump.gz" -mtime +14 -delete
|
||||||
find $BACKUP_DIR -name "*.dump" -mtime +7 -delete
|
find $BACKUP_DIR -name "*.dump" -mtime +14 -delete
|
||||||
|
|
||||||
ls -lh $BACKUP_DIR/ | tail -5
|
ls -lh $BACKUP_DIR/ | tail -5
|
||||||
|
|||||||
BIN
backups/releases/android/task-team-v1.0.0.apk
Normal file
BIN
backups/taskteam_20260329_2147.dump.gz
Normal file
@@ -13,7 +13,10 @@
|
|||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "info.hasdo.taskteam"
|
"bundleIdentifier": "info.hasdo.taskteam",
|
||||||
|
"infoPlist": {
|
||||||
|
"ITSAppUsesNonExemptEncryption": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
|
|||||||
39
mobile/fdroid/metadata/info.hasdo.taskteam.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
Categories:
|
||||||
|
- Office
|
||||||
|
- Time
|
||||||
|
License: AGPL-3.0-only
|
||||||
|
AuthorName: IT Enterprise Solution s.r.o.
|
||||||
|
AuthorEmail: apps@it-enterprise.cz
|
||||||
|
AuthorWebSite: https://it-enterprise.cz
|
||||||
|
SourceCode: https://git.hasdo.info/admin/task-team
|
||||||
|
IssueTracker: https://git.hasdo.info/admin/task-team/issues
|
||||||
|
|
||||||
|
AutoName: Task Team
|
||||||
|
Description: |
|
||||||
|
Personal life management system with AI assistant.
|
||||||
|
Manage tasks, goals, calendar, and team collaboration.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
* Task management with groups and priorities
|
||||||
|
* Calendar with color-coded time zones
|
||||||
|
* AI chat assistant (Claude)
|
||||||
|
* Goal planning with AI study planner
|
||||||
|
* Team collaboration (assign, transfer, subtasks)
|
||||||
|
* Multi-language (CZ, HE, RU, UA)
|
||||||
|
* Odoo, Moodle, Pohoda connectors
|
||||||
|
|
||||||
|
RepoType: git
|
||||||
|
Repo: https://git.hasdo.info/admin/task-team.git
|
||||||
|
|
||||||
|
Builds:
|
||||||
|
- versionName: 1.0.0
|
||||||
|
versionCode: 1
|
||||||
|
commit: v1.0.0
|
||||||
|
subdir: mobile
|
||||||
|
gradle:
|
||||||
|
- yes
|
||||||
|
|
||||||
|
AutoUpdateMode: Version
|
||||||
|
UpdateCheckMode: Tags
|
||||||
|
CurrentVersion: 1.0.0
|
||||||
|
CurrentVersionCode: 1
|
||||||
36
mobile/store-listing/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Task Team — Store Listing
|
||||||
|
|
||||||
|
## Short Description (80 chars)
|
||||||
|
Osobní systém řízení života s AI asistentem. Úkoly, cíle, kalendář, spolupráce.
|
||||||
|
|
||||||
|
## Full Description
|
||||||
|
Task Team je komplexní systém řízení osobního života propojený s AI agentem.
|
||||||
|
|
||||||
|
**Hlavní funkce:**
|
||||||
|
- Správa úkolů s barevnými skupinami a prioritami
|
||||||
|
- Kalendář s časovými zónami pro každou skupinu
|
||||||
|
- AI chat asistent pro plánování a doporučení
|
||||||
|
- Cílový planner s AI generovanými studijními plány
|
||||||
|
- Týmová spolupráce: přiřazení, předání, podúkoly
|
||||||
|
- 4 jazyky: čeština, hebrejština, ruština, ukrajinština
|
||||||
|
- Connectors: Odoo, Moodle, Pohoda
|
||||||
|
- PWA + Android + iOS
|
||||||
|
|
||||||
|
**Technologie:** React Native, Expo, Fastify, PostgreSQL, Redis, Claude AI
|
||||||
|
|
||||||
|
## Links
|
||||||
|
- Web: https://tasks.hasdo.info
|
||||||
|
- API Docs: https://api.hasdo.info/docs
|
||||||
|
- Source: https://git.hasdo.info/admin/task-team
|
||||||
|
- Android: https://expo.dev/accounts/it-enterprise/projects/task-team
|
||||||
|
- Company: IT Enterprise Solution s.r.o.
|
||||||
|
- Contact: apps@it-enterprise.cz
|
||||||
|
|
||||||
|
## APKPure Submission
|
||||||
|
1. Go to https://apkpure.com/submit-apk
|
||||||
|
2. Upload APK from Expo build
|
||||||
|
3. Fill in listing details from above
|
||||||
|
|
||||||
|
## Aurora Store
|
||||||
|
Aurora Store indexes F-Droid and other repos automatically.
|
||||||
|
Once on F-Droid, it appears in Aurora.
|
||||||
@@ -5,7 +5,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
VERSION=${1:-$(date +%Y%m%d_%H%M)}
|
VERSION=${1:-$(date +%Y%m%d_%H%M)}
|
||||||
PLATFORM=${2:-android}
|
PLATFORM=${2:-android}
|
||||||
FILE=$3
|
FILE=${3:-}
|
||||||
|
|
||||||
if [ -z "${FILE:-}" ]; then
|
if [ -z "${FILE:-}" ]; then
|
||||||
echo "Usage: upload-release.sh <version> <platform> <file>"
|
echo "Usage: upload-release.sh <version> <platform> <file>"
|
||||||
|
|||||||
15
scripts/weekly-report.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# AI Weekly Report — runs every Monday 8:00 AM
|
||||||
|
export ANTHROPIC_API_KEY="$(grep ANTHROPIC_API_KEY /opt/task-team/api/.env | cut -d= -f2)"
|
||||||
|
|
||||||
|
# Get task stats
|
||||||
|
STATS=$(curl -s http://localhost:3000/api/v1/system/health | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(d.get(tasks,{})))" 2>/dev/null || echo "{}")
|
||||||
|
|
||||||
|
# Generate AI report
|
||||||
|
REPORT=$(curl -s -X POST http://localhost:3000/api/v1/chat \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"message\":\"Vytvor tydeni report. Stats: $STATS. Shrn co se podarilo, co je treba zlepsit, doporuceni na pristi tyden. Odpovez v cestine, strucne.\"}" | python3 -c "import sys,json; print(json.load(sys.stdin).get(data,{}).get(reply,No report))" 2>/dev/null || echo "Report generation failed")
|
||||||
|
|
||||||
|
# Log
|
||||||
|
echo "$(date): $REPORT" >> /var/log/taskteam-weekly.log
|
||||||
|
echo "$REPORT"
|
||||||