From 59619c4ed106cca811dedd53553756fca6e5071c Mon Sep 17 00:00:00 2001 From: jasonMPM Date: Fri, 6 Mar 2026 00:03:06 -0600 Subject: [PATCH] Add files via upload --- backend/app/__init__.py | 6 + backend/app/models.py | 20 ++- backend/app/routes/projects.py | 25 ++- frontend/src/api/projects.js | 4 + .../src/components/Projects/ProjectCard.jsx | 145 ++++++++++-------- .../src/components/Projects/ProjectList.jsx | 94 ++++++++++-- 6 files changed, 211 insertions(+), 83 deletions(-) diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 9ce677b..12253d2 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,9 +1,12 @@ import os + from flask import Flask, send_from_directory from sqlalchemy import text + 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') @@ -17,6 +20,7 @@ def create_app(config_name=None): 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') @@ -43,7 +47,9 @@ def _run_migrations(): """ migrations = [ 'ALTER TABLE projects ADD COLUMN drive_url VARCHAR(500)', + 'ALTER TABLE projects ADD COLUMN archived_at DATETIME', ] + with db.engine.connect() as conn: for stmt in migrations: try: diff --git a/backend/app/models.py b/backend/app/models.py index a600007..b90dba1 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,6 +1,7 @@ from .extensions import db from datetime import datetime, date + class Project(db.Model): __tablename__ = 'projects' @@ -9,11 +10,14 @@ class Project(db.Model): color = db.Column(db.String(7), nullable=False, default='#C9A84C') description = db.Column(db.Text) drive_url = db.Column(db.String(500)) + archived_at = db.Column(db.DateTime, nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) deliverables = db.relationship( - 'Deliverable', backref='project', - cascade='all, delete-orphan', lazy=True + 'Deliverable', + backref='project', + cascade='all, delete-orphan', + lazy=True, ) def to_dict(self, include_deliverables=True): @@ -23,6 +27,7 @@ class Project(db.Model): 'color': self.color, 'description': self.description, 'drive_url': self.drive_url, + 'archived_at': self.archived_at.isoformat() if self.archived_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None, } if include_deliverables: @@ -36,7 +41,11 @@ 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) + 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') @@ -44,9 +53,8 @@ class Deliverable(db.Model): def effective_status(self): """ - Returns 'overdue' if the due date has passed and the deliverable - has not been marked completed. Completed deliverables are never - auto-downgraded regardless of date. + Returns 'overdue' if the due date has passed and the deliverable has not been + marked completed. Completed deliverables are never auto-downgraded. """ if self.status != 'completed' and self.due_date < date.today(): return 'overdue' diff --git a/backend/app/routes/projects.py b/backend/app/routes/projects.py index e59a7ec..aae73e2 100644 --- a/backend/app/routes/projects.py +++ b/backend/app/routes/projects.py @@ -1,20 +1,23 @@ from flask import Blueprint, jsonify, request from ..models import Project, Deliverable from ..extensions import db -from datetime import date +from datetime import date, datetime projects_bp = Blueprint('projects', __name__) + @projects_bp.route('/projects', methods=['GET']) def get_projects(): projects = Project.query.order_by(Project.created_at.desc()).all() return jsonify([p.to_dict() for p in projects]) + @projects_bp.route('/projects/', methods=['GET']) def get_project(id): project = Project.query.get_or_404(id) return jsonify(project.to_dict()) + @projects_bp.route('/projects', methods=['POST']) def create_project(): data = request.get_json() @@ -26,6 +29,7 @@ def create_project(): ) 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( @@ -34,9 +38,11 @@ def create_project(): due_date=date.fromisoformat(d['due_date']), status=d.get('status', 'upcoming'), )) + db.session.commit() return jsonify(project.to_dict()), 201 + @projects_bp.route('/projects/', methods=['PATCH']) def update_project(id): project = Project.query.get_or_404(id) @@ -47,6 +53,23 @@ def update_project(id): db.session.commit() return jsonify(project.to_dict()) + +@projects_bp.route('/projects//archive', methods=['PATCH']) +def archive_project(id): + project = Project.query.get_or_404(id) + project.archived_at = datetime.utcnow() + db.session.commit() + return jsonify(project.to_dict()) + + +@projects_bp.route('/projects//unarchive', methods=['PATCH']) +def unarchive_project(id): + project = Project.query.get_or_404(id) + project.archived_at = None + db.session.commit() + return jsonify(project.to_dict()) + + @projects_bp.route('/projects/', methods=['DELETE']) def delete_project(id): project = Project.query.get_or_404(id) diff --git a/frontend/src/api/projects.js b/frontend/src/api/projects.js index 933261c..d126ce3 100644 --- a/frontend/src/api/projects.js +++ b/frontend/src/api/projects.js @@ -1,7 +1,11 @@ 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) +export const archiveProject = (id) => axios.patch(`${B}/projects/${id}/archive`).then(r => r.data) +export const unarchiveProject = (id) => axios.patch(`${B}/projects/${id}/unarchive`).then(r => r.data) diff --git a/frontend/src/components/Projects/ProjectCard.jsx b/frontend/src/components/Projects/ProjectCard.jsx index 65d5d49..d55ac61 100644 --- a/frontend/src/components/Projects/ProjectCard.jsx +++ b/frontend/src/components/Projects/ProjectCard.jsx @@ -4,27 +4,23 @@ import { formatDate } from '../../utils/dateHelpers' import useFocusStore from '../../store/useFocusStore' import useProjectStore from '../../store/useProjectStore' import { deleteDeliverable } from '../../api/deliverables' +import { archiveProject, unarchiveProject } from '../../api/projects' import DeliverableModal from '../Deliverables/DeliverableModal' import ContextMenu from '../UI/ContextMenu' function DriveIcon() { return ( - - - - - - - + + ) } -export default function ProjectCard({ project, onEdit, onDelete }) { - const openFocus = useFocusStore(s => s.openFocus) - const { removeDeliverable } = useProjectStore() - const [delModal, setDelModal] = useState({ open: false, deliverable: null }) - const [ctxMenu, setCtxMenu] = useState(null) +export default function ProjectCard({ project, onEdit, onDelete, onArchiveToggle }) { + const openFocus = useFocusStore(s => s.openFocus) + const { removeDeliverable, updateProject } = useProjectStore() + const [delModal, setDelModal] = useState({ open: false, deliverable: null }) + const [ctxMenu, setCtxMenu] = useState(null) const openDelEdit = (d) => setDelModal({ open: true, deliverable: d }) @@ -52,11 +48,25 @@ export default function ProjectCard({ project, onEdit, onDelete }) { const handleHeaderCtx = (e) => { e.preventDefault() + const isArchived = !!project.archived_at setCtxMenu({ x: e.clientX, y: e.clientY, items: [ - { icon: '✎', label: 'Edit Project', action: () => onEdit(project) }, - ...(project.drive_url ? [{ icon: '⬡', label: 'Open Drive', action: () => window.open(project.drive_url, '_blank') }] : []), + { icon: '✎', label: 'Edit Project', action: () => onEdit(project) }, + ...(project.drive_url + ? [{ icon: '⬡', label: 'Open Drive', action: () => window.open(project.drive_url, '_blank') }] + : []), + { + icon: isArchived ? '↺' : '⏸', + label: isArchived ? 'Unarchive Project' : 'Archive Project', + action: async () => { + const updated = isArchived + ? await unarchiveProject(project.id) + : await archiveProject(project.id) + updateProject(updated) + onArchiveToggle?.() + }, + }, { separator: true }, { icon: '✕', label: 'Delete Project', danger: true, action: () => onDelete(project) }, ], @@ -64,61 +74,68 @@ export default function ProjectCard({ project, onEdit, onDelete }) { } return ( -
-
-
+
- {/* Header — double-click to edit, right-click for menu */} -
onEdit(project)} - onContextMenu={handleHeaderCtx} - title="Double-click to edit project" - > -
-
- {project.name} -
-
- {project.drive_url && ( - e.stopPropagation()} - className="flex items-center gap-1 text-[10px] text-text-muted hover:text-text-primary bg-surface hover:bg-surface-border/40 border border-surface-border hover:border-gold/30 rounded px-1.5 py-1 transition-all mr-1"> - Drive - - )} - - -
-
+ {/* Header */} +
onEdit(project)} + onContextMenu={handleHeaderCtx} + title="Double-click to edit project" + > +
+ {project.name} - {project.description && ( -

{project.description}

+ {project.archived_at && ( + + Archived + )} - {/* Deliverable rows */} -
- {(project.deliverables || []).map(d => ( - - ))} - {(!project.deliverables || project.deliverables.length === 0) && ( -

No deliverables

- )} -
+ {project.drive_url && ( + e.stopPropagation()} + className="flex items-center gap-1 text-[10px] text-text-muted hover:text-text-primary bg-surface hover:bg-surface-border/40 border border-surface-border hover:border-gold/30 rounded px-1.5 py-1 transition-all mr-1" + > + Drive + + )} + + +
+ + {project.description && ( +

{project.description}

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

No deliverables

+ )}
{/* Local deliverable edit modal */} diff --git a/frontend/src/components/Projects/ProjectList.jsx b/frontend/src/components/Projects/ProjectList.jsx index 484b367..84a3e4e 100644 --- a/frontend/src/components/Projects/ProjectList.jsx +++ b/frontend/src/components/Projects/ProjectList.jsx @@ -5,28 +5,58 @@ import Button from '../UI/Button' import AgendaPanel from '../Calendar/AgendaPanel' import useProjectStore from '../../store/useProjectStore' import useUIStore from '../../store/useUIStore' -import { deleteProject } from '../../api/projects' +import { deleteProject, fetchProjects } from '../../api/projects' + +const VIEW_OPTIONS = [ + { key: 'active', label: 'Active' }, + { key: 'archived', label: 'Archived' }, + { key: 'all', label: 'All' }, +] export default function ProjectList({ onRegisterNewProject }) { - const { projects, removeProject } = useProjectStore() + const { projects, removeProject, setProjects } = useProjectStore() const { sidebarTab, setSidebarTab } = useUIStore() - const [showModal, setShowModal] = useState(false) - const [editing, setEditing] = useState(null) + const [showModal, setShowModal] = useState(false) + const [editing, setEditing] = useState(null) + const [projectView, setProjectView] = useState('active') + const [search, setSearch] = useState('') - useEffect(() => { onRegisterNewProject?.(() => setShowModal(true)) }, [onRegisterNewProject]) + useEffect(() => { + onRegisterNewProject?.(() => setShowModal(true)) + }, [onRegisterNewProject]) + + const refreshProjects = async () => { + const data = await fetchProjects() + setProjects(data) + } 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) + await deleteProject(p.id) + removeProject(p.id) } } const handleClose = () => { setShowModal(false); setEditing(null) } + const q = search.trim().toLowerCase() + const visibleProjects = (projects || []) + .filter(p => { + const isArchived = !!p.archived_at + if (projectView === 'active') return !isArchived + if (projectView === 'archived') return isArchived + return true + }) + .filter(p => { + if (!q) return true + const hay = `${p.name || ''} ${p.description || ''}`.toLowerCase() + return hay.includes(q) + }) + return (
- {/* Header — taller to give logo more presence */} + {/* Header */}
{[['projects','Projects'],['agenda','Upcoming']].map(([key, label]) => ( ))} @@ -58,7 +92,41 @@ export default function ProjectList({ onRegisterNewProject }) {
{sidebarTab === 'projects' ? (
- {projects.length === 0 ? ( + + {/* View toggle + Search + Refresh */} +
+
+ {VIEW_OPTIONS.map(v => ( + + ))} +
+ setSearch(e.target.value)} + placeholder="Search projects…" + className="flex-1 min-w-0 bg-surface-elevated border border-surface-border rounded-lg px-2.5 py-1.5 text-xs text-text-primary placeholder:text-text-muted/50 outline-none focus:border-gold/40 transition-colors" + /> + +
+ + {visibleProjects.length === 0 ? (
@@ -70,14 +138,16 @@ export default function ProjectList({ onRegisterNewProject }) {
-

No projects yet

+

+ {q ? 'No matching projects' : projectView === 'archived' ? 'No archived projects' : 'No projects yet'} +

Press N or click + Project

) : ( - projects.map(p => ( - + visibleProjects.map(p => ( + )) )}