updates, back and front #1
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -35,6 +36,21 @@ def create_app(config_name=None):
|
|||||||
if path and os.path.exists(os.path.join(static_folder, path)):
|
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, 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
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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?.()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user