Compare commits
7 Commits
87d5bfe23a
...
feature/da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aec1445e9 | ||
|
|
0fa2302b26 | ||
|
|
22f048989a | ||
|
|
c604df281d | ||
|
|
2db45de4c4 | ||
|
|
892a2ceba1 | ||
|
|
5d8af1f173 |
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
# Build Stage
|
||||
FROM node:22-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production Stage
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
52
docker-compose.yml
Normal file
52
docker-compose.yml
Normal file
@@ -0,0 +1,52 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: scrum-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: scrumpass
|
||||
MYSQL_DATABASE: scrum_manager
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pscrumpass" ]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile
|
||||
container_name: scrum-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
DB_HOST: mysql
|
||||
DB_PORT: 3306
|
||||
DB_USER: root
|
||||
DB_PASSWORD: scrumpass
|
||||
DB_NAME: scrum_manager
|
||||
PORT: 3001
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: scrum-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
84
k8s/base/backend/deployment.yaml
Normal file
84
k8s/base/backend/deployment.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: backend
|
||||
labels:
|
||||
app.kubernetes.io/name: backend
|
||||
app.kubernetes.io/component: api
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: backend
|
||||
app.kubernetes.io/component: api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: backend
|
||||
app.kubernetes.io/component: api
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-mysql
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for MySQL to be ready..."
|
||||
until nc -z mysql 3306; do
|
||||
echo "MySQL is not ready yet, retrying in 3s..."
|
||||
sleep 3
|
||||
done
|
||||
echo "MySQL is ready!"
|
||||
containers:
|
||||
- name: backend
|
||||
image: scrum-backend:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 3001
|
||||
name: http
|
||||
env:
|
||||
- name: DB_HOST
|
||||
value: mysql
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mysql-secret
|
||||
key: DB_USER
|
||||
- name: DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mysql-secret
|
||||
key: DB_PASSWORD
|
||||
- name: DB_NAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mysql-secret
|
||||
key: DB_NAME
|
||||
- name: PORT
|
||||
value: "3001"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 5
|
||||
17
k8s/base/backend/service.yaml
Normal file
17
k8s/base/backend/service.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: backend
|
||||
labels:
|
||||
app.kubernetes.io/name: backend
|
||||
app.kubernetes.io/component: api
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 3001
|
||||
targetPort: 3001
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app.kubernetes.io/name: backend
|
||||
app.kubernetes.io/component: api
|
||||
31
k8s/base/frontend/configmap.yaml
Normal file
31
k8s/base/frontend/configmap.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: frontend-nginx-config
|
||||
labels:
|
||||
app.kubernetes.io/name: frontend
|
||||
app.kubernetes.io/component: web
|
||||
data:
|
||||
default.conf: |
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to backend service
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
58
k8s/base/frontend/deployment.yaml
Normal file
58
k8s/base/frontend/deployment.yaml
Normal file
@@ -0,0 +1,58 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: frontend
|
||||
labels:
|
||||
app.kubernetes.io/name: frontend
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: frontend
|
||||
app.kubernetes.io/component: web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: frontend
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: scrum-frontend:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 80
|
||||
name: http
|
||||
volumeMounts:
|
||||
- name: nginx-config
|
||||
mountPath: /etc/nginx/conf.d/default.conf
|
||||
subPath: default.conf
|
||||
readOnly: true
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 128Mi
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
volumes:
|
||||
- name: nginx-config
|
||||
configMap:
|
||||
name: frontend-nginx-config
|
||||
17
k8s/base/frontend/service.yaml
Normal file
17
k8s/base/frontend/service.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: frontend
|
||||
labels:
|
||||
app.kubernetes.io/name: frontend
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
type: NodePort
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app.kubernetes.io/name: frontend
|
||||
app.kubernetes.io/component: web
|
||||
25
k8s/base/kustomization.yaml
Normal file
25
k8s/base/kustomization.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: scrum-manager
|
||||
|
||||
labels:
|
||||
- pairs:
|
||||
app.kubernetes.io/part-of: scrum-manager
|
||||
app.kubernetes.io/managed-by: kustomize
|
||||
includeSelectors: true
|
||||
|
||||
resources:
|
||||
- namespace.yaml
|
||||
# MySQL
|
||||
- mysql/secret.yaml
|
||||
- mysql/pvc.yaml
|
||||
- mysql/deployment.yaml
|
||||
- mysql/service.yaml
|
||||
# Backend
|
||||
- backend/deployment.yaml
|
||||
- backend/service.yaml
|
||||
# Frontend
|
||||
- frontend/configmap.yaml
|
||||
- frontend/deployment.yaml
|
||||
- frontend/service.yaml
|
||||
74
k8s/base/mysql/deployment.yaml
Normal file
74
k8s/base/mysql/deployment.yaml
Normal file
@@ -0,0 +1,74 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mysql
|
||||
labels:
|
||||
app.kubernetes.io/name: mysql
|
||||
app.kubernetes.io/component: database
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate # MySQL requires Recreate since PVC is ReadWriteOnce
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: mysql
|
||||
app.kubernetes.io/component: database
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: mysql
|
||||
app.kubernetes.io/component: database
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql
|
||||
image: mysql:8.0
|
||||
ports:
|
||||
- containerPort: 3306
|
||||
name: mysql
|
||||
env:
|
||||
- name: MYSQL_ROOT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mysql-secret
|
||||
key: MYSQL_ROOT_PASSWORD
|
||||
- name: MYSQL_DATABASE
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mysql-secret
|
||||
key: DB_NAME
|
||||
volumeMounts:
|
||||
- name: mysql-data
|
||||
mountPath: /var/lib/mysql
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 1Gi
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- mysqladmin
|
||||
- ping
|
||||
- -h
|
||||
- localhost
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- mysqladmin
|
||||
- ping
|
||||
- -h
|
||||
- localhost
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 5
|
||||
volumes:
|
||||
- name: mysql-data
|
||||
persistentVolumeClaim:
|
||||
claimName: mysql-data-pvc
|
||||
13
k8s/base/mysql/pvc.yaml
Normal file
13
k8s/base/mysql/pvc.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: mysql-data-pvc
|
||||
labels:
|
||||
app.kubernetes.io/name: mysql
|
||||
app.kubernetes.io/component: database
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
17
k8s/base/mysql/secret.yaml
Normal file
17
k8s/base/mysql/secret.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: mysql-secret
|
||||
labels:
|
||||
app.kubernetes.io/name: mysql
|
||||
app.kubernetes.io/component: database
|
||||
type: Opaque
|
||||
data:
|
||||
# Base64 encoded values — change these for production!
|
||||
# echo -n 'scrumpass' | base64 => c2NydW1wYXNz
|
||||
# echo -n 'root' | base64 => cm9vdA==
|
||||
# echo -n 'scrum_manager' | base64 => c2NydW1fbWFuYWdlcg==
|
||||
MYSQL_ROOT_PASSWORD: c2NydW1wYXNz
|
||||
DB_USER: cm9vdA==
|
||||
DB_PASSWORD: c2NydW1wYXNz
|
||||
DB_NAME: c2NydW1fbWFuYWdlcg==
|
||||
17
k8s/base/mysql/service.yaml
Normal file
17
k8s/base/mysql/service.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: mysql
|
||||
labels:
|
||||
app.kubernetes.io/name: mysql
|
||||
app.kubernetes.io/component: database
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 3306
|
||||
targetPort: 3306
|
||||
protocol: TCP
|
||||
name: mysql
|
||||
selector:
|
||||
app.kubernetes.io/name: mysql
|
||||
app.kubernetes.io/component: database
|
||||
6
k8s/base/namespace.yaml
Normal file
6
k8s/base/namespace.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: scrum-manager
|
||||
labels:
|
||||
app.kubernetes.io/part-of: scrum-manager
|
||||
23
nginx.conf
Normal file
23
nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
1193
package-lock.json
generated
1193
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:integration": "vitest -c vitest.integration.config.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
@@ -16,6 +18,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -24,8 +29,11 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
}
|
||||
12
server/Dockerfile
Normal file
12
server/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
107
server/db.js
Normal file
107
server/db.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || 'scrumpass',
|
||||
database: process.env.DB_NAME || 'scrum_manager',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
});
|
||||
|
||||
export async function initDB() {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'employee',
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
color VARCHAR(20) DEFAULT '#818cf8',
|
||||
avatar VARCHAR(10) DEFAULT '',
|
||||
dept VARCHAR(100) DEFAULT ''
|
||||
)
|
||||
`);
|
||||
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
status ENUM('todo','inprogress','review','done') NOT NULL DEFAULT 'todo',
|
||||
priority ENUM('critical','high','medium','low') NOT NULL DEFAULT 'medium',
|
||||
assignee_id VARCHAR(36),
|
||||
reporter_id VARCHAR(36),
|
||||
due_date DATE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (reporter_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS subtasks (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
done BOOLEAN DEFAULT FALSE,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
user_id VARCHAR(36),
|
||||
text TEXT NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS task_tags (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
tag VARCHAR(100) NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY unique_task_tag (task_id, tag)
|
||||
)
|
||||
`);
|
||||
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS dependencies (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
depends_on_user_id VARCHAR(36),
|
||||
description TEXT NOT NULL,
|
||||
resolved BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (depends_on_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('✅ Database tables initialized');
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
export default pool;
|
||||
44
server/index.js
Normal file
44
server/index.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { initDB } from './db.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import taskRoutes from './routes/tasks.js';
|
||||
import exportRoutes from './routes/export.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/tasks', taskRoutes);
|
||||
app.use('/api/export', exportRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Initialize DB and start server
|
||||
// Initialize DB and start server
|
||||
async function start() {
|
||||
try {
|
||||
await initDB();
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Backend server running on port ${PORT}`);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to start server:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
start();
|
||||
}
|
||||
|
||||
export { app, start };
|
||||
2481
server/package-lock.json
generated
Normal file
2481
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
server/package.json
Normal file
20
server/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "scrum-manager-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"mysql2": "^3.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"supertest": "^7.2.2",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
134
server/routes/auth.js
Normal file
134
server/routes/auth.js
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import pool from '../db.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Email and password required' });
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT * FROM users WHERE email = ?', [email]);
|
||||
if (rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
const user = rows[0];
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: user.id, name: user.name, role: user.role, email: user.email,
|
||||
color: user.color, avatar: user.avatar, dept: user.dept,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/register
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { name, email, password, role, dept } = req.body;
|
||||
if (!name || !email || !password) {
|
||||
return res.status(400).json({ error: 'Name, email and password required' });
|
||||
}
|
||||
|
||||
const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
|
||||
if (existing.length > 0) {
|
||||
return res.status(409).json({ error: 'Email already registered' });
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
const avatar = name.split(' ').map(w => w[0]).join('').substring(0, 2).toUpperCase();
|
||||
const colors = ['#818cf8', '#f59e0b', '#34d399', '#f472b6', '#fb923c', '#60a5fa', '#a78bfa'];
|
||||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||
|
||||
await pool.query(
|
||||
'INSERT INTO users (id, name, role, email, password_hash, color, avatar, dept) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, name, role || 'employee', email, password_hash, color, avatar, dept || '']
|
||||
);
|
||||
|
||||
res.status(201).json({ id, name, role: role || 'employee', email, color, avatar, dept: dept || '' });
|
||||
} catch (err) {
|
||||
console.error('Register error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/users
|
||||
router.get('/users', async (_req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT id, name, role, email, color, avatar, dept FROM users');
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('Get users error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/users — Admin-create a new user (manager/cto/ceo)
|
||||
router.post('/users', async (req, res) => {
|
||||
try {
|
||||
const { name, email, password, role, dept } = req.body;
|
||||
if (!name || !email || !password) {
|
||||
return res.status(400).json({ error: 'Name, email and password required' });
|
||||
}
|
||||
|
||||
const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
|
||||
if (existing.length > 0) {
|
||||
return res.status(409).json({ error: 'Email already registered' });
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
const avatar = name.split(' ').map(w => w[0]).join('').substring(0, 2).toUpperCase();
|
||||
const colors = ['#818cf8', '#f59e0b', '#34d399', '#f472b6', '#fb923c', '#60a5fa', '#a78bfa'];
|
||||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||
|
||||
await pool.query(
|
||||
'INSERT INTO users (id, name, role, email, password_hash, color, avatar, dept) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, name, role || 'employee', email, password_hash, color, avatar, dept || '']
|
||||
);
|
||||
|
||||
res.status(201).json({ id, name, role: role || 'employee', email, color, avatar, dept: dept || '' });
|
||||
} catch (err) {
|
||||
console.error('Create user error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/auth/users/:id — Delete a user (unassign their tasks first)
|
||||
router.delete('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const [user] = await pool.query('SELECT id FROM users WHERE id = ?', [id]);
|
||||
if (user.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Unassign tasks assigned to or reported by this user
|
||||
await pool.query('UPDATE tasks SET assignee_id = NULL WHERE assignee_id = ?', [id]);
|
||||
await pool.query('UPDATE tasks SET reporter_id = NULL WHERE reporter_id = ?', [id]);
|
||||
|
||||
// Delete the user (cascading will handle comments, etc. via ON DELETE SET NULL)
|
||||
await pool.query('DELETE FROM users WHERE id = ?', [id]);
|
||||
|
||||
res.json({ success: true, id });
|
||||
} catch (err) {
|
||||
console.error('Delete user error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
131
server/routes/export.js
Normal file
131
server/routes/export.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Router } from 'express';
|
||||
import pool from '../db.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Helper: escape CSV field
|
||||
function csvEscape(val) {
|
||||
if (val == null) return '';
|
||||
const str = String(val);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
// Helper: convert rows to CSV string
|
||||
function toCsv(headers, rows) {
|
||||
const headerLine = headers.map(csvEscape).join(',');
|
||||
const dataLines = rows.map(row => headers.map(h => csvEscape(row[h])).join(','));
|
||||
return [headerLine, ...dataLines].join('\n');
|
||||
}
|
||||
|
||||
// Helper: build month filter clause for a date column
|
||||
function monthFilter(column, month) {
|
||||
if (!month || !/^\d{4}-\d{2}$/.test(month)) return { clause: '', params: [] };
|
||||
const [year, mon] = month.split('-');
|
||||
const start = `${year}-${mon}-01`;
|
||||
// Last day of month
|
||||
const nextMonth = parseInt(mon) === 12 ? `${parseInt(year) + 1}-01-01` : `${year}-${String(parseInt(mon) + 1).padStart(2, '0')}-01`;
|
||||
return { clause: ` AND ${column} >= ? AND ${column} < ?`, params: [start, nextMonth] };
|
||||
}
|
||||
|
||||
// GET /api/export/tasks?month=YYYY-MM
|
||||
router.get('/tasks', async (req, res) => {
|
||||
try {
|
||||
const { month } = req.query;
|
||||
const mf = monthFilter('t.due_date', month);
|
||||
|
||||
const [rows] = await pool.query(`
|
||||
SELECT t.id, t.title, t.description, t.status, t.priority,
|
||||
t.due_date, t.created_at,
|
||||
a.name AS assignee_name, a.email AS assignee_email,
|
||||
r.name AS reporter_name,
|
||||
GROUP_CONCAT(tt.tag SEPARATOR '; ') AS tags
|
||||
FROM tasks t
|
||||
LEFT JOIN users a ON t.assignee_id = a.id
|
||||
LEFT JOIN users r ON t.reporter_id = r.id
|
||||
LEFT JOIN task_tags tt ON tt.task_id = t.id
|
||||
WHERE 1=1 ${mf.clause}
|
||||
GROUP BY t.id
|
||||
ORDER BY t.created_at DESC
|
||||
`, mf.params);
|
||||
|
||||
const csv = toCsv(
|
||||
['id', 'title', 'description', 'status', 'priority', 'due_date', 'created_at', 'assignee_name', 'assignee_email', 'reporter_name', 'tags'],
|
||||
rows
|
||||
);
|
||||
|
||||
const filename = month ? `tasks_${month}.csv` : 'tasks_all.csv';
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(csv);
|
||||
} catch (err) {
|
||||
console.error('Export tasks error:', err);
|
||||
res.status(500).json({ error: 'Export failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/export/users?month=YYYY-MM
|
||||
router.get('/users', async (req, res) => {
|
||||
try {
|
||||
const { month } = req.query;
|
||||
const mf = monthFilter('t.due_date', month);
|
||||
|
||||
const [rows] = await pool.query(`
|
||||
SELECT u.id, u.name, u.email, u.role, u.dept,
|
||||
COUNT(t.id) AS total_tasks,
|
||||
SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) AS completed_tasks,
|
||||
SUM(CASE WHEN t.status != 'done' AND t.due_date < CURDATE() THEN 1 ELSE 0 END) AS overdue_tasks
|
||||
FROM users u
|
||||
LEFT JOIN tasks t ON t.assignee_id = u.id ${mf.clause ? 'AND' + mf.clause.replace(' AND', '') : ''}
|
||||
GROUP BY u.id
|
||||
ORDER BY u.name
|
||||
`, mf.params);
|
||||
|
||||
const csv = toCsv(
|
||||
['id', 'name', 'email', 'role', 'dept', 'total_tasks', 'completed_tasks', 'overdue_tasks'],
|
||||
rows
|
||||
);
|
||||
|
||||
const filename = month ? `users_${month}.csv` : 'users_all.csv';
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(csv);
|
||||
} catch (err) {
|
||||
console.error('Export users error:', err);
|
||||
res.status(500).json({ error: 'Export failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/export/activities?month=YYYY-MM
|
||||
router.get('/activities', async (req, res) => {
|
||||
try {
|
||||
const { month } = req.query;
|
||||
const mf = monthFilter('a.timestamp', month);
|
||||
|
||||
const [rows] = await pool.query(`
|
||||
SELECT a.id, a.text AS activity, a.timestamp,
|
||||
t.title AS task_title, t.status AS task_status
|
||||
FROM activities a
|
||||
LEFT JOIN tasks t ON a.task_id = t.id
|
||||
WHERE 1=1 ${mf.clause}
|
||||
ORDER BY a.timestamp DESC
|
||||
`, mf.params);
|
||||
|
||||
const csv = toCsv(
|
||||
['id', 'activity', 'timestamp', 'task_title', 'task_status'],
|
||||
rows
|
||||
);
|
||||
|
||||
const filename = month ? `activities_${month}.csv` : 'activities_all.csv';
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(csv);
|
||||
} catch (err) {
|
||||
console.error('Export activities error:', err);
|
||||
res.status(500).json({ error: 'Export failed' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
257
server/routes/tasks.js
Normal file
257
server/routes/tasks.js
Normal file
@@ -0,0 +1,257 @@
|
||||
import { Router } from 'express';
|
||||
import pool from '../db.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Helper: fetch full task with subtasks, comments, activities, tags, dependencies
|
||||
async function getFullTask(taskId) {
|
||||
const [taskRows] = await pool.query('SELECT * FROM tasks WHERE id = ?', [taskId]);
|
||||
if (taskRows.length === 0) return null;
|
||||
|
||||
const task = taskRows[0];
|
||||
const [subtasks] = await pool.query('SELECT id, title, done FROM subtasks WHERE task_id = ? ORDER BY id', [taskId]);
|
||||
const [comments] = await pool.query('SELECT id, user_id AS userId, text, timestamp FROM comments WHERE task_id = ? ORDER BY timestamp', [taskId]);
|
||||
const [activities] = await pool.query('SELECT id, text, timestamp FROM activities WHERE task_id = ? ORDER BY timestamp', [taskId]);
|
||||
const [tagRows] = await pool.query('SELECT tag FROM task_tags WHERE task_id = ?', [taskId]);
|
||||
const [depRows] = await pool.query('SELECT id, depends_on_user_id AS dependsOnUserId, description, resolved FROM dependencies WHERE task_id = ? ORDER BY created_at', [taskId]);
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
assignee: task.assignee_id || '',
|
||||
reporter: task.reporter_id || '',
|
||||
dueDate: task.due_date ? task.due_date.toISOString().split('T')[0] : '',
|
||||
tags: tagRows.map(r => r.tag),
|
||||
subtasks: subtasks.map(s => ({ id: s.id, title: s.title, done: !!s.done })),
|
||||
comments: comments.map(c => ({ id: c.id, userId: c.userId, text: c.text, timestamp: c.timestamp?.toISOString() || '' })),
|
||||
activity: activities.map(a => ({ id: a.id, text: a.text, timestamp: a.timestamp?.toISOString() || '' })),
|
||||
dependencies: depRows.map(d => ({ id: d.id, dependsOnUserId: d.dependsOnUserId || '', description: d.description, resolved: !!d.resolved })),
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/tasks
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const [taskRows] = await pool.query('SELECT id FROM tasks ORDER BY created_at DESC');
|
||||
const tasks = await Promise.all(taskRows.map(t => getFullTask(t.id)));
|
||||
res.json(tasks.filter(Boolean));
|
||||
} catch (err) {
|
||||
console.error('Get tasks error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks
|
||||
router.post('/', async (req, res) => {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const { title, description, status, priority, assignee, reporter, dueDate, tags, subtasks, dependencies } = req.body;
|
||||
const id = randomUUID();
|
||||
|
||||
await conn.query(
|
||||
'INSERT INTO tasks (id, title, description, status, priority, assignee_id, reporter_id, due_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, title, description || '', status || 'todo', priority || 'medium', assignee || null, reporter || null, dueDate || null]
|
||||
);
|
||||
|
||||
// Insert tags
|
||||
if (tags && tags.length > 0) {
|
||||
const tagValues = tags.map(tag => [id, tag]);
|
||||
await conn.query('INSERT INTO task_tags (task_id, tag) VALUES ?', [tagValues]);
|
||||
}
|
||||
|
||||
// Insert subtasks
|
||||
if (subtasks && subtasks.length > 0) {
|
||||
for (const st of subtasks) {
|
||||
await conn.query('INSERT INTO subtasks (id, task_id, title, done) VALUES (?, ?, ?, ?)',
|
||||
[st.id || randomUUID(), id, st.title, st.done || false]);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert dependencies
|
||||
if (dependencies && dependencies.length > 0) {
|
||||
for (const dep of dependencies) {
|
||||
await conn.query(
|
||||
'INSERT INTO dependencies (id, task_id, depends_on_user_id, description, resolved) VALUES (?, ?, ?, ?, ?)',
|
||||
[randomUUID(), id, dep.dependsOnUserId || null, dep.description, false]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add creation activity
|
||||
const actId = randomUUID();
|
||||
await conn.query('INSERT INTO activities (id, task_id, text) VALUES (?, ?, ?)',
|
||||
[actId, id, '📝 Task created']);
|
||||
|
||||
await conn.commit();
|
||||
const task = await getFullTask(id);
|
||||
res.status(201).json(task);
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
console.error('Create task error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/tasks/:id
|
||||
router.put('/:id', async (req, res) => {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const { title, description, status, priority, assignee, reporter, dueDate, tags } = req.body;
|
||||
const taskId = req.params.id;
|
||||
|
||||
// Check task exists
|
||||
const [existing] = await conn.query('SELECT id FROM tasks WHERE id = ?', [taskId]);
|
||||
if (existing.length === 0) {
|
||||
await conn.rollback();
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
|
||||
await conn.query(
|
||||
`UPDATE tasks SET title = COALESCE(?, title), description = COALESCE(?, description),
|
||||
status = COALESCE(?, status), priority = COALESCE(?, priority),
|
||||
assignee_id = COALESCE(?, assignee_id), reporter_id = COALESCE(?, reporter_id),
|
||||
due_date = COALESCE(?, due_date) WHERE id = ?`,
|
||||
[title, description, status, priority, assignee, reporter, dueDate, taskId]
|
||||
);
|
||||
|
||||
// Update tags if provided
|
||||
if (tags !== undefined) {
|
||||
await conn.query('DELETE FROM task_tags WHERE task_id = ?', [taskId]);
|
||||
if (tags.length > 0) {
|
||||
const tagValues = tags.map(tag => [taskId, tag]);
|
||||
await conn.query('INSERT INTO task_tags (task_id, tag) VALUES ?', [tagValues]);
|
||||
}
|
||||
}
|
||||
|
||||
await conn.commit();
|
||||
const task = await getFullTask(taskId);
|
||||
res.json(task);
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
console.error('Update task error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/subtasks
|
||||
router.post('/:id/subtasks', async (req, res) => {
|
||||
try {
|
||||
const { title } = req.body;
|
||||
const id = randomUUID();
|
||||
await pool.query('INSERT INTO subtasks (id, task_id, title, done) VALUES (?, ?, ?, ?)',
|
||||
[id, req.params.id, title, false]);
|
||||
res.status(201).json({ id, title, done: false });
|
||||
} catch (err) {
|
||||
console.error('Add subtask error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/tasks/:id/subtasks/:sid
|
||||
router.put('/:id/subtasks/:sid', async (req, res) => {
|
||||
try {
|
||||
const { done } = req.body;
|
||||
await pool.query('UPDATE subtasks SET done = ? WHERE id = ? AND task_id = ?',
|
||||
[done, req.params.sid, req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Toggle subtask error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/comments
|
||||
router.post('/:id/comments', async (req, res) => {
|
||||
try {
|
||||
const { userId, text } = req.body;
|
||||
const id = randomUUID();
|
||||
const timestamp = new Date();
|
||||
await pool.query('INSERT INTO comments (id, task_id, user_id, text, timestamp) VALUES (?, ?, ?, ?, ?)',
|
||||
[id, req.params.id, userId, text, timestamp]);
|
||||
res.status(201).json({ id, userId, text, timestamp: timestamp.toISOString() });
|
||||
} catch (err) {
|
||||
console.error('Add comment error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/activity
|
||||
router.post('/:id/activity', async (req, res) => {
|
||||
try {
|
||||
const { text } = req.body;
|
||||
const id = randomUUID();
|
||||
const timestamp = new Date();
|
||||
await pool.query('INSERT INTO activities (id, task_id, text, timestamp) VALUES (?, ?, ?, ?)',
|
||||
[id, req.params.id, text, timestamp]);
|
||||
res.status(201).json({ id, text, timestamp: timestamp.toISOString() });
|
||||
} catch (err) {
|
||||
console.error('Add activity error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- DEPENDENCY ROUTES ---
|
||||
|
||||
// POST /api/tasks/:id/dependencies
|
||||
router.post('/:id/dependencies', async (req, res) => {
|
||||
try {
|
||||
const { dependsOnUserId, description } = req.body;
|
||||
const id = randomUUID();
|
||||
await pool.query(
|
||||
'INSERT INTO dependencies (id, task_id, depends_on_user_id, description, resolved) VALUES (?, ?, ?, ?, ?)',
|
||||
[id, req.params.id, dependsOnUserId || null, description, false]
|
||||
);
|
||||
res.status(201).json({ id, dependsOnUserId: dependsOnUserId || '', description, resolved: false });
|
||||
} catch (err) {
|
||||
console.error('Add dependency error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/tasks/:id/dependencies/:depId
|
||||
router.put('/:id/dependencies/:depId', async (req, res) => {
|
||||
try {
|
||||
const { resolved } = req.body;
|
||||
await pool.query('UPDATE dependencies SET resolved = ? WHERE id = ? AND task_id = ?',
|
||||
[resolved, req.params.depId, req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Update dependency error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/tasks/:id/dependencies/:depId
|
||||
router.delete('/:id/dependencies/:depId', async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM dependencies WHERE id = ? AND task_id = ?',
|
||||
[req.params.depId, req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Delete dependency error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/tasks/:id
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM tasks WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Delete task error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
127
server/tests/auth.test.js
Normal file
127
server/tests/auth.test.js
Normal file
@@ -0,0 +1,127 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { app } from '../index.js';
|
||||
import pool from '../db.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../db.js', () => ({
|
||||
default: {
|
||||
query: vi.fn(),
|
||||
},
|
||||
initDB: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('bcryptjs', () => ({
|
||||
default: {
|
||||
compare: vi.fn(),
|
||||
hash: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('Auth Routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
it('logs in successfully with correct credentials', async () => {
|
||||
const mockUser = {
|
||||
id: 'u1',
|
||||
name: 'Test User',
|
||||
email: 'test@test.com',
|
||||
password_hash: 'hashed_password',
|
||||
role: 'employee',
|
||||
dept: 'dev'
|
||||
};
|
||||
|
||||
// Mock DB response
|
||||
pool.query.mockResolvedValue([[mockUser]]);
|
||||
// Mock bcrypt comparison
|
||||
bcrypt.compare.mockResolvedValue(true);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: 'test@test.com', password: 'password' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(expect.objectContaining({
|
||||
id: 'u1',
|
||||
email: 'test@test.com'
|
||||
}));
|
||||
expect(res.body).not.toHaveProperty('password_hash');
|
||||
});
|
||||
|
||||
it('returns 401 for invalid password', async () => {
|
||||
const mockUser = {
|
||||
id: 'u1',
|
||||
email: 'test@test.com',
|
||||
password_hash: 'hashed_password'
|
||||
};
|
||||
|
||||
pool.query.mockResolvedValue([[mockUser]]);
|
||||
bcrypt.compare.mockResolvedValue(false);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: 'test@test.com', password: 'wrong' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'Invalid email or password' });
|
||||
});
|
||||
|
||||
it('returns 401 for user not found', async () => {
|
||||
pool.query.mockResolvedValue([[]]); // Empty array
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: 'notfound@test.com', password: 'password' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth/register', () => {
|
||||
it('registers a new user successfully', async () => {
|
||||
pool.query.mockResolvedValueOnce([[]]); // Check existing email (empty)
|
||||
pool.query.mockResolvedValueOnce({}); // Insert success
|
||||
|
||||
bcrypt.hash.mockResolvedValue('hashed_new_password');
|
||||
|
||||
const newUser = {
|
||||
name: 'New User',
|
||||
email: 'new@test.com',
|
||||
password: 'password',
|
||||
role: 'employee'
|
||||
};
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send(newUser);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual(expect.objectContaining({
|
||||
name: 'New User',
|
||||
email: 'new@test.com'
|
||||
}));
|
||||
|
||||
// Verify DB insert called
|
||||
expect(pool.query).toHaveBeenCalledTimes(2);
|
||||
expect(pool.query).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('INSERT INTO users'),
|
||||
expect.arrayContaining(['New User', 'employee', 'new@test.com', 'hashed_new_password'])
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 409 if email already exists', async () => {
|
||||
pool.query.mockResolvedValueOnce([[{ id: 'existing' }]]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({ name: 'User', email: 'existing@test.com', password: 'pw' });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
});
|
||||
120
server/tests/tasks.test.js
Normal file
120
server/tests/tasks.test.js
Normal file
@@ -0,0 +1,120 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { app } from '../index.js';
|
||||
import pool from '../db.js';
|
||||
|
||||
// Mock DB
|
||||
const mockQuery = vi.fn();
|
||||
const mockRelease = vi.fn();
|
||||
const mockCommit = vi.fn();
|
||||
const mockRollback = vi.fn();
|
||||
const mockBeginTransaction = vi.fn();
|
||||
|
||||
vi.mock('../db.js', () => ({
|
||||
default: {
|
||||
query: vi.fn(),
|
||||
getConnection: vi.fn()
|
||||
},
|
||||
initDB: vi.fn()
|
||||
}));
|
||||
|
||||
describe('Task Routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockQuery.mockReset(); // Important to clear implementations
|
||||
pool.query = mockQuery;
|
||||
pool.getConnection.mockResolvedValue({
|
||||
query: mockQuery,
|
||||
release: mockRelease,
|
||||
beginTransaction: mockBeginTransaction,
|
||||
commit: mockCommit,
|
||||
rollback: mockRollback
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/tasks', () => {
|
||||
it('returns list of tasks', async () => {
|
||||
// Mock fetching task IDs
|
||||
mockQuery.mockResolvedValueOnce([[{ id: 't1' }]]);
|
||||
|
||||
// Mock getFullTask queries for 't1'
|
||||
// 1. Task details
|
||||
mockQuery.mockResolvedValueOnce([[{ id: 't1', title: 'Task 1', status: 'todo' }]]); // Task
|
||||
// 2. Subtasks
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Subtasks
|
||||
// 3. Comments
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Comments
|
||||
// 4. Activities
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Activities
|
||||
// 5. Tags
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Tags
|
||||
// 6. Dependencies
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Dependencies
|
||||
|
||||
const res = await request(app).get('/api/tasks');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0].title).toBe('Task 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/tasks', () => {
|
||||
it('creates a new task', async () => {
|
||||
// Mock INSERTs (1: Task, 2: Activities) -> Return {}
|
||||
mockQuery.mockResolvedValueOnce({}); // Insert task
|
||||
mockQuery.mockResolvedValueOnce({}); // Insert activity
|
||||
|
||||
// getFullTask queries (3-8)
|
||||
mockQuery.mockResolvedValueOnce([[{ id: 'new-id', title: 'New Task', status: 'todo' }]]); // Task
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Subtasks
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Comments
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Activities
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Tags
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Deps
|
||||
|
||||
const newTask = {
|
||||
// For getFullTask called at end
|
||||
title: 'New Task',
|
||||
description: 'Desc',
|
||||
status: 'todo'
|
||||
};
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks')
|
||||
.send(newTask);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockBeginTransaction).toHaveBeenCalled();
|
||||
expect(mockCommit).toHaveBeenCalled();
|
||||
expect(mockRelease).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolls back on error', async () => {
|
||||
mockQuery.mockRejectedValue(new Error('DB Error'));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks')
|
||||
.send({ title: 'Task' });
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(mockRollback).toHaveBeenCalled();
|
||||
expect(mockRelease).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/tasks/:id', () => {
|
||||
it('deletes a task', async () => {
|
||||
mockQuery.mockResolvedValue({});
|
||||
|
||||
const res = await request(app).delete('/api/tasks/t1');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('DELETE FROM tasks'),
|
||||
['t1']
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
9
server/vitest.config.js
Normal file
9
server/vitest.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
192
src/App.tsx
192
src/App.tsx
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { SEED_TASKS } from './data';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiFetchTasks, apiFetchUsers, apiCreateTask, apiUpdateTask, apiAddActivity, apiAddDependency, apiToggleDependency, apiRemoveDependency, apiCreateUser, apiDeleteUser } from './api';
|
||||
import type { Task, User, Status } from './data';
|
||||
import { STATUS_LABELS } from './data';
|
||||
import { LoginPage } from './Login';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { TopNavbar, BottomToggleBar } from './NavBars';
|
||||
@@ -24,7 +25,8 @@ const VIEW_PAGES = ['calendar', 'kanban', 'list'];
|
||||
export default function App() {
|
||||
const now = new Date();
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [tasks, setTasks] = useState<Task[]>(SEED_TASKS);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [activePage, setActivePage] = useState('calendar');
|
||||
const [activeView, setActiveView] = useState('calendar');
|
||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
@@ -34,13 +36,29 @@ export default function App() {
|
||||
const [calView, setCalView] = useState('month');
|
||||
const [filterUser, setFilterUser] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [quickAddDay, setQuickAddDay] = useState<{ date: string; rect: { top: number; left: number } } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Load data from API when user logs in
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
setLoading(true);
|
||||
Promise.all([apiFetchTasks(), apiFetchUsers()])
|
||||
.then(([fetchedTasks, fetchedUsers]) => {
|
||||
setTasks(fetchedTasks);
|
||||
setUsers(fetchedUsers);
|
||||
})
|
||||
.catch(err => console.error('Failed to load data:', err))
|
||||
.finally(() => setLoading(false));
|
||||
}, [currentUser]);
|
||||
|
||||
if (!currentUser) return <LoginPage onLogin={u => { setCurrentUser(u); setActivePage('calendar'); setActiveView('calendar'); }} />;
|
||||
|
||||
const handleNavigate = (page: string) => {
|
||||
setActivePage(page);
|
||||
if (VIEW_PAGES.includes(page)) setActiveView(page);
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
const handleViewChange = (view: string) => {
|
||||
@@ -55,29 +73,129 @@ export default function App() {
|
||||
setQuickAddDay({ date, rect: { top: rect.bottom, left: rect.left } });
|
||||
};
|
||||
|
||||
const handleQuickAdd = (partial: Partial<Task>) => {
|
||||
const task: Task = {
|
||||
id: `t${Date.now()}`, title: partial.title || '', description: partial.description || '',
|
||||
status: (partial.status || 'todo') as Status, priority: partial.priority || 'medium',
|
||||
assignee: partial.assignee || 'u1', reporter: currentUser.id, dueDate: partial.dueDate || '',
|
||||
tags: partial.tags || [], subtasks: partial.subtasks || [], comments: partial.comments || [],
|
||||
activity: [{ id: `a${Date.now()}`, text: '📝 Task created', timestamp: new Date().toISOString() }],
|
||||
};
|
||||
setTasks(prev => [...prev, task]);
|
||||
setQuickAddDay(null);
|
||||
const handleQuickAdd = async (partial: Partial<Task>) => {
|
||||
try {
|
||||
const created = await apiCreateTask({
|
||||
title: partial.title || '',
|
||||
description: partial.description || '',
|
||||
status: partial.status || 'todo',
|
||||
priority: partial.priority || 'medium',
|
||||
assignee: partial.assignee || currentUser.id,
|
||||
reporter: currentUser.id,
|
||||
dueDate: partial.dueDate || '',
|
||||
tags: partial.tags || [],
|
||||
});
|
||||
setTasks(prev => [...prev, created]);
|
||||
setQuickAddDay(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to quick-add task:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTask = (task: Task) => setTasks(prev => [...prev, { ...task, reporter: currentUser.id }]);
|
||||
const handleAddTask = async (task: Task) => {
|
||||
try {
|
||||
const created = await apiCreateTask({
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
assignee: task.assignee,
|
||||
reporter: currentUser.id,
|
||||
dueDate: task.dueDate,
|
||||
tags: task.tags,
|
||||
dependencies: (task.dependencies || []).map(d => ({ dependsOnUserId: d.dependsOnUserId, description: d.description })),
|
||||
});
|
||||
setTasks(prev => [...prev, created]);
|
||||
} catch (err) {
|
||||
console.error('Failed to add task:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTask = (updated: Task) => {
|
||||
setTasks(prev => prev.map(t => t.id === updated.id ? updated : t));
|
||||
setActiveTask(updated);
|
||||
const handleUpdateTask = async (updated: Task) => {
|
||||
try {
|
||||
const result = await apiUpdateTask(updated.id, {
|
||||
title: updated.title,
|
||||
description: updated.description,
|
||||
status: updated.status,
|
||||
priority: updated.priority,
|
||||
assignee: updated.assignee,
|
||||
reporter: updated.reporter,
|
||||
dueDate: updated.dueDate,
|
||||
tags: updated.tags,
|
||||
});
|
||||
setTasks(prev => prev.map(t => t.id === result.id ? result : t));
|
||||
setActiveTask(result);
|
||||
} catch (err) {
|
||||
console.error('Failed to update task:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewTask = () => { setAddModalDefaults({}); setShowAddModal(true); };
|
||||
const handleKanbanAdd = (status: Status) => { setAddModalDefaults({ status }); setShowAddModal(true); };
|
||||
const handleToggleDone = (taskId: string) => {
|
||||
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, status: t.status === 'done' ? 'todo' : 'done' as Status } : t));
|
||||
const handleToggleDone = async (taskId: string) => {
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (!task) return;
|
||||
const newStatus = task.status === 'done' ? 'todo' : 'done';
|
||||
try {
|
||||
const result = await apiUpdateTask(taskId, { status: newStatus });
|
||||
await apiAddActivity(taskId, `🔄 ${currentUser.name} changed status to ${newStatus}`);
|
||||
setTasks(prev => prev.map(t => t.id === taskId ? result : t));
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle done:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveTask = async (taskId: string, newStatus: Status) => {
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (!task || task.status === newStatus) return;
|
||||
// Optimistic update
|
||||
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, status: newStatus } : t));
|
||||
try {
|
||||
const result = await apiUpdateTask(taskId, { status: newStatus });
|
||||
await apiAddActivity(taskId, `🔄 ${currentUser.name} moved task to ${STATUS_LABELS[newStatus]}`);
|
||||
setTasks(prev => prev.map(t => t.id === taskId ? result : t));
|
||||
} catch (err) {
|
||||
console.error('Failed to move task:', err);
|
||||
// Revert on failure
|
||||
setTasks(prev => prev.map(t => t.id === taskId ? task : t));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddDep = async (taskId: string, dep: { dependsOnUserId: string; description: string }) => {
|
||||
try {
|
||||
const newDep = await apiAddDependency(taskId, dep);
|
||||
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, dependencies: [...(t.dependencies || []), newDep] } : t));
|
||||
if (activeTask?.id === taskId) setActiveTask(prev => prev ? { ...prev, dependencies: [...(prev.dependencies || []), newDep] } : prev);
|
||||
} catch (err) { console.error('Failed to add dependency:', err); }
|
||||
};
|
||||
|
||||
const handleToggleDep = async (taskId: string, depId: string, resolved: boolean) => {
|
||||
try {
|
||||
await apiToggleDependency(taskId, depId, resolved);
|
||||
const updateDeps = (deps: any[]) => deps.map((d: any) => d.id === depId ? { ...d, resolved } : d);
|
||||
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, dependencies: updateDeps(t.dependencies || []) } : t));
|
||||
if (activeTask?.id === taskId) setActiveTask(prev => prev ? { ...prev, dependencies: updateDeps(prev.dependencies || []) } : prev);
|
||||
} catch (err) { console.error('Failed to toggle dependency:', err); }
|
||||
};
|
||||
|
||||
const handleRemoveDep = async (taskId: string, depId: string) => {
|
||||
try {
|
||||
await apiRemoveDependency(taskId, depId);
|
||||
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, dependencies: (t.dependencies || []).filter((d: any) => d.id !== depId) } : t));
|
||||
if (activeTask?.id === taskId) setActiveTask(prev => prev ? { ...prev, dependencies: (prev.dependencies || []).filter((d: any) => d.id !== depId) } : prev);
|
||||
} catch (err) { console.error('Failed to remove dependency:', err); }
|
||||
};
|
||||
|
||||
const handleAddUser = async (data: { name: string; email: string; password: string; role: string; dept: string }) => {
|
||||
const newUser = await apiCreateUser(data);
|
||||
setUsers(prev => [...prev, newUser]);
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (id: string) => {
|
||||
await apiDeleteUser(id);
|
||||
setUsers(prev => prev.filter(u => u.id !== id));
|
||||
// Unassign tasks locally too
|
||||
setTasks(prev => prev.map(t => t.assignee === id ? { ...t, assignee: '' } : t).map(t => t.reporter === id ? { ...t, reporter: '' } : t));
|
||||
};
|
||||
|
||||
const displayPage = VIEW_PAGES.includes(activePage) ? activeView : activePage;
|
||||
@@ -85,35 +203,45 @@ export default function App() {
|
||||
|
||||
const pageTitle = PAGE_TITLES[displayPage] || 'Calendar';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="app-shell" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
|
||||
<p style={{ color: '#818cf8', fontSize: 18 }}>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<TopNavbar title={pageTitle} filterUser={filterUser} onFilterChange={setFilterUser}
|
||||
searchQuery={searchQuery} onSearch={setSearchQuery} onNewTask={handleNewTask} />
|
||||
searchQuery={searchQuery} onSearch={setSearchQuery} onNewTask={handleNewTask}
|
||||
onOpenSidebar={() => setSidebarOpen(true)} users={users} />
|
||||
<div className="app-body">
|
||||
<Sidebar currentUser={currentUser} activePage={activePage} onNavigate={handleNavigate}
|
||||
onSignOut={() => { setCurrentUser(null); setActivePage('calendar'); setActiveView('calendar'); }} />
|
||||
onSignOut={() => { setCurrentUser(null); setActivePage('calendar'); setActiveView('calendar'); setSidebarOpen(false); }}
|
||||
isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} users={users} />
|
||||
<div className="main-content">
|
||||
{displayPage === 'calendar' && (
|
||||
<CalendarView tasks={tasks} currentUser={currentUser} calMonth={calMonth} calView={calView}
|
||||
onMonthChange={setCalMonth} onViewChange={setCalView} onTaskClick={handleTaskClick}
|
||||
onDayClick={handleDayClick} filterUser={filterUser} searchQuery={searchQuery} />
|
||||
onDayClick={handleDayClick} filterUser={filterUser} searchQuery={searchQuery} users={users} />
|
||||
)}
|
||||
{displayPage === 'kanban' && (
|
||||
<KanbanBoard tasks={tasks} currentUser={currentUser} onTaskClick={handleTaskClick}
|
||||
onAddTask={handleKanbanAdd} filterUser={filterUser} searchQuery={searchQuery} />
|
||||
onAddTask={handleKanbanAdd} onMoveTask={handleMoveTask} filterUser={filterUser} searchQuery={searchQuery} users={users} />
|
||||
)}
|
||||
{displayPage === 'list' && (
|
||||
<ListView tasks={tasks} currentUser={currentUser} onTaskClick={handleTaskClick}
|
||||
filterUser={filterUser} searchQuery={searchQuery} onToggleDone={handleToggleDone} />
|
||||
filterUser={filterUser} searchQuery={searchQuery} onToggleDone={handleToggleDone} users={users} />
|
||||
)}
|
||||
{displayPage === 'dashboard' && <DashboardPage tasks={tasks} currentUser={currentUser} />}
|
||||
{displayPage === 'dashboard' && <DashboardPage tasks={tasks} currentUser={currentUser} users={users} />}
|
||||
{displayPage === 'mytasks' && (
|
||||
<ListView tasks={filteredMyTasks} currentUser={currentUser} onTaskClick={handleTaskClick}
|
||||
filterUser={null} searchQuery={searchQuery} onToggleDone={handleToggleDone} />
|
||||
filterUser={null} searchQuery={searchQuery} onToggleDone={handleToggleDone} users={users} />
|
||||
)}
|
||||
{displayPage === 'teamtasks' && <TeamTasksPage tasks={tasks} currentUser={currentUser} />}
|
||||
{displayPage === 'reports' && <ReportsPage tasks={tasks} />}
|
||||
{displayPage === 'members' && <MembersPage tasks={tasks} />}
|
||||
{displayPage === 'teamtasks' && <TeamTasksPage tasks={tasks} currentUser={currentUser} users={users} />}
|
||||
{displayPage === 'reports' && <ReportsPage tasks={tasks} users={users} currentUser={currentUser} />}
|
||||
{displayPage === 'members' && <MembersPage tasks={tasks} users={users} currentUser={currentUser} onAddUser={handleAddUser} onDeleteUser={handleDeleteUser} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -121,8 +249,8 @@ export default function App() {
|
||||
<BottomToggleBar activeView={activeView} onViewChange={handleViewChange} />
|
||||
)}
|
||||
|
||||
{activeTask && <TaskDrawer task={activeTask} currentUser={currentUser} onClose={() => setActiveTask(null)} onUpdate={handleUpdateTask} />}
|
||||
{showAddModal && <AddTaskModal onClose={() => setShowAddModal(false)} onAdd={handleAddTask} defaultDate={addModalDefaults.date} defaultStatus={addModalDefaults.status} />}
|
||||
{activeTask && <TaskDrawer task={activeTask} currentUser={currentUser} onClose={() => setActiveTask(null)} onUpdate={handleUpdateTask} onAddDependency={handleAddDep} onToggleDependency={handleToggleDep} onRemoveDependency={handleRemoveDep} users={users} />}
|
||||
{showAddModal && <AddTaskModal onClose={() => setShowAddModal(false)} onAdd={handleAddTask} defaultDate={addModalDefaults.date} defaultStatus={addModalDefaults.status} users={users} currentUser={currentUser} />}
|
||||
|
||||
{quickAddDay && (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 199 }} onClick={() => setQuickAddDay(null)}>
|
||||
@@ -130,7 +258,7 @@ export default function App() {
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<QuickAddPanel date={quickAddDay.date} onAdd={handleQuickAdd}
|
||||
onOpenFull={() => { setAddModalDefaults({ date: quickAddDay.date }); setShowAddModal(true); setQuickAddDay(null); }}
|
||||
onClose={() => setQuickAddDay(null)} />
|
||||
onClose={() => setQuickAddDay(null)} users={users} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import type { Task, User } from './data';
|
||||
import { USERS, PRIORITY_COLORS } from './data';
|
||||
import { PRIORITY_COLORS } from './data';
|
||||
import { Avatar } from './Shared';
|
||||
|
||||
interface CalendarProps {
|
||||
tasks: Task[]; currentUser: User; calMonth: { year: number; month: number }; calView: string;
|
||||
onMonthChange: (m: { year: number; month: number }) => void; onViewChange: (v: string) => void;
|
||||
onTaskClick: (t: Task) => void; onDayClick: (date: string, el: HTMLElement) => void;
|
||||
filterUser: string | null; searchQuery: string;
|
||||
filterUser: string | null; searchQuery: string; users: User[];
|
||||
}
|
||||
|
||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
@@ -47,29 +47,29 @@ function getWeekDays(_year: number, _month: number) {
|
||||
function dateStr(d: Date) { return d.toISOString().split('T')[0]; }
|
||||
function isToday(d: Date) { const t = new Date(); return d.getDate() === t.getDate() && d.getMonth() === t.getMonth() && d.getFullYear() === t.getFullYear(); }
|
||||
|
||||
function TaskChip({ task, onClick }: { task: Task; onClick: () => void }) {
|
||||
function TaskChip({ task, onClick, users }: { task: Task; onClick: () => void; users: User[] }) {
|
||||
const p = PRIORITY_COLORS[task.priority];
|
||||
return (
|
||||
<div className="task-chip" style={{ background: p.bg, borderLeftColor: p.color }} onClick={e => { e.stopPropagation(); onClick(); }}>
|
||||
<span className="task-chip-dot" style={{ background: p.color }} />
|
||||
<span className="task-chip-title">{task.title}</span>
|
||||
<Avatar userId={task.assignee} size={14} />
|
||||
<Avatar userId={task.assignee} size={14} users={users} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MorePopover({ tasks, onTaskClick, onClose }: { tasks: Task[]; onTaskClick: (t: Task) => void; onClose: () => void }) {
|
||||
function MorePopover({ tasks, onTaskClick, onClose, users }: { tasks: Task[]; onTaskClick: (t: Task) => void; onClose: () => void; users: User[] }) {
|
||||
return (
|
||||
<div className="more-popover" onClick={e => e.stopPropagation()}>
|
||||
<div className="more-popover-title">{tasks.length} tasks</div>
|
||||
{tasks.map(t => <TaskChip key={t.id} task={t} onClick={() => { onTaskClick(t); onClose(); }} />)}
|
||||
{tasks.map(t => <TaskChip key={t.id} task={t} onClick={() => { onTaskClick(t); onClose(); }} users={users} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickAddPanel({ date, onAdd, onOpenFull, onClose }: { date: string; onAdd: (t: Partial<Task>) => void; onOpenFull: () => void; onClose: () => void }) {
|
||||
export function QuickAddPanel({ date, onAdd, onOpenFull, onClose, users }: { date: string; onAdd: (t: Partial<Task>) => void; onOpenFull: () => void; onClose: () => void; users: User[] }) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [assignee, setAssignee] = useState('u1');
|
||||
const [assignee, setAssignee] = useState(users[0]?.id || '');
|
||||
const [priority, setPriority] = useState<'medium' | 'low' | 'high' | 'critical'>('medium');
|
||||
|
||||
const submit = () => {
|
||||
@@ -86,7 +86,7 @@ export function QuickAddPanel({ date, onAdd, onOpenFull, onClose }: { date: stri
|
||||
onChange={e => setTitle(e.target.value)} onKeyDown={e => e.key === 'Enter' && submit()} />
|
||||
<div className="quick-add-row">
|
||||
<select className="quick-add-select" value={assignee} onChange={e => setAssignee(e.target.value)}>
|
||||
{USERS.map(u => <option key={u.id} value={u.id}>{u.avatar} {u.name}</option>)}
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.avatar} {u.name}</option>)}
|
||||
</select>
|
||||
<select className="quick-add-select" value={priority} onChange={e => setPriority(e.target.value as any)}>
|
||||
{['critical', 'high', 'medium', 'low'].map(p => <option key={p} value={p}>{p}</option>)}
|
||||
@@ -100,7 +100,7 @@ export function QuickAddPanel({ date, onAdd, onOpenFull, onClose }: { date: stri
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarView({ tasks, currentUser, calMonth, calView, onMonthChange, onViewChange, onTaskClick, onDayClick, filterUser, searchQuery }: CalendarProps) {
|
||||
export function CalendarView({ tasks, currentUser, calMonth, calView, onMonthChange, onViewChange, onTaskClick, onDayClick, filterUser, searchQuery, users }: CalendarProps) {
|
||||
const [morePopover, setMorePopover] = useState<{ date: string; tasks: Task[] } | null>(null);
|
||||
const filtered = filterTasks(tasks, currentUser, filterUser, searchQuery);
|
||||
|
||||
@@ -147,14 +147,14 @@ export function CalendarView({ tasks, currentUser, calMonth, calView, onMonthCha
|
||||
{cell.date.getDate()}
|
||||
</div>
|
||||
<div className="day-tasks">
|
||||
{show.map(t => <TaskChip key={t.id} task={t} onClick={() => onTaskClick(t)} />)}
|
||||
{show.map(t => <TaskChip key={t.id} task={t} onClick={() => onTaskClick(t)} users={users} />)}
|
||||
{extra > 0 && (
|
||||
<span className="more-tasks-link" onClick={e => { e.stopPropagation(); setMorePopover({ date: ds, tasks: dayTasks }); }}>
|
||||
+{extra} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{morePopover?.date === ds && <MorePopover tasks={morePopover.tasks} onTaskClick={onTaskClick} onClose={() => setMorePopover(null)} />}
|
||||
{morePopover?.date === ds && <MorePopover tasks={morePopover.tasks} onTaskClick={onTaskClick} onClose={() => setMorePopover(null)} users={users} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { Task, User } from './data';
|
||||
import { USERS, STATUS_COLORS, PRIORITY_COLORS } from './data';
|
||||
import { STATUS_COLORS, PRIORITY_COLORS } from './data';
|
||||
import { Avatar } from './Shared';
|
||||
|
||||
export function DashboardPage({ tasks, currentUser }: { tasks: Task[]; currentUser: User }) {
|
||||
export function DashboardPage({ tasks, currentUser, users }: { tasks: Task[]; currentUser: User; users: User[] }) {
|
||||
const total = tasks.length;
|
||||
const completed = tasks.filter(t => t.status === 'done').length;
|
||||
const overdue = tasks.filter(t => new Date(t.dueDate + 'T00:00:00') < new Date() && t.status !== 'done').length;
|
||||
const critical = tasks.filter(t => t.priority === 'critical' && t.status !== 'done').length;
|
||||
|
||||
const isLeader = currentUser.role === 'cto' || currentUser.role === 'manager';
|
||||
const isLeader = ['ceo', 'cto', 'manager', 'tech_lead', 'scrum_master', 'product_owner'].includes(currentUser.role);
|
||||
const myTasks = tasks.filter(t => t.assignee === currentUser.id);
|
||||
const myDone = myTasks.filter(t => t.status === 'done').length;
|
||||
|
||||
@@ -32,13 +32,13 @@ export function DashboardPage({ tasks, currentUser }: { tasks: Task[]; currentUs
|
||||
<>
|
||||
<div className="workload-card">
|
||||
<div className="workload-card-title">Team Workload</div>
|
||||
{USERS.filter(u => u.id !== currentUser.id || true).map(u => {
|
||||
{users.map(u => {
|
||||
const ut = tasks.filter(t => t.assignee === u.id);
|
||||
const done = ut.filter(t => t.status === 'done').length;
|
||||
const pct = ut.length ? Math.round((done / ut.length) * 100) : 0;
|
||||
return (
|
||||
<div key={u.id} className="workload-row">
|
||||
<Avatar userId={u.id} size={28} />
|
||||
<Avatar userId={u.id} size={28} users={users} />
|
||||
<span className="workload-name">{u.name}</span>
|
||||
<span className="workload-dept">{u.dept}</span>
|
||||
<div className="workload-bar">
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { useState } from 'react';
|
||||
import type { Task, User, Status } from './data';
|
||||
import { PRIORITY_COLORS, STATUS_COLORS, STATUS_LABELS } from './data';
|
||||
import { Avatar, PriorityBadge, StatusBadge, ProgressBar } from './Shared';
|
||||
|
||||
function TaskCard({ task, onClick }: { task: Task; onClick: () => void }) {
|
||||
function TaskCard({ task, onClick, users }: { task: Task; onClick: () => void; users: User[] }) {
|
||||
const p = PRIORITY_COLORS[task.priority];
|
||||
const due = new Date(task.dueDate + 'T00:00:00');
|
||||
const overdue = due < new Date() && task.status !== 'done';
|
||||
const commCount = task.comments.length;
|
||||
return (
|
||||
<div className="task-card" style={{ borderLeftColor: p.color }} onClick={onClick}>
|
||||
<div className="task-card" style={{ borderLeftColor: p.color }}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('text/plain', task.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
(e.currentTarget as HTMLElement).classList.add('dragging');
|
||||
}}
|
||||
onDragEnd={e => (e.currentTarget as HTMLElement).classList.remove('dragging')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="task-card-row">
|
||||
<span className="task-card-title">{task.title}</span>
|
||||
<Avatar userId={task.assignee} size={24} />
|
||||
<Avatar userId={task.assignee} size={24} users={users} />
|
||||
</div>
|
||||
<div className="task-card-badges">
|
||||
<PriorityBadge level={task.priority} />
|
||||
@@ -27,12 +37,29 @@ function TaskCard({ task, onClick }: { task: Task; onClick: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanColumn({ status, statusLabel, tasks, color, onTaskClick, onAddTask }: {
|
||||
function KanbanColumn({ status, statusLabel, tasks, color, onTaskClick, onAddTask, onMoveTask, users }: {
|
||||
status: Status; statusLabel: string; tasks: Task[]; color: string;
|
||||
onTaskClick: (t: Task) => void; onAddTask: (s: Status) => void;
|
||||
onMoveTask: (taskId: string, newStatus: Status) => void; users: User[];
|
||||
}) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="kanban-column">
|
||||
<div
|
||||
className={`kanban-column ${dragOver ? 'kanban-column-dragover' : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOver(true); }}
|
||||
onDragEnter={e => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={e => {
|
||||
// Only set false if leaving the column (not entering a child)
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOver(false);
|
||||
}}
|
||||
onDrop={e => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const taskId = e.dataTransfer.getData('text/plain');
|
||||
if (taskId) onMoveTask(taskId, status);
|
||||
}}
|
||||
>
|
||||
<div className="kanban-col-header">
|
||||
<div className="kanban-col-dot" style={{ background: color }} />
|
||||
<span className="kanban-col-label">{statusLabel}</span>
|
||||
@@ -41,9 +68,11 @@ function KanbanColumn({ status, statusLabel, tasks, color, onTaskClick, onAddTas
|
||||
</div>
|
||||
<div className="kanban-col-body">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="kanban-empty">No tasks here · Click + to add one</div>
|
||||
<div className="kanban-empty">
|
||||
{dragOver ? '⬇ Drop here' : 'No tasks here · Click + to add one'}
|
||||
</div>
|
||||
) : (
|
||||
tasks.map(t => <TaskCard key={t.id} task={t} onClick={() => onTaskClick(t)} />)
|
||||
tasks.map(t => <TaskCard key={t.id} task={t} onClick={() => onTaskClick(t)} users={users} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,10 +81,11 @@ function KanbanColumn({ status, statusLabel, tasks, color, onTaskClick, onAddTas
|
||||
|
||||
interface KanbanProps {
|
||||
tasks: Task[]; currentUser: User; onTaskClick: (t: Task) => void;
|
||||
onAddTask: (s: Status) => void; filterUser: string | null; searchQuery: string;
|
||||
onAddTask: (s: Status) => void; onMoveTask: (taskId: string, newStatus: Status) => void;
|
||||
filterUser: string | null; searchQuery: string; users: User[];
|
||||
}
|
||||
|
||||
export function KanbanBoard({ tasks, currentUser, onTaskClick, onAddTask, filterUser, searchQuery }: KanbanProps) {
|
||||
export function KanbanBoard({ tasks, currentUser, onTaskClick, onAddTask, onMoveTask, filterUser, searchQuery, users }: KanbanProps) {
|
||||
let filtered = tasks;
|
||||
if (currentUser.role === 'employee') filtered = filtered.filter(t => t.assignee === currentUser.id);
|
||||
if (filterUser) filtered = filtered.filter(t => t.assignee === filterUser);
|
||||
@@ -66,7 +96,8 @@ export function KanbanBoard({ tasks, currentUser, onTaskClick, onAddTask, filter
|
||||
<div className="kanban-board">
|
||||
{statuses.map(s => (
|
||||
<KanbanColumn key={s} status={s} statusLabel={STATUS_LABELS[s]} color={STATUS_COLORS[s]}
|
||||
tasks={filtered.filter(t => t.status === s)} onTaskClick={onTaskClick} onAddTask={onAddTask} />
|
||||
tasks={filtered.filter(t => t.status === s)} onTaskClick={onTaskClick}
|
||||
onAddTask={onAddTask} onMoveTask={onMoveTask} users={users} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,12 +7,13 @@ interface ListProps {
|
||||
tasks: Task[]; currentUser: User; onTaskClick: (t: Task) => void;
|
||||
filterUser: string | null; searchQuery: string;
|
||||
onToggleDone: (taskId: string) => void;
|
||||
users: User[];
|
||||
}
|
||||
|
||||
type SortKey = 'dueDate' | 'priority' | 'status' | 'assignee';
|
||||
const PRIO_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
|
||||
export function ListView({ tasks, currentUser, onTaskClick, filterUser, searchQuery, onToggleDone }: ListProps) {
|
||||
export function ListView({ tasks, currentUser, onTaskClick, filterUser, searchQuery, onToggleDone, users }: ListProps) {
|
||||
const [sortBy, setSortBy] = useState<SortKey>('dueDate');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [menuOpen, setMenuOpen] = useState<string | null>(null);
|
||||
@@ -53,14 +54,14 @@ export function ListView({ tasks, currentUser, onTaskClick, filterUser, searchQu
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map(t => {
|
||||
const u = getUserById(t.assignee);
|
||||
const u = getUserById(users, t.assignee);
|
||||
const due = new Date(t.dueDate + 'T00:00:00');
|
||||
const overdue = due < new Date() && t.status !== 'done';
|
||||
return (
|
||||
<tr key={t.id}>
|
||||
<td><input type="checkbox" checked={t.status === 'done'} onChange={() => onToggleDone(t.id)} /></td>
|
||||
<td onClick={() => onTaskClick(t)} style={{ cursor: 'pointer' }}>{t.title}</td>
|
||||
<td><div style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Avatar userId={t.assignee} size={20} />{u?.name}</div></td>
|
||||
<td><div style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Avatar userId={t.assignee} size={20} users={users} />{u?.name}</div></td>
|
||||
<td><PriorityBadge level={t.priority} /></td>
|
||||
<td><StatusBadge status={t.status} /></td>
|
||||
<td style={{ color: overdue ? '#ef4444' : undefined }}>{due.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</td>
|
||||
|
||||
@@ -1,43 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
import { USERS } from './data';
|
||||
import type { User } from './data';
|
||||
import { apiLogin, apiRegister } from './api';
|
||||
|
||||
export function LoginPage({ onLogin }: { onLogin: (u: User) => void }) {
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [pass, setPass] = useState('');
|
||||
const [role, setRole] = useState('employee');
|
||||
const [dept, setDept] = useState('');
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const user = USERS.find(u => u.email === email && u.pass === pass);
|
||||
if (user) { onLogin(user); }
|
||||
else { setError('Invalid email or password'); }
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const user = await apiLogin(email, pass);
|
||||
onLogin(user);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Invalid email or password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !email.trim() || !pass.trim()) {
|
||||
setError('All fields are required');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const user = await apiRegister({ name, email, password: pass, role, dept });
|
||||
onLogin(user);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-bg">
|
||||
<form className="login-card" onSubmit={handleSubmit}>
|
||||
<form className="login-card" onSubmit={mode === 'login' ? handleLogin : handleRegister}>
|
||||
<div className="login-logo">
|
||||
<div className="login-logo-icon">⚡</div>
|
||||
<span className="login-title">Scrum-manager</span>
|
||||
</div>
|
||||
<p className="login-tagline">Your team's command center</p>
|
||||
<div className="login-divider" />
|
||||
<label className="login-label">Email</label>
|
||||
|
||||
{mode === 'register' && (
|
||||
<>
|
||||
<label className="login-label" htmlFor="register-name">Name</label>
|
||||
<div className="login-input-wrap">
|
||||
<input id="register-name" className={`login-input ${error ? 'error' : ''}`} type="text" placeholder="Your full name"
|
||||
value={name} onChange={e => { setName(e.target.value); setError(''); }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="login-label" htmlFor="login-email">Email</label>
|
||||
<div className="login-input-wrap">
|
||||
<input className={`login-input ${error ? 'error' : ''}`} type="email" placeholder="you@company.io"
|
||||
<input id="login-email" className={`login-input ${error ? 'error' : ''}`} type="email" placeholder="you@company.io"
|
||||
value={email} onChange={e => { setEmail(e.target.value); setError(''); }} />
|
||||
</div>
|
||||
<label className="login-label">Password</label>
|
||||
<label className="login-label" htmlFor="login-password">Password</label>
|
||||
<div className="login-input-wrap">
|
||||
<input className={`login-input ${error ? 'error' : ''}`} type={showPass ? 'text' : 'password'} placeholder="••••••••"
|
||||
<input id="login-password" className={`login-input ${error ? 'error' : ''}`} type={showPass ? 'text' : 'password'} placeholder="••••••••"
|
||||
value={pass} onChange={e => { setPass(e.target.value); setError(''); }} />
|
||||
<button type="button" className="login-eye" onClick={() => setShowPass(!showPass)}>{showPass ? '🙈' : '👁'}</button>
|
||||
</div>
|
||||
<button type="submit" className="login-btn">Sign In</button>
|
||||
|
||||
{mode === 'register' && (
|
||||
<>
|
||||
<label className="login-label" htmlFor="register-role">Role</label>
|
||||
<div className="login-input-wrap">
|
||||
<select id="register-role" className="login-input" value={role} onChange={e => setRole(e.target.value)}>
|
||||
<option value="employee">Employee</option>
|
||||
<option value="tech_lead">Tech Lead</option>
|
||||
<option value="scrum_master">Scrum Master</option>
|
||||
<option value="product_owner">Product Owner</option>
|
||||
<option value="designer">Designer</option>
|
||||
<option value="qa">QA Engineer</option>
|
||||
<option value="manager">Manager</option>
|
||||
<option value="cto">CTO</option>
|
||||
<option value="ceo">CEO</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="login-label" htmlFor="register-dept">Department</label>
|
||||
<div className="login-input-wrap">
|
||||
<input id="register-dept" className="login-input" type="text" placeholder="e.g. Backend, Frontend, DevOps"
|
||||
value={dept} onChange={e => setDept(e.target.value)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button type="submit" className="login-btn" disabled={loading}>
|
||||
{loading ? '...' : mode === 'login' ? 'Sign In' : 'Create Account'}
|
||||
</button>
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
<div className="login-hint">💡 Try: subodh@corp.io / cto123</div>
|
||||
<div className="login-hint" style={{ cursor: 'pointer' }} onClick={() => { setMode(mode === 'login' ? 'register' : 'login'); setError(''); }}>
|
||||
{mode === 'login' ? '📝 No account? Register here' : '🔑 Already have an account? Sign in'}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { USERS } from './data';
|
||||
|
||||
import type { User } from './data';
|
||||
|
||||
interface TopNavbarProps {
|
||||
title: string;
|
||||
@@ -8,11 +7,14 @@ interface TopNavbarProps {
|
||||
searchQuery: string;
|
||||
onSearch: (q: string) => void;
|
||||
onNewTask: () => void;
|
||||
onOpenSidebar: () => void;
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export function TopNavbar({ title, filterUser, onFilterChange, searchQuery, onSearch, onNewTask }: TopNavbarProps) {
|
||||
export function TopNavbar({ title, filterUser, onFilterChange, searchQuery, onSearch, onNewTask, onOpenSidebar, users }: TopNavbarProps) {
|
||||
return (
|
||||
<div className="top-navbar">
|
||||
<button className="hamburger-btn" onClick={onOpenSidebar}>☰</button>
|
||||
<span className="navbar-title">{title}</span>
|
||||
<div className="navbar-search">
|
||||
<span className="navbar-search-icon">🔍</span>
|
||||
@@ -21,7 +23,7 @@ export function TopNavbar({ title, filterUser, onFilterChange, searchQuery, onSe
|
||||
<div className="navbar-right">
|
||||
<div className="filter-chips">
|
||||
<span className={`filter-chip filter-chip-all ${!filterUser ? 'active' : ''}`} onClick={() => onFilterChange(null)}>All</span>
|
||||
{USERS.map(u => (
|
||||
{users.map(u => (
|
||||
<div key={u.id} className={`filter-chip ${filterUser === u.id ? 'active' : ''}`}
|
||||
style={{ background: u.color, borderColor: filterUser === u.id ? u.color : 'transparent' }}
|
||||
title={u.name} onClick={() => onFilterChange(u.id === filterUser ? null : u.id)}>
|
||||
|
||||
113
src/Pages.tsx
113
src/Pages.tsx
@@ -1,22 +1,21 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Task, User } from './data';
|
||||
import { USERS, PRIORITY_COLORS } from './data';
|
||||
import { PRIORITY_COLORS } from './data';
|
||||
import { Avatar, StatusBadge } from './Shared';
|
||||
|
||||
export function TeamTasksPage({ tasks }: { tasks: Task[]; currentUser: User }) {
|
||||
export function TeamTasksPage({ tasks, users }: { tasks: Task[]; currentUser: User; users: User[] }) {
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const members = USERS;
|
||||
return (
|
||||
<div className="team-tasks">
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16 }}>Team Tasks</h2>
|
||||
{members.map(m => {
|
||||
{users.map(m => {
|
||||
const mTasks = tasks.filter(t => t.assignee === m.id);
|
||||
const isOpen = expanded[m.id] !== false;
|
||||
return (
|
||||
<div key={m.id} className="team-group">
|
||||
<div className="team-group-header" onClick={() => setExpanded(e => ({ ...e, [m.id]: !isOpen }))}>
|
||||
<Avatar userId={m.id} size={28} />
|
||||
<Avatar userId={m.id} size={28} users={users} />
|
||||
<span className="team-group-name">{m.name}</span>
|
||||
<span className="team-group-count">({mTasks.length} tasks)</span>
|
||||
<span style={{ color: '#64748b' }}>{isOpen ? '▼' : '▶'}</span>
|
||||
@@ -43,37 +42,81 @@ export function TeamTasksPage({ tasks }: { tasks: Task[]; currentUser: User }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MembersPage({ tasks }: { tasks: Task[] }) {
|
||||
export function MembersPage({ tasks, users, currentUser, onAddUser, onDeleteUser }: { tasks: Task[]; users: User[]; currentUser: User; onAddUser: (data: { name: string; email: string; password: string; role: string; dept: string }) => Promise<void>; onDeleteUser: (id: string) => Promise<void> }) {
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [addForm, setAddForm] = useState({ name: '', email: '', password: '', role: 'employee', dept: '' });
|
||||
const [addError, setAddError] = useState('');
|
||||
const [addLoading, setAddLoading] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
const canManage = ['ceo', 'cto', 'manager'].includes(currentUser.role);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!addForm.name.trim() || !addForm.email.trim() || !addForm.password.trim()) {
|
||||
setAddError('Name, email and password are required');
|
||||
return;
|
||||
}
|
||||
setAddLoading(true);
|
||||
setAddError('');
|
||||
try {
|
||||
await onAddUser(addForm);
|
||||
setShowAdd(false);
|
||||
setAddForm({ name: '', email: '', password: '', role: 'employee', dept: '' });
|
||||
} catch (err: any) {
|
||||
setAddError(err.message || 'Failed to add employee');
|
||||
} finally {
|
||||
setAddLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await onDeleteUser(id);
|
||||
setConfirmDelete(null);
|
||||
} catch (err: any) {
|
||||
console.error('Delete failed:', err);
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="members-page">
|
||||
<div className="members-header">
|
||||
<h2>Team Members</h2>
|
||||
<button className="btn-ghost" onClick={() => setShowInvite(true)}>+ Invite Member</button>
|
||||
{canManage && <button className="btn-primary" onClick={() => setShowAdd(true)}>+ Add Employee</button>}
|
||||
</div>
|
||||
<table className="members-table">
|
||||
<thead><tr><th>Avatar</th><th>Full Name</th><th>Role</th><th>Dept</th><th>Assigned</th><th>Done</th><th>Active</th></tr></thead>
|
||||
<thead><tr><th>Avatar</th><th>Full Name</th><th>Role</th><th>Dept</th><th>Assigned</th><th>Done</th><th>Active</th>{canManage && <th>Actions</th>}</tr></thead>
|
||||
<tbody>
|
||||
{USERS.map(u => {
|
||||
{users.map(u => {
|
||||
const ut = tasks.filter(t => t.assignee === u.id);
|
||||
const done = ut.filter(t => t.status === 'done').length;
|
||||
const active = ut.filter(t => t.status !== 'done').length;
|
||||
const roleColors: Record<string, string> = { cto: '#818cf8', manager: '#fb923c', employee: '#22c55e' };
|
||||
const roleColors: Record<string, string> = { ceo: '#eab308', cto: '#818cf8', manager: '#fb923c', tech_lead: '#06b6d4', scrum_master: '#a855f7', product_owner: '#ec4899', designer: '#f43f5e', qa: '#14b8a6', employee: '#22c55e' };
|
||||
return (
|
||||
<React.Fragment key={u.id}>
|
||||
<tr onClick={() => setExpanded(expanded === u.id ? null : u.id)}>
|
||||
<td><Avatar userId={u.id} size={28} /></td>
|
||||
<td><Avatar userId={u.id} size={28} users={users} /></td>
|
||||
<td>{u.name}</td>
|
||||
<td><span style={{ background: `${roleColors[u.role]}22`, color: roleColors[u.role], padding: '2px 8px', borderRadius: 10, fontSize: 10, fontWeight: 600 }}>{u.role.toUpperCase()}</span></td>
|
||||
<td>{u.dept}</td>
|
||||
<td>{ut.length}</td>
|
||||
<td>{done}</td>
|
||||
<td>{active}</td>
|
||||
{canManage && (
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
{u.id !== currentUser.id && (
|
||||
<button className="btn-danger-sm" onClick={() => setConfirmDelete(u.id)} title="Delete employee">🗑</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{expanded === u.id && (
|
||||
<tr><td colSpan={7}>
|
||||
<tr><td colSpan={canManage ? 8 : 7}>
|
||||
<div className="member-expand">
|
||||
{ut.map(t => (
|
||||
<div key={t.id} className="team-task-row">
|
||||
@@ -92,22 +135,48 @@ export function MembersPage({ tasks }: { tasks: Task[] }) {
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{showInvite && (
|
||||
<div className="modal-backdrop" onClick={() => setShowInvite(false)}>
|
||||
{/* Add Employee Modal */}
|
||||
{showAdd && (
|
||||
<div className="modal-backdrop" onClick={() => setShowAdd(false)}>
|
||||
<div className="modal invite-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header"><h2>Invite Member</h2><button className="drawer-close" onClick={() => setShowInvite(false)}>✕</button></div>
|
||||
<div className="modal-header"><h2>Add Employee</h2><button className="drawer-close" onClick={() => setShowAdd(false)}>✕</button></div>
|
||||
<div className="modal-body">
|
||||
<div className="modal-field"><label>Email</label><input className="modal-input" placeholder="member@company.io" /></div>
|
||||
<div className="modal-field">
|
||||
<label>Role</label>
|
||||
<select className="modal-input"><option value="employee">Employee</option><option value="manager">Manager</option></select>
|
||||
<div className="modal-field"><label>Full Name *</label><input className="modal-input" placeholder="John Doe" value={addForm.name} onChange={e => { setAddForm(f => ({ ...f, name: e.target.value })); setAddError(''); }} /></div>
|
||||
<div className="modal-field"><label>Email *</label><input className="modal-input" type="email" placeholder="john@company.io" value={addForm.email} onChange={e => { setAddForm(f => ({ ...f, email: e.target.value })); setAddError(''); }} /></div>
|
||||
<div className="modal-field"><label>Password *</label><input className="modal-input" type="password" placeholder="••••••••" value={addForm.password} onChange={e => { setAddForm(f => ({ ...f, password: e.target.value })); setAddError(''); }} /></div>
|
||||
<div className="modal-field"><label>Role</label>
|
||||
<select className="modal-input" value={addForm.role} onChange={e => setAddForm(f => ({ ...f, role: e.target.value }))}>
|
||||
<option value="employee">Employee</option><option value="tech_lead">Tech Lead</option><option value="scrum_master">Scrum Master</option>
|
||||
<option value="product_owner">Product Owner</option><option value="designer">Designer</option><option value="qa">QA Engineer</option>
|
||||
<option value="manager">Manager</option><option value="cto">CTO</option><option value="ceo">CEO</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-field"><label>Department</label><input className="modal-input" placeholder="e.g. Engineering" value={addForm.dept} onChange={e => setAddForm(f => ({ ...f, dept: e.target.value }))} /></div>
|
||||
{addError && <p style={{ color: '#ef4444', fontSize: 12, margin: '4px 0 0' }}>{addError}</p>}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn-ghost" onClick={() => setShowAdd(false)}>Cancel</button>
|
||||
<button className="btn-primary" onClick={handleAdd} disabled={addLoading}>{addLoading ? 'Adding...' : 'Add Employee'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{confirmDelete && (
|
||||
<div className="modal-backdrop" onClick={() => setConfirmDelete(null)}>
|
||||
<div className="modal invite-modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 400 }}>
|
||||
<div className="modal-header"><h2>Confirm Delete</h2><button className="drawer-close" onClick={() => setConfirmDelete(null)}>✕</button></div>
|
||||
<div className="modal-body">
|
||||
<p style={{ color: '#cbd5e1', fontSize: 14 }}>Are you sure you want to delete <strong>{users.find(u => u.id === confirmDelete)?.name}</strong>? Their tasks will be unassigned.</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn-ghost" onClick={() => setConfirmDelete(null)}>Cancel</button>
|
||||
<button className="btn-danger" onClick={() => handleDelete(confirmDelete)} disabled={deleteLoading}>{deleteLoading ? 'Deleting...' : 'Delete'}</button>
|
||||
</div>
|
||||
<div className="modal-footer"><button className="btn-ghost" onClick={() => setShowInvite(false)}>Cancel</button><button className="btn-primary" onClick={() => setShowInvite(false)}>Send Invite</button></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Task } from './data';
|
||||
import { USERS, STATUS_COLORS, PRIORITY_COLORS } from './data';
|
||||
import { useState } from 'react';
|
||||
import type { Task, User } from './data';
|
||||
import { STATUS_COLORS, PRIORITY_COLORS } from './data';
|
||||
import { apiExportCsv } from './api';
|
||||
import { BarChart, Bar, PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
|
||||
const tooltipStyle = {
|
||||
@@ -8,14 +10,19 @@ const tooltipStyle = {
|
||||
labelStyle: { color: '#94a3b8' },
|
||||
};
|
||||
|
||||
export function ReportsPage({ tasks }: { tasks: Task[] }) {
|
||||
export function ReportsPage({ tasks, users, currentUser }: { tasks: Task[]; users: User[]; currentUser: User }) {
|
||||
const [exportType, setExportType] = useState<'tasks' | 'users' | 'activities'>('tasks');
|
||||
const [exportMonth, setExportMonth] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const canExport = ['ceo', 'cto', 'manager'].includes(currentUser.role);
|
||||
const total = tasks.length;
|
||||
const completed = tasks.filter(t => t.status === 'done').length;
|
||||
const overdue = tasks.filter(t => new Date(t.dueDate + 'T00:00:00') < new Date() && t.status !== 'done').length;
|
||||
const critical = tasks.filter(t => t.priority === 'critical' && t.status !== 'done').length;
|
||||
|
||||
// Tasks per member (stacked by status)
|
||||
const memberData = USERS.map(u => {
|
||||
const memberData = users.map(u => {
|
||||
const ut = tasks.filter(t => t.assignee === u.id);
|
||||
return {
|
||||
name: u.name.split(' ')[0],
|
||||
@@ -36,7 +43,7 @@ export function ReportsPage({ tasks }: { tasks: Task[] }) {
|
||||
const completionData = days.map((d, i) => ({ name: d, completed: [1, 0, 2, 1, 3, 0, 1][i] }));
|
||||
|
||||
// Overdue by member
|
||||
const overdueData = USERS.map(u => ({
|
||||
const overdueData = users.map(u => ({
|
||||
name: u.name.split(' ')[0],
|
||||
overdue: tasks.filter(t => t.assignee === u.id && new Date(t.dueDate + 'T00:00:00') < new Date() && t.status !== 'done').length,
|
||||
})).filter(d => d.overdue > 0);
|
||||
@@ -113,6 +120,44 @@ export function ReportsPage({ tasks }: { tasks: Task[] }) {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canExport && (
|
||||
<div className="chart-card" style={{ marginTop: 24 }}>
|
||||
<div className="chart-card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>📥</span> Export Data
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div className="modal-field" style={{ margin: 0 }}>
|
||||
<label style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>Dataset</label>
|
||||
<select className="modal-input" style={{ width: 160 }} value={exportType} onChange={e => setExportType(e.target.value as 'tasks' | 'users' | 'activities')}>
|
||||
<option value="tasks">Tasks</option>
|
||||
<option value="users">Users & Workload</option>
|
||||
<option value="activities">Activity Log</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-field" style={{ margin: 0 }}>
|
||||
<label style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>Month (optional)</label>
|
||||
<input className="modal-input" type="month" style={{ width: 160 }} value={exportMonth} onChange={e => setExportMonth(e.target.value)} />
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
disabled={exporting}
|
||||
onClick={async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
await apiExportCsv(exportType, exportMonth || undefined);
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{exporting ? 'Exporting...' : '⬇ Download CSV'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { PRIORITY_COLORS, STATUS_COLORS, STATUS_LABELS, getUserById } from './data';
|
||||
import type { Priority, Status, Subtask } from './data';
|
||||
import type { Priority, Status, Subtask, User } from './data';
|
||||
|
||||
export function Avatar({ userId, size = 28 }: { userId: string; size?: number }) {
|
||||
const u = getUserById(userId);
|
||||
export function Avatar({ userId, size = 28, users }: { userId: string; size?: number; users: User[] }) {
|
||||
if (!users || !users.length) return null;
|
||||
const u = getUserById(users, userId);
|
||||
if (!u) return null;
|
||||
return (
|
||||
<div className="avatar" style={{ width: size, height: size, fontSize: size * 0.36, background: u.color }}>
|
||||
@@ -50,7 +51,7 @@ export function ProgressBar({ subtasks }: { subtasks: Subtask[] }) {
|
||||
}
|
||||
|
||||
export function RoleBadge({ role }: { role: string }) {
|
||||
const colors: Record<string, string> = { cto: '#818cf8', manager: '#fb923c', employee: '#22c55e' };
|
||||
const colors: Record<string, string> = { ceo: '#eab308', cto: '#818cf8', manager: '#fb923c', tech_lead: '#06b6d4', scrum_master: '#a855f7', product_owner: '#ec4899', designer: '#f43f5e', qa: '#14b8a6', employee: '#22c55e' };
|
||||
const c = colors[role] || '#64748b';
|
||||
return <span className="role-badge" style={{ background: `${c}22`, color: c }}>{role.toUpperCase()}</span>;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ import type { User } from './data';
|
||||
import { Avatar } from './Shared';
|
||||
import { RoleBadge } from './Shared';
|
||||
|
||||
const ALL_ROLES = ['ceo', 'cto', 'manager', 'tech_lead', 'scrum_master', 'product_owner', 'employee', 'designer', 'qa'];
|
||||
const LEADER_ROLES = ['ceo', 'cto', 'manager', 'tech_lead', 'scrum_master', 'product_owner'];
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'dashboard', icon: '⊞', label: 'Dashboard', roles: ['cto', 'manager', 'employee'] },
|
||||
{ id: 'calendar', icon: '📅', label: 'Calendar', roles: ['cto', 'manager', 'employee'] },
|
||||
{ id: 'kanban', icon: '▦', label: 'Kanban Board', roles: ['cto', 'manager', 'employee'] },
|
||||
{ id: 'mytasks', icon: '✓', label: 'My Tasks', roles: ['employee'] },
|
||||
{ id: 'teamtasks', icon: '👥', label: 'Team Tasks', roles: ['cto', 'manager'] },
|
||||
{ id: 'reports', icon: '📊', label: 'Reports', roles: ['cto', 'manager'] },
|
||||
{ id: 'members', icon: '👤', label: 'Members', roles: ['cto'] },
|
||||
{ id: 'dashboard', icon: '⊞', label: 'Dashboard', roles: ALL_ROLES },
|
||||
{ id: 'calendar', icon: '📅', label: 'Calendar', roles: ALL_ROLES },
|
||||
{ id: 'kanban', icon: '▦', label: 'Kanban Board', roles: ALL_ROLES },
|
||||
{ id: 'mytasks', icon: '✓', label: 'My Tasks', roles: ['employee', 'designer', 'qa'] },
|
||||
{ id: 'teamtasks', icon: '👥', label: 'Team Tasks', roles: LEADER_ROLES },
|
||||
{ id: 'reports', icon: '📊', label: 'Reports', roles: LEADER_ROLES },
|
||||
{ id: 'members', icon: '👤', label: 'Members', roles: ['ceo', 'cto'] },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -17,36 +20,42 @@ interface SidebarProps {
|
||||
activePage: string;
|
||||
onNavigate: (page: string) => void;
|
||||
onSignOut: () => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export function Sidebar({ currentUser, activePage, onNavigate, onSignOut }: SidebarProps) {
|
||||
export function Sidebar({ currentUser, activePage, onNavigate, onSignOut, isOpen, onClose, users }: SidebarProps) {
|
||||
const filteredNav = NAV_ITEMS.filter(n => n.roles.includes(currentUser.role));
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-logo">
|
||||
<div className="sidebar-logo-icon">⚡</div>
|
||||
<span className="sidebar-logo-text">Scrum-manager</span>
|
||||
</div>
|
||||
<div className="sidebar-divider" />
|
||||
<div className="sidebar-section-label">Navigate</div>
|
||||
<nav className="sidebar-nav">
|
||||
{filteredNav.map(n => (
|
||||
<div key={n.id} className={`sidebar-item ${activePage === n.id ? 'active' : ''}`} onClick={() => onNavigate(n.id)}>
|
||||
<span className="sidebar-item-icon">{n.icon}</span>
|
||||
{n.label}
|
||||
<>
|
||||
{isOpen && <div className="sidebar-backdrop visible" onClick={onClose} />}
|
||||
<div className={`sidebar ${isOpen ? 'sidebar-open' : ''}`}>
|
||||
<div className="sidebar-logo">
|
||||
<div className="sidebar-logo-icon">⚡</div>
|
||||
<span className="sidebar-logo-text">Scrum-manager</span>
|
||||
</div>
|
||||
<div className="sidebar-divider" />
|
||||
<div className="sidebar-section-label">Navigate</div>
|
||||
<nav className="sidebar-nav">
|
||||
{filteredNav.map(n => (
|
||||
<div key={n.id} className={`sidebar-item ${activePage === n.id ? 'active' : ''}`} onClick={() => onNavigate(n.id)}>
|
||||
<span className="sidebar-item-icon">{n.icon}</span>
|
||||
{n.label}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-profile">
|
||||
<Avatar userId={currentUser.id} size={36} users={users} />
|
||||
<div className="sidebar-profile-info">
|
||||
<div className="sidebar-profile-name">{currentUser.name}</div>
|
||||
<RoleBadge role={currentUser.role} />
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-profile">
|
||||
<Avatar userId={currentUser.id} size={36} />
|
||||
<div className="sidebar-profile-info">
|
||||
<div className="sidebar-profile-name">{currentUser.name}</div>
|
||||
<RoleBadge role={currentUser.role} />
|
||||
</div>
|
||||
<div style={{ padding: '0 16px 12px' }}>
|
||||
<button className="sidebar-signout" onClick={onSignOut}>Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '0 16px 12px' }}>
|
||||
<button className="sidebar-signout" onClick={onSignOut}>Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { useState } from 'react';
|
||||
import type { Task, User, Status, Priority } from './data';
|
||||
import { USERS, STATUS_LABELS, getUserById } from './data';
|
||||
import { STATUS_LABELS, getUserById } from './data';
|
||||
import { Avatar, Tag, ProgressBar } from './Shared';
|
||||
|
||||
interface DrawerProps {
|
||||
task: Task; currentUser: User; onClose: () => void;
|
||||
onUpdate: (updated: Task) => void;
|
||||
onAddDependency: (taskId: string, dep: { dependsOnUserId: string; description: string }) => void;
|
||||
onToggleDependency: (taskId: string, depId: string, resolved: boolean) => void;
|
||||
onRemoveDependency: (taskId: string, depId: string) => void;
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export function TaskDrawer({ task, currentUser, onClose, onUpdate }: DrawerProps) {
|
||||
export function TaskDrawer({ task, currentUser, onClose, onUpdate, onAddDependency, onToggleDependency, onRemoveDependency, users }: DrawerProps) {
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [subtaskText, setSubtaskText] = useState('');
|
||||
const [depUser, setDepUser] = useState('');
|
||||
const [depDesc, setDepDesc] = useState('');
|
||||
|
||||
const updateField = (field: string, value: any) => {
|
||||
const now = new Date().toISOString();
|
||||
@@ -48,8 +54,16 @@ export function TaskDrawer({ task, currentUser, onClose, onUpdate }: DrawerProps
|
||||
setCommentText('');
|
||||
};
|
||||
|
||||
const reporter = getUserById(task.reporter);
|
||||
const handleAddDep = () => {
|
||||
if (!depDesc.trim()) return;
|
||||
onAddDependency(task.id, { dependsOnUserId: depUser, description: depDesc });
|
||||
setDepDesc('');
|
||||
setDepUser('');
|
||||
};
|
||||
|
||||
const reporter = getUserById(users, task.reporter);
|
||||
const doneCount = task.subtasks.filter(s => s.done).length;
|
||||
const unresolvedDeps = (task.dependencies || []).filter(d => !d.resolved).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -67,25 +81,25 @@ export function TaskDrawer({ task, currentUser, onClose, onUpdate }: DrawerProps
|
||||
<div>
|
||||
<div className="drawer-meta-label">Assignee</div>
|
||||
<div className="drawer-meta-val">
|
||||
<Avatar userId={task.assignee} size={20} />
|
||||
<select className="drawer-select" value={task.assignee} onChange={e => updateField('assignee', e.target.value)}>
|
||||
{USERS.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
<Avatar userId={task.assignee} size={20} users={users} />
|
||||
<select className="drawer-select" aria-label="Assignee" value={task.assignee} onChange={e => updateField('assignee', e.target.value)}>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="drawer-meta-label">Reporter</div>
|
||||
<div className="drawer-meta-val"><Avatar userId={task.reporter} size={20} /> {reporter?.name}</div>
|
||||
<div className="drawer-meta-val"><Avatar userId={task.reporter} size={20} users={users} /> {reporter?.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="drawer-meta-label">Status</div>
|
||||
<select className="drawer-select" value={task.status} onChange={e => updateField('status', e.target.value)}>
|
||||
<select className="drawer-select" aria-label="Status" value={task.status} onChange={e => updateField('status', e.target.value)}>
|
||||
{Object.entries(STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div className="drawer-meta-label">Priority</div>
|
||||
<select className="drawer-select" value={task.priority} onChange={e => updateField('priority', e.target.value)}>
|
||||
<select className="drawer-select" aria-label="Priority" value={task.priority} onChange={e => updateField('priority', e.target.value)}>
|
||||
{['critical', 'high', 'medium', 'low'].map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -99,6 +113,50 @@ export function TaskDrawer({ task, currentUser, onClose, onUpdate }: DrawerProps
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies Section */}
|
||||
<div className="drawer-section">
|
||||
<div className="drawer-section-title">
|
||||
🔗 Dependencies
|
||||
{unresolvedDeps > 0 && <span className="dep-unresolved-badge">{unresolvedDeps} blocking</span>}
|
||||
</div>
|
||||
{(task.dependencies || []).length === 0 && (
|
||||
<div className="dep-empty">No dependencies yet</div>
|
||||
)}
|
||||
{(task.dependencies || []).map(dep => {
|
||||
const depUser = getUserById(users, dep.dependsOnUserId);
|
||||
return (
|
||||
<div key={dep.id} className={`dep-item ${dep.resolved ? 'dep-resolved' : 'dep-unresolved'}`}>
|
||||
<button
|
||||
className={`dep-check ${dep.resolved ? 'checked' : ''}`}
|
||||
onClick={() => onToggleDependency(task.id, dep.id, !dep.resolved)}
|
||||
title={dep.resolved ? 'Mark unresolved' : 'Mark resolved'}
|
||||
>
|
||||
{dep.resolved ? '✓' : ''}
|
||||
</button>
|
||||
<div className="dep-info">
|
||||
{depUser && (
|
||||
<span className="dep-user">
|
||||
<Avatar userId={dep.dependsOnUserId} size={18} users={users} />
|
||||
<span>{depUser.name}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className={`dep-desc ${dep.resolved ? 'done' : ''}`}>{dep.description}</span>
|
||||
</div>
|
||||
<button className="dep-remove" onClick={() => onRemoveDependency(task.id, dep.id)} title="Remove">✕</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="dep-add-row">
|
||||
<select className="dep-add-select" value={depUser} onChange={e => setDepUser(e.target.value)}>
|
||||
<option value="">Anyone</option>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.avatar} {u.name}</option>)}
|
||||
</select>
|
||||
<input className="dep-add-input" placeholder="Describe the dependency..." value={depDesc}
|
||||
onChange={e => setDepDesc(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAddDep()} />
|
||||
<button className="dep-add-btn" onClick={handleAddDep}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawer-section">
|
||||
<div className="drawer-section-title">Subtasks <span style={{ color: '#64748b', fontWeight: 400, fontSize: 12 }}>{doneCount} of {task.subtasks.length} complete</span></div>
|
||||
{task.subtasks.length > 0 && <ProgressBar subtasks={task.subtasks} />}
|
||||
@@ -117,10 +175,10 @@ export function TaskDrawer({ task, currentUser, onClose, onUpdate }: DrawerProps
|
||||
<div className="drawer-section">
|
||||
<div className="drawer-section-title">Comments</div>
|
||||
{task.comments.map(c => {
|
||||
const cu = getUserById(c.userId);
|
||||
const cu = getUserById(users, c.userId);
|
||||
return (
|
||||
<div key={c.id} className="comment-item">
|
||||
<Avatar userId={c.userId} size={26} />
|
||||
<Avatar userId={c.userId} size={26} users={users} />
|
||||
<div className="comment-bubble">
|
||||
<div className="comment-header">
|
||||
<span className="comment-name">{cu?.name}</span>
|
||||
@@ -132,7 +190,7 @@ export function TaskDrawer({ task, currentUser, onClose, onUpdate }: DrawerProps
|
||||
);
|
||||
})}
|
||||
<div className="comment-input-row">
|
||||
<Avatar userId={currentUser.id} size={26} />
|
||||
<Avatar userId={currentUser.id} size={26} users={users} />
|
||||
<input placeholder="Add a comment..." value={commentText} onChange={e => setCommentText(e.target.value)} onKeyDown={e => e.key === 'Enter' && addComment()} />
|
||||
<button onClick={addComment}>Post</button>
|
||||
</div>
|
||||
@@ -157,26 +215,51 @@ interface ModalProps {
|
||||
onAdd: (task: Task) => void;
|
||||
defaultDate?: string;
|
||||
defaultStatus?: Status;
|
||||
users: User[];
|
||||
currentUser: User;
|
||||
}
|
||||
|
||||
export function AddTaskModal({ onClose, onAdd, defaultDate, defaultStatus }: ModalProps) {
|
||||
interface PendingDep {
|
||||
id: string;
|
||||
dependsOnUserId: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function AddTaskModal({ onClose, onAdd, defaultDate, defaultStatus, users, currentUser }: ModalProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [assignee, setAssignee] = useState('u1');
|
||||
const [assignee, setAssignee] = useState(currentUser.id);
|
||||
const [priority, setPriority] = useState<Priority>('medium');
|
||||
const [status, setStatus] = useState<Status>(defaultStatus || 'todo');
|
||||
const [dueDate, setDueDate] = useState(defaultDate || new Date().toISOString().split('T')[0]);
|
||||
const [tags, setTags] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
// Dependencies state
|
||||
const [deps, setDeps] = useState<PendingDep[]>([]);
|
||||
const [depUser, setDepUser] = useState('');
|
||||
const [depDesc, setDepDesc] = useState('');
|
||||
|
||||
const addDep = () => {
|
||||
if (!depDesc.trim()) return;
|
||||
setDeps(prev => [...prev, { id: `pd${Date.now()}`, dependsOnUserId: depUser, description: depDesc }]);
|
||||
setDepDesc('');
|
||||
setDepUser('');
|
||||
};
|
||||
|
||||
const removeDep = (id: string) => {
|
||||
setDeps(prev => prev.filter(d => d.id !== id));
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!title.trim()) { setError(true); return; }
|
||||
const task: Task = {
|
||||
id: `t${Date.now()}`, title, description: desc, status, priority,
|
||||
assignee, reporter: 'u1', dueDate,
|
||||
assignee, reporter: currentUser.id, dueDate,
|
||||
tags: tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [],
|
||||
subtasks: [], comments: [],
|
||||
activity: [{ id: `a${Date.now()}`, text: `📝 Task created`, timestamp: new Date().toISOString() }],
|
||||
dependencies: deps.map(d => ({ id: d.id, dependsOnUserId: d.dependsOnUserId, description: d.description, resolved: false })),
|
||||
};
|
||||
onAdd(task);
|
||||
onClose();
|
||||
@@ -197,9 +280,9 @@ export function AddTaskModal({ onClose, onAdd, defaultDate, defaultStatus }: Mod
|
||||
</div>
|
||||
<div className="modal-grid">
|
||||
<div className="modal-field">
|
||||
<label>Assignee</label>
|
||||
<label>Assign To</label>
|
||||
<select className="modal-input" value={assignee} onChange={e => setAssignee(e.target.value)}>
|
||||
{USERS.map(u => <option key={u.id} value={u.id}>{u.avatar} {u.name}</option>)}
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.avatar} {u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-field">
|
||||
@@ -223,6 +306,33 @@ export function AddTaskModal({ onClose, onAdd, defaultDate, defaultStatus }: Mod
|
||||
<label>Tags (comma separated)</label>
|
||||
<input className="modal-input" placeholder="devops, backend, ..." value={tags} onChange={e => setTags(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* Dependencies Section */}
|
||||
<div className="modal-field">
|
||||
<label>🔗 Dependencies / Blockers</label>
|
||||
<div className="modal-deps-list">
|
||||
{deps.map(d => {
|
||||
const u = getUserById(users, d.dependsOnUserId);
|
||||
return (
|
||||
<div key={d.id} className="modal-dep-item">
|
||||
<span className="modal-dep-icon">⏳</span>
|
||||
{u && <span className="modal-dep-user">{u.avatar} {u.name}:</span>}
|
||||
<span className="modal-dep-desc">{d.description}</span>
|
||||
<button className="modal-dep-remove" onClick={() => removeDep(d.id)}>✕</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="modal-dep-add">
|
||||
<select className="modal-dep-select" value={depUser} onChange={e => setDepUser(e.target.value)}>
|
||||
<option value="">Blocked by (anyone)</option>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.avatar} {u.name}</option>)}
|
||||
</select>
|
||||
<input className="modal-dep-input" placeholder="e.g. Need API endpoints from backend team"
|
||||
value={depDesc} onChange={e => setDepDesc(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addDep())} />
|
||||
<button className="modal-dep-btn" onClick={addDep} type="button">+ Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn-ghost" onClick={onClose}>Cancel</button>
|
||||
|
||||
54
src/__tests__/App.test.tsx
Normal file
54
src/__tests__/App.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import App from '../App';
|
||||
import * as api from '../api';
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('../api', () => ({
|
||||
apiFetchTasks: vi.fn(),
|
||||
apiFetchUsers: vi.fn(),
|
||||
apiCreateTask: vi.fn(),
|
||||
apiUpdateTask: vi.fn(),
|
||||
apiAddActivity: vi.fn(),
|
||||
apiLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('App Component', () => {
|
||||
it('renders login page when no user is logged in', () => {
|
||||
render(<App />);
|
||||
expect(screen.getByRole('button', { name: /sig\s*n\s*in/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders main content after login', async () => {
|
||||
const mockUser = { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'admin', dept: 'Engineering' };
|
||||
const mockTasks = [{ id: 't1', title: 'Task 1', status: 'todo' }];
|
||||
const mockUsers = [mockUser];
|
||||
|
||||
// Mock API responses
|
||||
(api.apiLogin as any).mockResolvedValue(mockUser);
|
||||
(api.apiFetchTasks as any).mockResolvedValue(mockTasks);
|
||||
(api.apiFetchUsers as any).mockResolvedValue(mockUsers);
|
||||
|
||||
render(<App />);
|
||||
|
||||
// Simulate login
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const loginButton = screen.getByRole('button', { name: /sig\s*n\s*in/i });
|
||||
|
||||
await userEvent.type(emailInput, 'test@example.com');
|
||||
await userEvent.type(passwordInput, 'password');
|
||||
await userEvent.click(loginButton);
|
||||
|
||||
// Wait for data loading
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check if main content is rendered (e.g., Sidebar, Calendar)
|
||||
expect(screen.getByText('Calendar')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test User')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
67
src/__tests__/Kanban.test.tsx
Normal file
67
src/__tests__/Kanban.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { KanbanBoard } from '../Kanban';
|
||||
import type { Task, User } from '../data';
|
||||
|
||||
// Mock Shared components
|
||||
vi.mock('../Shared', () => ({
|
||||
Avatar: ({ userId }: any) => <div data-testid="avatar">{userId}</div>,
|
||||
PriorityBadge: ({ level }: any) => <div>{level}</div>,
|
||||
StatusBadge: ({ status }: any) => <div>{status}</div>,
|
||||
ProgressBar: () => <div>Progress</div>
|
||||
}));
|
||||
|
||||
describe('KanbanBoard Component', () => {
|
||||
const mockUser: User = { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'emp', dept: 'dev', avatar: 'TU', color: '#000' };
|
||||
const mockUsers = [mockUser];
|
||||
|
||||
const mockTasks: Task[] = [
|
||||
{ id: 't1', title: 'Task 1', description: 'Desc', status: 'todo', priority: 'medium', assignee: 'u1', reporter: 'u1', dueDate: '2023-12-31', tags: [], subtasks: [], comments: [], activity: [], dependencies: [] },
|
||||
{ id: 't2', title: 'Task 2', description: 'Desc', status: 'inprogress', priority: 'high', assignee: 'u1', reporter: 'u1', dueDate: '2023-12-31', tags: [], subtasks: [], comments: [], activity: [], dependencies: [] },
|
||||
];
|
||||
|
||||
const mockOnTaskClick = vi.fn();
|
||||
const mockOnAddTask = vi.fn();
|
||||
const mockOnMoveTask = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders columns and tasks', () => {
|
||||
render(<KanbanBoard tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onAddTask={mockOnAddTask} onMoveTask={mockOnMoveTask} filterUser={null} searchQuery="" users={mockUsers} />);
|
||||
|
||||
expect(screen.getByText('To Do')).toBeInTheDocument();
|
||||
expect(screen.getByText('In Progress')).toBeInTheDocument();
|
||||
expect(screen.getByText('Review')).toBeInTheDocument();
|
||||
expect(screen.getByText('Done')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Task 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Task 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters tasks by search query', () => {
|
||||
render(<KanbanBoard tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onAddTask={mockOnAddTask} onMoveTask={mockOnMoveTask} filterUser={null} searchQuery="Task 1" users={mockUsers} />);
|
||||
|
||||
expect(screen.getByText('Task 1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Task 2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onAddTask when + button clicked', () => {
|
||||
render(<KanbanBoard tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onAddTask={mockOnAddTask} onMoveTask={mockOnMoveTask} filterUser={null} searchQuery="" users={mockUsers} />);
|
||||
|
||||
// There are multiple + buttons (one per column)
|
||||
const addButtons = screen.getAllByText('+');
|
||||
fireEvent.click(addButtons[0]); // Click first column (Todo) add button
|
||||
|
||||
expect(mockOnAddTask).toHaveBeenCalledWith('todo');
|
||||
});
|
||||
|
||||
it('calls onTaskClick when task clicked', () => {
|
||||
render(<KanbanBoard tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onAddTask={mockOnAddTask} onMoveTask={mockOnMoveTask} filterUser={null} searchQuery="" users={mockUsers} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Task 1'));
|
||||
expect(mockOnTaskClick).toHaveBeenCalledWith(mockTasks[0]);
|
||||
});
|
||||
});
|
||||
57
src/__tests__/ListView.test.tsx
Normal file
57
src/__tests__/ListView.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ListView } from '../ListView';
|
||||
import type { Task, User } from '../data';
|
||||
|
||||
describe('ListView Component', () => {
|
||||
const mockUser: User = { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'emp', dept: 'dev', avatar: 'TU', color: '#000' };
|
||||
const mockUsers = [mockUser];
|
||||
|
||||
// Test data
|
||||
const mockTasks: Task[] = [
|
||||
{ id: 't1', title: 'Task 1', description: 'Desc', status: 'todo', priority: 'medium', assignee: 'u1', reporter: 'u1', dueDate: '2023-12-31', tags: [], subtasks: [], comments: [], activity: [], dependencies: [] },
|
||||
{ id: 't2', title: 'Task 2', description: 'Desc', status: 'done', priority: 'high', assignee: 'u1', reporter: 'u1', dueDate: '2023-12-31', tags: [], subtasks: [], comments: [], activity: [], dependencies: [] },
|
||||
];
|
||||
|
||||
const mockOnTaskClick = vi.fn();
|
||||
const mockOnToggleDone = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders tasks in list', () => {
|
||||
render(<ListView tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onToggleDone={mockOnToggleDone} filterUser={null} searchQuery="" users={mockUsers} />);
|
||||
|
||||
expect(screen.getByText('Task 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Task 2')).toBeInTheDocument();
|
||||
// Check for headers (using getAllByText because buttons also have these labels)
|
||||
expect(screen.getAllByText('Title').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Assignee').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('filters tasks by search', () => {
|
||||
render(<ListView tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onToggleDone={mockOnToggleDone} filterUser={null} searchQuery="Task 1" users={mockUsers} />);
|
||||
|
||||
expect(screen.getByText('Task 1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Task 2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles task click', () => {
|
||||
render(<ListView tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onToggleDone={mockOnToggleDone} filterUser={null} searchQuery="" users={mockUsers} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Task 1'));
|
||||
expect(mockOnTaskClick).toHaveBeenCalledWith(mockTasks[0]);
|
||||
});
|
||||
|
||||
it('handles toggle done', () => {
|
||||
render(<ListView tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onToggleDone={mockOnToggleDone} filterUser={null} searchQuery="" users={mockUsers} />);
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
fireEvent.click(checkboxes[0]);
|
||||
|
||||
expect(mockOnToggleDone).toHaveBeenCalledWith('t1');
|
||||
});
|
||||
});
|
||||
107
src/__tests__/Login.test.tsx
Normal file
107
src/__tests__/Login.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { LoginPage } from '../Login';
|
||||
import * as api from '../api';
|
||||
|
||||
// Mock API
|
||||
vi.mock('../api', () => ({
|
||||
apiLogin: vi.fn(),
|
||||
apiRegister: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('LoginPage Component', () => {
|
||||
const mockOnLogin = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnLogin.mockClear();
|
||||
});
|
||||
|
||||
it('renders login form by default', () => {
|
||||
render(<LoginPage onLogin={mockOnLogin} />);
|
||||
expect(screen.getByText(/scrum-manager/i)).toBeInTheDocument(); // Logo check
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/no account\? register here/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
it('switches to register mode', async () => {
|
||||
render(<LoginPage onLogin={mockOnLogin} />);
|
||||
await userEvent.click(screen.getByText(/no account\? register here/i));
|
||||
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/already have an account\? sign in/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles successful login', async () => {
|
||||
const mockUser = { id: 'u1', name: 'Test', email: 'test@example.com', role: 'emp', dept: 'dev' };
|
||||
(api.apiLogin as any).mockResolvedValue(mockUser);
|
||||
|
||||
render(<LoginPage onLogin={mockOnLogin} />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/email/i), 'test@example.com');
|
||||
await userEvent.type(screen.getByLabelText(/password/i), 'password');
|
||||
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.apiLogin).toHaveBeenCalledWith('test@example.com', 'password');
|
||||
expect(mockOnLogin).toHaveBeenCalledWith(mockUser);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles login failure', async () => {
|
||||
(api.apiLogin as any).mockRejectedValue(new Error('Invalid credentials'));
|
||||
|
||||
render(<LoginPage onLogin={mockOnLogin} />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/email/i), 'wrong@example.com');
|
||||
await userEvent.type(screen.getByLabelText(/password/i), 'wrongpass');
|
||||
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(mockOnLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles successful registration', async () => {
|
||||
const mockUser = { id: 'u2', name: 'New User', email: 'new@example.com', role: 'employee', dept: 'dev' };
|
||||
(api.apiRegister as any).mockResolvedValue(mockUser);
|
||||
|
||||
render(<LoginPage onLogin={mockOnLogin} />);
|
||||
await userEvent.click(screen.getByText(/no account\? register here/i));
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/name/i), 'New User');
|
||||
await userEvent.type(screen.getByLabelText(/email/i), 'new@example.com');
|
||||
await userEvent.type(screen.getByLabelText(/password/i), 'password123');
|
||||
await userEvent.type(screen.getByLabelText(/department/i), 'DevOps'); // "e.g. Backend..." placeholder, checking label
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /create account/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.apiRegister).toHaveBeenCalledWith({
|
||||
name: 'New User',
|
||||
email: 'new@example.com',
|
||||
password: 'password123',
|
||||
role: 'employee', // Default
|
||||
dept: 'DevOps'
|
||||
});
|
||||
expect(mockOnLogin).toHaveBeenCalledWith(mockUser);
|
||||
});
|
||||
});
|
||||
|
||||
it('validates registration inputs', async () => {
|
||||
render(<LoginPage onLogin={mockOnLogin} />);
|
||||
await userEvent.click(screen.getByText(/no account\? register here/i));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /create account/i }));
|
||||
|
||||
expect(screen.getByText(/all fields are required/i)).toBeInTheDocument();
|
||||
expect(api.apiRegister).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
108
src/__tests__/TaskDrawer.test.tsx
Normal file
108
src/__tests__/TaskDrawer.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TaskDrawer } from '../TaskDrawer';
|
||||
import type { Task, User } from '../data';
|
||||
|
||||
// Mock Shared components to avoid testing their specific implementation here
|
||||
vi.mock('../Shared', () => ({
|
||||
Avatar: ({ userId }: any) => <div data-testid="avatar">{userId}</div>,
|
||||
Tag: ({ label }: any) => <div>{label}</div>,
|
||||
ProgressBar: () => <div>Progress</div>
|
||||
}));
|
||||
|
||||
describe('TaskDrawer Component', () => {
|
||||
const mockUser: User = { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'emp', dept: 'dev', avatar: 'TU', color: '#000' };
|
||||
const mockUsers = [mockUser, { id: 'u2', name: 'Other User', email: 'other@example.com', role: 'emp', dept: 'dev', avatar: 'OU', color: '#fff' }];
|
||||
|
||||
const mockTask: Task = {
|
||||
id: 't1',
|
||||
title: 'Test Task',
|
||||
description: 'Test Description',
|
||||
status: 'todo',
|
||||
priority: 'medium',
|
||||
assignee: 'u1',
|
||||
reporter: 'u1',
|
||||
dueDate: '2023-12-31',
|
||||
tags: ['bug'],
|
||||
subtasks: [],
|
||||
comments: [],
|
||||
activity: [],
|
||||
dependencies: []
|
||||
};
|
||||
|
||||
const mockOnUpdate = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnAddDep = vi.fn();
|
||||
const mockOnToggleDep = vi.fn();
|
||||
const mockOnRemoveDep = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders task details correctly', () => {
|
||||
render(<TaskDrawer task={mockTask} currentUser={mockUser} onClose={mockOnClose} onUpdate={mockOnUpdate} onAddDependency={mockOnAddDep} onToggleDependency={mockOnToggleDep} onRemoveDependency={mockOnRemoveDep} users={mockUsers} />);
|
||||
|
||||
// screen.debug();
|
||||
|
||||
expect(screen.getByText('Test Task')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Description')).toBeInTheDocument();
|
||||
// Check for mocked avatar with userId
|
||||
const avatars = screen.getAllByTestId('avatar');
|
||||
expect(avatars.length).toBeGreaterThan(0);
|
||||
expect(avatars[0]).toHaveTextContent('u1');
|
||||
// expect(screen.getByText(/Test User/i)).toBeInTheDocument(); // Assignee name might be tricky in Select/Div
|
||||
expect(screen.getByText('bug')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates title/description calling onUpdate', async () => {
|
||||
// Title isn't editable in the drawer based on code, only via modal or just display?
|
||||
// Checking code: <h2 className="drawer-title">{task.title}</h2>. It's not an input.
|
||||
// But status, priority, assignee are selects.
|
||||
|
||||
render(<TaskDrawer task={mockTask} currentUser={mockUser} onClose={mockOnClose} onUpdate={mockOnUpdate} onAddDependency={mockOnAddDep} onToggleDependency={mockOnToggleDep} onRemoveDependency={mockOnRemoveDep} users={mockUsers} />);
|
||||
|
||||
// Update status
|
||||
const statusSelect = screen.getByRole('combobox', { name: /status/i });
|
||||
await userEvent.selectOptions(statusSelect, 'done');
|
||||
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 't1',
|
||||
status: 'done'
|
||||
}));
|
||||
});
|
||||
|
||||
it('adds a subtask', async () => {
|
||||
render(<TaskDrawer task={mockTask} currentUser={mockUser} onClose={mockOnClose} onUpdate={mockOnUpdate} onAddDependency={mockOnAddDep} onToggleDependency={mockOnToggleDep} onRemoveDependency={mockOnRemoveDep} users={mockUsers} />);
|
||||
|
||||
const input = screen.getByPlaceholderText(/add a subtask/i);
|
||||
await userEvent.type(input, 'New Subtask{enter}');
|
||||
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
subtasks: expect.arrayContaining([expect.objectContaining({ title: 'New Subtask', done: false })])
|
||||
}));
|
||||
});
|
||||
|
||||
it('adds a comment', async () => {
|
||||
render(<TaskDrawer task={mockTask} currentUser={mockUser} onClose={mockOnClose} onUpdate={mockOnUpdate} onAddDependency={mockOnAddDep} onToggleDependency={mockOnToggleDep} onRemoveDependency={mockOnRemoveDep} users={mockUsers} />);
|
||||
|
||||
const input = screen.getByPlaceholderText(/add a comment/i);
|
||||
await userEvent.type(input, 'This is a comment{enter}');
|
||||
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
comments: expect.arrayContaining([expect.objectContaining({ text: 'This is a comment', userId: 'u1' })])
|
||||
}));
|
||||
});
|
||||
|
||||
it('adds a dependency', async () => {
|
||||
render(<TaskDrawer task={mockTask} currentUser={mockUser} onClose={mockOnClose} onUpdate={mockOnUpdate} onAddDependency={mockOnAddDep} onToggleDependency={mockOnToggleDep} onRemoveDependency={mockOnRemoveDep} users={mockUsers} />);
|
||||
|
||||
const descInput = screen.getByPlaceholderText(/describe the dependency/i);
|
||||
await userEvent.type(descInput, 'Blocked by API{enter}');
|
||||
|
||||
// Default select is "Anyone" (empty string).
|
||||
expect(mockOnAddDep).toHaveBeenCalledWith('t1', { dependsOnUserId: '', description: 'Blocked by API' });
|
||||
});
|
||||
});
|
||||
137
src/api.ts
Normal file
137
src/api.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
async function request(url: string, options?: RequestInit) {
|
||||
const res = await fetch(`${API_BASE}${url}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || 'Request failed');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Auth
|
||||
export async function apiLogin(email: string, password: string) {
|
||||
return request('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiRegister(data: {
|
||||
name: string; email: string; password: string; role?: string; dept?: string;
|
||||
}) {
|
||||
return request('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiFetchUsers() {
|
||||
return request('/auth/users');
|
||||
}
|
||||
|
||||
export async function apiCreateUser(data: {
|
||||
name: string; email: string; password: string; role?: string; dept?: string;
|
||||
}) {
|
||||
return request('/auth/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiDeleteUser(id: string) {
|
||||
return request(`/auth/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiExportCsv(type: 'tasks' | 'users' | 'activities', month?: string) {
|
||||
const params = month ? `?month=${month}` : '';
|
||||
const res = await fetch(`${API_BASE}/export/${type}${params}`);
|
||||
if (!res.ok) throw new Error('Export failed');
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = month ? `${type}_${month}.csv` : `${type}_all.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Tasks
|
||||
export async function apiFetchTasks() {
|
||||
return request('/tasks');
|
||||
}
|
||||
|
||||
export async function apiCreateTask(task: {
|
||||
title: string; description?: string; status?: string; priority?: string;
|
||||
assignee?: string; reporter?: string; dueDate?: string; tags?: string[];
|
||||
subtasks?: { title: string; done: boolean }[];
|
||||
dependencies?: { dependsOnUserId: string; description: string }[];
|
||||
}) {
|
||||
return request('/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(task),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiUpdateTask(id: string, updates: Record<string, unknown>) {
|
||||
return request(`/tasks/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiAddSubtask(taskId: string, title: string) {
|
||||
return request(`/tasks/${taskId}/subtasks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiToggleSubtask(taskId: string, subtaskId: string, done: boolean) {
|
||||
return request(`/tasks/${taskId}/subtasks/${subtaskId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ done }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiAddComment(taskId: string, userId: string, text: string) {
|
||||
return request(`/tasks/${taskId}/comments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, text }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiAddActivity(taskId: string, text: string) {
|
||||
return request(`/tasks/${taskId}/activity`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
// Dependencies
|
||||
export async function apiAddDependency(taskId: string, dep: { dependsOnUserId: string; description: string }) {
|
||||
return request(`/tasks/${taskId}/dependencies`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dep),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiToggleDependency(taskId: string, depId: string, resolved: boolean) {
|
||||
return request(`/tasks/${taskId}/dependencies/${depId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ resolved }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiRemoveDependency(taskId: string, depId: string) {
|
||||
return request(`/tasks/${taskId}/dependencies/${depId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
98
src/data.ts
98
src/data.ts
@@ -1,29 +1,20 @@
|
||||
const now = new Date();
|
||||
const d = (offset: number) => {
|
||||
const dt = new Date(now);
|
||||
dt.setDate(dt.getDate() + offset);
|
||||
return dt.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
export const USERS = [
|
||||
{ id: 'u1', name: 'Subodh Pawar', role: 'cto', email: 'subodh@corp.io', pass: 'cto123', color: '#818cf8', avatar: 'SP', dept: 'Leadership' },
|
||||
{ id: 'u2', name: 'Ankit Sharma', role: 'employee', email: 'ankit@corp.io', pass: 'emp123', color: '#f59e0b', avatar: 'AS', dept: 'DevOps' },
|
||||
{ id: 'u3', name: 'Priya Nair', role: 'employee', email: 'priya@corp.io', pass: 'emp123', color: '#34d399', avatar: 'PN', dept: 'Backend' },
|
||||
{ id: 'u4', name: 'Rahul Mehta', role: 'employee', email: 'rahul@corp.io', pass: 'emp123', color: '#f472b6', avatar: 'RM', dept: 'Frontend' },
|
||||
{ id: 'u5', name: 'Deepa Iyer', role: 'manager', email: 'deepa@corp.io', pass: 'mgr123', color: '#fb923c', avatar: 'DI', dept: 'QA' },
|
||||
];
|
||||
|
||||
export type User = typeof USERS[number];
|
||||
export type Priority = 'critical' | 'high' | 'medium' | 'low';
|
||||
export type Status = 'todo' | 'inprogress' | 'review' | 'done';
|
||||
|
||||
export interface User {
|
||||
id: string; name: string; role: string; email: string;
|
||||
color: string; avatar: string; dept: string;
|
||||
}
|
||||
|
||||
export interface Subtask { id: string; title: string; done: boolean }
|
||||
export interface Comment { id: string; userId: string; text: string; timestamp: string }
|
||||
export interface Activity { id: string; text: string; timestamp: string }
|
||||
export interface Dependency { id: string; dependsOnUserId: string; description: string; resolved: boolean }
|
||||
export interface Task {
|
||||
id: string; title: string; description: string; status: Status; priority: Priority;
|
||||
assignee: string; reporter: string; dueDate: string; tags: string[];
|
||||
subtasks: Subtask[]; comments: Comment[]; activity: Activity[];
|
||||
dependencies: Dependency[];
|
||||
}
|
||||
|
||||
export const PRIORITY_COLORS: Record<Priority, { color: string; bg: string }> = {
|
||||
@@ -41,77 +32,4 @@ export const STATUS_LABELS: Record<Status, string> = {
|
||||
todo: 'To Do', inprogress: 'In Progress', review: 'Review', done: 'Done',
|
||||
};
|
||||
|
||||
export const SEED_TASKS: Task[] = [
|
||||
{
|
||||
id: 't1', title: 'ArgoCD pipeline for staging', description: 'Set up ArgoCD GitOps pipeline for the staging environment with automatic sync and rollback capabilities.',
|
||||
status: 'inprogress', priority: 'critical', assignee: 'u2', reporter: 'u1', dueDate: d(3), tags: ['devops', 'ci-cd'],
|
||||
subtasks: [{ id: 's1', title: 'Configure ArgoCD manifest', done: true }, { id: 's2', title: 'Setup GitOps repo structure', done: false }, { id: 's3', title: 'Test rollback workflow', done: false }],
|
||||
comments: [{ id: 'c1', userId: 'u1', text: 'Make sure we use Helm charts for this.', timestamp: '2026-02-14T10:22:00' }],
|
||||
activity: [{ id: 'a1', text: '🔄 Subodh moved to In Progress', timestamp: '2026-02-14T10:22:00' }, { id: 'a2', text: '✅ Ankit completed subtask "Configure ArgoCD manifest"', timestamp: '2026-02-14T14:30:00' }],
|
||||
},
|
||||
{
|
||||
id: 't2', title: 'Harbor registry cleanup script', description: 'Write a cron-based cleanup script to remove stale Docker images older than 30 days from Harbor.',
|
||||
status: 'todo', priority: 'medium', assignee: 'u3', reporter: 'u5', dueDate: d(8), tags: ['backend', 'devops'],
|
||||
subtasks: [{ id: 's4', title: 'Draft retention policy', done: false }, { id: 's5', title: 'Write cleanup script', done: false }],
|
||||
comments: [{ id: 'c2', userId: 'u5', text: 'Coordinate with DevOps for registry credentials.', timestamp: '2026-02-13T09:00:00' }],
|
||||
activity: [{ id: 'a3', text: '📝 Deepa created task', timestamp: '2026-02-13T09:00:00' }, { id: 'a4', text: '👤 Assigned to Priya', timestamp: '2026-02-13T09:01:00' }],
|
||||
},
|
||||
{
|
||||
id: 't3', title: 'SonarQube quality gate fix', description: 'Fix failing quality gates in SonarQube for the API microservice — coverage is below threshold.',
|
||||
status: 'review', priority: 'high', assignee: 'u2', reporter: 'u1', dueDate: d(1), tags: ['quality', 'testing'],
|
||||
subtasks: [{ id: 's6', title: 'Identify uncovered code paths', done: true }, { id: 's7', title: 'Write missing unit tests', done: true }, { id: 's8', title: 'Verify gate passes', done: false }],
|
||||
comments: [{ id: 'c3', userId: 'u1', text: 'Coverage must be above 80%.', timestamp: '2026-02-12T11:00:00' }, { id: 'c4', userId: 'u2', text: 'Currently at 76%, adding tests now.', timestamp: '2026-02-13T15:00:00' }],
|
||||
activity: [{ id: 'a5', text: '🔄 Ankit moved to Review', timestamp: '2026-02-14T16:00:00' }, { id: 'a6', text: '✅ Ankit completed 2 subtasks', timestamp: '2026-02-14T15:30:00' }],
|
||||
},
|
||||
{
|
||||
id: 't4', title: 'MinIO bucket lifecycle policy', description: 'Configure lifecycle policies for MinIO buckets to auto-expire temporary uploads after 7 days.',
|
||||
status: 'done', priority: 'low', assignee: 'u5', reporter: 'u1', dueDate: d(-2), tags: ['infrastructure'],
|
||||
subtasks: [{ id: 's9', title: 'Define lifecycle rules', done: true }, { id: 's10', title: 'Apply and test policy', done: true }],
|
||||
comments: [{ id: 'c5', userId: 'u5', text: 'Done and verified on staging.', timestamp: '2026-02-13T17:00:00' }],
|
||||
activity: [{ id: 'a7', text: '✅ Deepa moved to Done', timestamp: '2026-02-13T17:00:00' }, { id: 'a8', text: '💬 Deepa added a comment', timestamp: '2026-02-13T17:01:00' }],
|
||||
},
|
||||
{
|
||||
id: 't5', title: 'Jenkins shared library refactor', description: 'Refactor Jenkins shared libraries to use declarative pipeline syntax and reduce duplication.',
|
||||
status: 'inprogress', priority: 'high', assignee: 'u2', reporter: 'u5', dueDate: d(6), tags: ['devops', 'refactor'],
|
||||
subtasks: [{ id: 's11', title: 'Audit existing shared libs', done: true }, { id: 's12', title: 'Migrate to declarative syntax', done: false }, { id: 's13', title: 'Update pipeline docs', done: false }],
|
||||
comments: [{ id: 'c6', userId: 'u2', text: 'Found 12 redundant pipeline stages.', timestamp: '2026-02-14T10:00:00' }],
|
||||
activity: [{ id: 'a9', text: '🔄 Ankit started working', timestamp: '2026-02-13T11:00:00' }, { id: 'a10', text: '✅ Completed audit subtask', timestamp: '2026-02-14T10:00:00' }],
|
||||
},
|
||||
{
|
||||
id: 't6', title: 'Grafana k8s dashboard', description: 'Create comprehensive Grafana dashboards for Kubernetes cluster monitoring including pod health and resource usage.',
|
||||
status: 'todo', priority: 'medium', assignee: 'u4', reporter: 'u1', dueDate: d(12), tags: ['monitoring', 'frontend'],
|
||||
subtasks: [{ id: 's14', title: 'Design dashboard layout', done: false }, { id: 's15', title: 'Configure Prometheus data sources', done: false }],
|
||||
comments: [{ id: 'c7', userId: 'u1', text: 'Use the standard k8s mixin as a starting point.', timestamp: '2026-02-12T14:00:00' }],
|
||||
activity: [{ id: 'a11', text: '📝 Subodh created task', timestamp: '2026-02-12T14:00:00' }, { id: 'a12', text: '👤 Assigned to Rahul', timestamp: '2026-02-12T14:01:00' }],
|
||||
},
|
||||
{
|
||||
id: 't7', title: 'React component audit', description: 'Audit all React components for accessibility compliance and performance optimizations.',
|
||||
status: 'inprogress', priority: 'medium', assignee: 'u4', reporter: 'u5', dueDate: d(5), tags: ['frontend', 'a11y'],
|
||||
subtasks: [{ id: 's16', title: 'Run Lighthouse audit', done: true }, { id: 's17', title: 'Fix critical a11y issues', done: false }, { id: 's18', title: 'Document findings', done: false }],
|
||||
comments: [{ id: 'c8', userId: 'u4', text: 'Initial Lighthouse score is 72.', timestamp: '2026-02-14T08:00:00' }],
|
||||
activity: [{ id: 'a13', text: '🔄 Rahul moved to In Progress', timestamp: '2026-02-13T09:00:00' }, { id: 'a14', text: '✅ Completed Lighthouse audit', timestamp: '2026-02-14T08:00:00' }],
|
||||
},
|
||||
{
|
||||
id: 't8', title: 'PostgreSQL backup strategy', description: 'Implement automated daily backups for PostgreSQL with point-in-time recovery and off-site storage.',
|
||||
status: 'todo', priority: 'critical', assignee: 'u3', reporter: 'u1', dueDate: d(2), tags: ['database', 'infrastructure'],
|
||||
subtasks: [{ id: 's19', title: 'Setup pg_basebackup cron', done: false }, { id: 's20', title: 'Configure WAL archiving', done: false }, { id: 's21', title: 'Test restore procedure', done: false }],
|
||||
comments: [{ id: 'c9', userId: 'u1', text: 'This is critical — we need backups before the release.', timestamp: '2026-02-14T09:00:00' }],
|
||||
activity: [{ id: 'a15', text: '📝 Subodh created task', timestamp: '2026-02-14T09:00:00' }, { id: 'a16', text: '⚠️ Marked as Critical priority', timestamp: '2026-02-14T09:01:00' }],
|
||||
},
|
||||
{
|
||||
id: 't9', title: 'API rate limiting middleware', description: 'Add rate limiting middleware to the Express API with configurable thresholds per endpoint.',
|
||||
status: 'review', priority: 'high', assignee: 'u3', reporter: 'u5', dueDate: d(4), tags: ['backend', 'security'],
|
||||
subtasks: [{ id: 's22', title: 'Research rate limiting libraries', done: true }, { id: 's23', title: 'Implement middleware', done: true }, { id: 's24', title: 'Add integration tests', done: false }],
|
||||
comments: [{ id: 'c10', userId: 'u3', text: 'Using express-rate-limit with Redis store.', timestamp: '2026-02-14T16:00:00' }, { id: 'c11', userId: 'u5', text: 'Please add tests before moving to done.', timestamp: '2026-02-15T09:00:00' }],
|
||||
activity: [{ id: 'a17', text: '🔄 Priya moved to Review', timestamp: '2026-02-14T16:00:00' }, { id: 'a18', text: '💬 Deepa added a comment', timestamp: '2026-02-15T09:00:00' }],
|
||||
},
|
||||
{
|
||||
id: 't10', title: 'Mobile responsive QA sweep', description: 'Complete QA sweep of all pages for mobile responsiveness across iOS and Android devices.',
|
||||
status: 'done', priority: 'low', assignee: 'u5', reporter: 'u1', dueDate: d(-5), tags: ['qa', 'mobile'],
|
||||
subtasks: [{ id: 's25', title: 'Test on iOS Safari', done: true }, { id: 's26', title: 'Test on Android Chrome', done: true }, { id: 's27', title: 'File bug reports', done: true }],
|
||||
comments: [{ id: 'c12', userId: 'u5', text: 'All pages pass on both platforms. 3 minor bugs filed.', timestamp: '2026-02-10T15:00:00' }],
|
||||
activity: [{ id: 'a19', text: '✅ Deepa moved to Done', timestamp: '2026-02-10T15:00:00' }, { id: 'a20', text: '🐛 3 bugs filed in tracker', timestamp: '2026-02-10T15:30:00' }],
|
||||
},
|
||||
];
|
||||
|
||||
export function getUserById(id: string) { return USERS.find(u => u.id === id); }
|
||||
export function getUserById(users: User[], id: string) { return users.find(u => u.id === id); }
|
||||
|
||||
2838
src/index.css
2838
src/index.css
File diff suppressed because it is too large
Load Diff
2
src/test/setup.ts
Normal file
2
src/test/setup.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
36
tests/integration/health.test.ts
Normal file
36
tests/integration/health.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// fetch is global in Node 18+
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
const FRONTEND_URL = 'http://localhost:80';
|
||||
const BACKEND_URL = 'http://localhost:3001';
|
||||
|
||||
it('Frontend is reachable', async () => {
|
||||
try {
|
||||
const res = await fetch(FRONTEND_URL);
|
||||
expect(res.status).toBe(200);
|
||||
const text = await res.text();
|
||||
expect(text).toContain('<!doctype html>');
|
||||
} catch (e) {
|
||||
// If fetch fails (connection refused), test fails
|
||||
throw new Error(`Frontend not reachable at ${FRONTEND_URL}: ${e.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Backend health check / API is reachable', async () => {
|
||||
// We don't have a specific health endpoint, but we can try to hit an auth endpoint
|
||||
// that requires valid input, expecting a 400 or 401 instead of connection refused.
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
// Expecting 400 because we sent empty body, meaning server is up and parsing JSON
|
||||
expect(res.status).toBe(400);
|
||||
} catch (e) {
|
||||
throw new Error(`Backend not reachable at ${BACKEND_URL}: ${e.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
},
|
||||
})
|
||||
|
||||
11
vitest.integration.config.js
Normal file
11
vitest.integration.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['tests/integration/**/*.test.ts', 'tests/integration/**/*.test.js'],
|
||||
testTimeout: 20000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user