feat: implement project status management with 'on_hold' state and associated UI updates
-feat: ability to hard delete projects, plus a soft delete with auto pruning.
This commit is contained in:
56
backend/migrate_add_project_deleted_at.py
Normal file
56
backend/migrate_add_project_deleted_at.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add deleted_at column to projects table
|
||||||
|
|
||||||
|
Adds columns:
|
||||||
|
- projects.deleted_at: Timestamp set when status='deleted'; data hard-deleted after 60 days
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(db_path: str):
|
||||||
|
"""Run the migration."""
|
||||||
|
print(f"Migrating database: {db_path}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("projects table does not exist. Skipping migration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor.execute("PRAGMA table_info(projects)")
|
||||||
|
existing_cols = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
if 'deleted_at' not in existing_cols:
|
||||||
|
print("Adding deleted_at column to projects...")
|
||||||
|
cursor.execute("ALTER TABLE projects ADD COLUMN deleted_at DATETIME")
|
||||||
|
else:
|
||||||
|
print("deleted_at column already exists. Skipping.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("Migration completed successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Migration failed: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
db_path = "./data/terra-view.db"
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
db_path = sys.argv[1]
|
||||||
|
|
||||||
|
if not Path(db_path).exists():
|
||||||
|
print(f"Database not found: {db_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
migrate(db_path)
|
||||||
@@ -155,7 +155,7 @@ class Project(Base):
|
|||||||
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
|
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
||||||
status = Column(String, default="active") # active, completed, archived
|
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
|
||||||
|
|
||||||
# Project metadata
|
# Project metadata
|
||||||
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
||||||
@@ -166,6 +166,7 @@ class Project(Base):
|
|||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
deleted_at = Column(DateTime, nullable=True) # Set when status='deleted'; hard delete scheduled after 60 days
|
||||||
|
|
||||||
|
|
||||||
class MonitoringLocation(Base):
|
class MonitoringLocation(Base):
|
||||||
|
|||||||
@@ -57,9 +57,11 @@ async def get_projects_list(
|
|||||||
"""
|
"""
|
||||||
query = db.query(Project)
|
query = db.query(Project)
|
||||||
|
|
||||||
# Filter by status if provided
|
# Filter by status if provided; otherwise exclude soft-deleted projects
|
||||||
if status:
|
if status:
|
||||||
query = query.filter(Project.status == status)
|
query = query.filter(Project.status == status)
|
||||||
|
else:
|
||||||
|
query = query.filter(Project.status != "deleted")
|
||||||
|
|
||||||
# Filter by project type if provided
|
# Filter by project type if provided
|
||||||
if project_type_id:
|
if project_type_id:
|
||||||
@@ -118,9 +120,10 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
Get summary statistics for projects overview.
|
Get summary statistics for projects overview.
|
||||||
Returns HTML partial with stat cards.
|
Returns HTML partial with stat cards.
|
||||||
"""
|
"""
|
||||||
# Count projects by status
|
# Count projects by status (exclude deleted)
|
||||||
total_projects = db.query(func.count(Project.id)).scalar()
|
total_projects = db.query(func.count(Project.id)).filter(Project.status != "deleted").scalar()
|
||||||
active_projects = db.query(func.count(Project.id)).filter_by(status="active").scalar()
|
active_projects = db.query(func.count(Project.id)).filter_by(status="active").scalar()
|
||||||
|
on_hold_projects = db.query(func.count(Project.id)).filter_by(status="on_hold").scalar()
|
||||||
completed_projects = db.query(func.count(Project.id)).filter_by(status="completed").scalar()
|
completed_projects = db.query(func.count(Project.id)).filter_by(status="completed").scalar()
|
||||||
|
|
||||||
# Count total locations across all projects
|
# Count total locations across all projects
|
||||||
@@ -140,6 +143,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
"request": request,
|
"request": request,
|
||||||
"total_projects": total_projects,
|
"total_projects": total_projects,
|
||||||
"active_projects": active_projects,
|
"active_projects": active_projects,
|
||||||
|
"on_hold_projects": on_hold_projects,
|
||||||
"completed_projects": completed_projects,
|
"completed_projects": completed_projects,
|
||||||
"total_locations": total_locations,
|
"total_locations": total_locations,
|
||||||
"assigned_units": assigned_units,
|
"assigned_units": assigned_units,
|
||||||
@@ -178,13 +182,13 @@ async def search_projects(
|
|||||||
if not q.strip():
|
if not q.strip():
|
||||||
# Return recent active projects when no search term
|
# Return recent active projects when no search term
|
||||||
projects = db.query(Project).filter(
|
projects = db.query(Project).filter(
|
||||||
Project.status != "archived"
|
Project.status.notin_(["archived", "deleted"])
|
||||||
).order_by(Project.updated_at.desc()).limit(limit).all()
|
).order_by(Project.updated_at.desc()).limit(limit).all()
|
||||||
else:
|
else:
|
||||||
search_term = f"%{q}%"
|
search_term = f"%{q}%"
|
||||||
projects = db.query(Project).filter(
|
projects = db.query(Project).filter(
|
||||||
and_(
|
and_(
|
||||||
Project.status != "archived",
|
Project.status.notin_(["archived", "deleted"]),
|
||||||
or_(
|
or_(
|
||||||
Project.project_number.ilike(search_term),
|
Project.project_number.ilike(search_term),
|
||||||
Project.client_name.ilike(search_term),
|
Project.client_name.ilike(search_term),
|
||||||
@@ -223,13 +227,13 @@ async def search_projects_json(
|
|||||||
"""
|
"""
|
||||||
if not q.strip():
|
if not q.strip():
|
||||||
projects = db.query(Project).filter(
|
projects = db.query(Project).filter(
|
||||||
Project.status != "archived"
|
Project.status.notin_(["archived", "deleted"])
|
||||||
).order_by(Project.updated_at.desc()).limit(limit).all()
|
).order_by(Project.updated_at.desc()).limit(limit).all()
|
||||||
else:
|
else:
|
||||||
search_term = f"%{q}%"
|
search_term = f"%{q}%"
|
||||||
projects = db.query(Project).filter(
|
projects = db.query(Project).filter(
|
||||||
and_(
|
and_(
|
||||||
Project.status != "archived",
|
Project.status.notin_(["archived", "deleted"]),
|
||||||
or_(
|
or_(
|
||||||
Project.project_number.ilike(search_term),
|
Project.project_number.ilike(search_term),
|
||||||
Project.client_name.ilike(search_term),
|
Project.client_name.ilike(search_term),
|
||||||
@@ -359,18 +363,76 @@ async def update_project(
|
|||||||
@router.delete("/{project_id}")
|
@router.delete("/{project_id}")
|
||||||
async def delete_project(project_id: str, db: Session = Depends(get_db)):
|
async def delete_project(project_id: str, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Delete a project (soft delete by archiving).
|
Soft-delete a project. Sets status='deleted' and records deleted_at timestamp.
|
||||||
|
Data will be permanently removed after 60 days (or via /permanent endpoint).
|
||||||
"""
|
"""
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
project.status = "archived"
|
project.status = "deleted"
|
||||||
|
project.deleted_at = datetime.utcnow()
|
||||||
project.updated_at = datetime.utcnow()
|
project.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"success": True, "message": "Project archived successfully"}
|
return {"success": True, "message": "Project deleted. Data will be permanently removed after 60 days."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{project_id}/permanent")
|
||||||
|
async def permanently_delete_project(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Hard-delete a project and all related data. Only allowed when status='deleted'.
|
||||||
|
Removes: locations, assignments, sessions, scheduled actions, recurring schedules.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
if project.status != "deleted":
|
||||||
|
raise HTTPException(status_code=400, detail="Project must be soft-deleted before permanent deletion.")
|
||||||
|
|
||||||
|
# Delete related data
|
||||||
|
db.query(RecurringSchedule).filter_by(project_id=project_id).delete()
|
||||||
|
db.query(ScheduledAction).filter_by(project_id=project_id).delete()
|
||||||
|
db.query(RecordingSession).filter_by(project_id=project_id).delete()
|
||||||
|
db.query(UnitAssignment).filter_by(project_id=project_id).delete()
|
||||||
|
db.query(MonitoringLocation).filter_by(project_id=project_id).delete()
|
||||||
|
db.delete(project)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "message": "Project permanently deleted."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/hold")
|
||||||
|
async def hold_project(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Put a project on hold. Pauses without archiving; assignments and schedules remain.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
project.status = "on_hold"
|
||||||
|
project.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "message": "Project put on hold."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/unhold")
|
||||||
|
async def unhold_project(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Resume a project that was on hold.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
project.status = "active"
|
||||||
|
project.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "message": "Project resumed."}
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if project.status == 'active' %}
|
{% if project.status == 'active' %}
|
||||||
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
||||||
|
{% elif project.status == 'on_hold' %}
|
||||||
|
<span class="px-3 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
||||||
{% elif project.status == 'completed' %}
|
{% elif project.status == 'completed' %}
|
||||||
<span class="px-3 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
|
<span class="px-3 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
|
||||||
{% elif project.status == 'archived' %}
|
{% elif project.status == 'archived' %}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@
|
|||||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full">
|
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full">
|
||||||
Active
|
Active
|
||||||
</span>
|
</span>
|
||||||
|
{% elif item.project.status == 'on_hold' %}
|
||||||
|
<span class="px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">
|
||||||
|
On Hold
|
||||||
|
</span>
|
||||||
{% elif item.project.status == 'completed' %}
|
{% elif item.project.status == 'completed' %}
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 rounded-full">
|
<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 rounded-full">
|
||||||
Completed
|
Completed
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
|
|
||||||
{% if item.project.status == 'active' %}
|
{% if item.project.status == 'active' %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
||||||
|
{% elif item.project.status == 'on_hold' %}
|
||||||
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
||||||
{% elif item.project.status == 'completed' %}
|
{% elif item.project.status == 'completed' %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
|
||||||
{% elif item.project.status == 'archived' %}
|
{% elif item.project.status == 'archived' %}
|
||||||
|
|||||||
@@ -27,6 +27,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">On Hold</p>
|
||||||
|
<p class="text-3xl font-bold text-amber-600 dark:text-amber-400">{{ on_hold_projects }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
|
||||||
|
<svg class="w-8 h-8 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -279,6 +279,7 @@
|
|||||||
<select name="status" id="settings-status"
|
<select name="status" id="settings-status"
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
|
<option value="on_hold">On Hold</option>
|
||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
<option value="archived">Archived</option>
|
<option value="archived">Archived</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -329,15 +330,40 @@
|
|||||||
<!-- Danger Zone -->
|
<!-- Danger Zone -->
|
||||||
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-red-600 dark:text-red-400 mb-4">Danger Zone</h3>
|
<h3 class="text-lg font-semibold text-red-600 dark:text-red-400 mb-4">Danger Zone</h3>
|
||||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
<div class="space-y-3">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
<!-- On Hold -->
|
||||||
Archive this project to remove it from active listings. All data will be preserved.
|
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 flex items-center justify-between gap-4">
|
||||||
</p>
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Put Project On Hold</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Pause this project without archiving. Assignments and schedules remain in place.</p>
|
||||||
|
</div>
|
||||||
|
<div id="hold-btn-container" class="shrink-0">
|
||||||
|
<!-- Rendered by updateDangerZone() based on current status -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Archive -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700/40 border border-gray-200 dark:border-gray-600 rounded-lg p-4 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Archive Project</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Remove from active listings. All data is preserved and can be restored.</p>
|
||||||
|
</div>
|
||||||
<button onclick="archiveProject()"
|
<button onclick="archiveProject()"
|
||||||
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
class="shrink-0 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm">
|
||||||
Archive Project
|
Archive
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Delete -->
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Delete Project</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Permanently removes all project data after a 60-day grace period. This action is difficult to undo.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="openDeleteModal()"
|
||||||
|
class="shrink-0 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -596,6 +622,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Project Confirmation Modal -->
|
||||||
|
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="p-2 bg-red-100 dark:bg-red-900/40 rounded-lg">
|
||||||
|
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Delete Project</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
This project will be soft-deleted and <strong class="text-gray-900 dark:text-white">permanently removed after 60 days</strong>. All associated locations, assignments, and sessions will be lost.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-4">
|
||||||
|
Type <span class="font-mono font-bold text-red-600 dark:text-red-400">delete</span> to confirm:
|
||||||
|
</p>
|
||||||
|
<input type="text" id="delete-confirm-input"
|
||||||
|
placeholder="type delete"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||||
|
autocomplete="off">
|
||||||
|
<div class="flex gap-3 justify-end">
|
||||||
|
<button onclick="closeDeleteModal()"
|
||||||
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button id="confirm-delete-btn" disabled onclick="executeDeleteProject()"
|
||||||
|
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm disabled:opacity-40 disabled:cursor-not-allowed">
|
||||||
|
Delete Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const projectId = "{{ project_id }}";
|
const projectId = "{{ project_id }}";
|
||||||
let editingLocationId = null;
|
let editingLocationId = null;
|
||||||
@@ -662,6 +722,7 @@ async function loadProjectDetails() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('settings-error').classList.add('hidden');
|
document.getElementById('settings-error').classList.add('hidden');
|
||||||
|
updateDangerZone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load project details:', err);
|
console.error('Failed to load project details:', err);
|
||||||
}
|
}
|
||||||
@@ -1027,6 +1088,78 @@ function archiveProject() {
|
|||||||
document.getElementById('project-settings-form').dispatchEvent(new Event('submit'));
|
document.getElementById('project-settings-form').dispatchEvent(new Event('submit'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function holdProject() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/hold`, { method: 'POST' });
|
||||||
|
if (!response.ok) throw new Error('Failed to put project on hold');
|
||||||
|
await loadProjectDetails();
|
||||||
|
updateDangerZone();
|
||||||
|
htmx.trigger('#project-header', 'load');
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to put project on hold: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unholdProject() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/unhold`, { method: 'POST' });
|
||||||
|
if (!response.ok) throw new Error('Failed to resume project');
|
||||||
|
await loadProjectDetails();
|
||||||
|
updateDangerZone();
|
||||||
|
htmx.trigger('#project-header', 'load');
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to resume project: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDangerZone() {
|
||||||
|
const status = document.getElementById('settings-status').value;
|
||||||
|
const container = document.getElementById('hold-btn-container');
|
||||||
|
if (!container) return;
|
||||||
|
if (status === 'on_hold') {
|
||||||
|
container.innerHTML = `<button onclick="unholdProject()"
|
||||||
|
class="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors text-sm">
|
||||||
|
Resume Project
|
||||||
|
</button>`;
|
||||||
|
} else {
|
||||||
|
container.innerHTML = `<button onclick="holdProject()"
|
||||||
|
class="px-4 py-2 border border-amber-500 text-amber-600 dark:text-amber-400 rounded-lg hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors text-sm">
|
||||||
|
Put On Hold
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteModal() {
|
||||||
|
document.getElementById('delete-confirm-input').value = '';
|
||||||
|
document.getElementById('confirm-delete-btn').disabled = true;
|
||||||
|
document.getElementById('delete-project-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('delete-confirm-input').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
document.getElementById('delete-project-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDeleteProject() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) throw new Error('Failed to delete project');
|
||||||
|
closeDeleteModal();
|
||||||
|
window.location.href = '/projects';
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to delete project: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const input = document.getElementById('delete-confirm-input');
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener('input', function() {
|
||||||
|
document.getElementById('confirm-delete-btn').disabled = this.value !== 'delete';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Schedule Modal Functions
|
// Schedule Modal Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary Stats -->
|
<!-- Summary Stats -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
|
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-6 mb-8"
|
||||||
hx-get="/api/projects/stats"
|
hx-get="/api/projects/stats"
|
||||||
hx-trigger="load, every 30s"
|
hx-trigger="load, every 30s"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
@@ -43,6 +44,11 @@
|
|||||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||||
Active
|
Active
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="switchTab('on_hold')"
|
||||||
|
id="tab-on_hold"
|
||||||
|
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||||
|
On Hold
|
||||||
|
</button>
|
||||||
<button onclick="switchTab('completed')"
|
<button onclick="switchTab('completed')"
|
||||||
id="tab-completed"
|
id="tab-completed"
|
||||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||||
|
|||||||
Reference in New Issue
Block a user