diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3ba4b6b --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +SECRET_KEY=replace-with-a-strong-random-secret-key +FLASK_ENV=production +DATABASE_URL=sqlite:////app/data/fabdash.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff6378f --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +venv/ +.env +*.db +*.sqlite3 +migrations/ + +# Node +node_modules/ +dist/ +.DS_Store +*.local + +# IDE +.vscode/ +.idea/ +*.swp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..43aca49 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# ── Stage 1: Build React frontend ──────────────────────────────────────────── +FROM node:18-alpine AS frontend-build + +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci --silent +COPY frontend/ ./ +RUN npm run build + +# ── Stage 2: Flask + Gunicorn production image ──────────────────────────────── +FROM python:3.12-slim + +WORKDIR /app + +COPY backend/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/ ./ + +# Copy compiled React build into Flask static folder +COPY --from=frontend-build /app/frontend/dist ./app/static + +# Persistent data directory for SQLite +RUN mkdir -p /app/data + +EXPOSE 8080 + +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--timeout", "120", "run:app"] diff --git a/README.md b/README.md index 18318a6..95159c5 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,23 @@ # FabDash -**Fabrication Dashboard** — A sleek, modern project management & scheduling application built for fabrication workflows. +**Fabrication Dashboard** — A sleek, modern project management & scheduling application. -![Version](https://img.shields.io/badge/version-1.0.0--alpha-gold) +![Version](https://img.shields.io/badge/version-1.0.0-gold) ![Stack](https://img.shields.io/badge/stack-React%20%2B%20Flask%20%2B%20SQLite-informational) ![Theme](https://img.shields.io/badge/theme-Dark%20%2F%20Gold-yellow) ![Docker](https://img.shields.io/badge/deployment-Single%20Docker%20Container-blue) -![License](https://img.shields.io/badge/license-MIT-green) --- ## Table of Contents - [Overview](#overview) -- [Core Philosophy](#core-philosophy) - [Tech Stack](#tech-stack) - [Project Structure](#project-structure) -- [Data Architecture](#data-architecture) - [Features](#features) - [API Reference](#api-reference) -- [Component Architecture](#component-architecture) - [Docker Deployment](#docker-deployment) +- [Unraid Installation](#unraid-installation) - [Local Development](#local-development) - [Environment Variables](#environment-variables) - [Database Schema](#database-schema) @@ -30,57 +27,22 @@ ## Overview -**FabDash** is a self-hosted, full-stack project management and scheduling application built for fabrication teams who need a clean, fast, and visually intuitive way to manage multi-deliverable projects across time. It combines a large interactive calendar with a per-project timeline focus system — all wrapped in a dark, modern UI with gold accents. - -Deployed as a **single Docker container**, FabDash runs anywhere Docker runs with zero external dependencies. - ---- - -## Core Philosophy - -| Principle | Implementation | -|---|---| -| **Clarity over clutter** | One focused view at a time — calendar or timeline, never both fighting for space | -| **Speed of interaction** | Drag, click, and edit without losing context | -| **Data ownership** | Local SQLite persistence, no cloud dependency | -| **Easy deployment** | Single Docker container — one command to run anywhere | -| **Visual hierarchy** | Gold accents guide the eye to what matters most | +FabDash is a self-hosted project management dashboard built for fabrication teams. It features a large interactive calendar, multi-deliverable project tracking, drag-and-drop scheduling, and a per-project timeline Focus View. All wrapped in a dark/gold UI and deployed as a single Docker container. --- ## Tech Stack -### Frontend - -| Package | Version | Purpose | -|---|---|---| -| React | ^18.x | UI component framework | -| Vite | ^5.x | Build tool and dev server | -| Tailwind CSS | ^3.x | Utility-first styling with custom tokens | -| @fullcalendar/react | ^6.x | Main calendar view | -| @fullcalendar/daygrid | ^6.x | Month/week/day grid views | -| @fullcalendar/interaction | ^6.x | Drag-and-drop and click events | -| @fullcalendar/timegrid | ^6.x | Time-slot grid view | -| react-chrono | ^2.x | Deliverable Focus View timeline | -| Zustand | ^4.x | Global state management | -| Axios | ^1.x | HTTP client for Flask API | -| React Router | ^6.x | Client-side routing | -| date-fns | ^3.x | Date formatting and manipulation | - -### Backend - -| Package | Version | Purpose | -|---|---|---| -| Flask | ^3.x | REST API server + static file serving | -| Flask-SQLAlchemy | ^3.x | ORM for SQLite | -| Flask-CORS | ^4.x | Cross-origin support for React dev server | -| Flask-Migrate | ^4.x | Database schema migrations | -| Gunicorn | ^21.x | Production WSGI server (inside Docker) | - -### Database - -- **SQLite** — Zero-config, file-based persistence at `/app/data/fabdash.db` -- Mounted as a Docker volume so data survives container restarts +| Layer | Technology | +|---|---| +| Frontend | React 18, Vite, Tailwind CSS | +| Calendar | FullCalendar v6 (daygrid, timegrid, interaction) | +| Focus View | Custom horizontal timeline with react-chrono | +| State | Zustand | +| HTTP | Axios | +| Backend | Flask 3, Flask-SQLAlchemy, Flask-Migrate, Flask-CORS | +| Database | SQLite (persisted via Docker volume) | +| Server | Gunicorn (production) | --- @@ -103,6 +65,7 @@ fabdash/ │ ├── extensions.py │ ├── models.py │ └── routes/ +│ ├── __init__.py │ ├── projects.py │ └── deliverables.py │ @@ -110,6 +73,7 @@ fabdash/ ├── package.json ├── vite.config.js ├── tailwind.config.js + ├── postcss.config.js ├── index.html └── src/ ├── main.jsx @@ -119,16 +83,13 @@ fabdash/ │ └── deliverables.js ├── components/ │ ├── Calendar/ - │ │ ├── MainCalendar.jsx - │ │ ├── EventChip.jsx - │ │ └── CalendarToolbar.jsx + │ │ └── MainCalendar.jsx │ ├── Projects/ │ │ ├── ProjectList.jsx │ │ ├── ProjectCard.jsx │ │ └── ProjectModal.jsx │ ├── Deliverables/ - │ │ ├── DeliverableModal.jsx - │ │ └── DeliverableChip.jsx + │ │ └── DeliverableModal.jsx │ ├── FocusView/ │ │ ├── FocusDrawer.jsx │ │ ├── FocusTimeline.jsx @@ -141,9 +102,6 @@ fabdash/ ├── store/ │ ├── useProjectStore.js │ └── useFocusStore.js - ├── hooks/ - │ ├── useProjects.js - │ └── useDeliverables.js ├── utils/ │ ├── dateHelpers.js │ └── statusHelpers.js @@ -153,118 +111,16 @@ fabdash/ --- -## Data Architecture - -### Relationships - -``` -Project (1) ─────────────── (many) Deliverable - │ │ - ├── id (PK) ├── id (PK) - ├── name ├── project_id (FK → projects.id) - ├── color (hex) ├── title - ├── description ├── due_date - └── created_at ├── status (enum) - └── created_at -``` - -Each **Project** owns one or more **Deliverables**. Every deliverable inherits the parent project's color when rendered on the calendar. Deleting a project cascades to remove all its deliverables. - ---- - ## Features -### Main Calendar View - -The primary interface is a large, full-width FullCalendar grid with three switchable view modes: - -- **Month View** — Full month overview, all deliverables visible as colored event chips -- **Week View** — Focused 7-day view with time slots -- **Day View** — Single-day granularity for heavy scheduling days - -**Interactions:** - -- Drag-and-drop any deliverable to a new date — backend is patched immediately -- Click any event to open the Deliverable Focus View -- Right-click an event for quick actions: Edit, Delete, Open Project -- Click an empty date cell to open the Add Deliverable modal pre-filled with that date - ---- - -### Project & Deliverable Management - -**Adding a Project:** - -1. Click **"+ New Project"** in the sidebar -2. Enter project name, optional description, and choose a color swatch -3. Add one or more deliverables inline before saving -4. Submit — project and all deliverables persist in a single transaction - -**Editing & Deleting:** - -- Edit any deliverable from the sidebar, calendar event click, or Focus View -- Delete a project via the sidebar (confirmation dialog warns of cascade delete) -- Delete individual deliverables from their edit modal or via right-click context menu - ---- - -### Deliverable Focus View - -Clicking any calendar event opens a **slide-up drawer** with the full project timeline. - -- All deliverables render as a horizontal timeline via `react-chrono` in `HORIZONTAL_ALL` mode -- The clicked deliverable is highlighted with a gold glow and elevated scale -- Other deliverables appear as dimmed context nodes -- Drawer dismisses on outside click, `Escape` key, or close button -- Edit Deliverable and Edit Project buttons are available inline - -``` -[ Deliverable 1 ]────────[ Deliverable 2 ]────────[ ★ Deliverable 3 ★ ] - Jan 15, 2026 Feb 28, 2026 Mar 28, 2026 - Completed In Progress ← ACTIVE / FOCUSED -``` - ---- - -### Color Coding System - -Each project is assigned a hex color used across: - -- Background of event chips on the calendar grid -- Left border accent on sidebar project cards -- Timeline node color in the Focus View for non-active deliverables - -Colors are selectable from a curated 12-swatch palette (all readable on dark backgrounds) plus a custom hex input. - ---- - -### Theme & Design System - -```js -// tailwind.config.js -theme: { - extend: { - colors: { - gold: '#C9A84C', - 'gold-light': '#E8C96A', - 'gold-muted': '#8A6E2F', - surface: '#111111', - 'surface-raised': '#1A1A1A', - 'surface-elevated': '#242424', - 'surface-border': '#2E2E2E', - 'text-primary': '#F5F5F5', - 'text-muted': '#888888', - }, - boxShadow: { - gold: '0 0 12px rgba(201, 168, 76, 0.4)', - }, - fontFamily: { - sans: ['Inter', 'sans-serif'], - mono: ['JetBrains Mono', 'monospace'], - }, - }, -} -``` +- **Large calendar view** — Month, Week, and Day modes via FullCalendar +- **Drag-and-drop** — Move deliverables to new dates; backend updates instantly +- **Multi-deliverable projects** — Add unlimited deliverables per project, each with its own due date and status +- **Color-coded projects** — 12-swatch palette + custom hex; colors appear on calendar events and sidebar cards +- **Deliverable Focus View** — Click any calendar event to open a slide-up drawer showing the full project timeline, with the selected deliverable highlighted in gold +- **Status tracking** — Upcoming / In Progress / Completed / Overdue badges per deliverable +- **Dark/gold theme** — Dark surfaces with gold as the primary accent throughout +- **Full persistence** — SQLite database via Flask-SQLAlchemy, mounted as a Docker volume --- @@ -274,216 +130,231 @@ theme: { | Method | Endpoint | Description | |---|---|---| -| `GET` | `/api/projects` | Fetch all projects with nested deliverables | -| `POST` | `/api/projects` | Create a new project | -| `GET` | `/api/projects/:id` | Fetch a single project | -| `PATCH` | `/api/projects/:id` | Update project name, color, or description | -| `DELETE` | `/api/projects/:id` | Delete project and cascade deliverables | +| `GET` | `/api/projects` | List all projects with nested deliverables | +| `POST` | `/api/projects` | Create project (with optional deliverables inline) | +| `GET` | `/api/projects/:id` | Get single project | +| `PATCH` | `/api/projects/:id` | Update name, color, or description | +| `DELETE` | `/api/projects/:id` | Delete project + cascade deliverables | ### Deliverables | Method | Endpoint | Description | |---|---|---| -| `GET` | `/api/deliverables?project_id=:id` | Fetch deliverables for a project | -| `POST` | `/api/deliverables` | Create a new deliverable | -| `PATCH` | `/api/deliverables/:id` | Update title, date, or status | -| `DELETE` | `/api/deliverables/:id` | Delete a single deliverable | - -**Example — Create Project with Deliverables:** - -```json -POST /api/projects -{ - "name": "CODA", - "color": "#4A90D9", - "description": "Example fabrication project", - "deliverables": [ - { "title": "Deliverable 1 – Concept Brief", "due_date": "2026-01-15" }, - { "title": "Deliverable 2 – Draft Review", "due_date": "2026-02-28" }, - { "title": "Deliverable 3 – Final Submission", "due_date": "2026-03-28" } - ] -} -``` - ---- - -## Component Architecture - -``` -App -├── Sidebar -│ ├── ProjectList -│ │ └── ProjectCard (× N) -│ │ └── DeliverableChip (× N) -│ └── Button ["+ New Project"] -├── MainCalendar -│ ├── CalendarToolbar -│ └── FullCalendar -│ └── EventChip (× N) -├── ProjectModal -├── DeliverableModal -└── FocusDrawer - └── FocusTimeline - └── DeliverableCard (× N, one highlighted) -``` +| `GET` | `/api/deliverables?project_id=:id` | List deliverables for a project | +| `POST` | `/api/deliverables` | Create deliverable | +| `PATCH` | `/api/deliverables/:id` | Update title, due_date, or status | +| `DELETE` | `/api/deliverables/:id` | Delete deliverable | --- ## Docker Deployment -FabDash ships as a **single Docker container**. The build process compiles the React frontend with Vite, copies the static output into Flask's `static/` folder, and serves everything through Gunicorn. No separate web server or container needed. +FabDash uses a **multi-stage Docker build**: -### How It Works +1. Stage 1 compiles the React/Vite frontend into static files +2. Stage 2 copies those files into Flask's `static/` folder +3. Gunicorn serves the Flask API at `/api/*` and the React SPA at `/*` -``` -Docker Build -├── Stage 1 (node:18-alpine) -│ └── npm run build → frontend/dist/ -└── Stage 2 (python:3.12-slim) - ├── Copy dist/ → backend/app/static/ - ├── pip install -r requirements.txt - ├── Flask serves API at /api/* - ├── Flask serves React at /* (catch-all → index.html) - └── Gunicorn runs on port 8080 -``` - -### Dockerfile - -```dockerfile -# ── Stage 1: Build React frontend ────────────────────────────────────── -FROM node:18-alpine AS frontend-build - -WORKDIR /app/frontend -COPY frontend/package*.json ./ -RUN npm ci -COPY frontend/ ./ -RUN npm run build - -# ── Stage 2: Flask + Gunicorn production image ───────────────────────── -FROM python:3.12-slim - -WORKDIR /app - -# Install Python dependencies -COPY backend/requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt - -# Copy Flask app -COPY backend/ ./ - -# Copy compiled React build into Flask static folder -COPY --from=frontend-build /app/frontend/dist ./app/static - -# Persistent volume mount point for SQLite database -RUN mkdir -p /app/data - -EXPOSE 8080 - -CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "run:app"] -``` - -### docker-compose.yml - -```yaml -version: "3.9" - -services: - fabdash: - build: . - container_name: fabdash - ports: - - "8080:8080" - volumes: - - fabdash-data:/app/data # Persists SQLite database across restarts - environment: - - FLASK_ENV=production - - DATABASE_URL=sqlite:////app/data/fabdash.db - - SECRET_KEY=${SECRET_KEY} - restart: unless-stopped - -volumes: - fabdash-data: -``` - -### Flask Catch-All Route (SPA Support) - -Add this to `backend/app/__init__.py` so React Router handles all non-API routes: - -```python -import os -from flask import send_from_directory - -@app.route("/", defaults={"path": ""}) -@app.route("/") -def serve_react(path): - static_folder = os.path.join(app.root_path, "static") - if path and os.path.exists(os.path.join(static_folder, path)): - return send_from_directory(static_folder, path) - return send_from_directory(static_folder, "index.html") -``` - -### Deploy (One Command) +No Nginx, no separate containers, no reverse proxy required. ```bash -# Clone and run git clone https://github.com/yourname/fabdash.git cd fabdash -cp .env.example .env # Add your SECRET_KEY +cp .env.example .env +# Edit .env and set a strong SECRET_KEY docker compose up -d --build - -# FabDash is live at http://localhost:8080 +# App available at http://your-host:8080 ``` -### Useful Docker Commands +--- + +## Unraid Installation + +FabDash is designed to run cleanly on Unraid via a Docker Compose build from source. +Two methods are provided below — **Terminal (recommended)** and **Unraid Docker GUI**. + +--- + +### Method 1 — Terminal via SSH (Recommended) + +This method builds the image directly on your Unraid server and uses `docker compose` to manage the container. + +#### Step 1 — Enable SSH on Unraid + +Go to **Settings → Management Access → SSH** and ensure SSH is enabled. + +#### Step 2 — SSH into your Unraid server ```bash -docker compose logs -f fabdash # Stream logs -docker compose down # Stop container -docker compose up -d --build # Rebuild and restart -docker exec -it fabdash flask db upgrade # Run migrations +ssh root@YOUR_UNRAID_IP +``` + +#### Step 3 — Clone the repository + +```bash +cd /mnt/user/appdata +git clone https://github.com/yourname/fabdash.git +cd fabdash +``` + +#### Step 4 — Create your environment file + +```bash +cp .env.example .env +nano .env +``` + +Set a strong `SECRET_KEY`: + +```env +SECRET_KEY=your-strong-random-secret-key-here +FLASK_ENV=production +DATABASE_URL=sqlite:////app/data/fabdash.db +``` + +Press `Ctrl+X`, then `Y` to save. + +#### Step 5 — Build and start the container + +```bash +docker compose up -d --build +``` + +The build will take 2–4 minutes the first time (downloading Node and Python layers, compiling the React app). + +#### Step 6 — Access FabDash + +Open your browser and navigate to: + +``` +http://YOUR_UNRAID_IP:8080 +``` + +#### Useful Commands + +```bash +# View live logs +docker compose logs -f fabdash + +# Stop the container +docker compose down + +# Rebuild after a git pull (update) +git pull +docker compose up -d --build + +# Open a shell inside the container +docker exec -it fabdash bash + +# Run database migrations (after schema changes) +docker exec -it fabdash flask db upgrade +``` + +--- + +### Method 2 — Unraid Docker GUI (Manual Container) + +Use this method if you have already built and pushed the FabDash image to a registry (Docker Hub or GHCR). If you are running from source, use Method 1. + +#### Step 1 — Push image to a registry (on your dev machine) + +```bash +docker build -t yourdockerhubuser/fabdash:latest . +docker push yourdockerhubuser/fabdash:latest +``` + +#### Step 2 — Add container via Unraid Docker tab + +1. In Unraid, go to the **Docker** tab +2. Click **Add Container** +3. Switch to **Advanced View** (toggle top-right) + +Fill in the fields: + +| Field | Value | +|---|---| +| **Name** | `fabdash` | +| **Repository** | `yourdockerhubuser/fabdash:latest` | +| **Network Type** | Bridge | +| **Port** | Host: `8080` → Container: `8080` (TCP) | +| **Path** | Host: `/mnt/user/appdata/fabdash/data` → Container: `/app/data` | + +#### Step 3 — Add Environment Variables + +Click **Add another Path, Port, Variable...** and add: + +| Key | Value | +|---|---| +| `SECRET_KEY` | `your-strong-random-key` | +| `FLASK_ENV` | `production` | +| `DATABASE_URL` | `sqlite:////app/data/fabdash.db` | + +#### Step 4 — Apply + +Click **Apply**. Unraid will pull the image and start the container. Access at: + +``` +http://YOUR_UNRAID_IP:8080 +``` + +#### Updating the Container (GUI method) + +1. SSH into Unraid +2. Run: `docker pull yourdockerhubuser/fabdash:latest` +3. In the Docker tab, click the FabDash container icon → **Update** + +--- + +### Data Persistence on Unraid + +Your SQLite database is stored at: + +``` +/mnt/user/appdata/fabdash/data/fabdash.db +``` + +This path survives container restarts, image rebuilds, and Unraid reboots. Back it up with Unraid's **Appdata Backup** plugin or manually: + +```bash +cp /mnt/user/appdata/fabdash/data/fabdash.db /mnt/user/backups/fabdash-$(date +%Y%m%d).db ``` --- ## Local Development -For active development, run frontend and backend separately so Vite HMR works: +Run backend and frontend separately to get Vite hot-module reloading: -**Terminal 1 — Flask backend:** +**Terminal 1 — Flask:** ```bash cd backend python -m venv venv -source venv/bin/activate # Windows: venv\Scripts\activate +source venv/bin/activate # Windows: venv\Scripts\activate pip install -r requirements.txt -flask db upgrade +export FLASK_ENV=development flask run --port 5000 ``` -**Terminal 2 — React frontend:** +**Terminal 2 — React:** ```bash cd frontend npm install -npm run dev # Vite dev server at http://localhost:5173 +npm run dev # http://localhost:5173 ``` -Set `VITE_API_BASE_URL=http://localhost:5000/api` in `frontend/.env` during development. -In production (Docker), the frontend calls `/api/*` relative to the same origin — no CORS needed. +Vite proxies `/api/*` calls to Flask on port 5000 automatically via `vite.config.js`. --- ## Environment Variables -```env -# .env (used by docker-compose.yml) -SECRET_KEY=replace-with-a-strong-random-key -FLASK_ENV=production -DATABASE_URL=sqlite:////app/data/fabdash.db - -# frontend/.env (development only) -VITE_API_BASE_URL=http://localhost:5000/api -``` +| Variable | Default | Description | +|---|---|---| +| `SECRET_KEY` | *(required)* | Flask session secret — use a long random string | +| `FLASK_ENV` | `production` | Set to `development` for debug mode | +| `DATABASE_URL` | `sqlite:////app/data/fabdash.db` | Full SQLite path (4 slashes = absolute path) | --- @@ -516,69 +387,66 @@ CREATE INDEX idx_deliverables_due_date ON deliverables(due_date); ## Roadmap -### v1.0 — Core Release *(current scope)* +### v1.0 — Core Release *(current)* -- [x] Dark/gold design system with Tailwind custom tokens -- [x] FullCalendar main view with Month / Week / Day modes +- [x] Dark/gold Tailwind design system +- [x] FullCalendar with Month / Week / Day views - [x] Drag-and-drop deliverable rescheduling -- [x] Project creation with multiple deliverables and color selection +- [x] Multi-deliverable project creation with inline rows - [x] Add / Edit / Delete for projects and deliverables -- [x] Deliverable Focus View — slide-up drawer with horizontal timeline -- [x] Active deliverable highlighting in Focus View +- [x] Deliverable Focus View (slide-up drawer + horizontal timeline) +- [x] Active deliverable gold highlight in Focus View - [x] Status badges (Upcoming / In Progress / Completed / Overdue) -- [x] Flask REST API with SQLite persistence +- [x] Flask REST API + SQLite persistence - [x] Cascade delete (project → deliverables) -- [x] Right-click context menu on calendar events -- [x] Single Docker container deployment with persistent volume - ---- +- [x] Single Docker container deployment +- [x] Unraid installation guide (Terminal + GUI) ### v1.1 — Polish & UX -- [ ] Keyboard shortcuts (`N` = new project, `Esc` = close modal, arrow keys = calendar nav) -- [ ] Undo/redo for drag-and-drop with 30-second undo toast -- [ ] Animated transitions on drawer and modal open/close -- [ ] Deliverable sorting in Focus View (by date, by status) -- [ ] Empty state illustrations for no projects or no deliverables -- [ ] Responsive layout with collapsible sidebar for smaller screens +- [ ] Keyboard shortcuts (`N` = new project, `Esc` = close, arrow keys = calendar nav) +- [ ] 30-second undo toast for drag-and-drop moves +- [ ] Animated modal/drawer enter and exit transitions +- [ ] Hover tooltip on calendar events (preview without opening Focus View) +- [ ] Responsive layout with collapsible sidebar +- [ ] Empty state illustrations -### v1.2 — Enhanced Calendar +### v1.2 — Calendar Enhancements - [ ] Agenda sidebar showing all upcoming deliverables across projects -- [ ] Week numbers in calendar header -- [ ] Hover tooltip previewing deliverable details without opening Focus View +- [ ] Click empty date → pre-filled Add Deliverable modal with project selector - [ ] Date range selection for bulk deliverable creation -- [ ] "Today" jump button with smooth scroll +- [ ] "Today" jump button +- [ ] Week numbers in calendar header -### v2.0 — Collaboration & Auth +### v2.0 — Auth & Multi-user -- [ ] User authentication (Flask-Login + JWT) -- [ ] Multi-user support with project ownership and sharing +- [ ] User login (Flask-Login + JWT) +- [ ] Multi-user support with project ownership - [ ] Role-based access per project (Owner / Editor / Viewer) -- [ ] Activity log per project (who changed what, when) -- [ ] Comment threads on individual deliverables +- [ ] Activity log per project +- [ ] Comment threads on deliverables ### v2.1 — Notifications & Integrations - [ ] In-app notification center for approaching due dates -- [ ] Email reminders at configurable intervals before due date +- [ ] Email reminders at configurable intervals (Flask-Mail) - [ ] iCal / Google Calendar export per project -- [ ] Slack webhook integration for deliverable status changes -- [ ] CSV import/export for bulk project setup +- [ ] Slack webhook for deliverable status changes +- [ ] CSV import/export for bulk setup ### v2.2 — Advanced Views -- [ ] Gantt view as an alternate layout -- [ ] Kanban board (columns: Upcoming / In Progress / Completed) -- [ ] Cross-project timeline showing all projects on one horizontal axis +- [ ] Gantt view alternate layout +- [ ] Kanban board (columns by status) +- [ ] Cross-project timeline view - [ ] Workload heatmap showing deliverable density per day - [ ] Archived projects with searchable history ### v3.0 — Intelligence Layer -- [ ] AI-assisted scheduling suggestions based on project cadence -- [ ] Auto-detect and surface overdue deliverables on dashboard load -- [ ] Conflict detection — flag days with too many concurrent deliverables +- [ ] AI scheduling suggestions based on project cadence +- [ ] Conflict detection — flag overloaded days - [ ] Natural language input ("Add final draft due next Friday to CODA") --- diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..4002ef0 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,33 @@ +import os +from flask import Flask, send_from_directory +from .extensions import db, migrate, cors +from config import config + +def create_app(config_name=None): + if config_name is None: + config_name = os.environ.get('FLASK_ENV', 'production') + + app = Flask(__name__, static_folder='static', static_url_path='') + app.config.from_object(config.get(config_name, config['default'])) + + db.init_app(app) + migrate.init_app(app, db) + cors.init_app(app, resources={r'/api/*': {'origins': '*'}}) + + from .routes.projects import projects_bp + from .routes.deliverables import deliverables_bp + app.register_blueprint(projects_bp, url_prefix='/api') + app.register_blueprint(deliverables_bp, url_prefix='/api') + + with app.app_context(): + db.create_all() + + @app.route('/', defaults={'path': ''}) + @app.route('/') + def serve_react(path): + static_folder = app.static_folder + if path and os.path.exists(os.path.join(static_folder, path)): + return send_from_directory(static_folder, path) + return send_from_directory(static_folder, 'index.html') + + return app diff --git a/backend/app/extensions.py b/backend/app/extensions.py new file mode 100644 index 0000000..5f18a0c --- /dev/null +++ b/backend/app/extensions.py @@ -0,0 +1,7 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_cors import CORS + +db = SQLAlchemy() +migrate = Migrate() +cors = CORS() diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..f58df52 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,51 @@ +from .extensions import db +from datetime import datetime + +class Project(db.Model): + __tablename__ = 'projects' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False) + color = db.Column(db.String(7), nullable=False, default='#C9A84C') + description = db.Column(db.Text) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + deliverables = db.relationship( + 'Deliverable', backref='project', + cascade='all, delete-orphan', lazy=True + ) + + def to_dict(self, include_deliverables=True): + data = { + 'id': self.id, + 'name': self.name, + 'color': self.color, + 'description': self.description, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } + if include_deliverables: + data['deliverables'] = [ + d.to_dict() for d in sorted(self.deliverables, key=lambda x: x.due_date) + ] + return data + + +class Deliverable(db.Model): + __tablename__ = 'deliverables' + + id = db.Column(db.Integer, primary_key=True) + project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False) + title = db.Column(db.String(300), nullable=False) + due_date = db.Column(db.Date, nullable=False) + status = db.Column(db.String(20), nullable=False, default='upcoming') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def to_dict(self): + return { + 'id': self.id, + 'project_id': self.project_id, + 'title': self.title, + 'due_date': self.due_date.isoformat() if self.due_date else None, + 'status': self.status, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routes/deliverables.py b/backend/app/routes/deliverables.py new file mode 100644 index 0000000..da273f0 --- /dev/null +++ b/backend/app/routes/deliverables.py @@ -0,0 +1,44 @@ +from flask import Blueprint, jsonify, request +from ..models import Deliverable +from ..extensions import db +from datetime import date + +deliverables_bp = Blueprint('deliverables', __name__) + +@deliverables_bp.route('/deliverables', methods=['GET']) +def get_deliverables(): + project_id = request.args.get('project_id') + q = Deliverable.query + if project_id: + q = q.filter_by(project_id=int(project_id)) + return jsonify([d.to_dict() for d in q.order_by(Deliverable.due_date).all()]) + +@deliverables_bp.route('/deliverables', methods=['POST']) +def create_deliverable(): + data = request.get_json() + d = Deliverable( + project_id=data['project_id'], + title=data['title'], + due_date=date.fromisoformat(data['due_date']), + status=data.get('status', 'upcoming'), + ) + db.session.add(d) + db.session.commit() + return jsonify(d.to_dict()), 201 + +@deliverables_bp.route('/deliverables/', methods=['PATCH']) +def update_deliverable(id): + d = Deliverable.query.get_or_404(id) + data = request.get_json() + if 'title' in data: d.title = data['title'] + if 'due_date' in data: d.due_date = date.fromisoformat(data['due_date']) + if 'status' in data: d.status = data['status'] + db.session.commit() + return jsonify(d.to_dict()) + +@deliverables_bp.route('/deliverables/', methods=['DELETE']) +def delete_deliverable(id): + d = Deliverable.query.get_or_404(id) + db.session.delete(d) + db.session.commit() + return jsonify({'message': 'Deliverable deleted'}), 200 diff --git a/backend/app/routes/projects.py b/backend/app/routes/projects.py new file mode 100644 index 0000000..159394d --- /dev/null +++ b/backend/app/routes/projects.py @@ -0,0 +1,54 @@ +from flask import Blueprint, jsonify, request +from ..models import Project, Deliverable +from ..extensions import db +from datetime import date + +projects_bp = Blueprint('projects', __name__) + +@projects_bp.route('/projects', methods=['GET']) +def get_projects(): + projects = Project.query.order_by(Project.created_at.desc()).all() + return jsonify([p.to_dict() for p in projects]) + +@projects_bp.route('/projects/', methods=['GET']) +def get_project(id): + project = Project.query.get_or_404(id) + return jsonify(project.to_dict()) + +@projects_bp.route('/projects', methods=['POST']) +def create_project(): + data = request.get_json() + project = Project( + name=data['name'], + color=data.get('color', '#C9A84C'), + description=data.get('description', ''), + ) + db.session.add(project) + db.session.flush() + for d in data.get('deliverables', []): + if d.get('title') and d.get('due_date'): + db.session.add(Deliverable( + project_id=project.id, + title=d['title'], + due_date=date.fromisoformat(d['due_date']), + status=d.get('status', 'upcoming'), + )) + db.session.commit() + return jsonify(project.to_dict()), 201 + +@projects_bp.route('/projects/', methods=['PATCH']) +def update_project(id): + project = Project.query.get_or_404(id) + data = request.get_json() + for field in ('name', 'color', 'description'): + if field in data: + setattr(project, field, data[field]) + db.session.commit() + return jsonify(project.to_dict()) + +@projects_bp.route('/projects/', methods=['DELETE']) +def delete_project(id): + project = Project.query.get_or_404(id) + db.session.delete(project) + db.session.commit() + return jsonify({'message': 'Project deleted'}), 200 diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..c1376ea --- /dev/null +++ b/backend/config.py @@ -0,0 +1,18 @@ +import os + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-change-in-production') + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///fabdash.db') + SQLALCHEMY_TRACK_MODIFICATIONS = False + +class DevelopmentConfig(Config): + DEBUG = True + +class ProductionConfig(Config): + DEBUG = False + +config = { + 'development': DevelopmentConfig, + 'production': ProductionConfig, + 'default': DevelopmentConfig, +} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..690e58b --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +flask==3.1.0 +flask-sqlalchemy==3.1.1 +flask-migrate==4.0.7 +flask-cors==5.0.0 +gunicorn==23.0.0 +python-dotenv==1.0.1 diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000..72f8499 --- /dev/null +++ b/backend/run.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app() + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=False) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1b20030 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: "3.9" + +services: + fabdash: + build: . + container_name: fabdash + ports: + - "8080:8080" + volumes: + - fabdash-data:/app/data + environment: + - FLASK_ENV=production + - DATABASE_URL=sqlite:////app/data/fabdash.db + - SECRET_KEY=${SECRET_KEY} + restart: unless-stopped + +volumes: + fabdash-data: + driver: local diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..338461e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + FabDash + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a6b93eb --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "fabdash", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@fullcalendar/daygrid": "^6.1.15", + "@fullcalendar/interaction": "^6.1.15", + "@fullcalendar/react": "^6.1.15", + "@fullcalendar/timegrid": "^6.1.15", + "axios": "^1.7.9", + "date-fns": "^3.6.0", + "react": "^18.3.1", + "react-chrono": "^2.7.0", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "zustand": "^4.5.5" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "vite": "^5.4.11" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..e475fc2 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,29 @@ +import { useEffect } from 'react' +import ProjectList from './components/Projects/ProjectList' +import MainCalendar from './components/Calendar/MainCalendar' +import FocusDrawer from './components/FocusView/FocusDrawer' +import { fetchProjects } from './api/projects' +import useProjectStore from './store/useProjectStore' + +export default function App() { + const { setProjects, setLoading } = useProjectStore() + + useEffect(() => { + setLoading(true) + fetchProjects() + .then(data => { setProjects(data); setLoading(false) }) + .catch(() => setLoading(false)) + }, []) + + return ( +
+ +
+ +
+ +
+ ) +} diff --git a/frontend/src/api/deliverables.js b/frontend/src/api/deliverables.js new file mode 100644 index 0000000..df1a2b0 --- /dev/null +++ b/frontend/src/api/deliverables.js @@ -0,0 +1,6 @@ +import axios from 'axios' +const B = '/api' +export const fetchDeliverables = (pid) => axios.get(`${B}/deliverables`, { params: { project_id: pid } }).then(r => r.data) +export const createDeliverable = (data) => axios.post(`${B}/deliverables`, data).then(r => r.data) +export const updateDeliverable = (id, d) => axios.patch(`${B}/deliverables/${id}`, d).then(r => r.data) +export const deleteDeliverable = (id) => axios.delete(`${B}/deliverables/${id}`).then(r => r.data) diff --git a/frontend/src/api/projects.js b/frontend/src/api/projects.js new file mode 100644 index 0000000..933261c --- /dev/null +++ b/frontend/src/api/projects.js @@ -0,0 +1,7 @@ +import axios from 'axios' +const B = '/api' +export const fetchProjects = () => axios.get(`${B}/projects`).then(r => r.data) +export const fetchProject = (id) => axios.get(`${B}/projects/${id}`).then(r => r.data) +export const createProject = (data) => axios.post(`${B}/projects`, data).then(r => r.data) +export const updateProject = (id, d) => axios.patch(`${B}/projects/${id}`, d).then(r => r.data) +export const deleteProject = (id) => axios.delete(`${B}/projects/${id}`).then(r => r.data) diff --git a/frontend/src/components/Calendar/MainCalendar.jsx b/frontend/src/components/Calendar/MainCalendar.jsx new file mode 100644 index 0000000..cb0d169 --- /dev/null +++ b/frontend/src/components/Calendar/MainCalendar.jsx @@ -0,0 +1,70 @@ +import { useRef, useState, useCallback } from 'react' +import FullCalendar from '@fullcalendar/react' +import dayGridPlugin from '@fullcalendar/daygrid' +import timeGridPlugin from '@fullcalendar/timegrid' +import interactionPlugin from '@fullcalendar/interaction' +import useProjectStore from '../../store/useProjectStore' +import useFocusStore from '../../store/useFocusStore' +import { updateDeliverable } from '../../api/deliverables' +import DeliverableModal from '../Deliverables/DeliverableModal' + +export default function MainCalendar() { + const calRef = useRef(null) + const { projects, updateDeliverable: storeUpdate } = useProjectStore() + const openFocus = useFocusStore(s => s.openFocus) + const [modal, setModal] = useState({ open: false, deliverable: null, defaultDate: '' }) + + const events = projects.flatMap(p => + (p.deliverables || []).map(d => ({ + id: String(d.id), + title: `${p.name}: ${d.title}`, + start: d.due_date, + allDay: true, + backgroundColor: p.color, + borderColor: p.color, + extendedProps: { deliverableId: d.id, projectId: p.id }, + })) + ) + + const handleEventDrop = useCallback(async ({ event }) => { + const { deliverableId } = event.extendedProps + storeUpdate(await updateDeliverable(deliverableId, { due_date: event.startStr.substring(0,10) })) + }, [storeUpdate]) + + const handleEventClick = useCallback(({ event }) => { + const { deliverableId, projectId } = event.extendedProps + openFocus(projectId, deliverableId) + }, [openFocus]) + + const handleDateClick = useCallback(({ dateStr }) => { + setModal({ open: true, deliverable: null, defaultDate: dateStr.substring(0,10) }) + }, []) + + return ( +
+
+ +
+ setModal({ open: false, deliverable: null, defaultDate: '' })} + deliverable={modal.deliverable} + defaultDate={modal.defaultDate} + /> +
+ ) +} diff --git a/frontend/src/components/Deliverables/DeliverableModal.jsx b/frontend/src/components/Deliverables/DeliverableModal.jsx new file mode 100644 index 0000000..375a9c4 --- /dev/null +++ b/frontend/src/components/Deliverables/DeliverableModal.jsx @@ -0,0 +1,88 @@ +import { useState, useEffect } from 'react' +import Modal from '../UI/Modal' +import Button from '../UI/Button' +import { createDeliverable, updateDeliverable, deleteDeliverable } from '../../api/deliverables' +import useProjectStore from '../../store/useProjectStore' +import { STATUS_OPTIONS } from '../../utils/statusHelpers' + +export default function DeliverableModal({ isOpen, onClose, deliverable, projectId, defaultDate }) { + const { addDeliverable, updateDeliverable: storeUpdate, removeDeliverable, projects } = useProjectStore() + const [title, setTitle] = useState('') + const [dueDate, setDueDate] = useState('') + const [status, setStatus] = useState('upcoming') + const [pid, setPid] = useState('') + const [saving, setSaving] = useState(false) + const isEditing = !!deliverable + + useEffect(() => { + if (deliverable) { + setTitle(deliverable.title || ''); setDueDate(deliverable.due_date?.substring(0,10) || '') + setStatus(deliverable.status || 'upcoming'); setPid(deliverable.project_id || '') + } else { + setTitle(''); setDueDate(defaultDate || ''); setStatus('upcoming'); setPid(projectId || '') + } + }, [deliverable, isOpen, projectId, defaultDate]) + + const handleDelete = async () => { + if (!window.confirm('Delete this deliverable?')) return + await deleteDeliverable(deliverable.id); removeDeliverable(deliverable.id); onClose() + } + + const handleSubmit = async () => { + if (!title.trim() || !dueDate || !pid) return + setSaving(true) + try { + if (isEditing) { + storeUpdate(await updateDeliverable(deliverable.id, { title, due_date: dueDate, status })) + } else { + addDeliverable(await createDeliverable({ title, due_date: dueDate, status, project_id: Number(pid) })) + } + onClose() + } finally { setSaving(false) } + } + + return ( + +
+ {!isEditing && ( +
+ + +
+ )} +
+ + setTitle(e.target.value)} placeholder="Deliverable title..." /> +
+
+
+ + setDueDate(e.target.value)} /> +
+
+ + +
+
+
+ {isEditing ? :
} +
+ + +
+
+
+ + ) +} diff --git a/frontend/src/components/FocusView/DeliverableCard.jsx b/frontend/src/components/FocusView/DeliverableCard.jsx new file mode 100644 index 0000000..5f5b203 --- /dev/null +++ b/frontend/src/components/FocusView/DeliverableCard.jsx @@ -0,0 +1,34 @@ +import Badge from '../UI/Badge' +import { formatDate } from '../../utils/dateHelpers' + +export default function DeliverableCard({ deliverable, isActive, index, projectColor, onEdit }) { + return ( +
isActive && onEdit && onEdit(deliverable)} + className={`relative flex flex-col gap-2 rounded-xl border p-4 min-w-[190px] max-w-[230px] flex-shrink-0 transition-all duration-300 select-none + ${isActive + ? 'border-gold bg-surface-elevated shadow-gold scale-105 ring-2 ring-gold/30 cursor-pointer' + : 'border-surface-border bg-surface cursor-default' + }`} + > + {isActive && ( +
+ Selected +
+ )} +
+
+ + Deliverable {index + 1} + +
+

+ {deliverable.title} +

+

+ {formatDate(deliverable.due_date)} +

+ +
+ ) +} diff --git a/frontend/src/components/FocusView/FocusDrawer.jsx b/frontend/src/components/FocusView/FocusDrawer.jsx new file mode 100644 index 0000000..56a8276 --- /dev/null +++ b/frontend/src/components/FocusView/FocusDrawer.jsx @@ -0,0 +1,37 @@ +import { useState } from 'react' +import Drawer from '../UI/Drawer' +import FocusTimeline from './FocusTimeline' +import DeliverableModal from '../Deliverables/DeliverableModal' +import useFocusStore from '../../store/useFocusStore' +import useProjectStore from '../../store/useProjectStore' + +export default function FocusDrawer() { + const { isOpen, projectId, activeDeliverableId, closeFocus } = useFocusStore() + const getProjectById = useProjectStore(s => s.getProjectById) + const [editDel, setEditDel] = useState(null) + const [showModal, setShowModal] = useState(false) + + const project = projectId ? getProjectById(projectId) : null + + const handleEdit = (d) => { setEditDel(d); setShowModal(true) } + + return ( + <> + + {project && ( + + )} + + { setShowModal(false); setEditDel(null) }} + deliverable={editDel} + projectId={projectId} + /> + + ) +} diff --git a/frontend/src/components/FocusView/FocusTimeline.jsx b/frontend/src/components/FocusView/FocusTimeline.jsx new file mode 100644 index 0000000..7588776 --- /dev/null +++ b/frontend/src/components/FocusView/FocusTimeline.jsx @@ -0,0 +1,39 @@ +import DeliverableCard from './DeliverableCard' + +export default function FocusTimeline({ project, activeDeliverableId, onEditDeliverable }) { + const sorted = [...(project.deliverables || [])].sort((a, b) => new Date(a.due_date) - new Date(b.due_date)) + return ( +
+
+
+

{project.name}

+ {project.description && ( + — {project.description} + )} + {sorted.length} deliverable{sorted.length !== 1 ? 's' : ''} +
+
+ {sorted.map((d, i) => ( +
+ + {i < sorted.length - 1 && ( +
+
+ +
+ )} +
+ ))} + {sorted.length === 0 && ( +

No deliverables yet.

+ )} +
+
+ ) +} diff --git a/frontend/src/components/Projects/ProjectCard.jsx b/frontend/src/components/Projects/ProjectCard.jsx new file mode 100644 index 0000000..4711351 --- /dev/null +++ b/frontend/src/components/Projects/ProjectCard.jsx @@ -0,0 +1,42 @@ +import Badge from '../UI/Badge' +import { formatDate } from '../../utils/dateHelpers' +import useFocusStore from '../../store/useFocusStore' + +export default function ProjectCard({ project, onEdit, onDelete }) { + const openFocus = useFocusStore(s => s.openFocus) + return ( +
+
+
+
+
+
+ {project.name} +
+
+ + +
+
+ {project.description && ( +

{project.description}

+ )} +
+ {(project.deliverables || []).map(d => ( + + ))} + {(!project.deliverables || project.deliverables.length === 0) && ( +

No deliverables

+ )} +
+
+
+ ) +} diff --git a/frontend/src/components/Projects/ProjectList.jsx b/frontend/src/components/Projects/ProjectList.jsx new file mode 100644 index 0000000..dc7af49 --- /dev/null +++ b/frontend/src/components/Projects/ProjectList.jsx @@ -0,0 +1,41 @@ +import { useState } from 'react' +import ProjectCard from './ProjectCard' +import ProjectModal from './ProjectModal' +import Button from '../UI/Button' +import useProjectStore from '../../store/useProjectStore' +import { deleteProject } from '../../api/projects' + +export default function ProjectList() { + const { projects, removeProject } = useProjectStore() + const [showModal, setShowModal] = useState(false) + const [editing, setEditing] = useState(null) + + const handleEdit = (p) => { setEditing(p); setShowModal(true) } + const handleDelete = async (p) => { + if (window.confirm(`Delete "${p.name}" and all its deliverables?`)) { + await deleteProject(p.id); removeProject(p.id) + } + } + const handleClose = () => { setShowModal(false); setEditing(null) } + + return ( +
+
+

FabDash

+ +
+
+ {projects.length === 0 && ( +
+

No projects yet.

+

Click "+ Project" to begin.

+
+ )} + {projects.map(p => ( + + ))} +
+ +
+ ) +} diff --git a/frontend/src/components/Projects/ProjectModal.jsx b/frontend/src/components/Projects/ProjectModal.jsx new file mode 100644 index 0000000..faff17d --- /dev/null +++ b/frontend/src/components/Projects/ProjectModal.jsx @@ -0,0 +1,112 @@ +import { useState, useEffect } from 'react' +import Modal from '../UI/Modal' +import Button from '../UI/Button' +import { createProject, updateProject } from '../../api/projects' +import useProjectStore from '../../store/useProjectStore' +import { STATUS_OPTIONS } from '../../utils/statusHelpers' + +const PALETTE = ['#4A90D9','#2ECC9A','#9B59B6','#E74C3C','#E67E22','#27AE60','#E91E8C','#00BCD4','#5C6BC0','#F39C12','#C9A84C','#E8608A'] +const emptyRow = () => ({ title: '', due_date: '', status: 'upcoming' }) + +export default function ProjectModal({ isOpen, onClose, project }) { + const { addProject, updateProject: storeUpdate } = useProjectStore() + const [name, setName] = useState('') + const [desc, setDesc] = useState('') + const [color, setColor] = useState('#4A90D9') + const [rows, setRows] = useState([emptyRow()]) + const [saving, setSaving] = useState(false) + const isEditing = !!project + + useEffect(() => { + if (project) { + setName(project.name || '') + setDesc(project.description || '') + setColor(project.color || '#4A90D9') + setRows(project.deliverables?.length + ? project.deliverables.map(d => ({ id: d.id, title: d.title, due_date: d.due_date?.substring(0,10)||'', status: d.status })) + : [emptyRow()]) + } else { + setName(''); setDesc(''); setColor('#4A90D9'); setRows([emptyRow()]) + } + }, [project, isOpen]) + + const updRow = (i, f, v) => setRows(r => r.map((row, idx) => idx === i ? { ...row, [f]: v } : row)) + + const handleSubmit = async () => { + if (!name.trim()) return + setSaving(true) + try { + if (isEditing) { + const updated = await updateProject(project.id, { name, description: desc, color }) + storeUpdate({ ...updated, deliverables: project.deliverables }) + } else { + const valid = rows.filter(r => r.title.trim() && r.due_date) + const created = await createProject({ name, description: desc, color, deliverables: valid }) + addProject(created) + } + onClose() + } finally { setSaving(false) } + } + + return ( + +
+
+ + setName(e.target.value)} placeholder="e.g. CODA" /> +
+
+ +