Add files via upload
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal 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
20
.gitignore
vendored
Normal 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
28
Dockerfile
Normal 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
602
README.md
@@ -1,26 +1,23 @@
|
|||||||
# FabDash
|
# FabDash
|
||||||
|
|
||||||
**Fabrication Dashboard** — A sleek, modern project management & scheduling application built for fabrication workflows.
|
**Fabrication Dashboard** — A sleek, modern project management & scheduling application.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Overview](#overview)
|
- [Overview](#overview)
|
||||||
- [Core Philosophy](#core-philosophy)
|
|
||||||
- [Tech Stack](#tech-stack)
|
- [Tech Stack](#tech-stack)
|
||||||
- [Project Structure](#project-structure)
|
- [Project Structure](#project-structure)
|
||||||
- [Data Architecture](#data-architecture)
|
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [API Reference](#api-reference)
|
- [API Reference](#api-reference)
|
||||||
- [Component Architecture](#component-architecture)
|
|
||||||
- [Docker Deployment](#docker-deployment)
|
- [Docker Deployment](#docker-deployment)
|
||||||
|
- [Unraid Installation](#unraid-installation)
|
||||||
- [Local Development](#local-development)
|
- [Local Development](#local-development)
|
||||||
- [Environment Variables](#environment-variables)
|
- [Environment Variables](#environment-variables)
|
||||||
- [Database Schema](#database-schema)
|
- [Database Schema](#database-schema)
|
||||||
@@ -30,57 +27,22 @@
|
|||||||
|
|
||||||
## Overview
|
## 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.
|
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.
|
||||||
|
|
||||||
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 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
### Frontend
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
| Package | Version | Purpose |
|
| Frontend | React 18, Vite, Tailwind CSS |
|
||||||
|---|---|---|
|
| Calendar | FullCalendar v6 (daygrid, timegrid, interaction) |
|
||||||
| React | ^18.x | UI component framework |
|
| Focus View | Custom horizontal timeline with react-chrono |
|
||||||
| Vite | ^5.x | Build tool and dev server |
|
| State | Zustand |
|
||||||
| Tailwind CSS | ^3.x | Utility-first styling with custom tokens |
|
| HTTP | Axios |
|
||||||
| @fullcalendar/react | ^6.x | Main calendar view |
|
| Backend | Flask 3, Flask-SQLAlchemy, Flask-Migrate, Flask-CORS |
|
||||||
| @fullcalendar/daygrid | ^6.x | Month/week/day grid views |
|
| Database | SQLite (persisted via Docker volume) |
|
||||||
| @fullcalendar/interaction | ^6.x | Drag-and-drop and click events |
|
| Server | Gunicorn (production) |
|
||||||
| @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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -103,6 +65,7 @@ fabdash/
|
|||||||
│ ├── extensions.py
|
│ ├── extensions.py
|
||||||
│ ├── models.py
|
│ ├── models.py
|
||||||
│ └── routes/
|
│ └── routes/
|
||||||
|
│ ├── __init__.py
|
||||||
│ ├── projects.py
|
│ ├── projects.py
|
||||||
│ └── deliverables.py
|
│ └── deliverables.py
|
||||||
│
|
│
|
||||||
@@ -110,6 +73,7 @@ fabdash/
|
|||||||
├── package.json
|
├── package.json
|
||||||
├── vite.config.js
|
├── vite.config.js
|
||||||
├── tailwind.config.js
|
├── tailwind.config.js
|
||||||
|
├── postcss.config.js
|
||||||
├── index.html
|
├── index.html
|
||||||
└── src/
|
└── src/
|
||||||
├── main.jsx
|
├── main.jsx
|
||||||
@@ -119,16 +83,13 @@ fabdash/
|
|||||||
│ └── deliverables.js
|
│ └── deliverables.js
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── Calendar/
|
│ ├── Calendar/
|
||||||
│ │ ├── MainCalendar.jsx
|
│ │ └── MainCalendar.jsx
|
||||||
│ │ ├── EventChip.jsx
|
|
||||||
│ │ └── CalendarToolbar.jsx
|
|
||||||
│ ├── Projects/
|
│ ├── Projects/
|
||||||
│ │ ├── ProjectList.jsx
|
│ │ ├── ProjectList.jsx
|
||||||
│ │ ├── ProjectCard.jsx
|
│ │ ├── ProjectCard.jsx
|
||||||
│ │ └── ProjectModal.jsx
|
│ │ └── ProjectModal.jsx
|
||||||
│ ├── Deliverables/
|
│ ├── Deliverables/
|
||||||
│ │ ├── DeliverableModal.jsx
|
│ │ └── DeliverableModal.jsx
|
||||||
│ │ └── DeliverableChip.jsx
|
|
||||||
│ ├── FocusView/
|
│ ├── FocusView/
|
||||||
│ │ ├── FocusDrawer.jsx
|
│ │ ├── FocusDrawer.jsx
|
||||||
│ │ ├── FocusTimeline.jsx
|
│ │ ├── FocusTimeline.jsx
|
||||||
@@ -141,9 +102,6 @@ fabdash/
|
|||||||
├── store/
|
├── store/
|
||||||
│ ├── useProjectStore.js
|
│ ├── useProjectStore.js
|
||||||
│ └── useFocusStore.js
|
│ └── useFocusStore.js
|
||||||
├── hooks/
|
|
||||||
│ ├── useProjects.js
|
|
||||||
│ └── useDeliverables.js
|
|
||||||
├── utils/
|
├── utils/
|
||||||
│ ├── dateHelpers.js
|
│ ├── dateHelpers.js
|
||||||
│ └── statusHelpers.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
|
## Features
|
||||||
|
|
||||||
### Main Calendar View
|
- **Large calendar view** — Month, Week, and Day modes via FullCalendar
|
||||||
|
- **Drag-and-drop** — Move deliverables to new dates; backend updates instantly
|
||||||
The primary interface is a large, full-width FullCalendar grid with three switchable view modes:
|
- **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
|
||||||
- **Month View** — Full month overview, all deliverables visible as colored event chips
|
- **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
|
||||||
- **Week View** — Focused 7-day view with time slots
|
- **Status tracking** — Upcoming / In Progress / Completed / Overdue badges per deliverable
|
||||||
- **Day View** — Single-day granularity for heavy scheduling days
|
- **Dark/gold theme** — Dark surfaces with gold as the primary accent throughout
|
||||||
|
- **Full persistence** — SQLite database via Flask-SQLAlchemy, mounted as a Docker volume
|
||||||
**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'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -274,216 +130,231 @@ theme: {
|
|||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/projects` | Fetch all projects with nested deliverables |
|
| `GET` | `/api/projects` | List all projects with nested deliverables |
|
||||||
| `POST` | `/api/projects` | Create a new project |
|
| `POST` | `/api/projects` | Create project (with optional deliverables inline) |
|
||||||
| `GET` | `/api/projects/:id` | Fetch a single project |
|
| `GET` | `/api/projects/:id` | Get single project |
|
||||||
| `PATCH` | `/api/projects/:id` | Update project name, color, or description |
|
| `PATCH` | `/api/projects/:id` | Update name, color, or description |
|
||||||
| `DELETE` | `/api/projects/:id` | Delete project and cascade deliverables |
|
| `DELETE` | `/api/projects/:id` | Delete project + cascade deliverables |
|
||||||
|
|
||||||
### Deliverables
|
### Deliverables
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/deliverables?project_id=:id` | Fetch deliverables for a project |
|
| `GET` | `/api/deliverables?project_id=:id` | List deliverables for a project |
|
||||||
| `POST` | `/api/deliverables` | Create a new deliverable |
|
| `POST` | `/api/deliverables` | Create deliverable |
|
||||||
| `PATCH` | `/api/deliverables/:id` | Update title, date, or status |
|
| `PATCH` | `/api/deliverables/:id` | Update title, due_date, or status |
|
||||||
| `DELETE` | `/api/deliverables/:id` | Delete a single deliverable |
|
| `DELETE` | `/api/deliverables/:id` | Delete 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)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Docker Deployment
|
## 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 `/*`
|
||||||
|
|
||||||
```
|
No Nginx, no separate containers, no reverse proxy required.
|
||||||
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)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone and run
|
|
||||||
git clone https://github.com/yourname/fabdash.git
|
git clone https://github.com/yourname/fabdash.git
|
||||||
cd fabdash
|
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
|
docker compose up -d --build
|
||||||
|
# App available at http://your-host:8080
|
||||||
# FabDash is live at http://localhost: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
|
```bash
|
||||||
docker compose logs -f fabdash # Stream logs
|
ssh root@YOUR_UNRAID_IP
|
||||||
docker compose down # Stop container
|
```
|
||||||
docker compose up -d --build # Rebuild and restart
|
|
||||||
docker exec -it fabdash flask db upgrade # Run migrations
|
#### 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
|
## 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
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
python -m venv venv
|
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
|
pip install -r requirements.txt
|
||||||
flask db upgrade
|
export FLASK_ENV=development
|
||||||
flask run --port 5000
|
flask run --port 5000
|
||||||
```
|
```
|
||||||
|
|
||||||
**Terminal 2 — React frontend:**
|
**Terminal 2 — React:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
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.
|
Vite proxies `/api/*` calls to Flask on port 5000 automatically via `vite.config.js`.
|
||||||
In production (Docker), the frontend calls `/api/*` relative to the same origin — no CORS needed.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
```env
|
| Variable | Default | Description |
|
||||||
# .env (used by docker-compose.yml)
|
|---|---|---|
|
||||||
SECRET_KEY=replace-with-a-strong-random-key
|
| `SECRET_KEY` | *(required)* | Flask session secret — use a long random string |
|
||||||
FLASK_ENV=production
|
| `FLASK_ENV` | `production` | Set to `development` for debug mode |
|
||||||
DATABASE_URL=sqlite:////app/data/fabdash.db
|
| `DATABASE_URL` | `sqlite:////app/data/fabdash.db` | Full SQLite path (4 slashes = absolute path) |
|
||||||
|
|
||||||
# frontend/.env (development only)
|
|
||||||
VITE_API_BASE_URL=http://localhost:5000/api
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -516,69 +387,66 @@ CREATE INDEX idx_deliverables_due_date ON deliverables(due_date);
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### v1.0 — Core Release *(current scope)*
|
### v1.0 — Core Release *(current)*
|
||||||
|
|
||||||
- [x] Dark/gold design system with Tailwind custom tokens
|
- [x] Dark/gold Tailwind design system
|
||||||
- [x] FullCalendar main view with Month / Week / Day modes
|
- [x] FullCalendar with Month / Week / Day views
|
||||||
- [x] Drag-and-drop deliverable rescheduling
|
- [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] Add / Edit / Delete for projects and deliverables
|
||||||
- [x] Deliverable Focus View — slide-up drawer with horizontal timeline
|
- [x] Deliverable Focus View (slide-up drawer + horizontal timeline)
|
||||||
- [x] Active deliverable highlighting in Focus View
|
- [x] Active deliverable gold highlight in Focus View
|
||||||
- [x] Status badges (Upcoming / In Progress / Completed / Overdue)
|
- [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] Cascade delete (project → deliverables)
|
||||||
- [x] Right-click context menu on calendar events
|
- [x] Single Docker container deployment
|
||||||
- [x] Single Docker container deployment with persistent volume
|
- [x] Unraid installation guide (Terminal + GUI)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### v1.1 — Polish & UX
|
### v1.1 — Polish & UX
|
||||||
|
|
||||||
- [ ] Keyboard shortcuts (`N` = new project, `Esc` = close modal, arrow keys = calendar nav)
|
- [ ] Keyboard shortcuts (`N` = new project, `Esc` = close, arrow keys = calendar nav)
|
||||||
- [ ] Undo/redo for drag-and-drop with 30-second undo toast
|
- [ ] 30-second undo toast for drag-and-drop moves
|
||||||
- [ ] Animated transitions on drawer and modal open/close
|
- [ ] Animated modal/drawer enter and exit transitions
|
||||||
- [ ] Deliverable sorting in Focus View (by date, by status)
|
- [ ] Hover tooltip on calendar events (preview without opening Focus View)
|
||||||
- [ ] Empty state illustrations for no projects or no deliverables
|
- [ ] Responsive layout with collapsible sidebar
|
||||||
- [ ] Responsive layout with collapsible sidebar for smaller screens
|
- [ ] Empty state illustrations
|
||||||
|
|
||||||
### v1.2 — Enhanced Calendar
|
### v1.2 — Calendar Enhancements
|
||||||
|
|
||||||
- [ ] Agenda sidebar showing all upcoming deliverables across projects
|
- [ ] Agenda sidebar showing all upcoming deliverables across projects
|
||||||
- [ ] Week numbers in calendar header
|
- [ ] Click empty date → pre-filled Add Deliverable modal with project selector
|
||||||
- [ ] Hover tooltip previewing deliverable details without opening Focus View
|
|
||||||
- [ ] Date range selection for bulk deliverable creation
|
- [ ] 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)
|
- [ ] User login (Flask-Login + JWT)
|
||||||
- [ ] Multi-user support with project ownership and sharing
|
- [ ] Multi-user support with project ownership
|
||||||
- [ ] Role-based access per project (Owner / Editor / Viewer)
|
- [ ] Role-based access per project (Owner / Editor / Viewer)
|
||||||
- [ ] Activity log per project (who changed what, when)
|
- [ ] Activity log per project
|
||||||
- [ ] Comment threads on individual deliverables
|
- [ ] Comment threads on deliverables
|
||||||
|
|
||||||
### v2.1 — Notifications & Integrations
|
### v2.1 — Notifications & Integrations
|
||||||
|
|
||||||
- [ ] In-app notification center for approaching due dates
|
- [ ] 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
|
- [ ] iCal / Google Calendar export per project
|
||||||
- [ ] Slack webhook integration for deliverable status changes
|
- [ ] Slack webhook for deliverable status changes
|
||||||
- [ ] CSV import/export for bulk project setup
|
- [ ] CSV import/export for bulk setup
|
||||||
|
|
||||||
### v2.2 — Advanced Views
|
### v2.2 — Advanced Views
|
||||||
|
|
||||||
- [ ] Gantt view as an alternate layout
|
- [ ] Gantt view alternate layout
|
||||||
- [ ] Kanban board (columns: Upcoming / In Progress / Completed)
|
- [ ] Kanban board (columns by status)
|
||||||
- [ ] Cross-project timeline showing all projects on one horizontal axis
|
- [ ] Cross-project timeline view
|
||||||
- [ ] Workload heatmap showing deliverable density per day
|
- [ ] Workload heatmap showing deliverable density per day
|
||||||
- [ ] Archived projects with searchable history
|
- [ ] Archived projects with searchable history
|
||||||
|
|
||||||
### v3.0 — Intelligence Layer
|
### v3.0 — Intelligence Layer
|
||||||
|
|
||||||
- [ ] AI-assisted scheduling suggestions based on project cadence
|
- [ ] AI scheduling suggestions based on project cadence
|
||||||
- [ ] Auto-detect and surface overdue deliverables on dashboard load
|
- [ ] Conflict detection — flag overloaded days
|
||||||
- [ ] Conflict detection — flag days with too many concurrent deliverables
|
|
||||||
- [ ] Natural language input ("Add final draft due next Friday to CODA")
|
- [ ] Natural language input ("Add final draft due next Friday to CODA")
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
33
backend/app/__init__.py
Normal file
33
backend/app/__init__.py
Normal 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
|
||||||
7
backend/app/extensions.py
Normal file
7
backend/app/extensions.py
Normal 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
51
backend/app/models.py
Normal 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,
|
||||||
|
}
|
||||||
0
backend/app/routes/__init__.py
Normal file
0
backend/app/routes/__init__.py
Normal file
44
backend/app/routes/deliverables.py
Normal file
44
backend/app/routes/deliverables.py
Normal 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
|
||||||
54
backend/app/routes/projects.py
Normal file
54
backend/app/routes/projects.py
Normal 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
18
backend/config.py
Normal 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
6
backend/requirements.txt
Normal 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
6
backend/run.py
Normal 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
19
docker-compose.yml
Normal 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
15
frontend/index.html
Normal 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
31
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
29
frontend/src/App.jsx
Normal file
29
frontend/src/App.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
6
frontend/src/api/deliverables.js
Normal file
6
frontend/src/api/deliverables.js
Normal 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)
|
||||||
7
frontend/src/api/projects.js
Normal file
7
frontend/src/api/projects.js
Normal 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)
|
||||||
70
frontend/src/components/Calendar/MainCalendar.jsx
Normal file
70
frontend/src/components/Calendar/MainCalendar.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
frontend/src/components/Deliverables/DeliverableModal.jsx
Normal file
88
frontend/src/components/Deliverables/DeliverableModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
frontend/src/components/FocusView/DeliverableCard.jsx
Normal file
34
frontend/src/components/FocusView/DeliverableCard.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
frontend/src/components/FocusView/FocusDrawer.jsx
Normal file
37
frontend/src/components/FocusView/FocusDrawer.jsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
frontend/src/components/FocusView/FocusTimeline.jsx
Normal file
39
frontend/src/components/FocusView/FocusTimeline.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
frontend/src/components/Projects/ProjectCard.jsx
Normal file
42
frontend/src/components/Projects/ProjectCard.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
frontend/src/components/Projects/ProjectList.jsx
Normal file
41
frontend/src/components/Projects/ProjectList.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
frontend/src/components/Projects/ProjectModal.jsx
Normal file
112
frontend/src/components/Projects/ProjectModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
frontend/src/components/UI/Badge.jsx
Normal file
9
frontend/src/components/UI/Badge.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
frontend/src/components/UI/Button.jsx
Normal file
16
frontend/src/components/UI/Button.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
frontend/src/components/UI/Drawer.jsx
Normal file
24
frontend/src/components/UI/Drawer.jsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
frontend/src/components/UI/Modal.jsx
Normal file
22
frontend/src/components/UI/Modal.jsx
Normal 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
10
frontend/src/main.jsx
Normal 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>
|
||||||
|
)
|
||||||
12
frontend/src/store/useFocusStore.js
Normal file
12
frontend/src/store/useFocusStore.js
Normal 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
|
||||||
41
frontend/src/store/useProjectStore.js
Normal file
41
frontend/src/store/useProjectStore.js
Normal 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
|
||||||
74
frontend/src/styles/globals.css
Normal file
74
frontend/src/styles/globals.css
Normal 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; }
|
||||||
6
frontend/src/utils/dateHelpers.js
Normal file
6
frontend/src/utils/dateHelpers.js
Normal 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))
|
||||||
15
frontend/src/utils/statusHelpers.js
Normal file
15
frontend/src/utils/statusHelpers.js
Normal 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
|
||||||
27
frontend/tailwind.config.js
Normal file
27
frontend/tailwind.config.js
Normal 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
14
frontend/vite.config.js
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user