Compare commits

..

7 Commits

Author SHA1 Message Date
fe4d8b120c Merge pull request 'more fixes' (#3) from updates into main
Reviewed-on: #3
2026-03-12 10:36:22 -05:00
jason
ff0730b6c6 more fixes 2026-03-12 10:36:02 -05:00
0d0b71e749 Merge pull request 'fixes' (#2) from updates into main
Reviewed-on: #2
2026-03-12 10:28:53 -05:00
jason
1b8e0367f6 fixes 2026-03-12 10:28:37 -05:00
335879a4b7 Merge pull request 'updates, back and front' (#1) from updates into main
Reviewed-on: #1
2026-03-12 10:23:40 -05:00
jason
a1f8c90801 updates, back and front 2026-03-12 10:23:22 -05:00
jason
03ee3c542e updates and opti 2026-03-12 10:12:48 -05:00
13 changed files with 219 additions and 85 deletions

66
ROADMAP.md Normal file
View File

@@ -0,0 +1,66 @@
# FabDash Roadmap
A sleek, modern fabrication dashboard focused on simplicity and clarity.
## Current Features (v1.1)
### Core Management
- **Multi-Deliverable Projects**: Unlimited deliverables per project with customized colors and descriptions.
- **Project Archiving**: Move completed projects out of view without deleting data.
- **Google Drive Integration**: Optional per-project folder links available in sidebars and context menus.
### Visual Scheduling
- **FullCalendar Views**: Month, Week, and Day interactive grids.
- **Drag-and-Drop**: Reschedule deliverables directly on the calendar with instant backend syncing.
- **Undo Actions**: 30-second "Undo" toast with a countdown ring after rescheduling.
- **ISO Week Numbers**: Displayed for better alignment with manufacturing schedules.
### Enhanced Visualization
- **Workload Heatmap**:
- 4 per-status color-coded mini grids (Blue/Amber/Green/Red) with individual stat cards.
- Combined "All Tasks" heatmap with dominant-status coloring.
- Per-day status breakdown tooltips.
- **Focus View**: A persistent timeline drawer at the bottom for deep-diving into a project's chronology.
- **Event Tooltips**: Hover for quick deliverable/project info without leaving the calendar.
### Performance & UX
- **Smooth Reflows**: `ResizeObserver` ensures the calendar scales perfectly as the sidebar toggles.
- **Keyboard Shortcuts**: `N` (New Project), `B` (Toggle Sidebar), `T` (Today), `Arrows` (Navigation).
- **Custom Branding**: Easy square logo swapping at `frontend/public/logo.png`.
- **Public Landing Page**: Standalone `/landing.html` for marketing and quick-start.
---
## Proposed Features
### v1.2 — Polish & Productivity
- [ ] **Mobile Responsiveness**: Adaptive layouts for phones and tablets.
- [ ] **Print/Export to PDF**: Generate clean, printable snapshots of the calendar and agenda.
- [ ] **Bulk Status Selection**: Shift multiple deliverables to a new status from the heatmap or sidebar.
- [ ] **Animated Transitions**: Smooth exit animations for modals and drawers.
- [ ] **Mini-Calendar**: A small date picker in the sidebar for rapid navigation.
### v2.0 — Security & Multi-User
- [ ] **User Authentication**: Flask-Login + JWT integration for secure access.
- [ ] **Multi-User Support**: Individual accounts with project ownership.
- [x] **API Service Layer Abstraction**: Consolidate API interaction logic into Zustand actions.
- [ ] **Role-Based Access (RBAC)**: Define Owners, Editors, and Viewers per project.
- [ ] **Audit Logs**: Track who changed what and when.
### v2.1 — Connectivity & Notifications
- [ ] **Automated Reminders**: Email notifications for upcoming or overdue tasks.
- [ ] **Third-Party Integration**: iCal/Google Calendar feeds and Slack webhooks for status changes.
- [ ] **CSV Import/Export**: Bulk import capability for project migration.
### v3.0 — Intelligent Scheduling
- [ ] **AI-Assisted Scheduling**: Automated suggestions based on current workload.
- [ ] **Conflict Detection**: Visual warnings when days are overloaded.
- [ ] **Natural Language Entry**: "Add 'Cut Plates' to Tucson next Friday" via a command bar.
---
## Technical Debt & Growth
- [ ] **Test Coverage**: implement backend (pytest) and frontend (Vitest) test suites.
- [ ] **Migration Framework**: Switch to `Flask-Migrate` for formal schema versioning.
- [ ] **Structured Logging**: Centralized backend logs for better monitoring.
- [x] **Global Exception Handling**: Standardized API error responses.

View File

@@ -1,6 +1,7 @@
import os import os
from flask import Flask, send_from_directory from flask import Flask, send_from_directory, jsonify
from werkzeug.exceptions import HTTPException, NotFound, BadRequest
from sqlalchemy import text from sqlalchemy import text
from .extensions import db, migrate, cors from .extensions import db, migrate, cors
@@ -28,13 +29,26 @@ def create_app(config_name=None):
db.create_all() db.create_all()
_run_migrations() _run_migrations()
@app.route('/', defaults={'path': ''}) @app.route('/')
@app.route('/<path:path>') def index():
def serve_react(path): return send_from_directory(app.static_folder, 'index.html')
static_folder = app.static_folder
if path and os.path.exists(os.path.join(static_folder, path)): @app.errorhandler(404)
return send_from_directory(static_folder, path) def handle_404(e):
return send_from_directory(static_folder, 'index.html') if request.path.startswith('/api/'):
return jsonify({'error': 'Resource not found', 'message': str(e)}), 404
return send_from_directory(app.static_folder, 'index.html')
@app.errorhandler(400)
def bad_request(e):
return jsonify({'error': 'Bad request', 'message': str(e)}), 400
@app.errorhandler(Exception)
def handle_exception(e):
if isinstance(e, HTTPException):
return jsonify({'error': e.name, 'message': e.description}), e.code
app.logger.error(f"Unhandled exception: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error', 'message': 'An unexpected error occurred'}), 500
return app return app

View File

@@ -1,5 +1,5 @@
from .extensions import db from .extensions import db
from datetime import datetime, date from datetime import datetime, date, timezone
class Project(db.Model): class Project(db.Model):
@@ -11,7 +11,7 @@ class Project(db.Model):
description = db.Column(db.Text) description = db.Column(db.Text)
drive_url = db.Column(db.String(500)) drive_url = db.Column(db.String(500))
archived_at = db.Column(db.DateTime, nullable=True) archived_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
deliverables = db.relationship( deliverables = db.relationship(
'Deliverable', 'Deliverable',
@@ -49,7 +49,7 @@ class Deliverable(db.Model):
title = db.Column(db.String(300), nullable=False) title = db.Column(db.String(300), nullable=False)
due_date = db.Column(db.Date, nullable=False) due_date = db.Column(db.Date, nullable=False)
status = db.Column(db.String(20), nullable=False, default='upcoming') status = db.Column(db.String(20), nullable=False, default='upcoming')
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
def effective_status(self): def effective_status(self):
""" """

View File

@@ -1,7 +1,7 @@
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from ..models import Project, Deliverable from ..models import Project, Deliverable
from ..extensions import db from ..extensions import db
from datetime import date, datetime from datetime import date, datetime, timezone
projects_bp = Blueprint('projects', __name__) projects_bp = Blueprint('projects', __name__)
@@ -57,7 +57,7 @@ def update_project(id):
@projects_bp.route('/projects/<int:id>/archive', methods=['PATCH']) @projects_bp.route('/projects/<int:id>/archive', methods=['PATCH'])
def archive_project(id): def archive_project(id):
project = Project.query.get_or_404(id) project = Project.query.get_or_404(id)
project.archived_at = datetime.utcnow() project.archived_at = datetime.now(timezone.utc)
db.session.commit() db.session.commit()
return jsonify(project.to_dict()) return jsonify(project.to_dict())

View File

@@ -3,21 +3,17 @@ import ProjectList from './components/Projects/ProjectList'
import MainCalendar from './components/Calendar/MainCalendar' import MainCalendar from './components/Calendar/MainCalendar'
import FocusDrawer from './components/FocusView/FocusDrawer' import FocusDrawer from './components/FocusView/FocusDrawer'
import ToastContainer from './components/UI/Toast' import ToastContainer from './components/UI/Toast'
import { fetchProjects } from './api/projects'
import useProjectStore from './store/useProjectStore' import useProjectStore from './store/useProjectStore'
import useUIStore from './store/useUIStore' import useUIStore from './store/useUIStore'
export default function App() { export default function App() {
const { setProjects, setLoading } = useProjectStore() const { loadProjects } = useProjectStore()
const { sidebarOpen, toggleSidebar } = useUIStore() const { sidebarOpen, toggleSidebar } = useUIStore()
const calApiRef = useRef(null) const calApiRef = useRef(null)
const newProjectFn = useRef(null) const newProjectFn = useRef(null)
useEffect(() => { useEffect(() => {
setLoading(true) loadProjects()
fetchProjects()
.then(data => { setProjects(data); setLoading(false) })
.catch(() => setLoading(false))
}, []) }, [])
useEffect(() => { useEffect(() => {

View File

@@ -3,7 +3,6 @@ import { format, isToday } from 'date-fns'
import useProjectStore from '../../store/useProjectStore' import useProjectStore from '../../store/useProjectStore'
import useFocusStore from '../../store/useFocusStore' import useFocusStore from '../../store/useFocusStore'
import useUIStore from '../../store/useUIStore' import useUIStore from '../../store/useUIStore'
import { updateDeliverable as apiUpdate } from '../../api/deliverables'
import DeliverableModal from '../Deliverables/DeliverableModal' import DeliverableModal from '../Deliverables/DeliverableModal'
const STATUS_KEYS = ['overdue', 'in_progress', 'upcoming', 'completed'] const STATUS_KEYS = ['overdue', 'in_progress', 'upcoming', 'completed']
@@ -46,8 +45,7 @@ export default function HeatmapDayPanel({ date, onClose }) {
const next = STATUS_CYCLE[deliverable.status] || 'upcoming' const next = STATUS_CYCLE[deliverable.status] || 'upcoming'
setCycling(deliverable.id) setCycling(deliverable.id)
try { try {
const updated = await apiUpdate(deliverable.id, { status: next }) await storeUpdate(deliverable.id, { status: next })
storeUpdate(updated)
} finally { } finally {
setCycling(null) setCycling(null)
} }

View File

@@ -7,7 +7,6 @@ import useProjectStore from '../../store/useProjectStore'
import useFocusStore from '../../store/useFocusStore' import useFocusStore from '../../store/useFocusStore'
import useUIStore from '../../store/useUIStore' import useUIStore from '../../store/useUIStore'
import useToastStore from '../../store/useToastStore' import useToastStore from '../../store/useToastStore'
import { updateDeliverable, deleteDeliverable } from '../../api/deliverables'
import DeliverableModal from '../Deliverables/DeliverableModal' import DeliverableModal from '../Deliverables/DeliverableModal'
import ContextMenu from '../UI/ContextMenu' import ContextMenu from '../UI/ContextMenu'
import EventTooltip from './EventTooltip' import EventTooltip from './EventTooltip'
@@ -73,12 +72,12 @@ export default function MainCalendar({ onCalendarReady }) {
const { deliverableId } = event.extendedProps const { deliverableId } = event.extendedProps
const newDate = event.startStr.substring(0, 10) const newDate = event.startStr.substring(0, 10)
const oldDate = oldEvent.startStr.substring(0, 10) const oldDate = oldEvent.startStr.substring(0, 10)
storeUpdate(await updateDeliverable(deliverableId, { due_date: newDate })) await storeUpdate(deliverableId, { due_date: newDate })
addToast({ addToast({
message: `Moved to ${newDate}`, message: `Moved to ${newDate}`,
duration: 30, duration: 30,
undoFn: async () => { undoFn: async () => {
storeUpdate(await updateDeliverable(deliverableId, { due_date: oldDate })) await storeUpdate(deliverableId, { due_date: oldDate })
}, },
}) })
}, [storeUpdate, addToast]) }, [storeUpdate, addToast])
@@ -123,8 +122,7 @@ export default function MainCalendar({ onCalendarReady }) {
{ icon: '✕', label: 'Delete Deliverable', danger: true, { icon: '✕', label: 'Delete Deliverable', danger: true,
action: async () => { action: async () => {
if (window.confirm(`Delete "${deliverable.title}"?`)) { if (window.confirm(`Delete "${deliverable.title}"?`)) {
await deleteDeliverable(deliverableId) await removeDeliverable(deliverableId)
removeDeliverable(deliverableId)
} }
}, },
}, },

View File

@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import Modal from '../UI/Modal' import Modal from '../UI/Modal'
import Button from '../UI/Button' import Button from '../UI/Button'
import { createDeliverable, updateDeliverable, deleteDeliverable } from '../../api/deliverables'
import useProjectStore from '../../store/useProjectStore' import useProjectStore from '../../store/useProjectStore'
import { STATUS_OPTIONS } from '../../utils/statusHelpers' import { STATUS_OPTIONS } from '../../utils/statusHelpers'
@@ -25,7 +24,8 @@ export default function DeliverableModal({ isOpen, onClose, deliverable, project
const handleDelete = async () => { const handleDelete = async () => {
if (!window.confirm('Delete this deliverable?')) return if (!window.confirm('Delete this deliverable?')) return
await deleteDeliverable(deliverable.id); removeDeliverable(deliverable.id); onClose() await removeDeliverable(deliverable.id)
onClose()
} }
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -33,9 +33,9 @@ export default function DeliverableModal({ isOpen, onClose, deliverable, project
setSaving(true) setSaving(true)
try { try {
if (isEditing) { if (isEditing) {
storeUpdate(await updateDeliverable(deliverable.id, { title, due_date: dueDate, status })) await storeUpdate(deliverable.id, { title, due_date: dueDate, status })
} else { } else {
addDeliverable(await createDeliverable({ title, due_date: dueDate, status, project_id: Number(pid) })) await addDeliverable({ title, due_date: dueDate, status, project_id: Number(pid) })
} }
onClose() onClose()
} finally { setSaving(false) } } finally { setSaving(false) }

View File

@@ -2,7 +2,6 @@ import { useState } from 'react'
import Badge from '../UI/Badge' import Badge from '../UI/Badge'
import { formatDate } from '../../utils/dateHelpers' import { formatDate } from '../../utils/dateHelpers'
import ContextMenu from '../UI/ContextMenu' import ContextMenu from '../UI/ContextMenu'
import { updateDeliverable, deleteDeliverable } from '../../api/deliverables'
import useProjectStore from '../../store/useProjectStore' import useProjectStore from '../../store/useProjectStore'
import { STATUS_OPTIONS } from '../../utils/statusHelpers' import { STATUS_OPTIONS } from '../../utils/statusHelpers'
@@ -28,7 +27,7 @@ export default function DeliverableCard({ deliverable, isActive, index, projectC
icon: s.value === deliverable.status ? '●' : '○', icon: s.value === deliverable.status ? '●' : '○',
label: `Mark ${s.label}`, label: `Mark ${s.label}`,
action: async () => { action: async () => {
storeUpdate(await updateDeliverable(deliverable.id, { status: s.value })) await storeUpdate(deliverable.id, { status: s.value })
}, },
})), })),
{ separator: true }, { separator: true },
@@ -38,8 +37,7 @@ export default function DeliverableCard({ deliverable, isActive, index, projectC
danger: true, danger: true,
action: async () => { action: async () => {
if (window.confirm(`Delete "${deliverable.title}"?`)) { if (window.confirm(`Delete "${deliverable.title}"?`)) {
await deleteDeliverable(deliverable.id) await removeDeliverable(deliverable.id)
removeDeliverable(deliverable.id)
} }
}, },
}, },

View File

@@ -3,8 +3,6 @@ import Badge from '../UI/Badge'
import { formatDate } from '../../utils/dateHelpers' import { formatDate } from '../../utils/dateHelpers'
import useFocusStore from '../../store/useFocusStore' import useFocusStore from '../../store/useFocusStore'
import useProjectStore from '../../store/useProjectStore' import useProjectStore from '../../store/useProjectStore'
import { deleteDeliverable } from '../../api/deliverables'
import { archiveProject, unarchiveProject } from '../../api/projects'
import DeliverableModal from '../Deliverables/DeliverableModal' import DeliverableModal from '../Deliverables/DeliverableModal'
import ContextMenu from '../UI/ContextMenu' import ContextMenu from '../UI/ContextMenu'
@@ -18,7 +16,7 @@ function DriveIcon() {
export default function ProjectCard({ project, onEdit, onDelete, onArchiveToggle }) { export default function ProjectCard({ project, onEdit, onDelete, onArchiveToggle }) {
const openFocus = useFocusStore(s => s.openFocus) const openFocus = useFocusStore(s => s.openFocus)
const { removeDeliverable, updateProject } = useProjectStore() const { removeDeliverable, toggleArchive } = useProjectStore()
const [delModal, setDelModal] = useState({ open: false, deliverable: null }) const [delModal, setDelModal] = useState({ open: false, deliverable: null })
const [ctxMenu, setCtxMenu] = useState(null) const [ctxMenu, setCtxMenu] = useState(null)
@@ -37,8 +35,7 @@ export default function ProjectCard({ project, onEdit, onDelete, onArchiveToggle
icon: '✕', label: 'Delete Deliverable', danger: true, icon: '✕', label: 'Delete Deliverable', danger: true,
action: async () => { action: async () => {
if (window.confirm(`Delete "${d.title}"?`)) { if (window.confirm(`Delete "${d.title}"?`)) {
await deleteDeliverable(d.id) await removeDeliverable(d.id)
removeDeliverable(d.id)
} }
}, },
}, },
@@ -60,10 +57,7 @@ export default function ProjectCard({ project, onEdit, onDelete, onArchiveToggle
icon: isArchived ? '↺' : '⏸', icon: isArchived ? '↺' : '⏸',
label: isArchived ? 'Unarchive Project' : 'Archive Project', label: isArchived ? 'Unarchive Project' : 'Archive Project',
action: async () => { action: async () => {
const updated = isArchived await toggleArchive(project.id, isArchived)
? await unarchiveProject(project.id)
: await archiveProject(project.id)
updateProject(updated)
onArchiveToggle?.() onArchiveToggle?.()
}, },
}, },

View File

@@ -5,7 +5,6 @@ import Button from '../UI/Button'
import AgendaPanel from '../Calendar/AgendaPanel' import AgendaPanel from '../Calendar/AgendaPanel'
import useProjectStore from '../../store/useProjectStore' import useProjectStore from '../../store/useProjectStore'
import useUIStore from '../../store/useUIStore' import useUIStore from '../../store/useUIStore'
import { deleteProject, fetchProjects } from '../../api/projects'
const VIEW_OPTIONS = [ const VIEW_OPTIONS = [
{ key: 'active', label: 'Active' }, { key: 'active', label: 'Active' },
@@ -14,7 +13,7 @@ const VIEW_OPTIONS = [
] ]
export default function ProjectList({ onRegisterNewProject }) { export default function ProjectList({ onRegisterNewProject }) {
const { projects, removeProject, setProjects } = useProjectStore() const { projects, deleteProject, loadProjects } = useProjectStore()
const { sidebarTab, setSidebarTab } = useUIStore() const { sidebarTab, setSidebarTab } = useUIStore()
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState(null) const [editing, setEditing] = useState(null)
@@ -26,15 +25,13 @@ export default function ProjectList({ onRegisterNewProject }) {
}, [onRegisterNewProject]) }, [onRegisterNewProject])
const refreshProjects = async () => { const refreshProjects = async () => {
const data = await fetchProjects() await loadProjects()
setProjects(data)
} }
const handleEdit = (p) => { setEditing(p); setShowModal(true) } const handleEdit = (p) => { setEditing(p); setShowModal(true) }
const handleDelete = async (p) => { const handleDelete = async (p) => {
if (window.confirm(`Delete "${p.name}" and all its deliverables?`)) { if (window.confirm(`Delete "${p.name}" and all its deliverables?`)) {
await deleteProject(p.id) await deleteProject(p.id)
removeProject(p.id)
} }
} }
const handleClose = () => { setShowModal(false); setEditing(null) } const handleClose = () => { setShowModal(false); setEditing(null) }

View File

@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import Modal from '../UI/Modal' import Modal from '../UI/Modal'
import Button from '../UI/Button' import Button from '../UI/Button'
import { createProject, updateProject } from '../../api/projects'
import useProjectStore from '../../store/useProjectStore' import useProjectStore from '../../store/useProjectStore'
import { STATUS_OPTIONS } from '../../utils/statusHelpers' import { STATUS_OPTIONS } from '../../utils/statusHelpers'
@@ -9,7 +8,7 @@ const PALETTE = ['#4A90D9','#2ECC9A','#9B59B6','#E74C3C','#E67E22','#27AE60','#E
const emptyRow = () => ({ title: '', due_date: '', status: 'upcoming' }) const emptyRow = () => ({ title: '', due_date: '', status: 'upcoming' })
export default function ProjectModal({ isOpen, onClose, project }) { export default function ProjectModal({ isOpen, onClose, project }) {
const { addProject, updateProject: storeUpdate } = useProjectStore() const { createProject, updateProject } = useProjectStore()
const [name, setName] = useState('') const [name, setName] = useState('')
const [desc, setDesc] = useState('') const [desc, setDesc] = useState('')
const [color, setColor] = useState('#4A90D9') const [color, setColor] = useState('#4A90D9')
@@ -39,12 +38,10 @@ export default function ProjectModal({ isOpen, onClose, project }) {
setSaving(true) setSaving(true)
try { try {
if (isEditing) { if (isEditing) {
const updated = await updateProject(project.id, { name, description: desc, color, drive_url: driveUrl }) await updateProject(project.id, { name, description: desc, color, drive_url: driveUrl })
storeUpdate({ ...updated, deliverables: project.deliverables })
} else { } else {
const valid = rows.filter(r => r.title.trim() && r.due_date) const valid = rows.filter(r => r.title.trim() && r.due_date)
const created = await createProject({ name, description: desc, color, drive_url: driveUrl, deliverables: valid }) await createProject({ name, description: desc, color, drive_url: driveUrl, deliverables: valid })
addProject(created)
} }
onClose() onClose()
} finally { setSaving(false) } } finally { setSaving(false) }

View File

@@ -1,41 +1,117 @@
import { create } from 'zustand' import { create } from 'zustand'
import * as projectApi from '../api/projects'
import * as deliverableApi from '../api/deliverables'
const useProjectStore = create((set, get) => ({ const useProjectStore = create((set, get) => ({
projects: [], projects: [],
loading: false, loading: false,
error: null, 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), getProjectById: (id) => get().projects.find(p => p.id === id),
// Async Actions
loadProjects: async () => {
set({ loading: true, error: null })
try {
const data = await projectApi.fetchProjects()
set({ projects: data, loading: false })
} catch (err) {
set({ error: err.message, loading: false })
}
},
createProject: async (data) => {
set({ loading: true })
try {
const newProj = await projectApi.createProject(data)
set(s => ({ projects: [newProj, ...s.projects], loading: false }))
return newProj
} catch (err) {
set({ error: err.message, loading: false })
throw err
}
},
updateProject: async (id, data) => {
try {
const updated = await projectApi.updateProject(id, data)
set(s => ({
projects: s.projects.map(p => p.id === updated.id ? { ...updated, deliverables: p.deliverables } : p),
}))
return updated
} catch (err) {
set({ error: err.message })
throw err
}
},
deleteProject: async (id) => {
try {
await projectApi.deleteProject(id)
set(s => ({ projects: s.projects.filter(p => p.id !== id) }))
} catch (err) {
set({ error: err.message })
throw err
}
},
toggleArchive: async (id, isArchived) => {
try {
const updated = isArchived
? await projectApi.unarchiveProject(id)
: await projectApi.archiveProject(id)
set(s => ({
projects: s.projects.map(p => p.id === updated.id ? { ...updated, deliverables: p.deliverables } : p),
}))
} catch (err) {
set({ error: err.message })
throw err
}
},
addDeliverable: async (data) => {
try {
const d = await deliverableApi.createDeliverable(data)
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
),
}))
return d
} catch (err) {
set({ error: err.message })
throw err
}
},
updateDeliverable: async (id, data) => {
try {
const updated = await deliverableApi.updateDeliverable(id, data)
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
),
}))
return updated
} catch (err) {
set({ error: err.message })
throw err
}
},
removeDeliverable: async (id) => {
try {
await deliverableApi.deleteDeliverable(id)
set(s => ({
projects: s.projects.map(p => ({ ...p, deliverables: (p.deliverables||[]).filter(d => d.id !== id) })),
}))
} catch (err) {
set({ error: err.message })
throw err
}
},
})) }))
export default useProjectStore export default useProjectStore