Tier 3: appointments CRUD + time tracking with topbar timer

This commit is contained in:
2026-02-28 04:38:56 +00:00
parent 82d03ce23a
commit 6ad642084d
13 changed files with 1075 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/appointments">Appointments</a>
<span class="sep">/</span>
<span>{{ appointment.title }}</span>
</div>
<div class="detail-header">
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;">
<div>
<h1 class="detail-title">{{ appointment.title }}</h1>
<div class="detail-meta">
{% if appointment.all_day %}
<span class="detail-meta-item">
<span class="status-badge status-active">All Day</span>
</span>
{% endif %}
<span class="detail-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
{% if appointment.all_day %}
{{ appointment.start_at.strftime('%A, %B %-d, %Y') }}
{% if appointment.end_at and appointment.end_at.strftime('%Y-%m-%d') != appointment.start_at.strftime('%Y-%m-%d') %}
&ndash; {{ appointment.end_at.strftime('%A, %B %-d, %Y') }}
{% endif %}
{% else %}
{{ appointment.start_at.strftime('%A, %B %-d, %Y at %-I:%M %p') }}
{% if appointment.end_at %}
&ndash; {{ appointment.end_at.strftime('%-I:%M %p') }}
{% endif %}
{% endif %}
</span>
{% if appointment.location %}
<span class="detail-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
{{ appointment.location }}
</span>
{% endif %}
{% if appointment.recurrence %}
<span class="detail-meta-item">
<span class="row-tag">{{ appointment.recurrence }}</span>
</span>
{% endif %}
</div>
</div>
<div style="display: flex; gap: 8px; flex-shrink: 0;">
<a href="/appointments/{{ appointment.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
<form method="POST" action="/appointments/{{ appointment.id }}/delete" data-confirm="Delete this appointment?">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</div>
</div>
{% if appointment.description %}
<div class="card mb-4">
<div class="card-title mb-2">Description</div>
<div class="detail-body">{{ appointment.description }}</div>
</div>
{% endif %}
{% if appointment.tags %}
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px;">
{% for tag in appointment.tags %}
<span class="row-tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
<!-- Attendees -->
<div class="card">
<div class="card-header">
<span class="card-title">Attendees ({{ contacts | length }})</span>
</div>
{% if contacts %}
{% for c in contacts %}
<div class="list-row">
<div class="row-title">
<a href="/contacts/{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</a>
</div>
{% if c.company %}
<span class="row-meta">{{ c.company }}</span>
{% endif %}
{% if c.email %}
<span class="row-meta">{{ c.email }}</span>
{% endif %}
{% if c.role %}
<span class="row-tag">{{ c.role }}</span>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-state" style="padding: 24px;">
<div class="text-muted text-sm">No attendees added</div>
</div>
{% endif %}
</div>
<div class="text-muted text-xs mt-3">
Created {{ appointment.created_at.strftime('%B %-d, %Y') }}
{% if appointment.updated_at and appointment.updated_at != appointment.created_at %}
&middot; Updated {{ appointment.updated_at.strftime('%B %-d, %Y') }}
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/appointments">Appointments</a>
<span class="sep">/</span>
<span>{{ "Edit" if appointment else "New Appointment" }}</span>
</div>
<div class="page-header">
<h1 class="page-title">{{ "Edit Appointment" if appointment else "New Appointment" }}</h1>
</div>
<div class="card" style="max-width: 720px;">
<form method="POST" action="{{ '/appointments/' ~ appointment.id ~ '/edit' if appointment else '/appointments/create' }}">
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Title *</label>
<input type="text" name="title" class="form-input" required value="{{ appointment.title if appointment else '' }}">
</div>
<div class="form-group">
<label class="form-label">Start Date *</label>
<input type="date" name="start_date" class="form-input" required
value="{{ appointment.start_at.strftime('%Y-%m-%d') if appointment and appointment.start_at else '' }}">
</div>
<div class="form-group" id="start-time-group">
<label class="form-label">Start Time</label>
<input type="time" name="start_time" class="form-input" id="start-time-input"
value="{{ appointment.start_at.strftime('%H:%M') if appointment and appointment.start_at and not appointment.all_day else '' }}">
</div>
<div class="form-group">
<label class="form-label">End Date</label>
<input type="date" name="end_date" class="form-input"
value="{{ appointment.end_at.strftime('%Y-%m-%d') if appointment and appointment.end_at else '' }}">
</div>
<div class="form-group" id="end-time-group">
<label class="form-label">End Time</label>
<input type="time" name="end_time" class="form-input" id="end-time-input"
value="{{ appointment.end_at.strftime('%H:%M') if appointment and appointment.end_at and not appointment.all_day else '' }}">
</div>
<div class="form-group">
<label class="form-label" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" name="all_day" id="all-day-check"
{{ 'checked' if appointment and appointment.all_day else '' }}
onchange="toggleAllDay(this.checked)"
style="width: 16px; height: 16px;">
All Day Event
</label>
</div>
<div class="form-group">
<label class="form-label">Recurrence</label>
<select name="recurrence" class="form-select">
<option value="">None</option>
<option value="daily" {{ 'selected' if appointment and appointment.recurrence == 'daily' }}>Daily</option>
<option value="weekly" {{ 'selected' if appointment and appointment.recurrence == 'weekly' }}>Weekly</option>
<option value="monthly" {{ 'selected' if appointment and appointment.recurrence == 'monthly' }}>Monthly</option>
</select>
</div>
<div class="form-group full-width">
<label class="form-label">Location</label>
<input type="text" name="location" class="form-input" placeholder="Address, room, or video link"
value="{{ appointment.location if appointment and appointment.location else '' }}">
</div>
<div class="form-group full-width">
<label class="form-label">Description</label>
<textarea name="description" class="form-textarea" rows="3" placeholder="Notes about this appointment...">{{ appointment.description if appointment and appointment.description else '' }}</textarea>
</div>
<div class="form-group full-width">
<label class="form-label">Tags</label>
<input type="text" name="tags" class="form-input" placeholder="Comma-separated tags"
value="{{ appointment.tags | join(', ') if appointment and appointment.tags else '' }}">
</div>
{% if contacts %}
<div class="form-group full-width">
<label class="form-label">Attendees</label>
<div style="display: flex; flex-wrap: wrap; gap: 8px; padding: 8px; background: var(--surface2); border-radius: var(--radius); border: 1px solid var(--border); max-height: 200px; overflow-y: auto;">
{% for c in contacts %}
<label style="display: flex; align-items: center; gap: 6px; padding: 4px 10px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; white-space: nowrap;">
<input type="checkbox" name="contact_ids" value="{{ c.id }}"
{{ 'checked' if c.id|string in selected_contacts else '' }}
style="width: 14px; height: 14px;">
{{ c.first_name }} {{ c.last_name or '' }}
{% if c.company %}<span style="color: var(--muted); font-size: 0.78rem;">({{ c.company }})</span>{% endif %}
</label>
{% endfor %}
</div>
</div>
{% endif %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ "Update Appointment" if appointment else "Create Appointment" }}</button>
<a href="{{ '/appointments/' ~ appointment.id if appointment else '/appointments' }}" class="btn btn-secondary">Cancel</a>
</div>
</div>
</form>
</div>
<script>
function toggleAllDay(checked) {
const startTime = document.getElementById('start-time-input');
const endTime = document.getElementById('end-time-input');
if (checked) {
startTime.disabled = true;
startTime.style.opacity = '0.4';
endTime.disabled = true;
endTime.style.opacity = '0.4';
} else {
startTime.disabled = false;
startTime.style.opacity = '1';
endTime.disabled = false;
endTime.style.opacity = '1';
}
}
// Init on load
document.addEventListener('DOMContentLoaded', () => {
const cb = document.getElementById('all-day-check');
if (cb && cb.checked) toggleAllDay(true);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<div>
<h1 class="page-title">Appointments <span class="page-count">({{ count }})</span></h1>
</div>
<a href="/appointments/new" class="btn btn-primary">+ New Appointment</a>
</div>
<!-- Filters -->
<div class="filters-bar">
<a href="/appointments?timeframe=upcoming" class="btn {{ 'btn-primary' if timeframe == 'upcoming' else 'btn-secondary' }} btn-sm">Upcoming</a>
<a href="/appointments?timeframe=past" class="btn {{ 'btn-primary' if timeframe == 'past' else 'btn-secondary' }} btn-sm">Past</a>
<a href="/appointments?timeframe=all" class="btn {{ 'btn-primary' if timeframe == 'all' else 'btn-secondary' }} btn-sm">All</a>
</div>
{% if appointments %}
<div class="card">
{% set current_date = namespace(value='') %}
{% for appt in appointments %}
{% set appt_date = appt.start_at.strftime('%A, %B %-d, %Y') if appt.start_at else 'No Date' %}
{% if appt_date != current_date.value %}
{% if not loop.first %}</div>{% endif %}
<div class="date-group-label" style="padding: 12px 12px 4px; font-size: 0.78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; {% if not loop.first %}border-top: 1px solid var(--border); margin-top: 4px;{% endif %}">
{{ appt_date }}
</div>
<div>
{% set current_date.value = appt_date %}
{% endif %}
<div class="list-row">
<div style="flex-shrink: 0; min-width: 60px;">
{% if appt.all_day %}
<span class="status-badge status-active" style="font-size: 0.72rem;">All Day</span>
{% elif appt.start_at %}
<span style="font-size: 0.85rem; font-weight: 600; color: var(--text);">{{ appt.start_at.strftime('%-I:%M %p') }}</span>
{% endif %}
</div>
<div class="row-title">
<a href="/appointments/{{ appt.id }}">{{ appt.title }}</a>
</div>
{% if appt.location %}
<span class="row-meta">{{ appt.location }}</span>
{% endif %}
{% if appt.recurrence %}
<span class="row-tag">{{ appt.recurrence }}</span>
{% endif %}
{% if appt.contact_count and appt.contact_count > 0 %}
<span class="row-meta">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px;"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
{{ appt.contact_count }}
</span>
{% endif %}
<div class="row-actions">
<a href="/appointments/{{ appt.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form method="POST" action="/appointments/{{ appt.id }}/delete" data-confirm="Delete this appointment?">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red);">Delete</button>
</form>
</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">📅</div>
<div class="empty-state-text">No appointments {{ 'upcoming' if timeframe == 'upcoming' else 'found' }}</div>
<a href="/appointments/new" class="btn btn-primary">Schedule an Appointment</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -52,6 +52,10 @@
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Meetings
</a>
<a href="/appointments" class="nav-item {{ 'active' if active_nav == 'appointments' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
Appointments
</a>
<a href="/decisions" class="nav-item {{ 'active' if active_nav == 'decisions' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M9 12l2 2 4-4"/></svg>
Decisions
@@ -60,6 +64,10 @@
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
Files
</a>
<a href="/weblinks" class="nav-item {{ 'active' if active_nav == 'weblinks' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Weblinks
</a>
<a href="/capture" class="nav-item {{ 'active' if active_nav == 'capture' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
Capture

View File

@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ page_title }}</h1>
</div>
<div class="card">
<form method="post" action="/weblinks/folders/create">
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Folder Name *</label>
<input type="text" name="name" class="form-input" required placeholder="Folder name...">
</div>
<div class="form-group">
<label class="form-label">Parent Folder</label>
<select name="parent_id" class="form-select">
<option value="">None (top-level)</option>
{% for f in parent_folders %}
<option value="{{ f.id }}">{{ f.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Create Folder</button>
<a href="/weblinks" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ page_title }}</h1>
</div>
<div class="card">
<form method="post" action="{{ '/weblinks/' ~ item.id ~ '/edit' if item else '/weblinks/create' }}">
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Label *</label>
<input type="text" name="label" class="form-input" required
value="{{ item.label if item else '' }}" placeholder="Display name...">
</div>
<div class="form-group full-width">
<label class="form-label">URL *</label>
<input type="url" name="url" class="form-input" required
value="{{ item.url if item else '' }}" placeholder="https://...">
</div>
<div class="form-group">
<label class="form-label">Folder</label>
<select name="folder_id" class="form-select">
<option value="">None</option>
{% for f in folders %}
<option value="{{ f.id }}" {{ 'selected' if prefill_folder_id == f.id|string }}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Tags</label>
<input type="text" name="tags" class="form-input" placeholder="tag1, tag2, ..."
value="{{ item.tags|join(', ') if item and item.tags else '' }}">
</div>
<div class="form-group full-width">
<label class="form-label">Description</label>
<textarea name="description" class="form-textarea" rows="2">{{ item.description if item and item.description else '' }}</textarea>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Add Weblink' }}</button>
<a href="/weblinks" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

76
templates/weblinks.html Normal file
View File

@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Weblinks<span class="page-count">{{ items|length }}</span></h1>
<div class="flex gap-2">
<a href="/weblinks/folders/create" class="btn btn-secondary">+ New Folder</a>
<a href="/weblinks/create{{ '?folder_id=' ~ current_folder_id if current_folder_id }}" class="btn btn-primary">+ New Weblink</a>
</div>
</div>
<div class="weblinks-layout">
<!-- Folder sidebar -->
<div class="weblinks-folders">
<a href="/weblinks" class="weblink-folder-item {{ 'active' if not current_folder_id }}">
All Weblinks
</a>
{% for folder in top_folders %}
<a href="/weblinks?folder_id={{ folder.id }}" class="weblink-folder-item {{ 'active' if current_folder_id == folder.id|string }}">
{{ folder.name }}
{% if folder.link_count %}<span class="badge" style="margin-left: auto;">{{ folder.link_count }}</span>{% endif %}
</a>
{% for child in child_folder_map.get(folder.id|string, []) %}
<a href="/weblinks?folder_id={{ child.id }}" class="weblink-folder-item {{ 'active' if current_folder_id == child.id|string }}" style="padding-left: 28px;">
{{ child.name }}
{% if child.link_count %}<span class="badge" style="margin-left: auto;">{{ child.link_count }}</span>{% endif %}
</a>
{% endfor %}
{% endfor %}
</div>
<!-- Weblinks list -->
<div class="weblinks-content">
{% if current_folder %}
<div class="flex items-center justify-between mb-2">
<h2 style="font-size: 1rem; font-weight: 600;">{{ current_folder.name }}</h2>
<form action="/weblinks/folders/{{ current_folder.id }}/delete" method="post"
data-confirm="Delete folder '{{ current_folder.name }}'?" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Delete Folder</button>
</form>
</div>
{% endif %}
{% if items %}
<div class="card">
{% for item in items %}
<div class="list-row">
<span class="row-title">
<a href="{{ item.url }}" target="_blank" rel="noopener">{{ item.label }}</a>
</span>
<span class="row-meta text-xs" style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ item.url }}
</span>
{% if item.tags %}
{% for tag in item.tags %}
<span class="row-tag">{{ tag }}</span>
{% endfor %}
{% endif %}
<div class="row-actions">
<a href="/weblinks/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/weblinks/{{ item.id }}/delete" method="post" data-confirm="Delete this weblink?" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">&#128279;</div>
<div class="empty-state-text">No weblinks{{ ' in this folder' if current_folder }} yet</div>
<a href="/weblinks/create{{ '?folder_id=' ~ current_folder_id if current_folder_id }}" class="btn btn-primary">Add Weblink</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}