Add files via upload

This commit is contained in:
jasonMPM
2026-03-05 12:13:22 -06:00
committed by GitHub
parent bfa3887e61
commit 20e71ee7f9
40 changed files with 1352 additions and 368 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
SECRET_KEY=replace-with-a-strong-random-secret-key
FLASK_ENV=production
DATABASE_URL=sqlite:////app/data/fabdash.db

20
.gitignore vendored Normal file
View File

@@ -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

28
Dockerfile Normal file
View File

@@ -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"]

602
README.md
View File

@@ -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("/<path:path>")
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 24 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
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")
---

33
backend/app/__init__.py Normal file
View File

@@ -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('/<path:path>')
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

View File

@@ -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()

51
backend/app/models.py Normal file
View File

@@ -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,
}

View File

View File

@@ -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/<int:id>', 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/<int:id>', 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

View File

@@ -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/<int:id>', 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/<int:id>', 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/<int:id>', 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

18
backend/config.py Normal file
View File

@@ -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,
}

6
backend/requirements.txt Normal file
View File

@@ -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

6
backend/run.py Normal file
View File

@@ -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)

19
docker-compose.yml Normal file
View File

@@ -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

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FabDash</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

31
frontend/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

29
frontend/src/App.jsx Normal file
View File

@@ -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 (
<div className="flex h-screen bg-surface overflow-hidden">
<aside className="w-72 flex-shrink-0 bg-surface-raised border-r border-surface-border flex flex-col h-full">
<ProjectList />
</aside>
<main className="flex-1 overflow-hidden">
<MainCalendar />
</main>
<FocusDrawer />
</div>
)
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 (
<div className="h-full flex flex-col bg-surface p-4">
<div className="flex-1 overflow-hidden">
<FullCalendar
ref={calRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={{ left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }}
events={events}
editable={true}
eventDrop={handleEventDrop}
eventClick={handleEventClick}
dateClick={handleDateClick}
height="100%"
dayMaxEvents={4}
eventDisplay="block"
displayEventTime={false}
/>
</div>
<DeliverableModal
isOpen={modal.open}
onClose={() => setModal({ open: false, deliverable: null, defaultDate: '' })}
deliverable={modal.deliverable}
defaultDate={modal.defaultDate}
/>
</div>
)
}

View File

@@ -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 (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Edit Deliverable' : 'Add Deliverable'}>
<div className="space-y-4">
{!isEditing && (
<div>
<label className="block text-xs text-text-muted mb-1 font-medium">Project *</label>
<select className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-gold"
value={pid} onChange={e => setPid(e.target.value)}>
<option value="">Select a project...</option>
{projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
)}
<div>
<label className="block text-xs text-text-muted mb-1 font-medium">Title *</label>
<input className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-gold transition-colors"
value={title} onChange={e => setTitle(e.target.value)} placeholder="Deliverable title..." />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-text-muted mb-1 font-medium">Due Date *</label>
<input type="date" className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-gold transition-colors"
value={dueDate} onChange={e => setDueDate(e.target.value)} />
</div>
<div>
<label className="block text-xs text-text-muted mb-1 font-medium">Status</label>
<select className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-gold"
value={status} onChange={e => setStatus(e.target.value)}>
{STATUS_OPTIONS.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</div>
<div className="flex justify-between pt-2 border-t border-surface-border">
{isEditing ? <Button variant="danger" onClick={handleDelete}>Delete</Button> : <div />}
<div className="flex gap-2">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={saving || !title.trim() || !dueDate || !pid}>
{saving ? 'Saving...' : isEditing ? 'Save Changes' : 'Add Deliverable'}
</Button>
</div>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,34 @@
import Badge from '../UI/Badge'
import { formatDate } from '../../utils/dateHelpers'
export default function DeliverableCard({ deliverable, isActive, index, projectColor, onEdit }) {
return (
<div
onClick={() => 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 && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-gold text-surface text-[9px] font-black px-2.5 py-0.5 rounded-full tracking-widest uppercase">
Selected
</div>
)}
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: projectColor }} />
<span className={`text-[10px] font-semibold uppercase tracking-widest ${isActive ? 'text-gold' : 'text-text-muted/60'}`}>
Deliverable {index + 1}
</span>
</div>
<p className={`text-sm font-semibold leading-snug ${isActive ? 'text-text-primary' : 'text-text-muted'}`}>
{deliverable.title}
</p>
<p className={`text-xs font-mono ${isActive ? 'text-gold' : 'text-text-muted/50'}`}>
{formatDate(deliverable.due_date)}
</p>
<Badge status={deliverable.status} />
</div>
)
}

View File

@@ -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 (
<>
<Drawer isOpen={isOpen} onClose={closeFocus}>
{project && (
<FocusTimeline
project={project}
activeDeliverableId={activeDeliverableId}
onEditDeliverable={handleEdit}
/>
)}
</Drawer>
<DeliverableModal
isOpen={showModal}
onClose={() => { setShowModal(false); setEditDel(null) }}
deliverable={editDel}
projectId={projectId}
/>
</>
)
}

View File

@@ -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 (
<div className="px-6 pb-6 pt-5">
<div className="flex items-center gap-2 mb-5">
<div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: project.color }} />
<h3 className="text-gold font-bold text-base tracking-wide">{project.name}</h3>
{project.description && (
<span className="text-text-muted text-xs"> {project.description}</span>
)}
<span className="ml-auto text-text-muted/50 text-xs">{sorted.length} deliverable{sorted.length !== 1 ? 's' : ''}</span>
</div>
<div className="flex items-center overflow-x-auto pb-3 gap-0">
{sorted.map((d, i) => (
<div key={d.id} className="flex items-center flex-shrink-0">
<DeliverableCard
deliverable={d}
isActive={d.id === activeDeliverableId}
index={i}
projectColor={project.color}
onEdit={onEditDeliverable}
/>
{i < sorted.length - 1 && (
<div className="flex items-center flex-shrink-0 px-1">
<div className="h-px w-6 bg-surface-border" />
<span className="text-surface-border text-xs"></span>
</div>
)}
</div>
))}
{sorted.length === 0 && (
<p className="text-text-muted text-sm italic">No deliverables yet.</p>
)}
</div>
</div>
)
}

View File

@@ -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 (
<div className="bg-surface-elevated border border-surface-border rounded-lg overflow-hidden transition-all hover:border-gold/20">
<div className="h-1 w-full" style={{ backgroundColor: project.color }} />
<div className="p-3">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2 min-w-0">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: project.color }} />
<span className="text-sm font-semibold text-text-primary truncate">{project.name}</span>
</div>
<div className="flex gap-0.5 flex-shrink-0 ml-1">
<button onClick={() => onEdit(project)} className="text-text-muted hover:text-gold p-1 transition-colors text-sm"></button>
<button onClick={() => onDelete(project)} className="text-text-muted hover:text-red-400 p-1 transition-colors text-sm"></button>
</div>
</div>
{project.description && (
<p className="text-xs text-text-muted mb-2 line-clamp-1">{project.description}</p>
)}
<div className="space-y-1">
{(project.deliverables || []).map(d => (
<button key={d.id} onClick={() => openFocus(project.id, d.id)}
className="w-full flex items-center justify-between text-xs bg-surface rounded px-2 py-1.5 border border-transparent hover:border-gold/20 hover:bg-surface-border/20 transition-all text-left group">
<span className="text-text-muted group-hover:text-text-primary truncate flex-1 pr-2">{d.title}</span>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className="text-text-muted/60 font-mono text-[9px]">{formatDate(d.due_date)}</span>
<Badge status={d.status} />
</div>
</button>
))}
{(!project.deliverables || project.deliverables.length === 0) && (
<p className="text-[11px] text-text-muted/40 italic text-center py-1">No deliverables</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -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 (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b border-surface-border flex-shrink-0">
<h1 className="text-gold font-bold text-lg tracking-widest uppercase">FabDash</h1>
<Button size="sm" onClick={() => setShowModal(true)}>+ Project</Button>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{projects.length === 0 && (
<div className="text-center py-10">
<p className="text-text-muted text-sm">No projects yet.</p>
<p className="text-text-muted/50 text-xs mt-1">Click "+ Project" to begin.</p>
</div>
)}
{projects.map(p => (
<ProjectCard key={p.id} project={p} onEdit={handleEdit} onDelete={handleDelete} />
))}
</div>
<ProjectModal isOpen={showModal} onClose={handleClose} project={editing} />
</div>
)
}

View File

@@ -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 (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Edit Project' : 'New Project'} size="lg">
<div className="space-y-4">
<div>
<label className="block text-xs text-text-muted mb-1 font-medium">Project Name *</label>
<input className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-gold transition-colors"
value={name} onChange={e => setName(e.target.value)} placeholder="e.g. CODA" />
</div>
<div>
<label className="block text-xs text-text-muted mb-1 font-medium">Description</label>
<textarea className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-gold transition-colors resize-none"
rows={2} value={desc} onChange={e => setDesc(e.target.value)} placeholder="Optional..." />
</div>
<div>
<label className="block text-xs text-text-muted mb-2 font-medium">Color</label>
<div className="flex flex-wrap gap-2 items-center">
{PALETTE.map(c => (
<button key={c} type="button" onClick={() => setColor(c)}
className={`w-7 h-7 rounded-full transition-all ${color===c ? 'ring-2 ring-offset-2 ring-offset-surface-raised ring-white scale-110' : 'hover:scale-105'}`}
style={{ backgroundColor: c }} />
))}
<input type="color" value={color} onChange={e => setColor(e.target.value)}
className="w-7 h-7 rounded cursor-pointer border-0 bg-transparent" title="Custom color" />
</div>
</div>
{!isEditing && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs text-text-muted font-medium">Deliverables</label>
<button type="button" onClick={() => setRows(r => [...r, emptyRow()])}
className="text-xs text-gold hover:text-gold-light transition-colors">+ Add Row</button>
</div>
<div className="space-y-2">
{rows.map((r, i) => (
<div key={i} className="flex gap-2 items-center">
<input className="flex-1 bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-xs text-text-primary focus:outline-none focus:border-gold"
placeholder={`Deliverable ${i+1}`} value={r.title} onChange={e => updRow(i,'title',e.target.value)} />
<input type="date" className="bg-surface border border-surface-border rounded-lg px-2 py-1.5 text-xs text-text-primary focus:outline-none focus:border-gold"
value={r.due_date} onChange={e => updRow(i,'due_date',e.target.value)} />
<select className="bg-surface border border-surface-border rounded-lg px-2 py-1.5 text-xs text-text-primary focus:outline-none focus:border-gold"
value={r.status} onChange={e => updRow(i,'status',e.target.value)}>
{STATUS_OPTIONS.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
{rows.length > 1 && (
<button type="button" onClick={() => setRows(d => d.filter((_,idx)=>idx!==i))}
className="text-red-400 hover:text-red-300 px-1"></button>
)}
</div>
))}
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2 border-t border-surface-border">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={saving || !name.trim()}>
{saving ? 'Saving...' : isEditing ? 'Save Changes' : 'Create Project'}
</Button>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,9 @@
import { STATUS_COLORS, getStatusLabel } from '../../utils/statusHelpers'
export default function Badge({ status }) {
const c = STATUS_COLORS[status] || STATUS_COLORS.upcoming
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${c.bg} ${c.text} ${c.border}`}>
{getStatusLabel(status)}
</span>
)
}

View File

@@ -0,0 +1,16 @@
export default function Button({ children, variant='primary', size='md', onClick, type='button', disabled=false, className='' }) {
const base = 'font-medium rounded transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-gold/50 disabled:opacity-50 disabled:cursor-not-allowed'
const variants = {
primary: 'bg-gold text-surface hover:bg-gold-light',
secondary: 'bg-surface-elevated border border-surface-border text-text-primary hover:border-gold hover:text-gold',
danger: 'bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30',
ghost: 'text-text-muted hover:text-gold hover:bg-surface-elevated',
}
const sizes = { sm: 'px-3 py-1.5 text-xs', md: 'px-4 py-2 text-sm', lg: 'px-6 py-2.5 text-base' }
return (
<button type={type} onClick={onClick} disabled={disabled}
className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}>
{children}
</button>
)
}

View File

@@ -0,0 +1,24 @@
import { useEffect } from 'react'
export default function Drawer({ isOpen, onClose, children }) {
useEffect(() => {
const h = (e) => { if (e.key === 'Escape') onClose() }
if (isOpen) document.addEventListener('keydown', h)
return () => document.removeEventListener('keydown', h)
}, [isOpen, onClose])
return (
<>
{isOpen && <div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm" onClick={onClose} />}
<div
className={`fixed bottom-0 left-0 right-0 z-50 bg-surface-raised border-t border-surface-border rounded-t-2xl shadow-2xl transition-transform duration-300 ease-in-out ${isOpen ? 'translate-y-0' : 'translate-y-full'}`}
style={{ maxHeight: '65vh' }}
>
<div className="relative flex items-center justify-between px-6 py-3 border-b border-surface-border">
<div className="absolute left-1/2 -translate-x-1/2 top-2 w-10 h-1 bg-surface-border rounded-full" />
<div className="flex-1" />
<button onClick={onClose} className="text-text-muted hover:text-gold transition-colors text-lg"></button>
</div>
<div className="overflow-y-auto" style={{ maxHeight: 'calc(65vh - 52px)' }}>{children}</div>
</div>
</>
)
}

View File

@@ -0,0 +1,22 @@
import { useEffect } from 'react'
export default function Modal({ isOpen, onClose, title, children, size='md' }) {
useEffect(() => {
const h = (e) => { if (e.key === 'Escape') onClose() }
if (isOpen) document.addEventListener('keydown', h)
return () => document.removeEventListener('keydown', h)
}, [isOpen, onClose])
if (!isOpen) return null
const sizes = { sm:'max-w-md', md:'max-w-lg', lg:'max-w-2xl', xl:'max-w-4xl' }
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
<div className={`relative w-full ${sizes[size]} mx-4 bg-surface-raised border border-surface-border rounded-xl shadow-2xl`}>
<div className="flex items-center justify-between px-6 py-4 border-b border-surface-border">
<h2 className="text-lg font-semibold text-gold">{title}</h2>
<button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors text-xl leading-none"></button>
</div>
<div className="px-6 py-5 overflow-y-auto max-h-[80vh]">{children}</div>
</div>
</div>
)
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './styles/globals.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -0,0 +1,12 @@
import { create } from 'zustand'
const useFocusStore = create((set) => ({
isOpen: false,
projectId: null,
activeDeliverableId: null,
openFocus: (projectId, deliverableId) => set({ isOpen: true, projectId, activeDeliverableId: deliverableId }),
closeFocus: () => set({ isOpen: false, projectId: null, activeDeliverableId: null }),
}))
export default useFocusStore

View File

@@ -0,0 +1,41 @@
import { create } from 'zustand'
const useProjectStore = create((set, get) => ({
projects: [],
loading: false,
error: null,
setProjects: (projects) => set({ projects }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
addProject: (p) => set((s) => ({ projects: [p, ...s.projects] })),
updateProject: (updated) => set((s) => ({
projects: s.projects.map(p => p.id === updated.id ? { ...updated, deliverables: p.deliverables } : p),
})),
removeProject: (id) => set((s) => ({ projects: s.projects.filter(p => p.id !== id) })),
addDeliverable: (d) => set((s) => ({
projects: s.projects.map(p => p.id === d.project_id
? { ...p, deliverables: [...(p.deliverables||[]), d].sort((a,b) => new Date(a.due_date)-new Date(b.due_date)) }
: p
),
})),
updateDeliverable: (updated) => set((s) => ({
projects: s.projects.map(p => p.id === updated.project_id
? { ...p, deliverables: p.deliverables.map(d => d.id === updated.id ? updated : d) }
: p
),
})),
removeDeliverable: (id) => set((s) => ({
projects: s.projects.map(p => ({ ...p, deliverables: (p.deliverables||[]).filter(d => d.id !== id) })),
})),
getProjectById: (id) => get().projects.find(p => p.id === id),
}))
export default useProjectStore

View File

@@ -0,0 +1,74 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* { box-sizing: border-box; }
body {
background-color: #111111;
color: #F5F5F5;
font-family: 'Inter', system-ui, sans-serif;
margin: 0;
padding: 0;
overflow: hidden;
}
/* ── FullCalendar dark theme overrides ── */
.fc {
--fc-border-color: #2E2E2E;
--fc-button-bg-color: #1A1A1A;
--fc-button-border-color: #2E2E2E;
--fc-button-hover-bg-color: #242424;
--fc-button-hover-border-color: #C9A84C;
--fc-button-active-bg-color: #C9A84C;
--fc-button-active-border-color: #C9A84C;
--fc-today-bg-color: rgba(201, 168, 76, 0.07);
--fc-page-bg-color: #111111;
--fc-neutral-bg-color: #1A1A1A;
--fc-event-border-color: transparent;
}
.fc-theme-standard td,
.fc-theme-standard th,
.fc-theme-standard .fc-scrollgrid { border-color: #2E2E2E !important; }
.fc-col-header-cell-cushion,
.fc-daygrid-day-number { color: #F5F5F5 !important; text-decoration: none !important; }
.fc-toolbar-title { color: #C9A84C !important; font-weight: 600 !important; }
.fc-button { font-size: 0.8rem !important; padding: 0.3rem 0.75rem !important; font-weight: 500 !important; color: #F5F5F5 !important; }
.fc-button-primary:not(:disabled):active,
.fc-button-primary:not(:disabled).fc-button-active {
background-color: #C9A84C !important;
border-color: #C9A84C !important;
color: #111111 !important;
}
.fc-daygrid-event {
border-radius: 4px !important;
font-size: 0.72rem !important;
font-weight: 600 !important;
cursor: pointer !important;
padding: 1px 5px !important;
}
.fc-event-title { color: #111111 !important; font-weight: 700 !important; }
.fc-day-today .fc-daygrid-day-number {
background-color: #C9A84C !important;
color: #111111 !important;
border-radius: 50% !important;
width: 26px !important;
height: 26px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: #1A1A1A; }
::-webkit-scrollbar-thumb { background: #2E2E2E; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #C9A84C; }

View File

@@ -0,0 +1,6 @@
import { format, isBefore, isToday, parseISO } from 'date-fns'
export const formatDate = (s) => s ? format(parseISO(s), 'MMM d, yyyy') : ''
export const formatDateForInput = (s) => s ? s.substring(0, 10) : ''
export const isOverdue = (s) => s && isBefore(parseISO(s), new Date()) && !isToday(parseISO(s))
export const isDueToday = (s) => s && isToday(parseISO(s))

View File

@@ -0,0 +1,15 @@
export const STATUS_OPTIONS = [
{ value: 'upcoming', label: 'Upcoming' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'completed', label: 'Completed' },
{ value: 'overdue', label: 'Overdue' },
]
export const STATUS_COLORS = {
upcoming: { bg: 'bg-blue-500/20', text: 'text-blue-400', border: 'border-blue-500/30' },
in_progress: { bg: 'bg-amber-500/20', text: 'text-amber-400', border: 'border-amber-500/30' },
completed: { bg: 'bg-green-500/20', text: 'text-green-400', border: 'border-green-500/30' },
overdue: { bg: 'bg-red-500/20', text: 'text-red-400', border: 'border-red-500/30' },
}
export const getStatusLabel = (s) => STATUS_OPTIONS.find(o => o.value === s)?.label || s

View File

@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
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)',
'gold-lg':'0 0 24px rgba(201, 168, 76, 0.55)',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
}

14
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
},
},
},
})