Implement wiring hardening, runtime API config, smoke tests, and build scripts
This commit is contained in:
60
.github/workflows/release.yml
vendored
Normal file
60
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
name: Build & Publish Unsigned macOS Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install frontend deps
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Install backend deps
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Build & package Electron app
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm run electron:build
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
id: create_release
|
||||||
|
uses: actions/create-release@v1
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.ref_name || github.sha }}
|
||||||
|
release_name: Release ${{ github.ref_name || github.sha }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Upload packaged artifacts to release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
UPLOAD_URL: ${{ steps.create_release.outputs.upload_url }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Upload any dmg/zip files produced by electron-builder
|
||||||
|
for f in frontend/dist/*.{dmg,zip}; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
name=$(basename "$f")
|
||||||
|
echo "Uploading $f as $name"
|
||||||
|
curl -sSL -X POST -H "Authorization: token ${GITHUB_TOKEN}" -H "Content-Type: application/octet-stream" --data-binary @"$f" "${UPLOAD_URL}?name=${name}"
|
||||||
|
done
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -7,3 +7,11 @@ node_modules/
|
|||||||
# Database files (live data lives in Docker volume)
|
# Database files (live data lives in Docker volume)
|
||||||
backend/db.json
|
backend/db.json
|
||||||
backend/data/
|
backend/data/
|
||||||
|
|
||||||
|
# TLS certificates — never commit private keys
|
||||||
|
nginx/certs/*.pem
|
||||||
|
|
||||||
|
# Environment secrets
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production.local
|
||||||
|
|||||||
158
README.md
Normal file
158
README.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
Project Hub — Desktop app + backend
|
||||||
|
|
||||||
|
This repository contains a React+Vite frontend packaged as an Electron desktop app, and a small Node/Express backend using lowdb for JSON persistence. Development builds and packaged macOS installer artifacts are stored in `releases/`.
|
||||||
|
|
||||||
|
Quick links
|
||||||
|
|
||||||
|
- Releases: `releases/` (DMG, ZIP, packaged .app)
|
||||||
|
- Frontend: `frontend/`
|
||||||
|
- Backend: `backend/`
|
||||||
|
|
||||||
|
Development
|
||||||
|
|
||||||
|
1) Backend (fast, using Docker):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from project root
|
||||||
|
cd backend
|
||||||
|
# install deps (if needed)
|
||||||
|
npm install
|
||||||
|
# build image and run (example)
|
||||||
|
docker build -t project-manager-backend:latest .
|
||||||
|
docker run -d --name project-manager-api -p 4000:4000 \
|
||||||
|
-v project_manager_data:/app/data \
|
||||||
|
-e NODE_ENV=production -e PORT=4000 -e HOST=0.0.0.0 \
|
||||||
|
-e CORS_ORIGIN="http://localhost:5173" -e WRITE_API_KEY="dev-key" \
|
||||||
|
--restart unless-stopped project-manager-backend:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use `docker compose up -d` if you prefer Compose.
|
||||||
|
|
||||||
|
Endpoints
|
||||||
|
|
||||||
|
- Health: `GET /health`
|
||||||
|
- API root: `GET /api/projects`, `GET /api/members`, etc.
|
||||||
|
|
||||||
|
Config / env vars
|
||||||
|
|
||||||
|
- `PORT` (default 4000)
|
||||||
|
- `HOST` (default 0.0.0.0)
|
||||||
|
- `DB_FILE` (optional path to db file)
|
||||||
|
- `CORS_ORIGIN` (comma-separated allowed origins)
|
||||||
|
- `WRITE_API_KEY` (if set, write operations require `x-api-key` header)
|
||||||
|
|
||||||
|
2) Frontend (dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run electron:dev # starts Vite and launches Electron
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime API URL
|
||||||
|
|
||||||
|
The app now supports runtime API endpoint configuration:
|
||||||
|
|
||||||
|
- In the desktop UI, click **Server** in the top-right and set the API base URL (example: `http://localhost:4000/api`).
|
||||||
|
- The value is persisted in Electron app storage and used on next requests without rebuilding.
|
||||||
|
- If no runtime value is set, the app falls back to `VITE_API_URL`, then `http://localhost:4000/api`.
|
||||||
|
|
||||||
|
Write authentication (`WRITE_API_KEY`)
|
||||||
|
|
||||||
|
- If backend sets `WRITE_API_KEY`, all POST/PUT/DELETE endpoints require `x-api-key`.
|
||||||
|
- Frontend and Electron now propagate this key automatically when provided via:
|
||||||
|
- `WRITE_API_KEY` (preferred for Electron runtime)
|
||||||
|
- `VITE_WRITE_API_KEY` (frontend build-time fallback)
|
||||||
|
- GET endpoints remain readable without API key unless you add additional auth middleware.
|
||||||
|
|
||||||
|
Build & releases
|
||||||
|
|
||||||
|
- Build renderer and package Electron (mac example):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run electron:build
|
||||||
|
```
|
||||||
|
|
||||||
|
- Built artifacts are placed into `frontend/dist/` and this project’s `releases/` directory after packaging.
|
||||||
|
|
||||||
|
- Verified release build (runs persistence check before packaging):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:verified
|
||||||
|
```
|
||||||
|
|
||||||
|
- Verified release build with backend restart check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:verified:restart
|
||||||
|
```
|
||||||
|
|
||||||
|
- One-command verified release pipeline (persistence test + frontend build + dist sync + Electron packaging):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:verified
|
||||||
|
```
|
||||||
|
|
||||||
|
- Same as above, but also verifies persistence across backend restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:verified:restart
|
||||||
|
```
|
||||||
|
|
||||||
|
Unsigned macOS Gatekeeper note
|
||||||
|
|
||||||
|
These builds are currently unsigned. macOS Gatekeeper may prevent opening the app. Users can bypass with one of the following (instruct users to accept the risk):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Open by right-clicking the app and choosing "Open", or run:
|
||||||
|
xattr -r -d com.apple.quarantine "/Applications/Project Hub.app"
|
||||||
|
# or for a packaged app in releases:
|
||||||
|
xattr -r -d com.apple.quarantine "releases/Project Hub.app"
|
||||||
|
```
|
||||||
|
|
||||||
|
CI / Distribution suggestions
|
||||||
|
|
||||||
|
- Add a GitHub Actions workflow to build and upload unsigned artifacts to GitHub Releases (I can add this).
|
||||||
|
- To avoid Gatekeeper prompts for wide distribution, sign and notarize the app (requires Apple Developer account and credentials).
|
||||||
|
|
||||||
|
Security & production notes
|
||||||
|
|
||||||
|
- `lowdb` (JSON file) is fine short-term for small teams. For production/many users, migrate to a proper DB (Postgres, managed DB).
|
||||||
|
- Use `WRITE_API_KEY` or user accounts to protect write operations; always run behind TLS (reverse proxy like nginx/Traefik) for public hosting.
|
||||||
|
|
||||||
|
Need help?
|
||||||
|
|
||||||
|
I can add a GitHub Actions workflow to build/upload unsigned releases, or add signing/notarization steps if you acquire an Apple Developer account. Tell me which and I’ll scaffold it.
|
||||||
|
|
||||||
|
Persistence self-test
|
||||||
|
|
||||||
|
Run this before release to verify that created records are persisted and can be read back:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:persistence
|
||||||
|
```
|
||||||
|
|
||||||
|
If your backend is running under Docker Compose and you also want to verify persistence across a backend restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:persistence:restart
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional env vars:
|
||||||
|
|
||||||
|
- `API_BASE` (default `http://localhost:4000/api`)
|
||||||
|
- `WRITE_API_KEY` (needed if backend write endpoints are protected)
|
||||||
|
|
||||||
|
Wiring smoke test
|
||||||
|
|
||||||
|
Run a focused integration check for task/member/invite route wiring:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:wiring
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional env vars for this test:
|
||||||
|
|
||||||
|
- `API_BASE` (default `http://localhost:4000/api`)
|
||||||
|
- `WRITE_API_KEY` (required if write endpoints are protected)
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install --omit=dev
|
RUN apk add --no-cache curl && npm install --omit=dev
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:4000/health || exit 1
|
||||||
|
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|||||||
477
backend/index.js
477
backend/index.js
@@ -1,45 +1,254 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
import { Low } from 'lowdb';
|
import { Low } from 'lowdb';
|
||||||
import { JSONFile } from 'lowdb/node';
|
import { JSONFile } from 'lowdb/node';
|
||||||
import { join, dirname } from 'path';
|
import { join, dirname, isAbsolute, extname } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import { mkdirSync, unlinkSync, existsSync, readFileSync } from 'fs';
|
||||||
|
import { randomBytes, randomUUID } from 'crypto';
|
||||||
|
import multer from 'multer';
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const dataDir = join(__dirname, 'data');
|
const dataDir = join(__dirname, 'data');
|
||||||
import { mkdirSync } from 'fs';
|
|
||||||
mkdirSync(dataDir, { recursive: true });
|
const dbFileEnv = process.env.DB_FILE;
|
||||||
const dbFile = join(dataDir, 'db.json');
|
const dbFile = dbFileEnv ? (isAbsolute(dbFileEnv) ? dbFileEnv : join(dataDir, dbFileEnv)) : join(dataDir, 'db.json');
|
||||||
|
const legacyDbFile = join(__dirname, 'db.json');
|
||||||
|
mkdirSync(dirname(dbFile), { recursive: true });
|
||||||
const adapter = new JSONFile(dbFile);
|
const adapter = new JSONFile(dbFile);
|
||||||
const defaultData = { members: [], projects: [], tasks: [] };
|
const defaultData = { members: [], projects: [], tasks: [], invites: [] };
|
||||||
const db = new Low(adapter, defaultData);
|
const db = new Low(adapter, defaultData);
|
||||||
|
|
||||||
|
const safeReadJson = (filePath) => {
|
||||||
|
try {
|
||||||
|
if (!existsSync(filePath)) return null;
|
||||||
|
const raw = readFileSync(filePath, 'utf8').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toInitials = (name = '') =>
|
||||||
|
String(name)
|
||||||
|
.split(' ')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(w => w[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
|
||||||
|
const ensureProjectShape = (project) => {
|
||||||
|
if (!Array.isArray(project.members)) project.members = [];
|
||||||
|
if (!Array.isArray(project.tasks)) project.tasks = [];
|
||||||
|
if (!Array.isArray(project.milestones)) project.milestones = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
await db.read();
|
await db.read();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Primary db file was unreadable, reinitializing with defaults.', err?.message || err);
|
||||||
|
db.data = { ...defaultData };
|
||||||
|
await db.write();
|
||||||
|
}
|
||||||
|
if (!db.data) db.data = defaultData;
|
||||||
|
if (!db.data.invites) db.data.invites = [];
|
||||||
|
if (!db.data.members) db.data.members = [];
|
||||||
|
if (!db.data.projects) db.data.projects = [];
|
||||||
|
if (!db.data.tasks) db.data.tasks = [];
|
||||||
|
|
||||||
|
// One-time bootstrap from legacy backend/db.json when the runtime db is empty.
|
||||||
|
const runtimeHasData = (db.data.members.length + db.data.projects.length + db.data.invites.length) > 0;
|
||||||
|
if (!runtimeHasData && dbFile !== legacyDbFile) {
|
||||||
|
const legacy = safeReadJson(legacyDbFile);
|
||||||
|
if (legacy && typeof legacy === 'object') {
|
||||||
|
db.data.members = Array.isArray(legacy.members) ? legacy.members : [];
|
||||||
|
db.data.projects = Array.isArray(legacy.projects) ? legacy.projects : [];
|
||||||
|
db.data.invites = Array.isArray(legacy.invites) ? legacy.invites : [];
|
||||||
|
db.data.tasks = Array.isArray(legacy.tasks) ? legacy.tasks : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize projects and migrate any legacy flat tasks into nested project.tasks.
|
||||||
|
for (const project of db.data.projects) ensureProjectShape(project);
|
||||||
|
for (const task of db.data.tasks) {
|
||||||
|
const project = db.data.projects.find(p => p.id === task.projectId);
|
||||||
|
if (!project) continue;
|
||||||
|
ensureProjectShape(project);
|
||||||
|
const existing = project.tasks.find(t => t.id === task.id);
|
||||||
|
if (existing) continue;
|
||||||
|
project.tasks.push({
|
||||||
|
id: task.id || randomUUID(),
|
||||||
|
title: task.title || 'Untitled Task',
|
||||||
|
description: task.description || '',
|
||||||
|
status: task.status || 'todo',
|
||||||
|
priority: task.priority || 'medium',
|
||||||
|
dueDate: task.dueDate || '',
|
||||||
|
startDate: task.startDate || '',
|
||||||
|
assignedTo: task.assignedTo || task.memberId || '',
|
||||||
|
estimatedHours: task.estimatedHours,
|
||||||
|
actualHours: task.actualHours,
|
||||||
|
recurrence: task.recurrence || 'none',
|
||||||
|
subtasks: Array.isArray(task.subtasks) ? task.subtasks : [],
|
||||||
|
attachments: Array.isArray(task.attachments) ? task.attachments : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
db.data.tasks = [];
|
||||||
|
|
||||||
|
for (const project of db.data.projects) {
|
||||||
|
ensureProjectShape(project);
|
||||||
|
project.members = project.members
|
||||||
|
.map(member => {
|
||||||
|
if (member && typeof member === 'object') {
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
initials: member.initials || toInitials(member.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const roster = db.data.members.find(m => m.id === member);
|
||||||
|
if (!roster) return null;
|
||||||
|
return { ...roster, initials: roster.initials || toInitials(roster.name) };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
await db.write();
|
await db.write();
|
||||||
|
|
||||||
|
// ── File uploads (multer) ───────────────────────────────────────────────────
|
||||||
|
const UPLOAD_DIR = join(__dirname, 'uploads');
|
||||||
|
mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const ALLOWED_MIME = new Set([
|
||||||
|
'image/jpeg','image/png','image/gif','image/webp','image/svg+xml',
|
||||||
|
'application/pdf','text/plain','text/csv','text/markdown',
|
||||||
|
'application/json',
|
||||||
|
'application/zip','application/x-zip-compressed',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // xlsx
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // docx
|
||||||
|
]);
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
|
||||||
|
filename: (req, file, cb) => cb(null, randomUUID() + extname(file.originalname).toLowerCase()),
|
||||||
|
});
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (ALLOWED_MIME.has(file.mimetype)) cb(null, true);
|
||||||
|
else cb(new Error('File type not allowed'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Notification helper ────────────────────────────────────────────────────
|
||||||
|
async function notify({ webhookUrl, notifyEmail, subject, text, payload }) {
|
||||||
|
if (webhookUrl) {
|
||||||
|
try {
|
||||||
|
await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload || { subject, text }),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Webhook notification failed', err?.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (notifyEmail && process.env.SMTP_HOST) {
|
||||||
|
try {
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: Number(process.env.SMTP_PORT) || 587,
|
||||||
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
|
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } : undefined,
|
||||||
|
});
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@projecthub.local',
|
||||||
|
to: notifyEmail,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Email notification failed', err?.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(helmet());
|
||||||
|
app.use(morgan(process.env.MORGAN_FORMAT || 'combined'));
|
||||||
|
|
||||||
|
const configuredOrigins = (process.env.CORS_ORIGIN || '')
|
||||||
|
.split(',')
|
||||||
|
.map(o => o.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const allowedOrigins = configuredOrigins.length
|
||||||
|
? configuredOrigins
|
||||||
|
: ['http://localhost:5173', 'http://localhost:4000', 'null'];
|
||||||
|
|
||||||
|
const corsOptions = {
|
||||||
|
credentials: true,
|
||||||
|
origin: (origin, callback) => {
|
||||||
|
if (!origin) return callback(null, true);
|
||||||
|
if (origin === 'null' || origin.startsWith('file://')) return callback(null, true);
|
||||||
|
if (allowedOrigins.includes(origin)) return callback(null, true);
|
||||||
|
return callback(new Error('Not allowed by CORS'));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
app.use(cors(corsOptions));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: Number(process.env.RATE_WINDOW_MS) || 15 * 60 * 1000,
|
||||||
|
max: Number(process.env.RATE_MAX) || 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requireApiKey = (req, res, next) => {
|
||||||
|
const key = process.env.WRITE_API_KEY;
|
||||||
|
if (!key) return next();
|
||||||
|
const provided = req.get('x-api-key') || req.query.api_key;
|
||||||
|
if (provided === key) return next();
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Health
|
||||||
|
app.get('/health', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await db.read();
|
||||||
|
return res.json({ status: 'ok', timestamp: Date.now() });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ status: 'error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Member CRUD
|
// Member CRUD
|
||||||
app.get('/api/members', (req, res) => {
|
app.get('/api/members', (req, res) => {
|
||||||
res.json(db.data.members);
|
res.json(db.data.members);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/members', (req, res) => {
|
app.post('/api/members', limiter, requireApiKey, async (req, res) => {
|
||||||
const member = { id: Date.now().toString(), ...req.body };
|
const member = { id: Date.now().toString(), ...req.body };
|
||||||
|
member.initials = member.initials || toInitials(member.name);
|
||||||
db.data.members.push(member);
|
db.data.members.push(member);
|
||||||
db.write();
|
await db.write();
|
||||||
res.status(201).json(member);
|
res.status(201).json(member);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/members/:id', (req, res) => {
|
app.delete('/api/members/:id', limiter, requireApiKey, async (req, res) => {
|
||||||
db.data.members = db.data.members.filter(m => m.id !== req.params.id);
|
db.data.members = db.data.members.filter(m => m.id !== req.params.id);
|
||||||
db.write();
|
for (const project of db.data.projects) {
|
||||||
|
ensureProjectShape(project);
|
||||||
|
project.members = project.members.filter(m => m.id !== req.params.id);
|
||||||
|
}
|
||||||
|
await db.write();
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/members/:id', async (req, res) => {
|
app.put('/api/members/:id', limiter, requireApiKey, async (req, res) => {
|
||||||
const idx = db.data.members.findIndex(m => m.id === req.params.id);
|
const idx = db.data.members.findIndex(m => m.id === req.params.id);
|
||||||
if (idx === -1) return res.status(404).json({ error: 'Member not found' });
|
if (idx === -1) return res.status(404).json({ error: 'Member not found' });
|
||||||
db.data.members[idx] = { ...db.data.members[idx], ...req.body, id: req.params.id };
|
db.data.members[idx] = { ...db.data.members[idx], ...req.body, id: req.params.id };
|
||||||
@@ -52,55 +261,263 @@ app.get('/api/projects', (req, res) => {
|
|||||||
res.json(db.data.projects);
|
res.json(db.data.projects);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/projects', (req, res) => {
|
app.post('/api/projects', limiter, requireApiKey, async (req, res) => {
|
||||||
const project = { id: Date.now().toString(), members: [], ...req.body };
|
const project = { id: Date.now().toString(), members: [], tasks: [], milestones: [], ...req.body };
|
||||||
|
ensureProjectShape(project);
|
||||||
db.data.projects.push(project);
|
db.data.projects.push(project);
|
||||||
db.write();
|
await db.write();
|
||||||
res.status(201).json(project);
|
res.status(201).json(project);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/projects/:id', async (req, res) => {
|
app.put('/api/projects/:id', limiter, requireApiKey, async (req, res) => {
|
||||||
const idx = db.data.projects.findIndex(p => p.id === req.params.id);
|
const idx = db.data.projects.findIndex(p => p.id === req.params.id);
|
||||||
const updated = { ...req.body, id: req.params.id };
|
const updated = { ...req.body, id: req.params.id };
|
||||||
|
ensureProjectShape(updated);
|
||||||
if (idx === -1) db.data.projects.push(updated);
|
if (idx === -1) db.data.projects.push(updated);
|
||||||
else db.data.projects[idx] = updated;
|
else db.data.projects[idx] = updated;
|
||||||
await db.write();
|
await db.write();
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/projects/:id', (req, res) => {
|
app.delete('/api/projects/:id', limiter, requireApiKey, async (req, res) => {
|
||||||
db.data.projects = db.data.projects.filter(p => p.id !== req.params.id);
|
db.data.projects = db.data.projects.filter(p => p.id !== req.params.id);
|
||||||
db.write();
|
await db.write();
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assign member to project
|
// Assign member to project
|
||||||
app.post('/api/projects/:projectId/members/:memberId', (req, res) => {
|
app.post('/api/projects/:projectId/members/:memberId', limiter, requireApiKey, async (req, res) => {
|
||||||
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||||
if (!project) return res.status(404).json({ error: 'Project not found' });
|
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||||
if (!project.members.includes(req.params.memberId)) {
|
ensureProjectShape(project);
|
||||||
project.members.push(req.params.memberId);
|
const member = db.data.members.find(m => m.id === req.params.memberId);
|
||||||
db.write();
|
if (!member) return res.status(404).json({ error: 'Member not found' });
|
||||||
|
|
||||||
|
const alreadyAssigned = project.members.some(m => m.id === member.id);
|
||||||
|
if (!alreadyAssigned) {
|
||||||
|
project.members.push({ ...member, initials: member.initials || toInitials(member.name) });
|
||||||
|
await db.write();
|
||||||
}
|
}
|
||||||
res.json(project);
|
res.json(project);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tasks (assign to member in project)
|
// Tasks (assign to member in project)
|
||||||
app.post('/api/projects/:projectId/tasks', (req, res) => {
|
app.post('/api/projects/:projectId/tasks', limiter, requireApiKey, async (req, res) => {
|
||||||
const { title, description, memberId } = req.body;
|
const { title, description, memberId, assignedTo } = req.body;
|
||||||
|
const assigneeId = assignedTo || memberId || '';
|
||||||
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||||
if (!project) return res.status(404).json({ error: 'Project not found' });
|
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||||
if (!project.members.includes(memberId)) return res.status(400).json({ error: 'Member not assigned to project' });
|
ensureProjectShape(project);
|
||||||
const task = { id: Date.now().toString(), projectId: project.id, memberId, title, description, status: 'todo' };
|
if (assigneeId && !project.members.some(m => m.id === assigneeId)) {
|
||||||
db.data.tasks.push(task);
|
return res.status(400).json({ error: 'Member not assigned to project' });
|
||||||
db.write();
|
}
|
||||||
|
|
||||||
|
const task = {
|
||||||
|
id: randomUUID(),
|
||||||
|
title: String(title || '').trim() || 'Untitled Task',
|
||||||
|
description: description || '',
|
||||||
|
status: req.body.status || 'todo',
|
||||||
|
priority: req.body.priority || 'medium',
|
||||||
|
dueDate: req.body.dueDate || '',
|
||||||
|
startDate: req.body.startDate || '',
|
||||||
|
assignedTo: assigneeId,
|
||||||
|
estimatedHours: req.body.estimatedHours,
|
||||||
|
actualHours: req.body.actualHours,
|
||||||
|
recurrence: req.body.recurrence || 'none',
|
||||||
|
subtasks: Array.isArray(req.body.subtasks) ? req.body.subtasks : [],
|
||||||
|
attachments: Array.isArray(req.body.attachments) ? req.body.attachments : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
project.tasks.push(task);
|
||||||
|
await db.write();
|
||||||
res.status(201).json(task);
|
res.status(201).json(task);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/projects/:projectId/tasks', (req, res) => {
|
app.get('/api/projects/:projectId/tasks', (req, res) => {
|
||||||
const tasks = db.data.tasks.filter(t => t.projectId === req.params.projectId);
|
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||||
res.json(tasks);
|
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||||
|
ensureProjectShape(project);
|
||||||
|
res.json(project.tasks);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/projects/:projectId/tasks/:taskId', limiter, requireApiKey, async (req, res) => {
|
||||||
|
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||||
|
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||||
|
ensureProjectShape(project);
|
||||||
|
const idx = project.tasks.findIndex(t => t.id === req.params.taskId);
|
||||||
|
if (idx === -1) return res.status(404).json({ error: 'Task not found' });
|
||||||
|
|
||||||
|
const assigneeId = req.body.assignedTo || req.body.memberId || project.tasks[idx].assignedTo || '';
|
||||||
|
if (assigneeId && !project.members.some(m => m.id === assigneeId)) {
|
||||||
|
return res.status(400).json({ error: 'Member not assigned to project' });
|
||||||
|
}
|
||||||
|
|
||||||
|
project.tasks[idx] = {
|
||||||
|
...project.tasks[idx],
|
||||||
|
...req.body,
|
||||||
|
id: req.params.taskId,
|
||||||
|
assignedTo: assigneeId,
|
||||||
|
};
|
||||||
|
await db.write();
|
||||||
|
res.json(project.tasks[idx]);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/projects/:projectId/tasks/:taskId', limiter, requireApiKey, async (req, res) => {
|
||||||
|
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||||
|
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||||
|
ensureProjectShape(project);
|
||||||
|
const idx = project.tasks.findIndex(t => t.id === req.params.taskId);
|
||||||
|
if (idx === -1) return res.status(404).json({ error: 'Task not found' });
|
||||||
|
project.tasks.splice(idx, 1);
|
||||||
|
await db.write();
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Static uploads ──────────────────────────────────────────────────────────
|
||||||
|
app.use('/uploads', express.static(UPLOAD_DIR));
|
||||||
|
|
||||||
|
// ── File attachments ────────────────────────────────────────────────────────
|
||||||
|
// Attachments are stored inline in project.tasks[].attachments[]
|
||||||
|
|
||||||
|
app.post('/api/projects/:projectId/tasks/:taskId/attachments', limiter, requireApiKey, (req, res, next) => {
|
||||||
|
upload.single('file')(req, res, async err => {
|
||||||
|
if (err) return res.status(400).json({ error: err.message });
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'No file provided' });
|
||||||
|
|
||||||
|
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||||
|
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||||
|
ensureProjectShape(project);
|
||||||
|
const task = project.tasks.find(t => t.id === req.params.taskId);
|
||||||
|
if (!task) return res.status(404).json({ error: 'Task not found' });
|
||||||
|
|
||||||
|
if (!task.attachments) task.attachments = [];
|
||||||
|
const attachment = {
|
||||||
|
id: randomUUID(),
|
||||||
|
filename: req.file.filename,
|
||||||
|
originalName: req.file.originalname,
|
||||||
|
mimetype: req.file.mimetype,
|
||||||
|
size: req.file.size,
|
||||||
|
url: `/uploads/${req.file.filename}`,
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
task.attachments.push(attachment);
|
||||||
|
await db.write();
|
||||||
|
res.status(201).json(attachment);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/projects/:projectId/tasks/:taskId/attachments/:attachmentId', limiter, requireApiKey, async (req, res) => {
|
||||||
|
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||||
|
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||||
|
ensureProjectShape(project);
|
||||||
|
const task = project.tasks.find(t => t.id === req.params.taskId);
|
||||||
|
if (!task) return res.status(404).json({ error: 'Task not found' });
|
||||||
|
|
||||||
|
const idx = task.attachments?.findIndex(a => a.id === req.params.attachmentId);
|
||||||
|
if (idx === undefined || idx === -1) return res.status(404).json({ error: 'Attachment not found' });
|
||||||
|
|
||||||
|
const [removed] = task.attachments.splice(idx, 1);
|
||||||
|
// Delete file from disk
|
||||||
|
const filePath = join(UPLOAD_DIR, removed.filename);
|
||||||
|
if (existsSync(filePath)) { try { unlinkSync(filePath); } catch {} }
|
||||||
|
|
||||||
|
await db.write();
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Webhook/notification settings on project ────────────────────────────────
|
||||||
|
// Projects can have optional webhookUrl and notifyEmail; these are just
|
||||||
|
// stored as part of the project doc. The POST /api/projects/:id/notify endpoint
|
||||||
|
// fires a test notification.
|
||||||
|
app.post('/api/projects/:id/notify', limiter, requireApiKey, async (req, res) => {
|
||||||
|
const project = db.data.projects.find(p => p.id === req.params.id);
|
||||||
|
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||||
|
const { subject = 'Project Hub notification', text = `Update for project: ${project.name}` } = req.body;
|
||||||
|
notify({ webhookUrl: project.webhookUrl, notifyEmail: project.notifyEmail, subject, text, payload: { projectId: project.id, projectName: project.name, subject, text } });
|
||||||
|
res.json({ sent: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Invite system ────────────────────────────────────────────────────────────
|
||||||
|
// Generate an invite token for a project
|
||||||
|
app.post('/api/projects/:id/invites', limiter, requireApiKey, async (req, res) => {
|
||||||
|
const project = db.data.projects.find(p => p.id === req.params.id);
|
||||||
|
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||||
|
|
||||||
|
const token = randomBytes(32).toString('hex');
|
||||||
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||||
|
const invite = {
|
||||||
|
id: randomUUID(),
|
||||||
|
token,
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.name,
|
||||||
|
email: req.body.email || null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
acceptedAt: null,
|
||||||
|
};
|
||||||
|
db.data.invites.push(invite);
|
||||||
|
await db.write();
|
||||||
|
|
||||||
|
// Fire email if SMTP configured and email provided
|
||||||
|
if (req.body.email) {
|
||||||
|
const appUrl = process.env.APP_URL || 'http://localhost:5173';
|
||||||
|
notify({
|
||||||
|
notifyEmail: req.body.email,
|
||||||
|
subject: `You've been invited to "${project.name}"`,
|
||||||
|
text: `You've been invited to join the project "${project.name}".\n\nAccept your invitation:\n${appUrl}/invite/${token}\n\nThis link expires in 7 days.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({ token, expiresAt: invite.expiresAt, inviteId: invite.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate an invite token (public)
|
||||||
|
app.get('/api/invites/:token', (req, res) => {
|
||||||
|
const invite = db.data.invites.find(i => i.token === req.params.token);
|
||||||
|
if (!invite) return res.status(404).json({ error: 'Invite not found or expired' });
|
||||||
|
if (new Date(invite.expiresAt) < new Date()) return res.status(410).json({ error: 'Invite has expired' });
|
||||||
|
if (invite.acceptedAt) return res.status(409).json({ error: 'Invite already accepted' });
|
||||||
|
res.json({ projectId: invite.projectId, projectName: invite.projectName, email: invite.email, expiresAt: invite.expiresAt });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accept an invite (attaches provided member name to project if given)
|
||||||
|
app.post('/api/invites/:token/accept', limiter, async (req, res) => {
|
||||||
|
const invite = db.data.invites.find(i => i.token === req.params.token);
|
||||||
|
if (!invite) return res.status(404).json({ error: 'Invite not found' });
|
||||||
|
if (new Date(invite.expiresAt) < new Date()) return res.status(410).json({ error: 'Invite has expired' });
|
||||||
|
if (invite.acceptedAt) return res.status(409).json({ error: 'Invite already accepted' });
|
||||||
|
|
||||||
|
invite.acceptedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// Optionally add the member to the project
|
||||||
|
if (req.body.name) {
|
||||||
|
const project = db.data.projects.find(p => p.id === invite.projectId);
|
||||||
|
if (project) {
|
||||||
|
ensureProjectShape(project);
|
||||||
|
const initials = req.body.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||||
|
const email = (req.body.email || invite.email || '').trim();
|
||||||
|
let rosterMember = db.data.members.find(m => email && m.email === email);
|
||||||
|
if (!rosterMember) {
|
||||||
|
rosterMember = {
|
||||||
|
id: randomUUID(),
|
||||||
|
name: req.body.name,
|
||||||
|
role: req.body.role || 'Team Member',
|
||||||
|
initials,
|
||||||
|
email,
|
||||||
|
};
|
||||||
|
db.data.members.push(rosterMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project.members.some(m => m.id === rosterMember.id)) {
|
||||||
|
project.members.push({ ...rosterMember, initials: rosterMember.initials || toInitials(rosterMember.name) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.write();
|
||||||
|
res.json({ projectId: invite.projectId, projectName: invite.projectName });
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 4000;
|
const PORT = process.env.PORT || 4000;
|
||||||
app.listen(PORT, () => console.log(`Backend running on port ${PORT}`));
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
app.listen(PORT, HOST, () => console.log(`Backend running on ${HOST}:${PORT}`));
|
||||||
|
|||||||
192
backend/package-lock.json
generated
192
backend/package-lock.json
generated
@@ -10,7 +10,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"lowdb": "^6.0.1"
|
"express-rate-limit": "^6.8.0",
|
||||||
|
"helmet": "^6.0.1",
|
||||||
|
"lowdb": "^6.0.1",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"multer": "^2.1.1",
|
||||||
|
"nodemailer": "^8.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.0.1"
|
||||||
@@ -43,6 +48,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/append-field": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/array-flatten": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
@@ -59,6 +70,24 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/basic-auth": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "5.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/basic-auth/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -122,6 +151,23 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-from": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/busboy": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||||
|
"dependencies": {
|
||||||
|
"streamsearch": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -185,6 +231,21 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/concat-stream": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
|
"engines": [
|
||||||
|
"node >= 6.0"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.0.2",
|
||||||
|
"typedarray": "^0.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -386,6 +447,18 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "6.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.11.2.tgz",
|
||||||
|
"integrity": "sha512-a7uwwfNTh1U60ssiIkuLFWHt4hAC5yxlLGU2VP0X4YNlyEDZAqF4tK3GD3NSitVBrCQmQ0++0uOyFOgC2y4DDw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": "^4 || ^5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -555,6 +628,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/helmet": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/helmet/-/helmet-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-DWlwuXLLqbrIOltR6tFQXShj/+7Cyp0gLi6uAb8qMdFh/YBBFbKSgQ6nbXmScYd8emMctuthmgIa7tUfo9Rtyg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -755,12 +837,59 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/morgan": {
|
||||||
|
"version": "1.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
||||||
|
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"basic-auth": "~2.0.1",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"on-finished": "~2.3.0",
|
||||||
|
"on-headers": "~1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/morgan/node_modules/on-finished": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ee-first": "1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/multer": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"append-field": "^1.0.0",
|
||||||
|
"busboy": "^1.6.0",
|
||||||
|
"concat-stream": "^2.0.0",
|
||||||
|
"type-is": "^1.6.18"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@@ -770,6 +899,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nodemon": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.14",
|
"version": "3.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
||||||
@@ -867,6 +1005,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/on-headers": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -954,6 +1101,20 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
@@ -1163,6 +1324,23 @@
|
|||||||
"url": "https://github.com/sponsors/typicode"
|
"url": "https://github.com/sponsors/typicode"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/streamsearch": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||||
@@ -1221,6 +1399,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typedarray": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/undefsafe": {
|
"node_modules/undefsafe": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||||
@@ -1237,6 +1421,12 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/utils-merge": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
|||||||
@@ -5,12 +5,18 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"dev": "nodemon index.js"
|
"dev": "nodemon index.js",
|
||||||
|
"build": "node --check index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^6.8.0",
|
||||||
|
"helmet": "^6.0.1",
|
||||||
"lowdb": "^6.0.1",
|
"lowdb": "^6.0.1",
|
||||||
"cors": "^2.8.5"
|
"morgan": "^1.10.0",
|
||||||
|
"multer": "^2.1.1",
|
||||||
|
"nodemailer": "^8.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.0.1"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ services:
|
|||||||
- "${PORT_BACKEND:-4000}:${PORT_BACKEND:-4000}"
|
- "${PORT_BACKEND:-4000}:${PORT_BACKEND:-4000}"
|
||||||
environment:
|
environment:
|
||||||
- PORT=${PORT_BACKEND:-4000}
|
- PORT=${PORT_BACKEND:-4000}
|
||||||
|
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:5173,http://localhost:4000,null}
|
||||||
|
- WRITE_API_KEY=${WRITE_API_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/app/data
|
- db_data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -4,8 +4,18 @@ const fs = require('fs')
|
|||||||
|
|
||||||
const isDev = process.env.NODE_ENV !== 'production'
|
const isDev = process.env.NODE_ENV !== 'production'
|
||||||
const devUrl = 'http://localhost:5173'
|
const devUrl = 'http://localhost:5173'
|
||||||
|
const API_URL_KEY = 'runtime_api_url'
|
||||||
|
const DEFAULT_API_URL = process.env.VITE_API_URL || 'http://localhost:4000/api'
|
||||||
let mainWindow
|
let mainWindow
|
||||||
|
|
||||||
|
function normalizeApiUrl(value) {
|
||||||
|
if (!value || typeof value !== 'string') return null
|
||||||
|
const trimmed = value.trim().replace(/\/+$/, '')
|
||||||
|
if (!trimmed) return null
|
||||||
|
if (!/^https?:\/\//i.test(trimmed)) return null
|
||||||
|
return /\/api$/i.test(trimmed) ? trimmed : `${trimmed}/api`
|
||||||
|
}
|
||||||
|
|
||||||
function getStoragePath() {
|
function getStoragePath() {
|
||||||
return path.join(app.getPath('userData'), 'storage.json')
|
return path.join(app.getPath('userData'), 'storage.json')
|
||||||
}
|
}
|
||||||
@@ -31,9 +41,11 @@ function writeStorage(obj) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle('storage-get', () => {
|
ipcMain.handle('storage-get', (event, key) => {
|
||||||
const s = readStorage()
|
const s = readStorage()
|
||||||
return s
|
if (!key) return s
|
||||||
|
if (!(key in s)) return null
|
||||||
|
return { value: s[key] }
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('storage-set', (event, key, value) => {
|
ipcMain.handle('storage-set', (event, key, value) => {
|
||||||
@@ -50,7 +62,20 @@ ipcMain.handle('storage-remove', (event, key) => {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('get-api-url', () => process.env.VITE_API_URL || 'http://localhost:4000/api')
|
ipcMain.handle('get-api-url', () => {
|
||||||
|
const s = readStorage()
|
||||||
|
return normalizeApiUrl(s[API_URL_KEY]) || normalizeApiUrl(DEFAULT_API_URL) || 'http://localhost:4000/api'
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('set-api-url', (event, value) => {
|
||||||
|
const next = normalizeApiUrl(value)
|
||||||
|
if (!next) throw new Error('API URL must be a valid http(s) URL')
|
||||||
|
|
||||||
|
const s = readStorage()
|
||||||
|
s[API_URL_KEY] = next
|
||||||
|
writeStorage(s)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
const { contextBridge, ipcRenderer } = require('electron')
|
const { contextBridge, ipcRenderer } = require('electron')
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('ENV', {
|
contextBridge.exposeInMainWorld('ENV', {
|
||||||
API_URL: process.env.VITE_API_URL || 'http://localhost:4000/api'
|
API_URL: process.env.VITE_API_URL || 'http://localhost:4000/api',
|
||||||
|
API_KEY: process.env.WRITE_API_KEY || process.env.VITE_WRITE_API_KEY || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('app', {
|
contextBridge.exposeInMainWorld('app', {
|
||||||
@@ -10,5 +11,13 @@ contextBridge.exposeInMainWorld('app', {
|
|||||||
set: (key, value) => ipcRenderer.invoke('storage-set', key, value),
|
set: (key, value) => ipcRenderer.invoke('storage-set', key, value),
|
||||||
remove: (key) => ipcRenderer.invoke('storage-remove', key)
|
remove: (key) => ipcRenderer.invoke('storage-remove', key)
|
||||||
},
|
},
|
||||||
getAPIUrl: () => ipcRenderer.invoke('get-api-url')
|
getAPIUrl: () => ipcRenderer.invoke('get-api-url'),
|
||||||
|
setAPIUrl: (url) => ipcRenderer.invoke('set-api-url', url)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Backward-compatible alias used by the React app persistence wrapper.
|
||||||
|
contextBridge.exposeInMainWorld('storage', {
|
||||||
|
get: (key) => ipcRenderer.invoke('storage-get', key),
|
||||||
|
set: (key, value) => ipcRenderer.invoke('storage-set', key, value),
|
||||||
|
remove: (key) => ipcRenderer.invoke('storage-remove', key)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { api } from './api.js'
|
import { api } from './api.js'
|
||||||
|
import GanttView from './components/GanttView.jsx'
|
||||||
|
import CalendarView from './components/CalendarView.jsx'
|
||||||
|
import BurndownChart from './components/BurndownChart.jsx'
|
||||||
|
|
||||||
// The full App adapted from your original inline HTML (keeps UI and behavior).
|
// The full App adapted from your original inline HTML (keeps UI and behavior).
|
||||||
|
|
||||||
@@ -116,9 +119,17 @@ function App(){
|
|||||||
const [delId, setDelId] = useState(null)
|
const [delId, setDelId] = useState(null)
|
||||||
const [view, setView] = useState('projects')
|
const [view, setView] = useState('projects')
|
||||||
const [allMembers, setAllMembers] = useState([])
|
const [allMembers, setAllMembers] = useState([])
|
||||||
|
const [serverOpen, setServerOpen] = useState(false)
|
||||||
|
const [apiUrlInput, setApiUrlInput] = useState('')
|
||||||
|
const [apiUrlMsg, setApiUrlMsg] = useState('')
|
||||||
|
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
(async()=>{
|
(async()=>{
|
||||||
|
try {
|
||||||
|
const currentApiUrl = await api.getApiUrl()
|
||||||
|
setApiUrlInput(currentApiUrl)
|
||||||
|
} catch {}
|
||||||
|
|
||||||
try { const m = await api.getMembers(); setAllMembers(m) } catch {}
|
try { const m = await api.getMembers(); setAllMembers(m) } catch {}
|
||||||
try {
|
try {
|
||||||
const bp = await api.getProjects()
|
const bp = await api.getProjects()
|
||||||
@@ -158,6 +169,42 @@ function App(){
|
|||||||
setDelId(null)
|
setDelId(null)
|
||||||
api.deleteProject(id).catch(() => {})
|
api.deleteProject(id).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createTaskForProject = async (projectId, taskDraft) => {
|
||||||
|
try {
|
||||||
|
const created = await api.createTask(projectId, taskDraft)
|
||||||
|
setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: [...p.tasks, created] } : p))
|
||||||
|
if (sel?.id === projectId) setSel(s => s ? { ...s, tasks: [...s.tasks, created] } : s)
|
||||||
|
} catch {
|
||||||
|
const local = { ...taskDraft, id: uid(), subtasks: taskDraft.subtasks || [], attachments: taskDraft.attachments || [] }
|
||||||
|
setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: [...p.tasks, local] } : p))
|
||||||
|
if (sel?.id === projectId) setSel(s => s ? { ...s, tasks: [...s.tasks, local] } : s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTaskForProject = async (projectId, taskId, patch) => {
|
||||||
|
setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: p.tasks.map(t => t.id === taskId ? { ...t, ...patch } : t) } : p))
|
||||||
|
if (sel?.id === projectId) {
|
||||||
|
setSel(s => s ? { ...s, tasks: s.tasks.map(t => t.id === taskId ? { ...t, ...patch } : t) } : s)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await api.updateTask(projectId, taskId, patch)
|
||||||
|
setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: p.tasks.map(t => t.id === taskId ? updated : t) } : p))
|
||||||
|
if (sel?.id === projectId) {
|
||||||
|
setSel(s => s ? { ...s, tasks: s.tasks.map(t => t.id === taskId ? updated : t) } : s)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTaskForProject = async (projectId, taskId) => {
|
||||||
|
setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: p.tasks.filter(t => t.id !== taskId) } : p))
|
||||||
|
if (sel?.id === projectId) {
|
||||||
|
setSel(s => s ? { ...s, tasks: s.tasks.filter(t => t.id !== taskId) } : s)
|
||||||
|
}
|
||||||
|
try { await api.deleteTask(projectId, taskId) } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const change = u => {
|
const change = u => {
|
||||||
setProjects(ps => ps.map(p => p.id === u.id ? u : p))
|
setProjects(ps => ps.map(p => p.id === u.id ? u : p))
|
||||||
setSel(u)
|
setSel(u)
|
||||||
@@ -181,15 +228,22 @@ function App(){
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 3 }}>
|
<div style={{ display: 'flex', gap: 3 }}>
|
||||||
<button onClick={()=>setView('projects')} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: view==='projects' ? 'rgba(99,102,241,0.12)' : 'transparent', color: view==='projects' ? '#818cf8' : '#64748b' }}>Projects</button>
|
<button onClick={()=>setView('projects')} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: view==='projects' ? 'rgba(99,102,241,0.12)' : 'transparent', color: view==='projects' ? '#818cf8' : '#64748b' }}>Projects</button>
|
||||||
|
<button onClick={()=>setView('mytasks')} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: view==='mytasks' ? 'rgba(99,102,241,0.12)' : 'transparent', color: view==='mytasks' ? '#818cf8' : '#64748b' }}>My Tasks</button>
|
||||||
<button onClick={()=>setView('members')} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: view==='members' ? 'rgba(99,102,241,0.12)' : 'transparent', color: view==='members' ? '#818cf8' : '#64748b' }}>Members <span style={{ background: '#181828', padding: '1px 6px', borderRadius: 999, fontSize: 10, marginLeft: 3 }}>{allMembers.length}</span></button>
|
<button onClick={()=>setView('members')} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: view==='members' ? 'rgba(99,102,241,0.12)' : 'transparent', color: view==='members' ? '#818cf8' : '#64748b' }}>Members <span style={{ background: '#181828', padding: '1px 6px', borderRadius: 999, fontSize: 10, marginLeft: 3 }}>{allMembers.length}</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button onClick={() => { setApiUrlMsg(''); setServerOpen(true) }} style={{ ...btn(), background: 'transparent', color: '#94a3b8', border: '1px solid #252540', padding: '7px 12px', fontSize: 12, fontWeight: 500 }}>
|
||||||
|
Server
|
||||||
|
</button>
|
||||||
{view === 'projects' && <button onClick={()=>setEditing({name:'',description:'',status:'planning',color:COLORS[0],startDate:'',dueDate:'',members:[],tasks:[],milestones:[]})} style={{ ...btn(), background: '#6366f1', color: '#fff', padding: '8px 15px', display: 'flex', alignItems: 'center', gap: 6 }}>
|
{view === 'projects' && <button onClick={()=>setEditing({name:'',description:'',status:'planning',color:COLORS[0],startDate:'',dueDate:'',members:[],tasks:[],milestones:[]})} style={{ ...btn(), background: '#6366f1', color: '#fff', padding: '8px 15px', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<Plus size={14}/> New Project
|
<Plus size={14}/> New Project
|
||||||
</button>}
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '22px 24px', maxWidth: 1400, margin: '0 auto' }}>
|
<div style={{ padding: '22px 24px', maxWidth: 1400, margin: '0 auto' }}>
|
||||||
|
{view === 'mytasks' && <MyTasksView projects={projects} allMembers={allMembers} onSelectProject={id => { const p = projects.find(x => x.id === id); if(p) { setSel(p); setTab('tasks'); setView('projects') }}} />}
|
||||||
{view === 'members' && <MembersPage members={allMembers}
|
{view === 'members' && <MembersPage members={allMembers}
|
||||||
onAdd={async m => {
|
onAdd={async m => {
|
||||||
const temp = { ...m, id: uid() }
|
const temp = { ...m, id: uid() }
|
||||||
@@ -246,7 +300,7 @@ function App(){
|
|||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sel && <Panel project={sel} tab={tab} setTab={setTab} onClose={() => setSel(null)} onEdit={() => setEditing(sel)} onChange={change} allMembers={allMembers} />}
|
{sel && <Panel project={sel} tab={tab} setTab={setTab} onClose={() => setSel(null)} onEdit={() => setEditing(sel)} onChange={change} onTaskCreate={createTaskForProject} onTaskUpdate={updateTaskForProject} onTaskDelete={deleteTaskForProject} allMembers={allMembers} />}
|
||||||
{editing !== null && <FormModal project={editing} onSave={save} onClose={() => setEditing(null)} />}
|
{editing !== null && <FormModal project={editing} onSave={save} onClose={() => setEditing(null)} />}
|
||||||
{delId && (
|
{delId && (
|
||||||
<Overlay>
|
<Overlay>
|
||||||
@@ -260,6 +314,38 @@ function App(){
|
|||||||
</div>
|
</div>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{serverOpen && (
|
||||||
|
<Overlay>
|
||||||
|
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 14, padding: 24, width: '100%', maxWidth: 520 }}>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: '#f1f5f9', marginBottom: 8 }}>API Server</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 12 }}>Set the backend base URL used by this app. Example: http://localhost:4000/api</div>
|
||||||
|
<input
|
||||||
|
value={apiUrlInput}
|
||||||
|
onChange={e => setApiUrlInput(e.target.value)}
|
||||||
|
placeholder="http://localhost:4000/api"
|
||||||
|
style={{ ...inp(), marginBottom: 10 }}
|
||||||
|
/>
|
||||||
|
{apiUrlMsg && <div style={{ fontSize: 11, color: apiUrlMsg.startsWith('Saved') ? '#86efac' : '#fca5a5', marginBottom: 10 }}>{apiUrlMsg}</div>}
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
<button onClick={() => setServerOpen(false)} style={{ ...btn(), padding: '8px 14px', background: 'transparent', border: '1px solid #181828', color: '#94a3b8', fontWeight: 500 }}>Close</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await api.setApiUrl(apiUrlInput)
|
||||||
|
setApiUrlMsg('Saved. Reload the app if you changed environments.')
|
||||||
|
} catch {
|
||||||
|
setApiUrlMsg('Invalid API URL. Use a full http(s) URL.')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ ...btn(), padding: '8px 14px', background: '#6366f1', color: '#fff' }}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Overlay>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -308,11 +394,33 @@ function Card({ project: p, onOpen, onEdit, onDel }){
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Detail Panel ──
|
// ── Detail Panel ──
|
||||||
function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, allMembers = [] }){
|
function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, onTaskCreate, onTaskUpdate, onTaskDelete, allMembers = [] }){
|
||||||
const pr = prog(p.tasks), s = STATUS[p.status] || STATUS.planning
|
const pr = prog(p.tasks), s = STATUS[p.status] || STATUS.planning
|
||||||
const upTask = (id, u) => onChange({ ...p, tasks: p.tasks.map(t => t.id === id ? { ...t, ...u } : t) })
|
const upTask = (id, u) => {
|
||||||
const addTask = t => onChange({ ...p, tasks: [...p.tasks, { ...t, id: uid(), subtasks: [] }] })
|
const task = p.tasks.find(t => t.id === id)
|
||||||
const delTask = id => onChange({ ...p, tasks: p.tasks.filter(t => t.id !== id) })
|
if (!task) return
|
||||||
|
|
||||||
|
const merged = { ...task, ...u }
|
||||||
|
onTaskUpdate?.(p.id, id, u)
|
||||||
|
|
||||||
|
// Recurring: when marked done, auto-clone with next due date
|
||||||
|
if (u.status === 'done' && merged.recurrence && merged.recurrence !== 'none' && merged.dueDate) {
|
||||||
|
const base = new Date(merged.dueDate)
|
||||||
|
if (merged.recurrence === 'daily') base.setDate(base.getDate() + 1)
|
||||||
|
if (merged.recurrence === 'weekly') base.setDate(base.getDate() + 7)
|
||||||
|
if (merged.recurrence === 'monthly') base.setMonth(base.getMonth() + 1)
|
||||||
|
const next = {
|
||||||
|
...merged,
|
||||||
|
status: 'todo',
|
||||||
|
dueDate: base.toISOString().slice(0,10),
|
||||||
|
subtasks: (merged.subtasks || []).map(s => ({ ...s, done: false })),
|
||||||
|
}
|
||||||
|
delete next.id
|
||||||
|
onTaskCreate?.(p.id, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const addTask = t => onTaskCreate?.(p.id, { ...t, subtasks: t.subtasks || [] })
|
||||||
|
const delTask = id => onTaskDelete?.(p.id, id)
|
||||||
const togMs = id => onChange({ ...p, milestones: p.milestones.map(m => m.id === id ? { ...m, completed: !m.completed } : m) })
|
const togMs = id => onChange({ ...p, milestones: p.milestones.map(m => m.id === id ? { ...m, completed: !m.completed } : m) })
|
||||||
const addMs = m => onChange({ ...p, milestones: [...p.milestones, { ...m, id: uid(), completed: false }] })
|
const addMs = m => onChange({ ...p, milestones: [...p.milestones, { ...m, id: uid(), completed: false }] })
|
||||||
const delMs = id => onChange({ ...p, milestones: p.milestones.filter(m => m.id !== id) })
|
const delMs = id => onChange({ ...p, milestones: p.milestones.filter(m => m.id !== id) })
|
||||||
@@ -337,9 +445,12 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, allMembers
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#475569', marginBottom: 5 }}><span>Progress</span><span style={{ color: p.color, fontWeight: 700 }}>{pr}%</span></div>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#475569', marginBottom: 5 }}><span>Progress</span><span style={{ color: p.color, fontWeight: 700 }}>{pr}%</span></div>
|
||||||
<div style={{ height: 5, background: '#181828', borderRadius: 999 }}><div style={{ height: '100%', width: `${pr}%`, background: p.color, borderRadius: 999 }} /></div>
|
<div style={{ height: 5, background: '#181828', borderRadius: 999 }}><div style={{ height: '100%', width: `${pr}%`, background: p.color, borderRadius: 999 }} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 2, marginTop: 14 }}>
|
<div style={{ display: 'flex', gap: 2, marginTop: 14, flexWrap: 'wrap' }}>
|
||||||
{['tasks','milestones','team'].map(t => (
|
{['tasks','milestones','team','gantt','calendar','burndown'].map(t => (
|
||||||
<button key={t} onClick={() => setTab(t)} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: tab === t ? 'rgba(99,102,241,0.12)' : 'transparent', color: tab === t ? '#818cf8' : '#64748b' }}>{t.charAt(0).toUpperCase() + t.slice(1)} <span style={{ marginLeft: 3, background: '#181828', padding: '1px 6px', borderRadius: 999, fontSize: 10 }}>{t === 'tasks' ? p.tasks.length : t === 'milestones' ? p.milestones.length : p.members.length}</span></button>
|
<button key={t} onClick={() => setTab(t)} style={{ ...btn(), padding: '5px 12px', fontSize: 11, fontWeight: 500, background: tab === t ? 'rgba(99,102,241,0.12)' : 'transparent', color: tab === t ? '#818cf8' : '#64748b' }}>
|
||||||
|
{t === 'tasks' ? '📋 Tasks' : t === 'milestones' ? '🏁 Milestones' : t === 'team' ? '👥 Team' : t === 'gantt' ? '📊 Gantt' : t === 'calendar' ? '📅 Calendar' : '📉 Burndown'}
|
||||||
|
{(t === 'tasks' || t === 'milestones' || t === 'team') && <span style={{ marginLeft: 3, background: '#181828', padding: '1px 5px', borderRadius: 999, fontSize: 9 }}>{t === 'tasks' ? p.tasks.length : t === 'milestones' ? p.milestones.length : p.members.length}</span>}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,6 +458,9 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, allMembers
|
|||||||
{tab === 'tasks' && <TasksTab project={p} onUpdate={upTask} onAdd={addTask} onDel={delTask} />}
|
{tab === 'tasks' && <TasksTab project={p} onUpdate={upTask} onAdd={addTask} onDel={delTask} />}
|
||||||
{tab === 'milestones' && <MsTab project={p} onToggle={togMs} onAdd={addMs} onDel={delMs} />}
|
{tab === 'milestones' && <MsTab project={p} onToggle={togMs} onAdd={addMs} onDel={delMs} />}
|
||||||
{tab === 'team' && <TeamTab project={p} onChange={onChange} allMembers={allMembers} />}
|
{tab === 'team' && <TeamTab project={p} onChange={onChange} allMembers={allMembers} />}
|
||||||
|
{tab === 'gantt' && <GanttView project={p} />}
|
||||||
|
{tab === 'calendar' && <CalendarView project={p} onEditTask={t => upTask(t.id, {})} />}
|
||||||
|
{tab === 'burndown' && <BurndownChart project={p} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -357,10 +471,11 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, allMembers
|
|||||||
// For brevity these components are preserved from the original inline app; include full implementations below.
|
// For brevity these components are preserved from the original inline app; include full implementations below.
|
||||||
|
|
||||||
function TasksTab({ project: p, onUpdate, onAdd, onDel }){
|
function TasksTab({ project: p, onUpdate, onAdd, onDel }){
|
||||||
|
const EMPTY = { title: '', status: 'todo', priority: 'medium', dueDate: '', startDate: '', assignedTo: '', estimatedHours: '', recurrence: 'none' }
|
||||||
const [adding, setAdding] = useState(false)
|
const [adding, setAdding] = useState(false)
|
||||||
const [nt, setNt] = useState({ title: '', status: 'todo', priority: 'medium', dueDate: '', assignedTo: '' })
|
const [nt, setNt] = useState(EMPTY)
|
||||||
const [exp, setExp] = useState(null)
|
const [exp, setExp] = useState(null)
|
||||||
const submit = () => { if(!nt.title.trim()) return; onAdd(nt); setNt({ title:'', status:'todo', priority:'medium', dueDate:'', assignedTo:'' }); setAdding(false) }
|
const submit = () => { if(!nt.title.trim()) return; onAdd({ ...nt, estimatedHours: nt.estimatedHours ? Number(nt.estimatedHours) : undefined }); setNt(EMPTY); setAdding(false) }
|
||||||
const groups = { todo: p.tasks.filter(t => t.status === 'todo'), 'in-progress': p.tasks.filter(t => t.status === 'in-progress'), done: p.tasks.filter(t => t.status === 'done') }
|
const groups = { todo: p.tasks.filter(t => t.status === 'todo'), 'in-progress': p.tasks.filter(t => t.status === 'in-progress'), done: p.tasks.filter(t => t.status === 'done') }
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -375,7 +490,14 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){
|
|||||||
<select value={nt.priority} onChange={e => setNt({ ...nt, priority: e.target.value })} style={{ ...inp(), flex: 1, padding: '6px 8px' }}>
|
<select value={nt.priority} onChange={e => setNt({ ...nt, priority: e.target.value })} style={{ ...inp(), flex: 1, padding: '6px 8px' }}>
|
||||||
<option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option>
|
<option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="date" value={nt.dueDate} onChange={e => setNt({ ...nt, dueDate: e.target.value })} style={{ ...inp(), flex: 1, padding: '6px 8px' }} />
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 7, marginBottom: 8 }}>
|
||||||
|
<div style={{ flex: 1 }}><div style={{ fontSize: 9, color: '#475569', marginBottom: 3, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Start Date</div><input type="date" value={nt.startDate} onChange={e => setNt({ ...nt, startDate: e.target.value })} style={{ ...inp(), padding: '6px 8px' }} /></div>
|
||||||
|
<div style={{ flex: 1 }}><div style={{ fontSize: 9, color: '#475569', marginBottom: 3, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Due Date</div><input type="date" value={nt.dueDate} onChange={e => setNt({ ...nt, dueDate: e.target.value })} style={{ ...inp(), padding: '6px 8px' }} /></div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 7, marginBottom: 8 }}>
|
||||||
|
<div style={{ flex: 1 }}><div style={{ fontSize: 9, color: '#475569', marginBottom: 3, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Est. Hours</div><input type="number" min="0" step="0.5" value={nt.estimatedHours} onChange={e => setNt({ ...nt, estimatedHours: e.target.value })} placeholder="0" style={{ ...inp(), padding: '6px 8px' }} /></div>
|
||||||
|
<div style={{ flex: 1 }}><div style={{ fontSize: 9, color: '#475569', marginBottom: 3, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Recurrence</div><select value={nt.recurrence} onChange={e => setNt({ ...nt, recurrence: e.target.value })} style={{ ...inp(), padding: '6px 8px' }}><option value="none">None</option><option value="daily">Daily</option><option value="weekly">Weekly</option><option value="monthly">Monthly</option></select></div>
|
||||||
</div>
|
</div>
|
||||||
{p.members.length > 0 && (
|
{p.members.length > 0 && (
|
||||||
<select value={nt.assignedTo} onChange={e => setNt({ ...nt, assignedTo: e.target.value })} style={{ ...inp(), marginBottom: 9, padding: '6px 8px' }}>
|
<select value={nt.assignedTo} onChange={e => setNt({ ...nt, assignedTo: e.target.value })} style={{ ...inp(), marginBottom: 9, padding: '6px 8px' }}>
|
||||||
@@ -383,7 +505,7 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){
|
|||||||
{p.members.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
{p.members.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', gap: 7, marginTop: p.members.length > 0 ? 0 : 9 }}>
|
<div style={{ display: 'flex', gap: 7, marginTop: 2 }}>
|
||||||
<button onClick={submit} style={{ ...btn(), flex:1, padding: '7px', background: '#6366f1', color: '#fff' }}>Add Task</button>
|
<button onClick={submit} style={{ ...btn(), flex:1, padding: '7px', background: '#6366f1', color: '#fff' }}>Add Task</button>
|
||||||
<button onClick={() => setAdding(false)} style={{ ...btn(), flex:1, padding: '7px', background: 'transparent', border: '1px solid #181828', color: '#64748b', fontWeight: 500 }}>Cancel</button>
|
<button onClick={() => setAdding(false)} style={{ ...btn(), flex:1, padding: '7px', background: 'transparent', border: '1px solid #181828', color: '#64748b', fontWeight: 500 }}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,7 +515,7 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){
|
|||||||
groups[st].length > 0 && (
|
groups[st].length > 0 && (
|
||||||
<div key={st} style={{ marginBottom: 18 }}>
|
<div key={st} style={{ marginBottom: 18 }}>
|
||||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 7 }}>{st === 'in-progress' ? 'In Progress' : st === 'todo' ? 'To Do' : 'Done'} · {groups[st].length}</div>
|
<div style={{ fontSize: 10, fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 7 }}>{st === 'in-progress' ? 'In Progress' : st === 'todo' ? 'To Do' : 'Done'} · {groups[st].length}</div>
|
||||||
{groups[st].map(t => <TaskRow key={t.id} task={t} color={p.color} members={p.members} onUpdate={onUpdate} onDel={onDel} expanded={exp === t.id} onToggle={() => setExp(exp === t.id ? null : t.id)} />)}
|
{groups[st].map(t => <TaskRow key={t.id} task={t} color={p.color} members={p.members} projectId={p.id} onUpdate={onUpdate} onDel={onDel} expanded={exp === t.id} onToggle={() => setExp(exp === t.id ? null : t.id)} />)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
))}
|
))}
|
||||||
@@ -402,12 +524,27 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskRow({ task: t, color, members = [], onUpdate, onDel, expanded, onToggle }){
|
function TaskRow({ task: t, color, members = [], projectId, onUpdate, onDel, expanded, onToggle }){
|
||||||
const pr = PRI[t.priority] || PRI.medium, ov = overdue(t.dueDate)
|
const pr = PRI[t.priority] || PRI.medium, ov = overdue(t.dueDate)
|
||||||
const cycle = e => { e.stopPropagation(); const c = { todo: 'in-progress', 'in-progress': 'done', done: 'todo' }; onUpdate(t.id, { status: c[t.status] }) }
|
const cycle = e => { e.stopPropagation(); const c = { todo: 'in-progress', 'in-progress': 'done', done: 'todo' }; onUpdate(t.id, { status: c[t.status] }) }
|
||||||
const togSub = sid => onUpdate(t.id, { subtasks: t.subtasks.map(s => s.id === sid ? { ...s, done: !s.done } : s) })
|
const togSub = sid => onUpdate(t.id, { subtasks: (t.subtasks||[]).map(s => s.id === sid ? { ...s, done: !s.done } : s) })
|
||||||
const addSub = title => onUpdate(t.id, { subtasks: [...t.subtasks, { id: uid(), title, done: false }] })
|
const addSub = title => onUpdate(t.id, { subtasks: [...(t.subtasks||[]), { id: uid(), title, done: false }] })
|
||||||
const delSub = sid => onUpdate(t.id, { subtasks: t.subtasks.filter(s => s.id !== sid) })
|
const delSub = sid => onUpdate(t.id, { subtasks: (t.subtasks||[]).filter(s => s.id !== sid) })
|
||||||
|
|
||||||
|
const handleUpload = async e => {
|
||||||
|
const file = e.target.files?.[0]; if (!file || !projectId) return
|
||||||
|
try {
|
||||||
|
const att = await api.uploadAttachment(projectId, t.id, file)
|
||||||
|
onUpdate(t.id, { attachments: [...(t.attachments||[]), att] })
|
||||||
|
} catch { /* show nothing on failure */ }
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
const delAttachment = async id => {
|
||||||
|
if (!projectId) return
|
||||||
|
await api.deleteAttachment(projectId, t.id, id).catch(()=>{})
|
||||||
|
onUpdate(t.id, { attachments: (t.attachments||[]).filter(a => a.id !== id) })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 8, marginBottom: 5, overflow: 'hidden' }}>
|
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 8, marginBottom: 5, overflow: 'hidden' }}>
|
||||||
<div style={{ padding: '9px 11px', display: 'flex', alignItems: 'center', gap: 9, cursor: 'pointer' }} onClick={onToggle}>
|
<div style={{ padding: '9px 11px', display: 'flex', alignItems: 'center', gap: 9, cursor: 'pointer' }} onClick={onToggle}>
|
||||||
@@ -419,12 +556,35 @@ function TaskRow({ task: t, color, members = [], onUpdate, onDel, expanded, onTo
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 10, fontWeight: 700, color: pr.color }}>{pr.label}</span>
|
<span style={{ fontSize: 10, fontWeight: 700, color: pr.color }}>{pr.label}</span>
|
||||||
{t.dueDate && <span style={{ fontSize: 10, color: ov ? '#ef4444' : '#475569' }}>{fmt(t.dueDate)}</span>}
|
{t.dueDate && <span style={{ fontSize: 10, color: ov ? '#ef4444' : '#475569' }}>{fmt(t.dueDate)}</span>}
|
||||||
{t.subtasks.length > 0 && <span style={{ fontSize: 10, color: '#475569' }}>{t.subtasks.filter(s => s.done).length}/{t.subtasks.length}</span>}
|
{t.estimatedHours > 0 && <span style={{ fontSize: 9, color: '#3d4166' }} title="Estimated hours">⏱{t.estimatedHours}h</span>}
|
||||||
|
{t.recurrence && t.recurrence !== 'none' && <span style={{ fontSize: 9, color: '#6366f1' }} title={`Repeats ${t.recurrence}`}>🔁</span>}
|
||||||
|
{(t.attachments?.length > 0) && <span style={{ fontSize: 9, color: '#64748b' }} title="Attachments">📎{t.attachments.length}</span>}
|
||||||
|
{t.subtasks?.length > 0 && <span style={{ fontSize: 10, color: '#475569' }}>{t.subtasks.filter(s => s.done).length}/{t.subtasks.length}</span>}
|
||||||
{t.assignedTo && (() => { const m = members.find(x => x.id === t.assignedTo); return m ? <div title={m.name} style={{ width: 16, height: 16, borderRadius: '50%', background: color, color: '#fff', fontSize: 8, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{m.initials}</div> : null })()}
|
{t.assignedTo && (() => { const m = members.find(x => x.id === t.assignedTo); return m ? <div title={m.name} style={{ width: 16, height: 16, borderRadius: '50%', background: color, color: '#fff', fontSize: 8, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{m.initials}</div> : null })()}
|
||||||
<button onClick={e => { e.stopPropagation(); onDel(t.id) }} style={{ background: 'none', border: 'none', color: '#475569', cursor: 'pointer', padding: 0 }}><X size={11} /></button>
|
<button onClick={e => { e.stopPropagation(); onDel(t.id) }} style={{ background: 'none', border: 'none', color: '#475569', cursor: 'pointer', padding: 0 }}><X size={11} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expanded && <SubList task={t} onToggle={togSub} onAdd={addSub} onDel={delSub} />}
|
{expanded && (
|
||||||
|
<>
|
||||||
|
<SubList task={t} onToggle={togSub} onAdd={addSub} onDel={delSub} />
|
||||||
|
{/* Attachments section */}
|
||||||
|
<div style={{ borderTop: '1px solid #181828', padding: '8px 11px 10px', marginLeft: 27 }}>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 5 }}>Attachments</div>
|
||||||
|
{(t.attachments||[]).map(a => (
|
||||||
|
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 3 }}>
|
||||||
|
<span style={{ fontSize: 10 }}>📎</span>
|
||||||
|
<a href={api.toAbsoluteUrl(a.url)} target="_blank" rel="noreferrer" style={{ fontSize: 11, color: '#818cf8', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.originalName}</a>
|
||||||
|
<span style={{ fontSize: 9, color: '#3d4166' }}>{(a.size/1024).toFixed(0)}KB</span>
|
||||||
|
<button onClick={() => delAttachment(a.id)} style={{ background: 'none', border: 'none', color: '#475569', cursor: 'pointer', padding: 0 }}><X size={9} /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 10, color: '#6366f1', cursor: 'pointer', marginTop: 3 }}>
|
||||||
|
<Plus size={10} />Upload file
|
||||||
|
<input type="file" style={{ display: 'none' }} onChange={handleUpload} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -433,7 +593,7 @@ function SubList({ task, onToggle, onAdd, onDel }){
|
|||||||
const [val, setVal] = useState('')
|
const [val, setVal] = useState('')
|
||||||
return (
|
return (
|
||||||
<div style={{ borderTop: '1px solid #181828', padding: '8px 11px 10px 38px' }}>
|
<div style={{ borderTop: '1px solid #181828', padding: '8px 11px 10px 38px' }}>
|
||||||
{task.subtasks.map(s => (
|
{(task.subtasks||[]).map(s => (
|
||||||
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '3px 0' }}>
|
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '3px 0' }}>
|
||||||
<button onClick={() => onToggle(s.id)} style={{ width: 13, height: 13, borderRadius: 3, border: '1px solid #252540', background: s.done ? '#6366f1' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0, flexShrink: 0 }}>{s.done && <Check size={8} stroke="#fff" />}</button>
|
<button onClick={() => onToggle(s.id)} style={{ width: 13, height: 13, borderRadius: 3, border: '1px solid #252540', background: s.done ? '#6366f1' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0, flexShrink: 0 }}>{s.done && <Check size={8} stroke="#fff" />}</button>
|
||||||
<span style={{ flex: 1, fontSize: 11, color: s.done ? '#475569' : '#94a3b8', textDecoration: s.done ? 'line-through' : 'none' }}>{s.title}</span>
|
<span style={{ flex: 1, fontSize: 11, color: s.done ? '#475569' : '#94a3b8', textDecoration: s.done ? 'line-through' : 'none' }}>{s.title}</span>
|
||||||
@@ -492,6 +652,19 @@ function TeamTab({ project: p, onChange, allMembers = [] }){
|
|||||||
const [mode, setMode] = useState('roster')
|
const [mode, setMode] = useState('roster')
|
||||||
const [nm, setNm] = useState({ name: '', role: '' })
|
const [nm, setNm] = useState({ name: '', role: '' })
|
||||||
const [rSearch, setRSearch] = useState('')
|
const [rSearch, setRSearch] = useState('')
|
||||||
|
const [inviting, setInviting] = useState(false)
|
||||||
|
const [inviteEmail, setInviteEmail] = useState('')
|
||||||
|
const [inviteResult, setInviteResult] = useState(null)
|
||||||
|
const [inviteErr, setInviteErr] = useState('')
|
||||||
|
|
||||||
|
const doInvite = async () => {
|
||||||
|
setInviteErr(''); setInviteResult(null)
|
||||||
|
try {
|
||||||
|
const res = await api.createInvite(p.id, inviteEmail)
|
||||||
|
setInviteResult(res)
|
||||||
|
setInviteEmail('')
|
||||||
|
} catch (e) { setInviteErr('Failed to create invite. Is the backend running?') }
|
||||||
|
}
|
||||||
|
|
||||||
const assigned = new Set(p.members.map(m => m.id))
|
const assigned = new Set(p.members.map(m => m.id))
|
||||||
const available = allMembers.filter(m => !assigned.has(m.id) &&
|
const available = allMembers.filter(m => !assigned.has(m.id) &&
|
||||||
@@ -508,7 +681,28 @@ function TeamTab({ project: p, onChange, allMembers = [] }){
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button onClick={() => { setAdding(true); setMode('roster') }} style={{ width: '100%', padding: '8px', border: '1px dashed #1e2030', borderRadius: 8, background: 'transparent', color: '#475569', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginBottom: 18 }}><Plus size={12}/> Assign Member</button>
|
<div style={{ display: 'flex', gap: 7, marginBottom: 18 }}>
|
||||||
|
<button onClick={() => { setAdding(true); setMode('roster') }} style={{ flex: 1, padding: '8px', border: '1px dashed #1e2030', borderRadius: 8, background: 'transparent', color: '#475569', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}><Plus size={12}/> Assign Member</button>
|
||||||
|
<button onClick={() => setInviting(!inviting)} style={{ flex: 1, padding: '8px', border: '1px dashed #3d2060', borderRadius: 8, background: inviting ? 'rgba(99,102,241,0.08)' : 'transparent', color: '#818cf8', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>✉ Invite by email</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite panel */}
|
||||||
|
{inviting && (
|
||||||
|
<div style={{ background: '#0d0d1a', border: '1px solid #252545', borderRadius: 10, padding: 13, marginBottom: 14 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#818cf8', marginBottom: 8 }}>Send invite link</div>
|
||||||
|
<div style={{ display: 'flex', gap: 7, marginBottom: 8 }}>
|
||||||
|
<input type="email" value={inviteEmail} onChange={e => setInviteEmail(e.target.value)} placeholder="colleague@example.com" style={{ ...inp(), flex: 1, padding: '6px 8px' }} onKeyDown={e => e.key === 'Enter' && doInvite()} />
|
||||||
|
<button onClick={doInvite} style={{ ...btn(), padding: '6px 14px', background: '#6366f1', color: '#fff', fontSize: 12 }}>Send</button>
|
||||||
|
</div>
|
||||||
|
{inviteErr && <div style={{ fontSize: 11, color: '#fca5a5', marginBottom: 6 }}>{inviteErr}</div>}
|
||||||
|
{inviteResult && (
|
||||||
|
<div style={{ fontSize: 11, color: '#86efac', background: '#0a1f0a', borderRadius: 6, padding: '8px 10px' }}>
|
||||||
|
✓ Invite created!{inviteResult.token && <span style={{ display: 'block', marginTop: 4, color: '#64748b', wordBreak: 'break-all' }}>Token: {inviteResult.token.slice(0,16)}…<button onClick={() => navigator.clipboard?.writeText(inviteResult.token).catch(()=>{})} style={{ ...btn(), marginLeft: 6, fontSize: 10, color: '#818cf8', padding: '1px 5px', border: '1px solid #252545' }}>copy</button></span>}
|
||||||
|
<span style={{ color: '#475569', display: 'block', marginTop: 3 }}>Expires: {new Date(inviteResult.expiresAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{adding && (
|
{adding && (
|
||||||
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 10, padding: 13, marginBottom: 14 }}>
|
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 10, padding: 13, marginBottom: 14 }}>
|
||||||
<div style={{ display: 'flex', gap: 5, marginBottom: 10 }}>
|
<div style={{ display: 'flex', gap: 5, marginBottom: 10 }}>
|
||||||
@@ -658,4 +852,102 @@ function MembersPage({ members, onAdd, onUpdate, onDelete }){
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── My Tasks (cross-project view) ──
|
||||||
|
const PRI_COLOR_MT = { high: '#ef4444', medium: '#f59e0b', low: '#10b981' }
|
||||||
|
|
||||||
|
function MyTasksView({ projects, allMembers, onSelectProject }) {
|
||||||
|
const [memberId, setMemberId] = useState(allMembers[0]?.id || '')
|
||||||
|
const [filter, setFilter] = useState('all')
|
||||||
|
|
||||||
|
const member = allMembers.find(m => m.id === memberId)
|
||||||
|
|
||||||
|
// Collect tasks across all projects for this member
|
||||||
|
const taskItems = []
|
||||||
|
projects.forEach(proj => {
|
||||||
|
proj.tasks.forEach(t => {
|
||||||
|
if (memberId && t.assignedTo !== memberId) return
|
||||||
|
taskItems.push({ task: t, project: proj })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtered = filter === 'all' ? taskItems : taskItems.filter(i => i.task.status === filter)
|
||||||
|
const todayNow = new Date()
|
||||||
|
|
||||||
|
const groups = {
|
||||||
|
overdue: filtered.filter(i => i.task.dueDate && new Date(i.task.dueDate) < todayNow && i.task.status !== 'done'),
|
||||||
|
inProgress: filtered.filter(i => i.task.status === 'in-progress'),
|
||||||
|
todo: filtered.filter(i => i.task.status === 'todo' && !(i.task.dueDate && new Date(i.task.dueDate) < todayNow)),
|
||||||
|
done: filtered.filter(i => i.task.status === 'done'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderItem = ({ task: t, project: proj }) => (
|
||||||
|
<div key={t.id} onClick={() => onSelectProject(proj.id)}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 13px', background: '#0d0d1a', border: '1px solid #1e2030', borderRadius: 8, marginBottom: 5, cursor: 'pointer' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.borderColor = '#2d3148'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.borderColor = '#1e2030'}
|
||||||
|
>
|
||||||
|
<div style={{ width: 8, height: 8, borderRadius: '50%', flexShrink: 0, background: t.status === 'done' ? '#10b981' : t.status === 'in-progress' ? '#f59e0b' : '#3d4166' }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 12, color: t.status === 'done' ? '#475569' : '#e2e8f0', textDecoration: t.status === 'done' ? 'line-through' : 'none', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.title}</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 2, alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 10, color: '#64748b', borderLeft: `2px solid ${proj.color || '#6366f1'}`, paddingLeft: 5 }}>{proj.name}</span>
|
||||||
|
<span style={{ fontSize: 10, color: PRI_COLOR_MT[t.priority] }}>{t.priority}</span>
|
||||||
|
{t.dueDate && <span style={{ fontSize: 10, color: new Date(t.dueDate) < todayNow && t.status !== 'done' ? '#ef4444' : '#475569' }}>{fmt(t.dueDate)}</span>}
|
||||||
|
{t.recurrence && t.recurrence !== 'none' && <span style={{ fontSize: 9, color: '#6366f1' }}>🔁 {t.recurrence}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{t.estimatedHours > 0 && <span style={{ fontSize: 10, color: '#3d4166', flexShrink: 0 }}>{t.estimatedHours}h</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const Section = ({ label, color, items }) => items.length === 0 ? null : (
|
||||||
|
<div style={{ marginBottom: 18 }}>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 700, color, textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 7 }}>{label} · {items.length}</div>
|
||||||
|
{items.map(renderItem)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 640 }}>
|
||||||
|
<div style={{ fontSize: 17, fontWeight: 700, color: '#f1f5f9', marginBottom: 18 }}>
|
||||||
|
✅ My Tasks
|
||||||
|
<span style={{ fontSize: 12, color: '#475569', fontWeight: 400, marginLeft: 8 }}>{taskItems.length} total across {projects.length} project{projects.length !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Member picker */}
|
||||||
|
{allMembers.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginBottom: 16, alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 11, color: '#64748b' }}>Showing tasks for:</span>
|
||||||
|
<select value={memberId} onChange={e => setMemberId(e.target.value)}
|
||||||
|
style={{ ...inp(), width: 'auto', padding: '5px 10px', fontSize: 12 }}>
|
||||||
|
<option value="">All members</option>
|
||||||
|
{allMembers.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 18 }}>
|
||||||
|
{['all','todo','in-progress','done'].map(s => (
|
||||||
|
<button key={s} onClick={() => setFilter(s)}
|
||||||
|
style={{ ...btn(), background: filter===s ? '#6366f1' : '#1e2030', color: filter===s ? '#fff' : '#94a3b8', padding: '5px 12px', fontSize: 11, border: 'none' }}>
|
||||||
|
{s === 'all' ? 'All' : s === 'in-progress' ? 'In Progress' : s.charAt(0).toUpperCase() + s.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 0', color: '#475569', fontSize: 13 }}>
|
||||||
|
{memberId ? 'No tasks assigned to this member.' : 'No tasks found.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section label="⚠ Overdue" color="#ef4444" items={groups.overdue} />
|
||||||
|
<Section label="In Progress" color="#f59e0b" items={groups.inProgress} />
|
||||||
|
<Section label="To Do" color="#64748b" items={groups.todo} />
|
||||||
|
{(filter === 'all' || filter === 'done') && <Section label="Done" color="#10b981" items={groups.done} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
@@ -1,17 +1,156 @@
|
|||||||
const BASE = (typeof window !== 'undefined' && window.ENV && window.ENV.API_URL) || import.meta.env.VITE_API_URL || 'http://localhost:4000/api'
|
const API_URL_STORAGE_KEY = 'project_hub_api_url'
|
||||||
const h = { 'Content-Type': 'application/json' }
|
const DEFAULT_API_URL = 'http://localhost:4000/api'
|
||||||
const json = r => { if (!r.ok) throw new Error(r.status); return r.json() }
|
|
||||||
|
let cachedBaseUrl = null
|
||||||
|
|
||||||
|
const normalizeApiUrl = (value) => {
|
||||||
|
if (!value || typeof value !== 'string') return null
|
||||||
|
const trimmed = value.trim().replace(/\/+$/, '')
|
||||||
|
if (!trimmed) return null
|
||||||
|
if (!/^https?:\/\//i.test(trimmed)) return null
|
||||||
|
return /\/api$/i.test(trimmed) ? trimmed : `${trimmed}/api`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEnvApiUrl = () => {
|
||||||
|
if (typeof window !== 'undefined' && window.ENV?.API_URL) return window.ENV.API_URL
|
||||||
|
return import.meta.env.VITE_API_URL || DEFAULT_API_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStoredApiUrl = () => {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(API_URL_STORAGE_KEY)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getWriteApiKey = () => {
|
||||||
|
if (typeof window !== 'undefined' && window.ENV?.API_KEY) return window.ENV.API_KEY
|
||||||
|
return import.meta.env.VITE_WRITE_API_KEY || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseResponse = async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => '')
|
||||||
|
throw new Error(body || String(response.status))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) return null
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
if (!contentType.includes('application/json')) return null
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSyncBaseUrl = () => {
|
||||||
|
if (cachedBaseUrl) return cachedBaseUrl
|
||||||
|
const candidate = normalizeApiUrl(getStoredApiUrl()) || normalizeApiUrl(getEnvApiUrl()) || DEFAULT_API_URL
|
||||||
|
cachedBaseUrl = candidate
|
||||||
|
return cachedBaseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBaseUrl = async () => {
|
||||||
|
if (cachedBaseUrl) return cachedBaseUrl
|
||||||
|
|
||||||
|
let runtimeUrl = null
|
||||||
|
if (typeof window !== 'undefined' && window.app?.getAPIUrl) {
|
||||||
|
runtimeUrl = await window.app.getAPIUrl().catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedBaseUrl =
|
||||||
|
normalizeApiUrl(runtimeUrl) ||
|
||||||
|
normalizeApiUrl(getStoredApiUrl()) ||
|
||||||
|
normalizeApiUrl(getEnvApiUrl()) ||
|
||||||
|
DEFAULT_API_URL
|
||||||
|
|
||||||
|
return cachedBaseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = async (path, { method = 'GET', body, write = false, formData = false } = {}) => {
|
||||||
|
const base = await getBaseUrl()
|
||||||
|
const headers = {}
|
||||||
|
|
||||||
|
if (!formData) headers['Content-Type'] = 'application/json'
|
||||||
|
if (write) {
|
||||||
|
const key = getWriteApiKey()
|
||||||
|
if (key) headers['x-api-key'] = key
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = { method, headers }
|
||||||
|
if (body !== undefined) options.body = formData ? body : JSON.stringify(body)
|
||||||
|
|
||||||
|
const response = await fetch(`${base}${path}`, options)
|
||||||
|
return parseResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toAbsoluteUrl = (maybeRelativeUrl) => {
|
||||||
|
if (!maybeRelativeUrl) return ''
|
||||||
|
if (/^https?:\/\//i.test(maybeRelativeUrl)) return maybeRelativeUrl
|
||||||
|
const root = getSyncBaseUrl().replace(/\/api$/i, '')
|
||||||
|
const suffix = maybeRelativeUrl.startsWith('/') ? maybeRelativeUrl : `/${maybeRelativeUrl}`
|
||||||
|
return `${root}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
getApiUrl: () => getBaseUrl(),
|
||||||
|
setApiUrl: async (url) => {
|
||||||
|
const normalized = normalizeApiUrl(url)
|
||||||
|
if (!normalized) throw new Error('API URL must be http(s) and non-empty')
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try { localStorage.setItem(API_URL_STORAGE_KEY, normalized) } catch {}
|
||||||
|
if (window.app?.setAPIUrl) await window.app.setAPIUrl(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedBaseUrl = normalized
|
||||||
|
return normalized
|
||||||
|
},
|
||||||
|
toAbsoluteUrl,
|
||||||
|
|
||||||
// Members
|
// Members
|
||||||
getMembers: () => fetch(`${BASE}/members`).then(json),
|
getMembers: () => request('/members'),
|
||||||
addMember: d => fetch(`${BASE}/members`, { method: 'POST', headers: h, body: JSON.stringify(d) }).then(json),
|
addMember: d => request('/members', { method: 'POST', body: d, write: true }),
|
||||||
updateMember: (id, d) => fetch(`${BASE}/members/${id}`, { method: 'PUT', headers: h, body: JSON.stringify(d) }).then(json),
|
updateMember: (id, d) => request(`/members/${id}`, { method: 'PUT', body: d, write: true }),
|
||||||
deleteMember: id => fetch(`${BASE}/members/${id}`, { method: 'DELETE' }),
|
deleteMember: id => request(`/members/${id}`, { method: 'DELETE', write: true }),
|
||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
getProjects: () => fetch(`${BASE}/projects`).then(json),
|
getProjects: () => request('/projects'),
|
||||||
createProject: d => fetch(`${BASE}/projects`, { method: 'POST', headers: h, body: JSON.stringify(d) }).then(json),
|
createProject: d => request('/projects', { method: 'POST', body: d, write: true }),
|
||||||
updateProject: (id,d) => fetch(`${BASE}/projects/${id}`, { method: 'PUT', headers: h, body: JSON.stringify(d) }),
|
updateProject: (id, d) => request(`/projects/${id}`, { method: 'PUT', body: d, write: true }),
|
||||||
deleteProject: id => fetch(`${BASE}/projects/${id}`, { method: 'DELETE' }),
|
deleteProject: id => request(`/projects/${id}`, { method: 'DELETE', write: true }),
|
||||||
|
|
||||||
|
// Project members
|
||||||
|
assignMemberToProject: (projectId, memberId) =>
|
||||||
|
request(`/projects/${projectId}/members/${memberId}`, { method: 'POST', write: true }),
|
||||||
|
|
||||||
|
// Tasks
|
||||||
|
getTasks: projectId => request(`/projects/${projectId}/tasks`),
|
||||||
|
createTask: (projectId, data) => request(`/projects/${projectId}/tasks`, { method: 'POST', body: data, write: true }),
|
||||||
|
updateTask: (projectId, taskId, data) =>
|
||||||
|
request(`/projects/${projectId}/tasks/${taskId}`, { method: 'PUT', body: data, write: true }),
|
||||||
|
deleteTask: (projectId, taskId) => request(`/projects/${projectId}/tasks/${taskId}`, { method: 'DELETE', write: true }),
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
uploadAttachment: (projectId, taskId, file) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
return request(`/projects/${projectId}/tasks/${taskId}/attachments`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
formData: true,
|
||||||
|
write: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteAttachment: (projectId, taskId, attachmentId) =>
|
||||||
|
request(`/projects/${projectId}/tasks/${taskId}/attachments/${attachmentId}`, { method: 'DELETE', write: true }),
|
||||||
|
|
||||||
|
// Invites
|
||||||
|
createInvite: (projectId, email) =>
|
||||||
|
request(`/projects/${projectId}/invites`, { method: 'POST', body: { email }, write: true }),
|
||||||
|
getInvite: token => request(`/invites/${token}`),
|
||||||
|
acceptInvite: (token, data) => request(`/invites/${token}/accept`, { method: 'POST', body: data }),
|
||||||
|
|
||||||
|
// Notify (test webhook/email)
|
||||||
|
notify: (projectId, subject, text) =>
|
||||||
|
request(`/projects/${projectId}/notify`, { method: 'POST', body: { subject, text }, write: true }),
|
||||||
}
|
}
|
||||||
|
|||||||
173
frontend/src/components/BurndownChart.jsx
Normal file
173
frontend/src/components/BurndownChart.jsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import React, { useMemo } from 'react'
|
||||||
|
|
||||||
|
const SVG_W = 480
|
||||||
|
const SVG_H = 220
|
||||||
|
const PAD = { top: 16, right: 20, bottom: 36, left: 40 }
|
||||||
|
const CW = SVG_W - PAD.left - PAD.right
|
||||||
|
const CH = SVG_H - PAD.top - PAD.bottom
|
||||||
|
|
||||||
|
const addDays = (d, n) => { const r = new Date(d); r.setDate(r.getDate() + n); return r }
|
||||||
|
const dayStr = d => d.toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
export default function BurndownChart({ project }) {
|
||||||
|
const { tasks, startDate: pStart, dueDate: pEnd } = project
|
||||||
|
const today = new Date(); today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const start = pStart ? new Date(pStart) : (() => {
|
||||||
|
const dates = tasks.filter(t => t.dueDate).map(t => new Date(t.dueDate))
|
||||||
|
return dates.length ? new Date(Math.min(...dates)) : today
|
||||||
|
})()
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const end = pEnd ? new Date(pEnd) : (() => {
|
||||||
|
const dates = tasks.filter(t => t.dueDate).map(t => new Date(t.dueDate))
|
||||||
|
return dates.length ? addDays(new Date(Math.max(...dates)), 1) : addDays(today, 14)
|
||||||
|
})()
|
||||||
|
end.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const total = tasks.length
|
||||||
|
const done = tasks.filter(t => t.status === 'done').length
|
||||||
|
const open = total - done
|
||||||
|
|
||||||
|
const spanDays = Math.max(Math.round((end - start) / 86400000), 1)
|
||||||
|
const daysLeft = Math.max(Math.round((end - today) / 86400000), 0)
|
||||||
|
const elapsed = Math.round((today - start) / 86400000)
|
||||||
|
|
||||||
|
// Ideal burndown: linear from (0, total) to (spanDays, 0)
|
||||||
|
const idealLine = total > 0
|
||||||
|
? [[0, total], [spanDays, 0]].map(([d, v]) => [
|
||||||
|
PAD.left + (d / spanDays) * CW,
|
||||||
|
PAD.top + (1 - v / total) * CH,
|
||||||
|
])
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Actual: we only know current state, so we draw from (0, total) → (elapsed, open)
|
||||||
|
// This gives a useful snapshot even without historical data
|
||||||
|
const actualPoints = total > 0 ? [
|
||||||
|
[PAD.left, PAD.top],
|
||||||
|
[PAD.left + Math.min(elapsed, spanDays) / spanDays * CW, PAD.top + (1 - open / total) * CH],
|
||||||
|
] : []
|
||||||
|
|
||||||
|
const todayX = PAD.left + Math.min(elapsed, spanDays) / spanDays * CW
|
||||||
|
|
||||||
|
// Y axis ticks (0%, 25%, 50%, 75%, 100%)
|
||||||
|
const yTicks = [0, 0.25, 0.5, 0.75, 1].map(frac => ({
|
||||||
|
y: PAD.top + (1 - frac) * CH,
|
||||||
|
label: Math.round(frac * total),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// X axis ticks — start / mid / end
|
||||||
|
const xTicks = [0, 0.5, 1].map(frac => {
|
||||||
|
const d = addDays(start, Math.round(frac * spanDays))
|
||||||
|
return { x: PAD.left + frac * CW, label: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const toPoints = pts => pts.map(([x, y]) => `${x},${y}`).join(' ')
|
||||||
|
|
||||||
|
const behind = total > 0 && elapsed > 0 && open > (total * (1 - elapsed / spanDays))
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: 'Total', value: total, color: '#64748b' },
|
||||||
|
{ label: 'Done', value: done, color: '#10b981' },
|
||||||
|
{ label: 'Open', value: open, color: '#f59e0b' },
|
||||||
|
{ label: 'Days left',value: daysLeft, color: daysLeft <= 3 ? '#ef4444' : '#818cf8' },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 0', color: '#475569', fontSize: 13 }}>
|
||||||
|
Add tasks to generate the burndown chart.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Stats row */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 18 }}>
|
||||||
|
{stats.map(s => (
|
||||||
|
<div key={s.label} style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 8, padding: '10px 12px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 800, color: s.color, lineHeight: 1 }}>{s.value}</div>
|
||||||
|
<div style={{ fontSize: 10, color: '#475569', marginTop: 3 }}>{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{behind && (
|
||||||
|
<div style={{ fontSize: 11, color: '#fca5a5', background: '#2d1515', border: '1px solid #3d1515', borderRadius: 6, padding: '6px 12px', marginBottom: 12, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
⚠ Behind schedule — ideal remaining at this point: {Math.round(total * (1 - elapsed / spanDays))} tasks
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SVG chart */}
|
||||||
|
<svg viewBox={`0 0 ${SVG_W} ${SVG_H}`} width="100%" style={{ display: 'block', overflow: 'visible' }}>
|
||||||
|
{/* Background */}
|
||||||
|
<rect x={PAD.left} y={PAD.top} width={CW} height={CH} fill="#0a0a18" rx={4} />
|
||||||
|
|
||||||
|
{/* Y grid lines */}
|
||||||
|
{yTicks.map((t, i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<line x1={PAD.left} y1={t.y} x2={PAD.left + CW} y2={t.y} stroke="#181828" strokeWidth={0.5} />
|
||||||
|
<text x={PAD.left - 4} y={t.y + 3.5} textAnchor="end" fill="#475569" fontSize={9}>{t.label}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* X axis ticks */}
|
||||||
|
{xTicks.map((t, i) => (
|
||||||
|
<text key={i} x={t.x} y={PAD.top + CH + 14} textAnchor="middle" fill="#475569" fontSize={9}>{t.label}</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Ideal line (dashed) */}
|
||||||
|
{idealLine.length === 2 && (
|
||||||
|
<line x1={idealLine[0][0]} y1={idealLine[0][1]} x2={idealLine[1][0]} y2={idealLine[1][1]}
|
||||||
|
stroke="#475569" strokeWidth={1.5} strokeDasharray="5,3" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Area under actual line */}
|
||||||
|
{actualPoints.length === 2 && (
|
||||||
|
<polygon
|
||||||
|
points={`${actualPoints[0][0]},${actualPoints[0][1]} ${actualPoints[1][0]},${actualPoints[1][1]} ${actualPoints[1][0]},${PAD.top + CH} ${actualPoints[0][0]},${PAD.top + CH}`}
|
||||||
|
fill="#6366f1" opacity={0.08} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actual line */}
|
||||||
|
{actualPoints.length === 2 && (
|
||||||
|
<polyline points={toPoints(actualPoints)} fill="none" stroke="#6366f1" strokeWidth={2.5} strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Today vertical line */}
|
||||||
|
{elapsed >= 0 && elapsed <= spanDays && (
|
||||||
|
<line x1={todayX} y1={PAD.top} x2={todayX} y2={PAD.top + CH}
|
||||||
|
stroke="#6366f1" strokeWidth={1} strokeDasharray="3,2" opacity={0.6} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Start / end dots */}
|
||||||
|
{actualPoints.length > 0 && <circle cx={actualPoints[0][0]} cy={actualPoints[0][1]} r={4} fill="#6366f1" />}
|
||||||
|
{actualPoints.length > 1 && <circle cx={actualPoints[1][0]} cy={actualPoints[1][1]} r={4} fill="#6366f1" />}
|
||||||
|
|
||||||
|
{/* Axes */}
|
||||||
|
<line x1={PAD.left} y1={PAD.top} x2={PAD.left} y2={PAD.top + CH} stroke="#252540" />
|
||||||
|
<line x1={PAD.left} y1={PAD.top + CH} x2={PAD.left + CW} y2={PAD.top + CH} stroke="#252540" />
|
||||||
|
|
||||||
|
{/* Y axis label */}
|
||||||
|
<text x={10} y={PAD.top + CH / 2} fill="#475569" fontSize={9} textAnchor="middle"
|
||||||
|
transform={`rotate(-90, 10, ${PAD.top + CH / 2})`}>Tasks remaining</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div style={{ display: 'flex', gap: 16, marginTop: 8 }}>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: '#475569' }}>
|
||||||
|
<svg width={24} height={8}><line x1={0} y1={4} x2={24} y2={4} stroke="#475569" strokeWidth={1.5} strokeDasharray="4,2" /></svg>
|
||||||
|
Ideal
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: '#475569' }}>
|
||||||
|
<svg width={24} height={8}><line x1={0} y1={4} x2={24} y2={4} stroke="#6366f1" strokeWidth={2.5} /></svg>
|
||||||
|
Actual
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 10, color: '#475569' }}>
|
||||||
|
Snapshot as of today — historical data recorded over time
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
147
frontend/src/components/CalendarView.jsx
Normal file
147
frontend/src/components/CalendarView.jsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||||
|
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']
|
||||||
|
|
||||||
|
const PRI_COLOR = { high: '#ef4444', medium: '#f59e0b', low: '#10b981' }
|
||||||
|
const STATUS_DOT = { todo: '#3d4166', 'in-progress': '#f59e0b', done: '#10b981' }
|
||||||
|
|
||||||
|
const isoDate = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||||
|
|
||||||
|
export default function CalendarView({ project, onEditTask }) {
|
||||||
|
const today = new Date()
|
||||||
|
const [year, setYear] = useState(today.getFullYear())
|
||||||
|
const [month, setMonth] = useState(today.getMonth())
|
||||||
|
|
||||||
|
const prevMonth = () => { if (month === 0) { setYear(y => y-1); setMonth(11) } else setMonth(m => m-1) }
|
||||||
|
const nextMonth = () => { if (month === 11) { setYear(y => y+1); setMonth(0) } else setMonth(m => m+1) }
|
||||||
|
|
||||||
|
// Build a map of dateString → items
|
||||||
|
const itemMap = {}
|
||||||
|
const push = (dateStr, item) => { if (!itemMap[dateStr]) itemMap[dateStr] = []; itemMap[dateStr].push(item) }
|
||||||
|
|
||||||
|
project.tasks.forEach(t => {
|
||||||
|
if (!t.dueDate) return
|
||||||
|
const d = new Date(t.dueDate); d.setHours(0,0,0,0)
|
||||||
|
push(isoDate(d), { type: 'task', data: t })
|
||||||
|
})
|
||||||
|
project.milestones.forEach(m => {
|
||||||
|
if (!m.date) return
|
||||||
|
const d = new Date(m.date); d.setHours(0,0,0,0)
|
||||||
|
push(isoDate(d), { type: 'milestone', data: m })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build calendar grid
|
||||||
|
const firstDay = new Date(year, month, 1).getDay()
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||||
|
const cells = []
|
||||||
|
for (let i = 0; i < firstDay; i++) cells.push(null)
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||||
|
// Pad to full weeks
|
||||||
|
while (cells.length % 7 !== 0) cells.push(null)
|
||||||
|
|
||||||
|
const todayStr = isoDate(today)
|
||||||
|
|
||||||
|
const MAX_VISIBLE = 3
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
|
<button onClick={prevMonth} style={{ background: 'none', border: '1px solid #1e2030', color: '#94a3b8', cursor: 'pointer', borderRadius: 6, padding: '4px 10px', fontSize: 16 }}>‹</button>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700, color: '#e2e8f0' }}>{MONTHS[month]} {year}</div>
|
||||||
|
<button onClick={nextMonth} style={{ background: 'none', border: '1px solid #1e2030', color: '#94a3b8', cursor: 'pointer', borderRadius: 6, padding: '4px 10px', fontSize: 16 }}>›</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day headers */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, marginBottom: 2 }}>
|
||||||
|
{DAYS.map(d => (
|
||||||
|
<div key={d} style={{ textAlign: 'center', fontSize: 10, fontWeight: 700, color: '#475569', padding: '4px 0', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{d}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
|
||||||
|
{cells.map((day, idx) => {
|
||||||
|
if (!day) return <div key={idx} style={{ minHeight: 80, background: '#0a0a18', borderRadius: 4 }} />
|
||||||
|
|
||||||
|
const dateStr = `${year}-${String(month+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`
|
||||||
|
const items = itemMap[dateStr] || []
|
||||||
|
const isToday = dateStr === todayStr
|
||||||
|
const visible = items.slice(0, MAX_VISIBLE)
|
||||||
|
const overflow = items.length - MAX_VISIBLE
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} style={{
|
||||||
|
minHeight: 80, background: '#0d0d1a',
|
||||||
|
border: `1px solid ${isToday ? '#6366f1' : '#181828'}`,
|
||||||
|
borderRadius: 6, padding: '5px 4px', display: 'flex', flexDirection: 'column', gap: 2,
|
||||||
|
}}>
|
||||||
|
{/* Day number */}
|
||||||
|
<div style={{
|
||||||
|
fontSize: 11, fontWeight: isToday ? 800 : 500,
|
||||||
|
color: isToday ? '#818cf8' : '#64748b',
|
||||||
|
marginBottom: 2, textAlign: 'right', paddingRight: 2,
|
||||||
|
}}>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
{visible.map((item, i) => (
|
||||||
|
item.type === 'task' ? (
|
||||||
|
<div key={i}
|
||||||
|
onClick={() => onEditTask && onEditTask(item.data)}
|
||||||
|
style={{
|
||||||
|
fontSize: 10, lineHeight: '14px',
|
||||||
|
background: '#0a0a18',
|
||||||
|
borderLeft: `2px solid ${PRI_COLOR[item.data.priority] || '#475569'}`,
|
||||||
|
borderRadius: '0 3px 3px 0',
|
||||||
|
padding: '2px 4px',
|
||||||
|
color: item.data.status === 'done' ? '#475569' : '#94a3b8',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: item.data.status === 'done' ? 'line-through' : 'none',
|
||||||
|
}}
|
||||||
|
title={item.data.title}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'inline-block', width: 5, height: 5, borderRadius: '50%', background: STATUS_DOT[item.data.status], marginRight: 3, verticalAlign: 'middle' }} />
|
||||||
|
{item.data.title}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={i} style={{
|
||||||
|
fontSize: 10, lineHeight: '14px',
|
||||||
|
background: 'rgba(139,92,246,0.15)',
|
||||||
|
borderLeft: '2px solid #8b5cf6',
|
||||||
|
borderRadius: '0 3px 3px 0',
|
||||||
|
padding: '2px 4px',
|
||||||
|
color: item.data.completed ? '#475569' : '#c4b5fd',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
textDecoration: item.data.completed ? 'line-through' : 'none',
|
||||||
|
}} title={item.data.title}>
|
||||||
|
◆ {item.data.title}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
|
||||||
|
{overflow > 0 && (
|
||||||
|
<div style={{ fontSize: 9, color: '#475569', paddingLeft: 4 }}>+{overflow} more</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div style={{ display: 'flex', gap: 14, marginTop: 14, flexWrap: 'wrap' }}>
|
||||||
|
{[['High priority','#ef4444'],['Medium','#f59e0b'],['Low','#10b981']].map(([l, c]) => (
|
||||||
|
<span key={l} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10, color: '#475569' }}>
|
||||||
|
<span style={{ display: 'inline-block', width: 8, height: 8, borderLeft: `3px solid ${c}`, borderRadius: '0 2px 2px 0', background: '#0a0a18' }} />{l}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10, color: '#475569' }}>
|
||||||
|
<span style={{ color: '#8b5cf6' }}>◆</span> Milestone
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
231
frontend/src/components/GanttView.jsx
Normal file
231
frontend/src/components/GanttView.jsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import React, { useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
const ROW_H = 34
|
||||||
|
const HEAD_H = 52
|
||||||
|
const LEFT_W = 200
|
||||||
|
const DAY_W = 28
|
||||||
|
const PAD_DAYS = 3
|
||||||
|
|
||||||
|
const STATUS_COLOR = {
|
||||||
|
todo: '#3d4166',
|
||||||
|
'in-progress': '#f59e0b',
|
||||||
|
done: '#10b981',
|
||||||
|
}
|
||||||
|
const PRI_COLOR = { high: '#ef4444', medium: '#f59e0b', low: '#10b981' }
|
||||||
|
|
||||||
|
const addDays = (d, n) => { const r = new Date(d); r.setDate(r.getDate() + n); return r }
|
||||||
|
const diffDays = (a, b) => Math.round((b - a) / 86400000)
|
||||||
|
const fmt = d => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
|
|
||||||
|
export default function GanttView({ project }) {
|
||||||
|
const svgRef = useRef(null)
|
||||||
|
const [tooltip, setTooltip] = useState(null)
|
||||||
|
|
||||||
|
const { tasks, milestones } = project
|
||||||
|
|
||||||
|
// Compute timeline bounds -----------------------------------------
|
||||||
|
const dates = []
|
||||||
|
const today = new Date(); today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
if (project.startDate) dates.push(new Date(project.startDate))
|
||||||
|
if (project.dueDate) dates.push(new Date(project.dueDate))
|
||||||
|
tasks.forEach(t => {
|
||||||
|
if (t.startDate) dates.push(new Date(t.startDate))
|
||||||
|
if (t.dueDate) dates.push(new Date(t.dueDate))
|
||||||
|
})
|
||||||
|
milestones.forEach(m => { if (m.date) dates.push(new Date(m.date)) })
|
||||||
|
dates.push(today)
|
||||||
|
|
||||||
|
if (dates.length === 0) {
|
||||||
|
return <div style={{ textAlign: 'center', padding: '60px 0', color: '#475569', fontSize: 13 }}>Add tasks with due dates to see the Gantt chart.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawStart = new Date(Math.min(...dates))
|
||||||
|
const rawEnd = new Date(Math.max(...dates))
|
||||||
|
rawStart.setHours(0, 0, 0, 0)
|
||||||
|
rawEnd.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const start = addDays(rawStart, -PAD_DAYS)
|
||||||
|
const end = addDays(rawEnd, PAD_DAYS + 1)
|
||||||
|
const totalDays = diffDays(start, end)
|
||||||
|
|
||||||
|
const SVG_W = LEFT_W + totalDays * DAY_W
|
||||||
|
const taskRows = tasks.filter(t => t.dueDate)
|
||||||
|
const msRows = milestones.filter(m => m.date)
|
||||||
|
const SVG_H = HEAD_H + (taskRows.length + msRows.length) * ROW_H + 10
|
||||||
|
|
||||||
|
const dayX = date => LEFT_W + diffDays(start, date) * DAY_W
|
||||||
|
const todayX = dayX(today)
|
||||||
|
|
||||||
|
// Build month tick marks
|
||||||
|
const months = []
|
||||||
|
const cur = new Date(start)
|
||||||
|
cur.setDate(1)
|
||||||
|
while (cur <= end) {
|
||||||
|
months.push({ label: cur.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }), x: dayX(cur) })
|
||||||
|
cur.setMonth(cur.getMonth() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Week ticks (only if span ≤ 120 days)
|
||||||
|
const weekTicks = []
|
||||||
|
if (totalDays <= 120) {
|
||||||
|
const w = new Date(start)
|
||||||
|
while (w <= end) {
|
||||||
|
weekTicks.push(dayX(w))
|
||||||
|
w.setDate(w.getDate() + 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ overflowX: 'auto', overflowY: 'auto', position: 'relative', maxHeight: '70vh' }}>
|
||||||
|
<svg ref={svgRef} width={SVG_W} height={SVG_H} style={{ display: 'block', fontSize: 11, fontFamily: 'inherit' }}>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip-chart"><rect x={LEFT_W} y={0} width={SVG_W - LEFT_W} height={SVG_H} /></clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Background */}
|
||||||
|
<rect width={SVG_W} height={SVG_H} fill="#090910" />
|
||||||
|
|
||||||
|
{/* Row backgrounds */}
|
||||||
|
{[...taskRows, ...msRows].map((_, i) => (
|
||||||
|
<rect key={i} x={0} y={HEAD_H + i * ROW_H} width={SVG_W} height={ROW_H}
|
||||||
|
fill={i % 2 === 0 ? '#0a0a18' : '#0d0d1a'} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Left column background */}
|
||||||
|
<rect x={0} y={0} width={LEFT_W} height={SVG_H} fill="#0d0d1a" />
|
||||||
|
<line x1={LEFT_W} y1={0} x2={LEFT_W} y2={SVG_H} stroke="#181828" strokeWidth={1} />
|
||||||
|
|
||||||
|
{/* Week grid lines */}
|
||||||
|
{weekTicks.map((x, i) => (
|
||||||
|
<line key={i} x1={x} y1={HEAD_H} x2={x} y2={SVG_H} stroke="#181828" strokeWidth={1} strokeDasharray="2,4" clipPath="url(#clip-chart)" />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Month header */}
|
||||||
|
<rect x={0} y={0} width={SVG_W} height={HEAD_H} fill="#0d0d1a" />
|
||||||
|
<line x1={0} y1={HEAD_H} x2={SVG_W} y2={HEAD_H} stroke="#181828" />
|
||||||
|
{months.map((m, i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<line x1={m.x} y1={0} x2={m.x} y2={HEAD_H} stroke="#252540" />
|
||||||
|
<text x={m.x + 6} y={22} fill="#64748b" fontSize={10} fontWeight={600}>{m.label}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Today line */}
|
||||||
|
<line x1={todayX} y1={HEAD_H} x2={todayX} y2={SVG_H} stroke="#6366f1" strokeWidth={1.5} strokeDasharray="4,3" clipPath="url(#clip-chart)" />
|
||||||
|
<text x={todayX + 3} y={HEAD_H - 6} fill="#6366f1" fontSize={9}>Today</text>
|
||||||
|
|
||||||
|
{/* Project span bar at header bottom */}
|
||||||
|
{project.startDate && project.dueDate && (() => {
|
||||||
|
const ps = dayX(new Date(project.startDate))
|
||||||
|
const pe = dayX(new Date(project.dueDate))
|
||||||
|
return <rect x={ps} y={HEAD_H - 8} width={Math.max(pe - ps, 4)} height={4} rx={2} fill={project.color || '#6366f1'} opacity={0.7} clipPath="url(#clip-chart)" />
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Task rows */}
|
||||||
|
{taskRows.map((t, i) => {
|
||||||
|
const y = HEAD_H + i * ROW_H
|
||||||
|
const due = new Date(t.dueDate)
|
||||||
|
const taskStart = t.startDate ? new Date(t.startDate)
|
||||||
|
: t.estimatedHours ? addDays(due, -Math.ceil(t.estimatedHours / 8))
|
||||||
|
: addDays(due, -1)
|
||||||
|
const x1 = Math.max(dayX(taskStart), LEFT_W + 2)
|
||||||
|
const x2 = Math.max(dayX(due), x1 + 6)
|
||||||
|
const barW = x2 - x1
|
||||||
|
const barColor = STATUS_COLOR[t.status] || '#3d4166'
|
||||||
|
const priColor = PRI_COLOR[t.priority] || '#475569'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={t.id} onMouseEnter={(e) => setTooltip({ t, x: e.clientX, y: e.clientY })} onMouseLeave={() => setTooltip(null)}>
|
||||||
|
{/* Left group: priority stripe + name */}
|
||||||
|
<rect x={0} y={y} width={LEFT_W} height={ROW_H} fill="transparent" />
|
||||||
|
<rect x={0} y={y} width={3} height={ROW_H} fill={priColor} opacity={0.7} />
|
||||||
|
{/* status dot */}
|
||||||
|
<circle cx={12} cy={y + ROW_H / 2} r={4}
|
||||||
|
fill={t.status === 'done' ? barColor : 'none'}
|
||||||
|
stroke={barColor} strokeWidth={1.5} />
|
||||||
|
{t.status === 'done' && <polyline points={`${10},${y + ROW_H / 2} ${12},${y + ROW_H / 2 + 2} ${15},${y + ROW_H / 2 - 2}`} fill="none" stroke="#fff" strokeWidth={1} />}
|
||||||
|
{t.status === 'in-progress' && <circle cx={12} cy={y + ROW_H / 2} r={2} fill={barColor} />}
|
||||||
|
<text x={22} y={y + ROW_H / 2 + 4} fill={t.status === 'done' ? '#475569' : '#cbd5e1'} fontSize={11}
|
||||||
|
textDecoration={t.status === 'done' ? 'line-through' : 'none'}>
|
||||||
|
{t.title.length > 20 ? t.title.slice(0, 19) + '…' : t.title}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Bar */}
|
||||||
|
<rect x={x1} y={y + 9} width={barW} height={ROW_H - 18} rx={3} fill={barColor} opacity={0.85}
|
||||||
|
clipPath="url(#clip-chart)" style={{ cursor: 'pointer' }} />
|
||||||
|
|
||||||
|
{/* Progress overlay */}
|
||||||
|
{t.estimatedHours > 0 && t.actualHours > 0 && (
|
||||||
|
<rect x={x1} y={y + 9}
|
||||||
|
width={Math.min(barW * (t.actualHours / t.estimatedHours), barW)}
|
||||||
|
height={ROW_H - 18} rx={3} fill="#fff" opacity={0.15}
|
||||||
|
clipPath="url(#clip-chart)" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Due date dot */}
|
||||||
|
<circle cx={dayX(due)} cy={y + ROW_H / 2} r={3} fill={barColor} clipPath="url(#clip-chart)" />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Milestone rows */}
|
||||||
|
{msRows.map((ms, i) => {
|
||||||
|
const y = HEAD_H + (taskRows.length + i) * ROW_H
|
||||||
|
const mx = dayX(new Date(ms.date))
|
||||||
|
const D = 7
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={ms.id}>
|
||||||
|
<rect x={0} y={y} width={LEFT_W} height={ROW_H} fill="transparent" />
|
||||||
|
<rect x={0} y={y} width={3} height={ROW_H} fill="#8b5cf6" opacity={0.7} />
|
||||||
|
<text x={22} y={y + ROW_H / 2 + 4} fill={ms.completed ? '#475569' : '#c4b5fd'} fontSize={11}
|
||||||
|
textDecoration={ms.completed ? 'line-through' : 'none'}>
|
||||||
|
{ms.title.length > 20 ? ms.title.slice(0, 19) + '…' : ms.title}
|
||||||
|
</text>
|
||||||
|
{/* Diamond */}
|
||||||
|
<polygon points={`${mx},${y + ROW_H / 2 - D} ${mx + D},${y + ROW_H / 2} ${mx},${y + ROW_H / 2 + D} ${mx - D},${y + ROW_H / 2}`}
|
||||||
|
fill={ms.completed ? '#8b5cf6' : 'none'} stroke="#8b5cf6" strokeWidth={1.5}
|
||||||
|
clipPath="url(#clip-chart)" />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Row dividers */}
|
||||||
|
{[...taskRows, ...msRows].map((_, i) => (
|
||||||
|
<line key={i} x1={0} y1={HEAD_H + (i + 1) * ROW_H} x2={SVG_W} y2={HEAD_H + (i + 1) * ROW_H}
|
||||||
|
stroke="#181828" strokeWidth={0.5} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Section labels */}
|
||||||
|
{taskRows.length > 0 && (
|
||||||
|
<text x={6} y={HEAD_H - 10} fill="#475569" fontSize={9} fontWeight={700} textTransform="uppercase">TASKS</text>
|
||||||
|
)}
|
||||||
|
{msRows.length > 0 && (
|
||||||
|
<text x={6} y={HEAD_H + taskRows.length * ROW_H - 10} fill="#7c3aed" fontSize={9} fontWeight={700}>MILESTONES</text>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
{tooltip && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: tooltip.y + 10, left: tooltip.x + 10,
|
||||||
|
background: '#1e2030', border: '1px solid #252540', borderRadius: 8,
|
||||||
|
padding: '8px 12px', zIndex: 9999, fontSize: 11, color: '#e2e8f0',
|
||||||
|
pointerEvents: 'none', maxWidth: 200,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 700, marginBottom: 4 }}>{tooltip.t.title}</div>
|
||||||
|
<div style={{ color: '#64748b' }}>Status: <span style={{ color: STATUS_COLOR[tooltip.t.status] }}>{tooltip.t.status}</span></div>
|
||||||
|
{tooltip.t.dueDate && <div style={{ color: '#64748b' }}>Due: {new Date(tooltip.t.dueDate).toLocaleDateString()}</div>}
|
||||||
|
{tooltip.t.estimatedHours && <div style={{ color: '#64748b' }}>Est: {tooltip.t.estimatedHours}h</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{taskRows.length === 0 && msRows.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 0', color: '#475569', fontSize: 13 }}>
|
||||||
|
Add tasks or milestones with dates to see the Gantt chart.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
package.json
28
package.json
@@ -4,10 +4,36 @@
|
|||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"build": "electron-builder"
|
"build": "electron-builder",
|
||||||
|
"build:desktop": "npm run build:renderer && npm run sync:dist && npm run build",
|
||||||
|
"build:renderer": "npm run build --prefix frontend",
|
||||||
|
"sync:dist": "rm -rf dist && mkdir -p dist && cp -R frontend/dist/* dist/",
|
||||||
|
"test:wiring": "node scripts/wiring-smoke-test.cjs",
|
||||||
|
"test:persistence": "node scripts/persistence-self-test.cjs",
|
||||||
|
"test:persistence:restart": "node scripts/persistence-self-test.cjs --restart-backend",
|
||||||
|
"build:verified": "npm run test:persistence && npm run build",
|
||||||
|
"build:verified:restart": "npm run test:persistence:restart && npm run build",
|
||||||
|
"release:verified": "npm run test:persistence && npm run build:renderer && npm run sync:dist && npm run build",
|
||||||
|
"release:verified:restart": "npm run test:persistence:restart && npm run build:renderer && npm run sync:dist && npm run build"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.projecthub.app",
|
||||||
|
"productName": "Project Hub",
|
||||||
|
"directories": {
|
||||||
|
"output": "releases"
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"identity": null,
|
||||||
|
"hardenedRuntime": false,
|
||||||
|
"target": [
|
||||||
|
{ "target": "dmg", "arch": ["arm64"] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"afterSign": "scripts/adhoc-sign.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron": "^30.0.0",
|
"electron": "^30.0.0",
|
||||||
"electron-builder": "^24.0.0"
|
"electron-builder": "^24.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
scripts/adhoc-sign.js
Normal file
15
scripts/adhoc-sign.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Post-build hook: ad-hoc sign the .app bundle so macOS 14+ will open it
|
||||||
|
// without requiring an Apple Developer certificate.
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
exports.default = async function (context) {
|
||||||
|
const appPath = path.join(
|
||||||
|
context.appOutDir,
|
||||||
|
`${context.packager.appInfo.productFilename}.app`,
|
||||||
|
);
|
||||||
|
console.log(`Ad-hoc signing: ${appPath}`);
|
||||||
|
execSync(`xattr -cr "${appPath}"`);
|
||||||
|
execSync(`codesign --force --deep --sign - "${appPath}"`);
|
||||||
|
console.log('Ad-hoc signing complete.');
|
||||||
|
};
|
||||||
185
scripts/persistence-self-test.cjs
Normal file
185
scripts/persistence-self-test.cjs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
const args = new Set(process.argv.slice(2));
|
||||||
|
if (args.has('--help') || args.has('-h')) {
|
||||||
|
console.log('Project Hub persistence self-test');
|
||||||
|
console.log('');
|
||||||
|
console.log('Usage:');
|
||||||
|
console.log(' node scripts/persistence-self-test.cjs [--restart-backend]');
|
||||||
|
console.log('');
|
||||||
|
console.log('Environment:');
|
||||||
|
console.log(' API_BASE default: http://localhost:4000/api');
|
||||||
|
console.log(' WRITE_API_KEY optional x-api-key for write endpoints');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = process.env.API_BASE || 'http://localhost:4000/api';
|
||||||
|
const HEALTH_URL = API_BASE.replace(/\/api\/?$/, '') + '/health';
|
||||||
|
const WRITE_API_KEY = process.env.WRITE_API_KEY || '';
|
||||||
|
const SHOULD_RESTART = args.has('--restart-backend');
|
||||||
|
|
||||||
|
const marker = `selftest-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const state = {
|
||||||
|
memberId: null,
|
||||||
|
projectId: null,
|
||||||
|
taskId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function log(msg) {
|
||||||
|
console.log(`[persistence-test] ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleep(ms) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 10000);
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...(WRITE_API_KEY ? { 'x-api-key': WRITE_API_KEY } : {}),
|
||||||
|
...(options.headers || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers,
|
||||||
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
}).finally(() => clearTimeout(timeout));
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let parsed = null;
|
||||||
|
try {
|
||||||
|
parsed = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const details = parsed ? JSON.stringify(parsed) : text;
|
||||||
|
throw new Error(`${response.status} ${response.statusText}${details ? `: ${details}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForHealth(maxSeconds = 30) {
|
||||||
|
const start = Date.now();
|
||||||
|
while ((Date.now() - start) / 1000 < maxSeconds) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(3000) });
|
||||||
|
if (response.ok) return;
|
||||||
|
} catch {
|
||||||
|
// Keep polling.
|
||||||
|
}
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
throw new Error(`Health check failed after ${maxSeconds}s at ${HEALTH_URL}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartBackendContainer() {
|
||||||
|
log('Restarting backend via docker compose...');
|
||||||
|
execSync('docker compose restart backend', { stdio: 'inherit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
if (state.projectId) {
|
||||||
|
try {
|
||||||
|
await request(`/projects/${state.projectId}`, { method: 'DELETE' });
|
||||||
|
log(`Cleanup: deleted project ${state.projectId}`);
|
||||||
|
} catch (e) {
|
||||||
|
log(`Cleanup warning: failed to delete project ${state.projectId}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.memberId) {
|
||||||
|
try {
|
||||||
|
await request(`/members/${state.memberId}`, { method: 'DELETE' });
|
||||||
|
log(`Cleanup: deleted member ${state.memberId}`);
|
||||||
|
} catch (e) {
|
||||||
|
log(`Cleanup warning: failed to delete member ${state.memberId}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
log(`Using API base ${API_BASE}`);
|
||||||
|
await waitForHealth();
|
||||||
|
log('Health check passed.');
|
||||||
|
|
||||||
|
const member = await request('/members', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
name: `Self Test ${marker}`,
|
||||||
|
role: 'QA Bot',
|
||||||
|
initials: 'ST',
|
||||||
|
email: `${marker}@local.test`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.memberId = member.id;
|
||||||
|
log(`Created member ${state.memberId}`);
|
||||||
|
|
||||||
|
const project = await request('/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
name: `Persistence ${marker}`,
|
||||||
|
description: 'Automated persistence verification',
|
||||||
|
status: 'active',
|
||||||
|
color: '#10b981',
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
dueDate: new Date(Date.now() + 86400000).toISOString().slice(0, 10),
|
||||||
|
members: [],
|
||||||
|
tasks: [],
|
||||||
|
milestones: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.projectId = project.id;
|
||||||
|
log(`Created project ${state.projectId}`);
|
||||||
|
|
||||||
|
await request(`/projects/${state.projectId}/members/${state.memberId}`, { method: 'POST' });
|
||||||
|
log(`Assigned member ${state.memberId} to project ${state.projectId}`);
|
||||||
|
|
||||||
|
const task = await request(`/projects/${state.projectId}/tasks`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
title: `Task ${marker}`,
|
||||||
|
description: 'Persistence check task',
|
||||||
|
memberId: state.memberId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.taskId = task.id;
|
||||||
|
log(`Created task ${state.taskId}`);
|
||||||
|
|
||||||
|
if (SHOULD_RESTART) {
|
||||||
|
restartBackendContainer();
|
||||||
|
await waitForHealth(45);
|
||||||
|
log('Backend is healthy after restart.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await request('/projects');
|
||||||
|
const persistedProject = projects.find(p => p.id === state.projectId);
|
||||||
|
if (!persistedProject) {
|
||||||
|
throw new Error(`Persisted project not found after verification step: ${state.projectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = await request(`/projects/${state.projectId}/tasks`);
|
||||||
|
const persistedTask = tasks.find(t => t.id === state.taskId);
|
||||||
|
if (!persistedTask) {
|
||||||
|
throw new Error(`Persisted task not found after verification step: ${state.taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Persistence verified: project and task were read back successfully.');
|
||||||
|
await cleanup();
|
||||||
|
log('PASS');
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(async err => {
|
||||||
|
console.error(`[persistence-test] FAIL: ${err.message}`);
|
||||||
|
await cleanup();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
190
scripts/wiring-smoke-test.cjs
Normal file
190
scripts/wiring-smoke-test.cjs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const API_BASE = process.env.API_BASE || 'http://localhost:4000/api';
|
||||||
|
const HEALTH_URL = API_BASE.replace(/\/api\/?$/, '') + '/health';
|
||||||
|
const WRITE_API_KEY = process.env.WRITE_API_KEY || '';
|
||||||
|
|
||||||
|
const marker = `wiring-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
const state = {
|
||||||
|
memberId: null,
|
||||||
|
projectId: null,
|
||||||
|
taskId: null,
|
||||||
|
inviteToken: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function log(message) {
|
||||||
|
console.log(`[wiring-test] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleep(ms) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForHealth(maxSeconds = 30) {
|
||||||
|
const started = Date.now();
|
||||||
|
while ((Date.now() - started) / 1000 < maxSeconds) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(3000) });
|
||||||
|
if (response.ok) return;
|
||||||
|
} catch {
|
||||||
|
// keep polling
|
||||||
|
}
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
throw new Error(`Backend not healthy at ${HEALTH_URL} after ${maxSeconds}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 10000);
|
||||||
|
const headers = {
|
||||||
|
...(options.body || options.formData ? {} : {}),
|
||||||
|
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...(options.write && WRITE_API_KEY ? { 'x-api-key': WRITE_API_KEY } : {}),
|
||||||
|
...(options.headers || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers,
|
||||||
|
body: options.formData || (options.body ? JSON.stringify(options.body) : undefined),
|
||||||
|
signal: controller.signal,
|
||||||
|
}).finally(() => clearTimeout(timeout));
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let parsed = null;
|
||||||
|
try {
|
||||||
|
parsed = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const details = parsed ? JSON.stringify(parsed) : text;
|
||||||
|
throw new Error(`${response.status} ${response.statusText}${details ? `: ${details}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
if (state.projectId) {
|
||||||
|
try {
|
||||||
|
await request(`/projects/${state.projectId}`, { method: 'DELETE', write: true });
|
||||||
|
log(`Cleanup: deleted project ${state.projectId}`);
|
||||||
|
} catch (err) {
|
||||||
|
log(`Cleanup warning: project delete failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.memberId) {
|
||||||
|
try {
|
||||||
|
await request(`/members/${state.memberId}`, { method: 'DELETE', write: true });
|
||||||
|
log(`Cleanup: deleted member ${state.memberId}`);
|
||||||
|
} catch (err) {
|
||||||
|
log(`Cleanup warning: member delete failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
log(`Using API base ${API_BASE}`);
|
||||||
|
await waitForHealth();
|
||||||
|
log('Health check passed');
|
||||||
|
|
||||||
|
const member = await request('/members', {
|
||||||
|
method: 'POST',
|
||||||
|
write: true,
|
||||||
|
body: {
|
||||||
|
name: `Wiring Member ${marker}`,
|
||||||
|
role: 'QA Bot',
|
||||||
|
email: `${marker}@local.test`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.memberId = member.id;
|
||||||
|
log(`Created member ${state.memberId}`);
|
||||||
|
|
||||||
|
const project = await request('/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
write: true,
|
||||||
|
body: {
|
||||||
|
name: `Wiring Project ${marker}`,
|
||||||
|
description: 'Wiring smoke test',
|
||||||
|
status: 'active',
|
||||||
|
color: '#6366f1',
|
||||||
|
members: [],
|
||||||
|
tasks: [],
|
||||||
|
milestones: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.projectId = project.id;
|
||||||
|
log(`Created project ${state.projectId}`);
|
||||||
|
|
||||||
|
const assigned = await request(`/projects/${state.projectId}/members/${state.memberId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
write: true,
|
||||||
|
});
|
||||||
|
if (!Array.isArray(assigned.members) || !assigned.members.some(m => m.id === state.memberId)) {
|
||||||
|
throw new Error('Assigned member missing from project.members');
|
||||||
|
}
|
||||||
|
log('Assigned member to project');
|
||||||
|
|
||||||
|
const task = await request(`/projects/${state.projectId}/tasks`, {
|
||||||
|
method: 'POST',
|
||||||
|
write: true,
|
||||||
|
body: {
|
||||||
|
title: `Wiring Task ${marker}`,
|
||||||
|
description: 'Task route check',
|
||||||
|
assignedTo: state.memberId,
|
||||||
|
status: 'todo',
|
||||||
|
priority: 'medium',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.taskId = task.id;
|
||||||
|
log(`Created task ${state.taskId}`);
|
||||||
|
|
||||||
|
const updatedTask = await request(`/projects/${state.projectId}/tasks/${state.taskId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
write: true,
|
||||||
|
body: { status: 'done' },
|
||||||
|
});
|
||||||
|
if (updatedTask.status !== 'done') {
|
||||||
|
throw new Error('Task update did not persist expected status');
|
||||||
|
}
|
||||||
|
log('Updated task status to done');
|
||||||
|
|
||||||
|
const invite = await request(`/projects/${state.projectId}/invites`, {
|
||||||
|
method: 'POST',
|
||||||
|
write: true,
|
||||||
|
body: { email: `${marker}.invite@local.test` },
|
||||||
|
});
|
||||||
|
state.inviteToken = invite.token;
|
||||||
|
log('Created invite token');
|
||||||
|
|
||||||
|
await request(`/invites/${state.inviteToken}`);
|
||||||
|
await request(`/invites/${state.inviteToken}/accept`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name: `Invitee ${marker}`, role: 'Collaborator', email: `${marker}.invite@local.test` },
|
||||||
|
});
|
||||||
|
log('Invite get/accept flow passed');
|
||||||
|
|
||||||
|
await request(`/projects/${state.projectId}/tasks/${state.taskId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
write: true,
|
||||||
|
});
|
||||||
|
log('Deleted task');
|
||||||
|
|
||||||
|
const tasksAfterDelete = await request(`/projects/${state.projectId}/tasks`);
|
||||||
|
if (tasksAfterDelete.some(t => t.id === state.taskId)) {
|
||||||
|
throw new Error('Task still present after delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
log('PASS');
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(async err => {
|
||||||
|
console.error(`[wiring-test] FAIL: ${err.message}`);
|
||||||
|
await cleanup();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user