feat: Enhance project and reservation management
- Updated reservation list to display estimated units and improved count display. - Added "Upcoming" status to project dashboard and header with corresponding styles. - Implemented a dropdown for quick status updates in project header. - Modified project list compact view to reflect new status labels. - Updated project overview to include a tab for upcoming projects. - Added migration script to introduce estimated_units column in job_reservations table.
This commit is contained in:
62
backend/migrate_add_estimated_units.py
Normal file
62
backend/migrate_add_estimated_units.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add estimated_units to job_reservations
|
||||||
|
|
||||||
|
Adds column:
|
||||||
|
- job_reservations.estimated_units: Estimated number of units for the reservation (nullable integer)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Default database path (matches production pattern)
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(db_path: str):
|
||||||
|
"""Run the migration."""
|
||||||
|
print(f"Migrating database: {db_path}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if job_reservations table exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("job_reservations table does not exist. Skipping migration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get existing columns in job_reservations
|
||||||
|
cursor.execute("PRAGMA table_info(job_reservations)")
|
||||||
|
existing_cols = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
# Add estimated_units column if it doesn't exist
|
||||||
|
if 'estimated_units' not in existing_cols:
|
||||||
|
print("Adding estimated_units column to job_reservations...")
|
||||||
|
cursor.execute("ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER")
|
||||||
|
else:
|
||||||
|
print("estimated_units 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 = DB_PATH
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -480,6 +480,7 @@ class JobReservation(Base):
|
|||||||
# For quantity reservations
|
# For quantity reservations
|
||||||
device_type = Column(String, default="seismograph") # seismograph | slm
|
device_type = Column(String, default="seismograph") # seismograph | slm
|
||||||
quantity_needed = Column(Integer, nullable=True) # e.g., 8 units
|
quantity_needed = Column(Integer, nullable=True) # e.g., 8 units
|
||||||
|
estimated_units = Column(Integer, nullable=True)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
|
|||||||
@@ -61,9 +61,53 @@ async def fleet_calendar_page(
|
|||||||
|
|
||||||
# Get projects for the reservation form dropdown
|
# Get projects for the reservation form dropdown
|
||||||
projects = db.query(Project).filter(
|
projects = db.query(Project).filter(
|
||||||
Project.status == "active"
|
Project.status.in_(["active", "upcoming", "on_hold"])
|
||||||
).order_by(Project.name).all()
|
).order_by(Project.name).all()
|
||||||
|
|
||||||
|
# Build a serializable list of items with dates for calendar bars
|
||||||
|
# Includes both tracked Projects (with dates) and Job Reservations (matching device_type)
|
||||||
|
project_colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#F97316']
|
||||||
|
# Map calendar device_type to project_type_ids
|
||||||
|
device_type_to_project_types = {
|
||||||
|
"seismograph": ["vibration_monitoring", "combined"],
|
||||||
|
"slm": ["sound_monitoring", "combined"],
|
||||||
|
}
|
||||||
|
relevant_project_types = device_type_to_project_types.get(device_type, [])
|
||||||
|
|
||||||
|
calendar_projects = []
|
||||||
|
for i, p in enumerate(projects):
|
||||||
|
if p.start_date and p.project_type_id in relevant_project_types:
|
||||||
|
calendar_projects.append({
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"start_date": p.start_date.isoformat(),
|
||||||
|
"end_date": p.end_date.isoformat() if p.end_date else None,
|
||||||
|
"color": project_colors[i % len(project_colors)],
|
||||||
|
"confirmed": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add job reservations for this device_type as bars
|
||||||
|
from sqlalchemy import or_ as _or
|
||||||
|
cal_window_end = date(year + ((month + 10) // 12), ((month + 10) % 12) + 1, 1)
|
||||||
|
reservations_for_cal = db.query(JobReservation).filter(
|
||||||
|
JobReservation.device_type == device_type,
|
||||||
|
JobReservation.start_date <= cal_window_end,
|
||||||
|
_or(
|
||||||
|
JobReservation.end_date >= date(year, month, 1),
|
||||||
|
JobReservation.end_date == None,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for res in reservations_for_cal:
|
||||||
|
end = res.end_date or res.estimated_end_date
|
||||||
|
calendar_projects.append({
|
||||||
|
"id": res.id,
|
||||||
|
"name": res.name,
|
||||||
|
"start_date": res.start_date.isoformat(),
|
||||||
|
"end_date": end.isoformat() if end else None,
|
||||||
|
"color": res.color,
|
||||||
|
"confirmed": bool(res.project_id),
|
||||||
|
})
|
||||||
|
|
||||||
# Calculate prev/next month navigation
|
# Calculate prev/next month navigation
|
||||||
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
|
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
|
||||||
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
|
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
|
||||||
@@ -81,6 +125,7 @@ async def fleet_calendar_page(
|
|||||||
"device_type": device_type,
|
"device_type": device_type,
|
||||||
"calendar_data": calendar_data,
|
"calendar_data": calendar_data,
|
||||||
"projects": projects,
|
"projects": projects,
|
||||||
|
"calendar_projects": calendar_projects,
|
||||||
"today": today.isoformat()
|
"today": today.isoformat()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -178,6 +223,7 @@ async def create_reservation(
|
|||||||
assignment_type=data["assignment_type"],
|
assignment_type=data["assignment_type"],
|
||||||
device_type=data.get("device_type", "seismograph"),
|
device_type=data.get("device_type", "seismograph"),
|
||||||
quantity_needed=data.get("quantity_needed"),
|
quantity_needed=data.get("quantity_needed"),
|
||||||
|
estimated_units=data.get("estimated_units"),
|
||||||
notes=data.get("notes"),
|
notes=data.get("notes"),
|
||||||
color=data.get("color", "#3B82F6")
|
color=data.get("color", "#3B82F6")
|
||||||
)
|
)
|
||||||
@@ -240,6 +286,7 @@ async def get_reservation(
|
|||||||
"assignment_type": reservation.assignment_type,
|
"assignment_type": reservation.assignment_type,
|
||||||
"device_type": reservation.device_type,
|
"device_type": reservation.device_type,
|
||||||
"quantity_needed": reservation.quantity_needed,
|
"quantity_needed": reservation.quantity_needed,
|
||||||
|
"estimated_units": reservation.estimated_units,
|
||||||
"notes": reservation.notes,
|
"notes": reservation.notes,
|
||||||
"color": reservation.color,
|
"color": reservation.color,
|
||||||
"assigned_units": [
|
"assigned_units": [
|
||||||
@@ -287,6 +334,8 @@ async def update_reservation(
|
|||||||
reservation.assignment_type = data["assignment_type"]
|
reservation.assignment_type = data["assignment_type"]
|
||||||
if "quantity_needed" in data:
|
if "quantity_needed" in data:
|
||||||
reservation.quantity_needed = data["quantity_needed"]
|
reservation.quantity_needed = data["quantity_needed"]
|
||||||
|
if "estimated_units" in data:
|
||||||
|
reservation.estimated_units = data["estimated_units"]
|
||||||
if "notes" in data:
|
if "notes" in data:
|
||||||
reservation.notes = data["notes"]
|
reservation.notes = data["notes"]
|
||||||
if "color" in data:
|
if "color" in data:
|
||||||
@@ -525,8 +574,9 @@ async def get_reservations_list(
|
|||||||
else:
|
else:
|
||||||
end_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
|
end_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
|
||||||
|
|
||||||
# Include TBD reservations that started before window end — show ALL device types
|
# Filter by device_type and date window
|
||||||
reservations = db.query(JobReservation).filter(
|
reservations = db.query(JobReservation).filter(
|
||||||
|
JobReservation.device_type == device_type,
|
||||||
JobReservation.start_date <= end_date,
|
JobReservation.start_date <= end_date,
|
||||||
or_(
|
or_(
|
||||||
JobReservation.end_date >= start_date,
|
JobReservation.end_date >= start_date,
|
||||||
@@ -563,9 +613,11 @@ async def get_reservations_list(
|
|||||||
# Check for calibration conflicts
|
# Check for calibration conflicts
|
||||||
conflicts = check_calibration_conflicts(db, res.id)
|
conflicts = check_calibration_conflicts(db, res.id)
|
||||||
|
|
||||||
|
location_count = res.quantity_needed or assigned_count
|
||||||
reservation_data.append({
|
reservation_data.append({
|
||||||
"reservation": res,
|
"reservation": res,
|
||||||
"assigned_count": assigned_count,
|
"assigned_count": assigned_count,
|
||||||
|
"location_count": location_count,
|
||||||
"assigned_units": assigned_units,
|
"assigned_units": assigned_units,
|
||||||
"has_conflicts": len(conflicts) > 0,
|
"has_conflicts": len(conflicts) > 0,
|
||||||
"conflict_count": len(conflicts)
|
"conflict_count": len(conflicts)
|
||||||
@@ -601,13 +653,35 @@ async def get_planner_availability(
|
|||||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||||
units = get_available_units_for_period(db, start, end, device_type, exclude_reservation_id)
|
units = get_available_units_for_period(db, start, end, device_type, exclude_reservation_id)
|
||||||
else:
|
else:
|
||||||
# No dates: return all non-retired units of this type
|
# No dates: return all non-retired units of this type, with current reservation info
|
||||||
from backend.models import RosterUnit as RU
|
from backend.models import RosterUnit as RU
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
today = date.today()
|
||||||
all_units = db.query(RU).filter(
|
all_units = db.query(RU).filter(
|
||||||
RU.device_type == device_type,
|
RU.device_type == device_type,
|
||||||
RU.retired == False
|
RU.retired == False
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
# Build a map: unit_id -> list of active/upcoming reservations
|
||||||
|
active_assignments = db.query(JobReservationUnit).join(
|
||||||
|
JobReservation, JobReservationUnit.reservation_id == JobReservation.id
|
||||||
|
).filter(
|
||||||
|
JobReservation.device_type == device_type,
|
||||||
|
JobReservation.end_date >= today
|
||||||
|
).all()
|
||||||
|
unit_reservations = {}
|
||||||
|
for assignment in active_assignments:
|
||||||
|
res = db.query(JobReservation).filter(JobReservation.id == assignment.reservation_id).first()
|
||||||
|
if not res:
|
||||||
|
continue
|
||||||
|
unit_reservations.setdefault(assignment.unit_id, []).append({
|
||||||
|
"reservation_id": res.id,
|
||||||
|
"reservation_name": res.name,
|
||||||
|
"start_date": res.start_date.isoformat() if res.start_date else None,
|
||||||
|
"end_date": res.end_date.isoformat() if res.end_date else None,
|
||||||
|
"color": res.color or "#3B82F6"
|
||||||
|
})
|
||||||
|
|
||||||
units = []
|
units = []
|
||||||
for u in all_units:
|
for u in all_units:
|
||||||
expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None
|
expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None
|
||||||
@@ -618,7 +692,8 @@ async def get_planner_availability(
|
|||||||
"calibration_status": "needs_calibration" if not u.last_calibrated else "valid",
|
"calibration_status": "needs_calibration" if not u.last_calibrated else "valid",
|
||||||
"deployed": u.deployed,
|
"deployed": u.deployed,
|
||||||
"out_for_calibration": u.out_for_calibration or False,
|
"out_for_calibration": u.out_for_calibration or False,
|
||||||
"note": u.note or ""
|
"note": u.note or "",
|
||||||
|
"reservations": unit_reservations.get(u.id, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sort: benched first (easier to assign), then deployed, then by ID
|
# Sort: benched first (easier to assign), then deployed, then by ID
|
||||||
@@ -736,7 +811,7 @@ async def promote_reservation_to_project(
|
|||||||
project_number=project_number,
|
project_number=project_number,
|
||||||
client_name=client_name,
|
client_name=client_name,
|
||||||
project_type_id=project_type_id,
|
project_type_id=project_type_id,
|
||||||
status="active",
|
status="upcoming",
|
||||||
start_date=reservation.start_date,
|
start_date=reservation.start_date,
|
||||||
end_date=reservation.end_date,
|
end_date=reservation.end_date,
|
||||||
description=reservation.notes,
|
description=reservation.notes,
|
||||||
|
|||||||
@@ -373,11 +373,13 @@ async def get_projects_list(
|
|||||||
"""
|
"""
|
||||||
query = db.query(Project)
|
query = db.query(Project)
|
||||||
|
|
||||||
# Filter by status if provided; otherwise exclude soft-deleted projects
|
# Filter by status if provided; otherwise exclude archived/deleted from default view
|
||||||
if status:
|
if status == "all":
|
||||||
|
query = query.filter(Project.status != "deleted")
|
||||||
|
elif status:
|
||||||
query = query.filter(Project.status == status)
|
query = query.filter(Project.status == status)
|
||||||
else:
|
else:
|
||||||
query = query.filter(Project.status != "deleted")
|
query = query.filter(Project.status.notin_(["deleted", "archived", "completed"]))
|
||||||
|
|
||||||
# Filter by project type if provided
|
# Filter by project type if provided
|
||||||
if project_type_id:
|
if project_type_id:
|
||||||
@@ -438,6 +440,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
"""
|
"""
|
||||||
# Count projects by status (exclude deleted)
|
# Count projects by status (exclude deleted)
|
||||||
total_projects = db.query(func.count(Project.id)).filter(Project.status != "deleted").scalar()
|
total_projects = db.query(func.count(Project.id)).filter(Project.status != "deleted").scalar()
|
||||||
|
upcoming_projects = db.query(func.count(Project.id)).filter_by(status="upcoming").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()
|
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()
|
||||||
@@ -459,6 +462,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,
|
||||||
|
"upcoming_projects": upcoming_projects,
|
||||||
"on_hold_projects": on_hold_projects,
|
"on_hold_projects": on_hold_projects,
|
||||||
"completed_projects": completed_projects,
|
"completed_projects": completed_projects,
|
||||||
"total_locations": total_locations,
|
"total_locations": total_locations,
|
||||||
|
|||||||
@@ -155,7 +155,7 @@
|
|||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Reservation Planner
|
Job Planner
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -44,52 +44,79 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Unit count -->
|
<!-- Counts -->
|
||||||
<div class="text-right mx-4 flex-shrink-0">
|
<div class="flex flex-col items-end gap-1 mx-4 flex-shrink-0">
|
||||||
<p class="text-base font-bold text-gray-900 dark:text-white">
|
{% set full = item.assigned_count == item.location_count and item.location_count > 0 %}
|
||||||
{% if res.quantity_needed %}
|
{% set remaining = item.location_count - item.assigned_count %}
|
||||||
{{ item.assigned_count }}/{{ res.quantity_needed }}
|
<!-- Number row -->
|
||||||
{% else %}
|
<div class="flex items-baseline gap-2">
|
||||||
{{ item.assigned_count }}
|
<span class="text-xs text-gray-400 dark:text-gray-500">est. {% if res.estimated_units %}{{ res.estimated_units }}{% else %}—{% endif %}</span>
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">·</span>
|
||||||
|
<span class="text-base font-bold {% if full %}text-green-600 dark:text-green-400{% elif item.assigned_count == 0 %}text-gray-400 dark:text-gray-500{% else %}text-amber-500 dark:text-amber-400{% endif %}">
|
||||||
|
{{ item.assigned_count }}/{{ item.location_count }}
|
||||||
|
</span>
|
||||||
|
{% if remaining > 0 %}
|
||||||
|
<span class="text-xs text-amber-500 dark:text-amber-400 whitespace-nowrap">({{ remaining }} more)</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<!-- Progress squares -->
|
||||||
|
{% if item.location_count > 0 %}
|
||||||
|
<div class="flex gap-0.5">
|
||||||
|
{% for i in range(item.location_count) %}
|
||||||
|
<span class="w-3 h-3 rounded-sm {% if i < item.assigned_count %}{% if full %}bg-green-500{% else %}bg-amber-500{% endif %}{% else %}bg-gray-300 dark:bg-gray-600{% endif %}"></span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{{ 'assigned' if item.assigned_count != 1 else 'assigned' }}
|
|
||||||
{% if res.quantity_needed %} needed{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action buttons (stop propagation so clicks don't toggle card) -->
|
<!-- Action buttons -->
|
||||||
<div class="flex items-center gap-1 flex-shrink-0">
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<!-- Assign units (always visible) -->
|
||||||
<button onclick="event.stopPropagation(); openPlanner('{{ res.id }}')"
|
<button onclick="event.stopPropagation(); openPlanner('{{ res.id }}')"
|
||||||
class="p-2 text-gray-400 hover:text-green-600 dark:hover:text-green-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
class="p-2 text-gray-400 hover:text-green-600 dark:hover:text-green-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
title="Plan units">
|
title="Assign units">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="event.stopPropagation(); openPromoteModal('{{ res.id }}', '{{ res.name }}')"
|
|
||||||
class="p-2 text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
<!-- "..." overflow menu -->
|
||||||
title="Promote to Project">
|
<div class="relative" onclick="event.stopPropagation()">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<button onclick="toggleResMenu('{{ res.id }}')"
|
||||||
|
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
title="More options">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="res-menu-{{ res.id }}"
|
||||||
|
class="hidden absolute right-0 top-8 z-20 w-44 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1">
|
||||||
|
<button onclick="openPromoteModal('{{ res.id }}', '{{ res.name }}'); toggleResMenu('{{ res.id }}')"
|
||||||
|
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Promote to Project
|
||||||
</button>
|
</button>
|
||||||
<button onclick="event.stopPropagation(); editReservation('{{ res.id }}')"
|
<button onclick="editReservation('{{ res.id }}'); toggleResMenu('{{ res.id }}')"
|
||||||
class="p-2 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2">
|
||||||
title="Edit">
|
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button onclick="event.stopPropagation(); deleteReservation('{{ res.id }}', '{{ res.name }}')"
|
<div class="border-t border-gray-100 dark:border-gray-700 my-1"></div>
|
||||||
class="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
<button onclick="deleteReservation('{{ res.id }}', '{{ res.name }}'); toggleResMenu('{{ res.id }}')"
|
||||||
title="Delete">
|
class="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<!-- Chevron (not in stopPropagation zone so clicking it still toggles the card) -->
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chevron -->
|
||||||
<svg id="chevron-{{ res.id }}" class="w-4 h-4 text-gray-400 transition-transform duration-200 ml-1 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg id="chevron-{{ res.id }}" class="w-4 h-4 text-gray-400 transition-transform duration-200 ml-1 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -104,15 +131,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-x-6 gap-y-1 text-sm mb-3">
|
<div class="grid grid-cols-2 gap-x-6 gap-y-1 text-sm mb-3">
|
||||||
{% if res.quantity_needed %}
|
<div class="text-gray-500 dark:text-gray-400">Estimated</div>
|
||||||
<div class="text-gray-500 dark:text-gray-400">Est. units needed</div>
|
<div class="font-medium {% if res.estimated_units %}text-gray-800 dark:text-gray-200{% else %}text-gray-400 dark:text-gray-500 italic{% endif %}">
|
||||||
<div class="font-medium text-gray-800 dark:text-gray-200">{{ res.quantity_needed }}</div>
|
{% if res.estimated_units %}{{ res.estimated_units }} unit{{ 's' if res.estimated_units != 1 else '' }}{% else %}not specified{% endif %}
|
||||||
{% endif %}
|
</div>
|
||||||
<div class="text-gray-500 dark:text-gray-400">Assigned</div>
|
<div class="text-gray-500 dark:text-gray-400">Locations</div>
|
||||||
<div class="font-medium text-gray-800 dark:text-gray-200">{{ item.assigned_count }} unit{{ 's' if item.assigned_count != 1 else '' }}</div>
|
<div class="font-medium text-gray-800 dark:text-gray-200">{{ item.assigned_count }} of {{ item.location_count }} filled</div>
|
||||||
{% if res.quantity_needed and item.assigned_count < res.quantity_needed %}
|
{% if item.assigned_count < item.location_count %}
|
||||||
<div class="text-gray-500 dark:text-gray-400">Still needed</div>
|
<div class="text-gray-500 dark:text-gray-400">Still needed</div>
|
||||||
<div class="font-medium text-amber-600 dark:text-amber-400">{{ res.quantity_needed - item.assigned_count }} more</div>
|
<div class="font-medium text-amber-600 dark:text-amber-400">{{ item.location_count - item.assigned_count }} location{{ 's' if (item.location_count - item.assigned_count) != 1 else '' }} remaining</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.has_conflicts %}
|
{% if item.has_conflicts %}
|
||||||
<div class="text-gray-500 dark:text-gray-400">Cal swaps</div>
|
<div class="text-gray-500 dark:text-gray-400">Cal swaps</div>
|
||||||
@@ -170,7 +197,7 @@
|
|||||||
<svg class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400">No reservations found</p>
|
<p class="text-gray-500 dark:text-gray-400">No jobs yet</p>
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Reservation" to plan unit assignments</p>
|
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Job" to start planning a deployment</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% if project.status == 'active' %}
|
{% if project.status == 'upcoming' %}
|
||||||
|
<span class="px-3 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
|
||||||
|
{% elif 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' %}
|
{% 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>
|
<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>
|
||||||
|
|||||||
@@ -3,12 +3,26 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
<div class="relative inline-block">
|
||||||
{% if project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
<select onchange="quickUpdateStatus(this.value)"
|
||||||
|
class="appearance-none cursor-pointer inline-flex items-center pl-3 pr-7 py-1 rounded-full text-sm font-medium border-0 focus:ring-2 focus:ring-offset-1 focus:ring-blue-500
|
||||||
|
{% if project.status == 'upcoming' %}bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200
|
||||||
|
{% elif project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
||||||
|
{% elif project.status == 'on_hold' %}bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200
|
||||||
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
||||||
{{ project.status|title }}
|
<option value="upcoming" {% if project.status == 'upcoming' %}selected{% endif %}>Upcoming</option>
|
||||||
|
<option value="active" {% if project.status == 'active' %}selected{% endif %}>Active</option>
|
||||||
|
<option value="on_hold" {% if project.status == 'on_hold' %}selected{% endif %}>On Hold</option>
|
||||||
|
<option value="completed" {% if project.status == 'completed' %}selected{% endif %}>Completed</option>
|
||||||
|
<option value="archived" {% if project.status == 'archived' %}selected{% endif %}>Archived</option>
|
||||||
|
</select>
|
||||||
|
<span class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-current opacity-60">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
{% if project_type %}
|
{% if project_type %}
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
|
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if item.project.status == 'active' %}
|
{% if item.project.status == 'upcoming' %}
|
||||||
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
|
||||||
|
{% elif 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' %}
|
{% 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>
|
<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>
|
||||||
|
|||||||
@@ -328,6 +328,7 @@
|
|||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Status</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Status</label>
|
||||||
<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="upcoming">Upcoming</option>
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
<option value="on_hold">On Hold</option>
|
<option value="on_hold">On Hold</option>
|
||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
@@ -758,6 +759,24 @@ const projectId = "{{ project_id }}";
|
|||||||
let editingLocationId = null;
|
let editingLocationId = null;
|
||||||
let projectTypeId = null;
|
let projectTypeId = null;
|
||||||
|
|
||||||
|
async function quickUpdateStatus(newStatus) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ status: newStatus })
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
// Reload the page to reflect new badge color and any side effects
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to update status');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error updating status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
function switchTab(tabName) {
|
function switchTab(tabName) {
|
||||||
// Hide all tab panels
|
// Hide all tab panels
|
||||||
|
|||||||
@@ -36,12 +36,17 @@
|
|||||||
<nav class="flex space-x-8 px-6" aria-label="Tabs">
|
<nav class="flex space-x-8 px-6" aria-label="Tabs">
|
||||||
<button onclick="switchTab('all')"
|
<button onclick="switchTab('all')"
|
||||||
id="tab-all"
|
id="tab-all"
|
||||||
class="tab-button border-b-2 border-seismo-orange text-seismo-orange 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">
|
||||||
All Projects
|
All Projects
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="switchTab('upcoming')"
|
||||||
|
id="tab-upcoming"
|
||||||
|
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">
|
||||||
|
Upcoming
|
||||||
|
</button>
|
||||||
<button onclick="switchTab('active')"
|
<button onclick="switchTab('active')"
|
||||||
id="tab-active"
|
id="tab-active"
|
||||||
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-seismo-orange text-seismo-orange px-1 py-4 text-sm font-medium">
|
||||||
Active
|
Active
|
||||||
</button>
|
</button>
|
||||||
<button onclick="switchTab('on_hold')"
|
<button onclick="switchTab('on_hold')"
|
||||||
@@ -66,7 +71,7 @@
|
|||||||
<!-- Projects List -->
|
<!-- Projects List -->
|
||||||
<div id="projects-list"
|
<div id="projects-list"
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||||
hx-get="/api/projects/list"
|
hx-get="/api/projects/list?status=active"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<!-- Loading skeletons -->
|
<!-- Loading skeletons -->
|
||||||
|
|||||||
Reference in New Issue
Block a user