@@ -1,9 +1,12 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import Flask, send_from_directory
|
from flask import Flask, send_from_directory
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from .extensions import db, migrate, cors
|
from .extensions import db, migrate, cors
|
||||||
from config import config
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_name=None):
|
def create_app(config_name=None):
|
||||||
if config_name is None:
|
if config_name is None:
|
||||||
config_name = os.environ.get('FLASK_ENV', 'production')
|
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.projects import projects_bp
|
||||||
from .routes.deliverables import deliverables_bp
|
from .routes.deliverables import deliverables_bp
|
||||||
|
|
||||||
app.register_blueprint(projects_bp, url_prefix='/api')
|
app.register_blueprint(projects_bp, url_prefix='/api')
|
||||||
app.register_blueprint(deliverables_bp, url_prefix='/api')
|
app.register_blueprint(deliverables_bp, url_prefix='/api')
|
||||||
|
|
||||||
@@ -43,7 +47,9 @@ def _run_migrations():
|
|||||||
"""
|
"""
|
||||||
migrations = [
|
migrations = [
|
||||||
'ALTER TABLE projects ADD COLUMN drive_url VARCHAR(500)',
|
'ALTER TABLE projects ADD COLUMN drive_url VARCHAR(500)',
|
||||||
|
'ALTER TABLE projects ADD COLUMN archived_at DATETIME',
|
||||||
]
|
]
|
||||||
|
|
||||||
with db.engine.connect() as conn:
|
with db.engine.connect() as conn:
|
||||||
for stmt in migrations:
|
for stmt in migrations:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from .extensions import db
|
from .extensions import db
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
|
||||||
class Project(db.Model):
|
class Project(db.Model):
|
||||||
__tablename__ = 'projects'
|
__tablename__ = 'projects'
|
||||||
|
|
||||||
@@ -9,11 +10,14 @@ class Project(db.Model):
|
|||||||
color = db.Column(db.String(7), nullable=False, default='#C9A84C')
|
color = db.Column(db.String(7), nullable=False, default='#C9A84C')
|
||||||
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)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
deliverables = db.relationship(
|
deliverables = db.relationship(
|
||||||
'Deliverable', backref='project',
|
'Deliverable',
|
||||||
cascade='all, delete-orphan', lazy=True
|
backref='project',
|
||||||
|
cascade='all, delete-orphan',
|
||||||
|
lazy=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self, include_deliverables=True):
|
def to_dict(self, include_deliverables=True):
|
||||||
@@ -23,6 +27,7 @@ class Project(db.Model):
|
|||||||
'color': self.color,
|
'color': self.color,
|
||||||
'description': self.description,
|
'description': self.description,
|
||||||
'drive_url': self.drive_url,
|
'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,
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
}
|
}
|
||||||
if include_deliverables:
|
if include_deliverables:
|
||||||
@@ -36,7 +41,11 @@ class Deliverable(db.Model):
|
|||||||
__tablename__ = 'deliverables'
|
__tablename__ = 'deliverables'
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
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)
|
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')
|
||||||
@@ -44,9 +53,8 @@ class Deliverable(db.Model):
|
|||||||
|
|
||||||
def effective_status(self):
|
def effective_status(self):
|
||||||
"""
|
"""
|
||||||
Returns 'overdue' if the due date has passed and the deliverable
|
Returns 'overdue' if the due date has passed and the deliverable has not been
|
||||||
has not been marked completed. Completed deliverables are never
|
marked completed. Completed deliverables are never auto-downgraded.
|
||||||
auto-downgraded regardless of date.
|
|
||||||
"""
|
"""
|
||||||
if self.status != 'completed' and self.due_date < date.today():
|
if self.status != 'completed' and self.due_date < date.today():
|
||||||
return 'overdue'
|
return 'overdue'
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
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
|
from datetime import date, datetime
|
||||||
|
|
||||||
projects_bp = Blueprint('projects', __name__)
|
projects_bp = Blueprint('projects', __name__)
|
||||||
|
|
||||||
|
|
||||||
@projects_bp.route('/projects', methods=['GET'])
|
@projects_bp.route('/projects', methods=['GET'])
|
||||||
def get_projects():
|
def get_projects():
|
||||||
projects = Project.query.order_by(Project.created_at.desc()).all()
|
projects = Project.query.order_by(Project.created_at.desc()).all()
|
||||||
return jsonify([p.to_dict() for p in projects])
|
return jsonify([p.to_dict() for p in projects])
|
||||||
|
|
||||||
|
|
||||||
@projects_bp.route('/projects/<int:id>', methods=['GET'])
|
@projects_bp.route('/projects/<int:id>', methods=['GET'])
|
||||||
def get_project(id):
|
def get_project(id):
|
||||||
project = Project.query.get_or_404(id)
|
project = Project.query.get_or_404(id)
|
||||||
return jsonify(project.to_dict())
|
return jsonify(project.to_dict())
|
||||||
|
|
||||||
|
|
||||||
@projects_bp.route('/projects', methods=['POST'])
|
@projects_bp.route('/projects', methods=['POST'])
|
||||||
def create_project():
|
def create_project():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
@@ -26,6 +29,7 @@ def create_project():
|
|||||||
)
|
)
|
||||||
db.session.add(project)
|
db.session.add(project)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
for d in data.get('deliverables', []):
|
for d in data.get('deliverables', []):
|
||||||
if d.get('title') and d.get('due_date'):
|
if d.get('title') and d.get('due_date'):
|
||||||
db.session.add(Deliverable(
|
db.session.add(Deliverable(
|
||||||
@@ -34,9 +38,11 @@ def create_project():
|
|||||||
due_date=date.fromisoformat(d['due_date']),
|
due_date=date.fromisoformat(d['due_date']),
|
||||||
status=d.get('status', 'upcoming'),
|
status=d.get('status', 'upcoming'),
|
||||||
))
|
))
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify(project.to_dict()), 201
|
return jsonify(project.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
@projects_bp.route('/projects/<int:id>', methods=['PATCH'])
|
@projects_bp.route('/projects/<int:id>', methods=['PATCH'])
|
||||||
def update_project(id):
|
def update_project(id):
|
||||||
project = Project.query.get_or_404(id)
|
project = Project.query.get_or_404(id)
|
||||||
@@ -47,6 +53,23 @@ def update_project(id):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify(project.to_dict())
|
return jsonify(project.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@projects_bp.route('/projects/<int:id>/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/<int:id>/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/<int:id>', methods=['DELETE'])
|
@projects_bp.route('/projects/<int:id>', methods=['DELETE'])
|
||||||
def delete_project(id):
|
def delete_project(id):
|
||||||
project = Project.query.get_or_404(id)
|
project = Project.query.get_or_404(id)
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const B = '/api'
|
const B = '/api'
|
||||||
|
|
||||||
export const fetchProjects = () => axios.get(`${B}/projects`).then(r => r.data)
|
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 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 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 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 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)
|
||||||
|
|||||||
@@ -4,25 +4,21 @@ 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 { 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'
|
||||||
|
|
||||||
function DriveIcon() {
|
function DriveIcon() {
|
||||||
return (
|
return (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.3 78" className="w-3.5 h-3.5">
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#0066da"/>
|
<path d="M4.43 20.933l2.893-5.01H22l-2.893 5.01H4.43zm1.55-5.01L2.55 10.08 9.163 2h4.903l-6.613 8.08 3.043 5.843H5.98zm8.753 0l-3.043-5.843L17.987 2H22l-4.297 6.89 3.043 5.843h-6.013z"/>
|
||||||
<path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#00ac47"/>
|
|
||||||
<path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z" fill="#ea4335"/>
|
|
||||||
<path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#00832d"/>
|
|
||||||
<path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#2684fc"/>
|
|
||||||
<path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 27h27.45c0-1.55-.4-3.1-1.2-4.5z" fill="#ffba00"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectCard({ project, onEdit, onDelete }) {
|
export default function ProjectCard({ project, onEdit, onDelete, onArchiveToggle }) {
|
||||||
const openFocus = useFocusStore(s => s.openFocus)
|
const openFocus = useFocusStore(s => s.openFocus)
|
||||||
const { removeDeliverable } = useProjectStore()
|
const { removeDeliverable, updateProject } = 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)
|
||||||
|
|
||||||
@@ -52,11 +48,25 @@ export default function ProjectCard({ project, onEdit, onDelete }) {
|
|||||||
|
|
||||||
const handleHeaderCtx = (e) => {
|
const handleHeaderCtx = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
const isArchived = !!project.archived_at
|
||||||
setCtxMenu({
|
setCtxMenu({
|
||||||
x: e.clientX, y: e.clientY,
|
x: e.clientX, y: e.clientY,
|
||||||
items: [
|
items: [
|
||||||
{ icon: '✎', label: 'Edit Project', action: () => onEdit(project) },
|
{ icon: '✎', label: 'Edit Project', action: () => onEdit(project) },
|
||||||
...(project.drive_url ? [{ icon: '⬡', label: 'Open Drive', action: () => window.open(project.drive_url, '_blank') }] : []),
|
...(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 },
|
{ separator: true },
|
||||||
{ icon: '✕', label: 'Delete Project', danger: true, action: () => onDelete(project) },
|
{ icon: '✕', label: 'Delete Project', danger: true, action: () => onDelete(project) },
|
||||||
],
|
],
|
||||||
@@ -64,61 +74,68 @@ export default function ProjectCard({ project, onEdit, onDelete }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-elevated border border-surface-border rounded-lg overflow-hidden transition-all hover:border-gold/20">
|
<div className={`rounded-xl border bg-surface-raised overflow-hidden ${
|
||||||
<div className="h-1 w-full" style={{ backgroundColor: project.color }} />
|
project.archived_at ? 'opacity-60 border-surface-border' : 'border-surface-border'
|
||||||
<div className="p-3">
|
}`}>
|
||||||
|
|
||||||
{/* Header — double-click to edit, right-click for menu */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
className="flex items-start justify-between mb-1.5 cursor-default"
|
className="flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-surface-elevated/40 transition-colors"
|
||||||
onDoubleClick={() => onEdit(project)}
|
onDoubleClick={() => onEdit(project)}
|
||||||
onContextMenu={handleHeaderCtx}
|
onContextMenu={handleHeaderCtx}
|
||||||
title="Double-click to edit project"
|
title="Double-click to edit project"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: project.color }} />
|
||||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: project.color }} />
|
<span className="font-semibold text-sm text-text-primary truncate flex-1">{project.name}</span>
|
||||||
<span className="text-sm font-semibold text-text-primary truncate">{project.name}</span>
|
|
||||||
</div>
|
{project.archived_at && (
|
||||||
<div className="flex items-center gap-0.5 flex-shrink-0 ml-1">
|
<span className="text-[9px] font-semibold tracking-widest uppercase text-amber-400/70 bg-amber-400/10 border border-amber-400/20 rounded px-1.5 py-0.5 flex-shrink-0">
|
||||||
|
Archived
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{project.drive_url && (
|
{project.drive_url && (
|
||||||
<a href={project.drive_url} target="_blank" rel="noopener noreferrer"
|
<a
|
||||||
title="Open Google Drive folder" onClick={e => e.stopPropagation()}
|
href={project.drive_url}
|
||||||
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">
|
target="_blank"
|
||||||
<DriveIcon /><span>Drive</span>
|
rel="noreferrer"
|
||||||
|
onClick={e => 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"
|
||||||
|
>
|
||||||
|
<DriveIcon /> Drive
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button onClick={() => onEdit(project)} className="text-text-muted hover:text-gold p-1 transition-colors text-sm" title="Edit project">✎</button>
|
<button onClick={() => onEdit(project)} className="text-text-muted hover:text-gold p-1 transition-colors text-sm" title="Edit project">✎</button>
|
||||||
<button onClick={() => onDelete(project)} className="text-text-muted hover:text-red-400 p-1 transition-colors text-sm" title="Delete project">✕</button>
|
<button onClick={() => onDelete(project)} className="text-text-muted hover:text-red-400 p-1 transition-colors text-sm" title="Delete project">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{project.description && (
|
{project.description && (
|
||||||
<p className="text-xs text-text-muted mb-2 line-clamp-1">{project.description}</p>
|
<p className="text-text-muted text-xs px-3 pb-1.5 -mt-1 truncate">{project.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Deliverable rows */}
|
{/* Deliverable rows */}
|
||||||
<div className="space-y-1">
|
<div className="px-2 pb-2 space-y-1">
|
||||||
{(project.deliverables || []).map(d => (
|
{(project.deliverables || []).map(d => (
|
||||||
<button
|
<button
|
||||||
key={d.id}
|
key={d.id}
|
||||||
onClick={() => openFocus(project.id, d.id)}
|
onClick={() => openFocus(project.id, d.id)}
|
||||||
onDoubleClick={(e) => { e.stopPropagation(); openDelEdit(d) }}
|
onDoubleClick={e => { e.stopPropagation(); openDelEdit(d) }}
|
||||||
onContextMenu={(e) => handleRowCtx(e, d)}
|
onContextMenu={e => handleRowCtx(e, d)}
|
||||||
title="Click: Focus View · Double-click: Edit · Right-click: Menu"
|
title="Click: Focus View · Double-click: Edit · Right-click: Menu"
|
||||||
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"
|
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>
|
<span className="truncate text-text-primary group-hover:text-white transition-colors">{d.title}</span>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0 ml-2">
|
||||||
<span className="text-text-muted/60 font-mono text-[9px]">{formatDate(d.due_date)}</span>
|
<span className="text-text-muted/60 text-[10px]">{formatDate(d.due_date)}</span>
|
||||||
<Badge status={d.status} />
|
<Badge status={d.status} />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{(!project.deliverables || project.deliverables.length === 0) && (
|
|
||||||
<p className="text-[11px] text-text-muted/40 italic text-center py-1">No deliverables</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{(!project.deliverables || project.deliverables.length === 0) && (
|
||||||
|
<p className="text-text-muted/40 text-xs px-1 py-1">No deliverables</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Local deliverable edit modal */}
|
{/* Local deliverable edit modal */}
|
||||||
|
|||||||
@@ -5,28 +5,58 @@ 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 } 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 }) {
|
export default function ProjectList({ onRegisterNewProject }) {
|
||||||
const { projects, removeProject } = useProjectStore()
|
const { projects, removeProject, setProjects } = 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)
|
||||||
|
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 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); removeProject(p.id)
|
await deleteProject(p.id)
|
||||||
|
removeProject(p.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const handleClose = () => { setShowModal(false); setEditing(null) }
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
|
|
||||||
{/* Header — taller to give logo more presence */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 px-4 py-4 border-b border-surface-border flex-shrink-0 pl-10">
|
<div className="flex items-center gap-3 px-4 py-4 border-b border-surface-border flex-shrink-0 pl-10">
|
||||||
<img
|
<img
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
@@ -48,7 +78,11 @@ export default function ProjectList({ onRegisterNewProject }) {
|
|||||||
<div className="flex border-b border-surface-border flex-shrink-0">
|
<div className="flex border-b border-surface-border flex-shrink-0">
|
||||||
{[['projects','Projects'],['agenda','Upcoming']].map(([key, label]) => (
|
{[['projects','Projects'],['agenda','Upcoming']].map(([key, label]) => (
|
||||||
<button key={key} onClick={() => setSidebarTab(key)}
|
<button key={key} onClick={() => setSidebarTab(key)}
|
||||||
className={`flex-1 py-2 text-xs font-semibold transition-colors ${sidebarTab === key ? 'text-gold border-b-2 border-gold' : 'text-text-muted hover:text-text-primary'}`}>
|
className={`flex-1 py-2 text-xs font-semibold transition-colors ${
|
||||||
|
sidebarTab === key
|
||||||
|
? 'text-gold border-b-2 border-gold'
|
||||||
|
: 'text-text-muted hover:text-text-primary'
|
||||||
|
}`}>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -58,7 +92,41 @@ export default function ProjectList({ onRegisterNewProject }) {
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{sidebarTab === 'projects' ? (
|
{sidebarTab === 'projects' ? (
|
||||||
<div className="p-3 space-y-2">
|
<div className="p-3 space-y-2">
|
||||||
{projects.length === 0 ? (
|
|
||||||
|
{/* View toggle + Search + Refresh */}
|
||||||
|
<div className="flex items-center gap-2 pb-1">
|
||||||
|
<div className="flex bg-surface-elevated border border-surface-border rounded-lg overflow-hidden flex-shrink-0">
|
||||||
|
{VIEW_OPTIONS.map(v => (
|
||||||
|
<button
|
||||||
|
key={v.key}
|
||||||
|
onClick={() => setProjectView(v.key)}
|
||||||
|
className={`px-2.5 py-1.5 text-[10px] font-semibold transition-colors ${
|
||||||
|
projectView === v.key
|
||||||
|
? 'bg-gold text-surface'
|
||||||
|
: 'text-text-muted hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
title={`Show ${v.label.toLowerCase()} projects`}
|
||||||
|
>
|
||||||
|
{v.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={refreshProjects}
|
||||||
|
className="flex-shrink-0 text-[10px] px-2 py-1.5 rounded-lg border border-surface-border bg-surface-elevated text-text-muted hover:text-text-primary hover:border-gold/30 transition-colors"
|
||||||
|
title="Refresh projects"
|
||||||
|
>
|
||||||
|
↺
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visibleProjects.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-center">
|
<div className="flex flex-col items-center justify-center py-10 px-4 text-center">
|
||||||
<div className="opacity-20 mb-3">
|
<div className="opacity-20 mb-3">
|
||||||
<svg width="56" height="56" viewBox="0 0 56 56" fill="none">
|
<svg width="56" height="56" viewBox="0 0 56 56" fill="none">
|
||||||
@@ -70,14 +138,16 @@ export default function ProjectList({ onRegisterNewProject }) {
|
|||||||
<line x1="17" y1="39" x2="30" y2="39" stroke="#C9A84C" strokeWidth="1.5" strokeDasharray="4 2.5"/>
|
<line x1="17" y1="39" x2="30" y2="39" stroke="#C9A84C" strokeWidth="1.5" strokeDasharray="4 2.5"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-text-muted text-sm font-medium">No projects yet</p>
|
<p className="text-text-muted text-sm font-medium">
|
||||||
|
{q ? 'No matching projects' : projectView === 'archived' ? 'No archived projects' : 'No projects yet'}
|
||||||
|
</p>
|
||||||
<p className="text-text-muted/50 text-xs mt-1">
|
<p className="text-text-muted/50 text-xs mt-1">
|
||||||
Press <kbd className="bg-surface-border px-1.5 py-0.5 rounded text-[10px] font-mono">N</kbd> or click + Project
|
Press <kbd className="bg-surface-border px-1.5 py-0.5 rounded text-[10px] font-mono">N</kbd> or click + Project
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
projects.map(p => (
|
visibleProjects.map(p => (
|
||||||
<ProjectCard key={p.id} project={p} onEdit={handleEdit} onDelete={handleDelete} />
|
<ProjectCard key={p.id} project={p} onEdit={handleEdit} onDelete={handleDelete} onArchiveToggle={refreshProjects} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user