updates, back and front

This commit is contained in:
jason
2026-03-12 10:23:22 -05:00
parent 03ee3c542e
commit a1f8c90801
11 changed files with 147 additions and 75 deletions

View File

@@ -43,6 +43,7 @@ A sleek, modern fabrication dashboard focused on simplicity and clarity.
### v2.0 — Security & Multi-User ### v2.0 — Security & Multi-User
- [ ] **User Authentication**: Flask-Login + JWT integration for secure access. - [ ] **User Authentication**: Flask-Login + JWT integration for secure access.
- [ ] **Multi-User Support**: Individual accounts with project ownership. - [ ] **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. - [ ] **Role-Based Access (RBAC)**: Define Owners, Editors, and Viewers per project.
- [ ] **Audit Logs**: Track who changed what and when. - [ ] **Audit Logs**: Track who changed what and when.
@@ -62,4 +63,4 @@ A sleek, modern fabrication dashboard focused on simplicity and clarity.
- [ ] **Test Coverage**: implement backend (pytest) and frontend (Vitest) test suites. - [ ] **Test Coverage**: implement backend (pytest) and frontend (Vitest) test suites.
- [ ] **Migration Framework**: Switch to `Flask-Migrate` for formal schema versioning. - [ ] **Migration Framework**: Switch to `Flask-Migrate` for formal schema versioning.
- [ ] **Structured Logging**: Centralized backend logs for better monitoring. - [ ] **Structured Logging**: Centralized backend logs for better monitoring.
- [ ] **Global Exception Handling**: Standardized API error responses. - [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
@@ -36,6 +37,21 @@ def create_app(config_name=None):
return send_from_directory(static_folder, path) return send_from_directory(static_folder, path)
return send_from_directory(static_folder, 'index.html') return send_from_directory(static_folder, 'index.html')
@app.errorhandler(404)
def not_found(e):
return jsonify({'error': 'Resource not found', 'message': str(e)}), 404
@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

@@ -4,10 +4,6 @@ import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid' import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction' import interactionPlugin from '@fullcalendar/interaction'
import useProjectStore from '../../store/useProjectStore' import useProjectStore from '../../store/useProjectStore'
import useFocusStore from '../../store/useFocusStore'
import useUIStore from '../../store/useUIStore'
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'
@@ -70,15 +66,14 @@ export default function MainCalendar({ onCalendarReady }) {
}, [openFocus]) }, [openFocus])
const handleEventDrop = useCallback(async ({ event, oldEvent }) => { const handleEventDrop = useCallback(async ({ event, oldEvent }) => {
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 +118,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

@@ -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 }), getProjectById: (id) => get().projects.find(p => p.id === id),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
addProject: (p) => set((s) => ({ projects: [p, ...s.projects] })), // 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 })
}
},
updateProject: (updated) => set((s) => ({ 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), projects: s.projects.map(p => p.id === updated.id ? { ...updated, deliverables: p.deliverables } : p),
})), }))
return updated
} catch (err) {
set({ error: err.message })
throw err
}
},
removeProject: (id) => set((s) => ({ projects: s.projects.filter(p => p.id !== id) })), 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
}
},
addDeliverable: (d) => set((s) => ({ 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 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, deliverables: [...(p.deliverables||[]), d].sort((a,b) => new Date(a.due_date)-new Date(b.due_date)) }
: p : p
), ),
})), }))
return d
} catch (err) {
set({ error: err.message })
throw err
}
},
updateDeliverable: (updated) => set((s) => ({ updateDeliverable: async (id, data) => {
try {
const updated = await deliverableApi.updateDeliverable(id, data)
set(s => ({
projects: s.projects.map(p => p.id === updated.project_id projects: s.projects.map(p => p.id === updated.project_id
? { ...p, deliverables: p.deliverables.map(d => d.id === updated.id ? updated : d) } ? { ...p, deliverables: (p.deliverables||[]).map(d => d.id === updated.id ? updated : d) }
: p : p
), ),
})), }))
return updated
} catch (err) {
set({ error: err.message })
throw err
}
},
removeDeliverable: (id) => set((s) => ({ removeDeliverable: async (id) => {
try {
await deliverableApi.deleteDeliverable(id)
set(s => ({
projects: s.projects.map(p => ({ ...p, deliverables: (p.deliverables||[]).filter(d => d.id !== id) })), projects: s.projects.map(p => ({ ...p, deliverables: (p.deliverables||[]).filter(d => d.id !== id) })),
})), }))
} catch (err) {
getProjectById: (id) => get().projects.find(p => p.id === id), set({ error: err.message })
throw err
}
},
})) }))
export default useProjectStore export default useProjectStore