pramodmisra Claude Opus 4.6 commited on
Commit
c5de9ee
Β·
1 Parent(s): 66acb5f

Add draft saving, user dashboard, and rename to Commission Agreement Intake Form

Browse files

- Draft system: save/update/delete drafts via API, load drafts into form
- User dashboard: shows drafts (edit/delete) and submitted forms with approval progress bars and collapsible approver details
- Post-login redirects to dashboard instead of form
- Nav bar: added Dashboard link, renamed Form to New Form
- Login page and app title renamed from Producer Intake to Commission Agreement Intake Form
- IntakeSubmission model: added updated_at, title columns; status default=draft; submitted_at nullable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

app/main.py CHANGED
@@ -6,7 +6,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
6
  from app.database import init_db
7
  from app.routes import auth_routes, form_routes, admin_routes, approval_routes
8
 
9
- app = FastAPI(title="Producer Intake Form", version="2.0.0")
10
 
11
  app.mount("/static", StaticFiles(directory="app/static"), name="static")
12
 
 
6
  from app.database import init_db
7
  from app.routes import auth_routes, form_routes, admin_routes, approval_routes
8
 
9
+ app = FastAPI(title="Commission Agreement Intake Form", version="2.0.0")
10
 
11
  app.mount("/static", StaticFiles(directory="app/static"), name="static")
12
 
app/models.py CHANGED
@@ -177,12 +177,15 @@ class Client(Base):
177
  # =====================================================================
178
 
179
  class IntakeSubmission(Base):
180
- """A completed intake form submission."""
181
  __tablename__ = "intake_submissions"
182
  id = Column(Integer, primary_key=True, autoincrement=True)
183
  submitted_by = Column(Integer, ForeignKey("users.id"), nullable=False)
184
- submitted_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
185
- status = Column(String(20), default="pending_approval") # pending_approval | approved | rejected
 
 
 
186
 
187
  # Form data stored as JSON blob for flexibility
188
  form_data = Column(JSON, nullable=False)
 
177
  # =====================================================================
178
 
179
  class IntakeSubmission(Base):
180
+ """A form submission β€” can be a draft or a submitted form."""
181
  __tablename__ = "intake_submissions"
182
  id = Column(Integer, primary_key=True, autoincrement=True)
183
  submitted_by = Column(Integer, ForeignKey("users.id"), nullable=False)
184
+ submitted_at = Column(DateTime, nullable=True) # NULL for drafts
185
+ updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc),
186
+ onupdate=lambda: datetime.now(timezone.utc))
187
+ status = Column(String(20), default="draft") # draft | pending_approval | approved | rejected
188
+ title = Column(String(300), nullable=True) # auto-generated label for dashboard
189
 
190
  # Form data stored as JSON blob for flexibility
191
  form_data = Column(JSON, nullable=False)
app/routes/auth_routes.py CHANGED
@@ -25,7 +25,7 @@ async def login(request: Request, email: str = Form(...), password: str = Form(.
25
  {"request": request, "error": "Invalid email or password"})
26
 
27
  token = create_access_token({"sub": str(user.id), "role": user.role})
28
- return RedirectResponse(url=f"/form?token={token}", status_code=303)
29
 
30
 
31
  @router.get("/logout")
 
25
  {"request": request, "error": "Invalid email or password"})
26
 
27
  token = create_access_token({"sub": str(user.id), "role": user.role})
28
+ return RedirectResponse(url=f"/dashboard?token={token}", status_code=303)
29
 
30
 
31
  @router.get("/logout")
app/routes/form_routes.py CHANGED
@@ -9,6 +9,7 @@ from app.models import (
9
  User, CommissionAgreement, Producer, Client, DropdownOption,
10
  IntakeSubmission, Approval,
11
  )
 
12
  from app.rules_engine import compute_summary, check_bell_ringer
13
  from app.pdf_generator import generate_pdf
14
 
@@ -53,6 +54,18 @@ async def intake_form(request: Request,
53
  DropdownOption.category == "employee", DropdownOption.is_active == True
54
  ).order_by(DropdownOption.display_order).all()
55
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  return templates.TemplateResponse("form.html", {
57
  "request": request,
58
  "user": current_user,
@@ -64,6 +77,7 @@ async def intake_form(request: Request,
64
  "departments": departments,
65
  "segments": segments,
66
  "employees": employees,
 
67
  })
68
 
69
 
@@ -114,15 +128,37 @@ async def api_submit_form(request: Request,
114
  summary = compute_summary(db, body)
115
 
116
  bell_triggered = summary.get("bell_ringer", {}).get("status") == "triggered"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
- submission = IntakeSubmission(
119
- submitted_by=current_user.id,
120
- form_data=body,
121
- summary_data=summary,
122
- bell_ringer_triggered=bell_triggered,
123
- dept_code=body.get("dept_code"),
124
- )
125
- db.add(submission)
126
  db.flush() # get id before creating approvals
127
 
128
  # ── Build base URL for approval links ──
@@ -324,3 +360,131 @@ async def get_submission(submission_id: int,
324
  "responded_at": a.responded_at.isoformat() if a.responded_at else None,
325
  } for a in approvals],
326
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  User, CommissionAgreement, Producer, Client, DropdownOption,
10
  IntakeSubmission, Approval,
11
  )
12
+ from datetime import datetime, timezone
13
  from app.rules_engine import compute_summary, check_bell_ringer
14
  from app.pdf_generator import generate_pdf
15
 
 
54
  DropdownOption.category == "employee", DropdownOption.is_active == True
55
  ).order_by(DropdownOption.display_order).all()
56
 
57
+ # Load draft if editing
58
+ draft_id = request.query_params.get("draft_id")
59
+ draft_data = None
60
+ if draft_id:
61
+ draft = db.query(IntakeSubmission).filter(
62
+ IntakeSubmission.id == int(draft_id),
63
+ IntakeSubmission.submitted_by == current_user.id,
64
+ IntakeSubmission.status == "draft",
65
+ ).first()
66
+ if draft:
67
+ draft_data = {"id": draft.id, "form_data": draft.form_data}
68
+
69
  return templates.TemplateResponse("form.html", {
70
  "request": request,
71
  "user": current_user,
 
77
  "departments": departments,
78
  "segments": segments,
79
  "employees": employees,
80
+ "draft": draft_data,
81
  })
82
 
83
 
 
128
  summary = compute_summary(db, body)
129
 
130
  bell_triggered = summary.get("bell_ringer", {}).get("status") == "triggered"
131
+ draft_id = body.pop("draft_id", None)
132
+
133
+ # If submitting from a draft, update the existing record
134
+ if draft_id:
135
+ submission = db.query(IntakeSubmission).filter(
136
+ IntakeSubmission.id == int(draft_id),
137
+ IntakeSubmission.submitted_by == current_user.id,
138
+ IntakeSubmission.status == "draft",
139
+ ).first()
140
+ if not submission:
141
+ return JSONResponse({"error": "Draft not found"}, status_code=404)
142
+ submission.form_data = body
143
+ submission.summary_data = summary
144
+ submission.bell_ringer_triggered = bell_triggered
145
+ submission.dept_code = body.get("dept_code")
146
+ submission.status = "pending_approval"
147
+ submission.submitted_at = datetime.now(timezone.utc)
148
+ submission.title = _draft_title(body)
149
+ else:
150
+ submission = IntakeSubmission(
151
+ submitted_by=current_user.id,
152
+ form_data=body,
153
+ summary_data=summary,
154
+ bell_ringer_triggered=bell_triggered,
155
+ dept_code=body.get("dept_code"),
156
+ status="pending_approval",
157
+ submitted_at=datetime.now(timezone.utc),
158
+ title=_draft_title(body),
159
+ )
160
+ db.add(submission)
161
 
 
 
 
 
 
 
 
 
162
  db.flush() # get id before creating approvals
163
 
164
  # ── Build base URL for approval links ──
 
360
  "responded_at": a.responded_at.isoformat() if a.responded_at else None,
361
  } for a in approvals],
362
  })
363
+
364
+
365
+ # =====================================================================
366
+ # DRAFT MANAGEMENT
367
+ # =====================================================================
368
+
369
+ def _draft_title(form_data: dict) -> str:
370
+ """Generate a human-readable title from form data."""
371
+ client = form_data.get("client_name") or form_data.get("client_code") or "Untitled"
372
+ dept = form_data.get("dept_code") or ""
373
+ return f"{client} β€” {dept}".strip(" β€”") if dept else client
374
+
375
+
376
+ @router.post("/api/save-draft")
377
+ async def save_draft(request: Request,
378
+ current_user: User = Depends(get_current_user),
379
+ db: Session = Depends(get_db)):
380
+ """Create or update a draft. Returns the draft ID."""
381
+ body = await request.json()
382
+ form_data = body.get("form_data", body)
383
+ draft_id = body.get("draft_id")
384
+
385
+ if draft_id:
386
+ draft = db.query(IntakeSubmission).filter(
387
+ IntakeSubmission.id == int(draft_id),
388
+ IntakeSubmission.submitted_by == current_user.id,
389
+ IntakeSubmission.status == "draft",
390
+ ).first()
391
+ if not draft:
392
+ return JSONResponse({"error": "Draft not found"}, status_code=404)
393
+ draft.form_data = form_data
394
+ draft.title = _draft_title(form_data)
395
+ draft.updated_at = datetime.now(timezone.utc)
396
+ else:
397
+ draft = IntakeSubmission(
398
+ submitted_by=current_user.id,
399
+ status="draft",
400
+ form_data=form_data,
401
+ title=_draft_title(form_data),
402
+ )
403
+ db.add(draft)
404
+
405
+ db.commit()
406
+ db.refresh(draft)
407
+ return JSONResponse({
408
+ "success": True,
409
+ "draft_id": draft.id,
410
+ "title": draft.title,
411
+ "message": "Draft saved.",
412
+ })
413
+
414
+
415
+ @router.delete("/api/draft/{draft_id}")
416
+ async def delete_draft(draft_id: int,
417
+ current_user: User = Depends(get_current_user),
418
+ db: Session = Depends(get_db)):
419
+ """Delete a user's draft."""
420
+ draft = db.query(IntakeSubmission).filter(
421
+ IntakeSubmission.id == draft_id,
422
+ IntakeSubmission.submitted_by == current_user.id,
423
+ IntakeSubmission.status == "draft",
424
+ ).first()
425
+ if not draft:
426
+ return JSONResponse({"error": "Draft not found"}, status_code=404)
427
+ db.delete(draft)
428
+ db.commit()
429
+ return JSONResponse({"success": True})
430
+
431
+
432
+ # =====================================================================
433
+ # USER DASHBOARD
434
+ # =====================================================================
435
+
436
+ @router.get("/dashboard", response_class=HTMLResponse)
437
+ async def user_dashboard(request: Request,
438
+ current_user: User = Depends(get_current_user),
439
+ db: Session = Depends(get_db)):
440
+ token = request.query_params.get("token", "")
441
+
442
+ # Get all submissions for this user
443
+ submissions = db.query(IntakeSubmission).filter(
444
+ IntakeSubmission.submitted_by == current_user.id,
445
+ ).order_by(IntakeSubmission.updated_at.desc()).all()
446
+
447
+ drafts = []
448
+ submitted = []
449
+
450
+ for s in submissions:
451
+ if s.status == "draft":
452
+ drafts.append({
453
+ "id": s.id,
454
+ "title": s.title or "Untitled Draft",
455
+ "updated_at": s.updated_at.strftime("%b %d, %Y %I:%M %p") if s.updated_at else "β€”",
456
+ })
457
+ else:
458
+ # Get approval status
459
+ approvals = db.query(Approval).filter(
460
+ Approval.submission_id == s.id
461
+ ).all()
462
+ approved_count = sum(1 for a in approvals if a.status == "approved")
463
+ pending_count = sum(1 for a in approvals if a.status == "pending")
464
+ rejected_count = sum(1 for a in approvals if a.status == "rejected")
465
+
466
+ submitted.append({
467
+ "id": s.id,
468
+ "title": s.title or f"Submission #{s.id}",
469
+ "status": s.status,
470
+ "submitted_at": s.submitted_at.strftime("%b %d, %Y %I:%M %p") if s.submitted_at else "β€”",
471
+ "bell_ringer": s.bell_ringer_triggered,
472
+ "total_approvals": len(approvals),
473
+ "approved_count": approved_count,
474
+ "pending_count": pending_count,
475
+ "rejected_count": rejected_count,
476
+ "approvals": [{
477
+ "role_label": a.role_label,
478
+ "approver_name": a.approver_name,
479
+ "status": a.status,
480
+ "responded_at": a.responded_at.strftime("%b %d, %Y %I:%M %p") if a.responded_at else None,
481
+ } for a in approvals],
482
+ })
483
+
484
+ return templates.TemplateResponse("dashboard.html", {
485
+ "request": request,
486
+ "user": current_user,
487
+ "token": token,
488
+ "drafts": drafts,
489
+ "submitted": submitted,
490
+ })
app/templates/base.html CHANGED
@@ -22,7 +22,8 @@
22
  <div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
23
  <div class="flex items-center space-x-6">
24
  <span class="text-lg font-bold tracking-tight">SW Commission Intake</span>
25
- <a href="/form?token={{ token }}" class="text-sm text-gray-200 hover:text-white transition">Form</a>
 
26
  {% if user.role in ('admin', 'superadmin') %}
27
  <a href="/admin/?token={{ token }}" class="text-sm text-gray-200 hover:text-white transition">Admin Panel</a>
28
  {% endif %}
 
22
  <div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
23
  <div class="flex items-center space-x-6">
24
  <span class="text-lg font-bold tracking-tight">SW Commission Intake</span>
25
+ <a href="/dashboard?token={{ token }}" class="text-sm text-gray-200 hover:text-white transition">Dashboard</a>
26
+ <a href="/form?token={{ token }}" class="text-sm text-gray-200 hover:text-white transition">New Form</a>
27
  {% if user.role in ('admin', 'superadmin') %}
28
  <a href="/admin/?token={{ token }}" class="text-sm text-gray-200 hover:text-white transition">Admin Panel</a>
29
  {% endif %}
app/templates/dashboard.html ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}My Dashboard{% endblock %}
3
+
4
+ {% block content %}
5
+ <div class="space-y-6">
6
+ <div class="flex items-center justify-between">
7
+ <div>
8
+ <h1 class="text-2xl font-bold sw-blue-text">My Dashboard</h1>
9
+ <p class="text-gray-500 text-sm mt-1">View your drafts and track submission approvals.</p>
10
+ </div>
11
+ <a href="/form?token={{ token }}"
12
+ class="bg-green-600 text-white px-5 py-2.5 rounded-lg font-semibold hover:bg-green-700 transition text-sm">
13
+ + New Form
14
+ </a>
15
+ </div>
16
+
17
+ <!-- ── Drafts Section ──────────────────────────────────── -->
18
+ <div class="bg-white rounded-lg shadow overflow-hidden">
19
+ <div class="sw-blue text-white px-5 py-3 font-semibold flex items-center justify-between">
20
+ <span>Drafts</span>
21
+ <span class="bg-white/20 text-xs px-2 py-0.5 rounded">{{ drafts|length }}</span>
22
+ </div>
23
+ {% if drafts %}
24
+ <div class="divide-y divide-gray-100">
25
+ {% for d in drafts %}
26
+ <div class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition">
27
+ <div>
28
+ <a href="/form?token={{ token }}&draft_id={{ d.id }}"
29
+ class="font-medium sw-blue-text hover:underline">{{ d.title }}</a>
30
+ <p class="text-xs text-gray-400 mt-0.5">Last edited: {{ d.updated_at }}</p>
31
+ </div>
32
+ <div class="flex items-center space-x-2">
33
+ <a href="/form?token={{ token }}&draft_id={{ d.id }}"
34
+ class="text-xs bg-blue-50 text-blue-700 px-3 py-1 rounded hover:bg-blue-100 transition">
35
+ Edit
36
+ </a>
37
+ <button onclick="deleteDraft({{ d.id }}, this)"
38
+ class="text-xs bg-red-50 text-red-600 px-3 py-1 rounded hover:bg-red-100 transition">
39
+ Delete
40
+ </button>
41
+ </div>
42
+ </div>
43
+ {% endfor %}
44
+ </div>
45
+ {% else %}
46
+ <div class="px-5 py-8 text-center text-gray-400 text-sm">
47
+ No drafts. <a href="/form?token={{ token }}" class="text-blue-500 hover:underline">Start a new form</a>.
48
+ </div>
49
+ {% endif %}
50
+ </div>
51
+
52
+ <!-- ── Submitted Forms Section ─────────────────────────── -->
53
+ <div class="bg-white rounded-lg shadow overflow-hidden">
54
+ <div class="sw-blue text-white px-5 py-3 font-semibold flex items-center justify-between">
55
+ <span>Submitted Forms</span>
56
+ <span class="bg-white/20 text-xs px-2 py-0.5 rounded">{{ submitted|length }}</span>
57
+ </div>
58
+ {% if submitted %}
59
+ <div class="divide-y divide-gray-100">
60
+ {% for s in submitted %}
61
+ <div class="px-5 py-4">
62
+ <div class="flex items-center justify-between mb-2">
63
+ <div class="flex items-center space-x-3">
64
+ <span class="font-medium sw-blue-text">{{ s.title }}</span>
65
+ {% if s.bell_ringer %}
66
+ <span class="text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded font-semibold">Bell Ringer</span>
67
+ {% endif %}
68
+ </div>
69
+ <div class="flex items-center space-x-3">
70
+ {% if s.status == 'pending_approval' %}
71
+ <span class="text-xs bg-yellow-100 text-yellow-800 px-2.5 py-1 rounded font-semibold">Pending Approval</span>
72
+ {% elif s.status == 'approved' %}
73
+ <span class="text-xs bg-green-100 text-green-800 px-2.5 py-1 rounded font-semibold">Approved</span>
74
+ {% elif s.status == 'rejected' %}
75
+ <span class="text-xs bg-red-100 text-red-800 px-2.5 py-1 rounded font-semibold">Rejected</span>
76
+ {% endif %}
77
+ <a href="/api/download-pdf/{{ s.id }}?token={{ token }}"
78
+ class="text-xs bg-blue-50 text-blue-700 px-3 py-1 rounded hover:bg-blue-100 transition">
79
+ PDF
80
+ </a>
81
+ </div>
82
+ </div>
83
+ <p class="text-xs text-gray-400 mb-3">Submitted: {{ s.submitted_at }} &middot; Submission #{{ s.id }}</p>
84
+
85
+ <!-- Approval Progress Bar -->
86
+ {% if s.total_approvals > 0 %}
87
+ <div class="mb-2">
88
+ <div class="flex items-center space-x-2 mb-1">
89
+ <div class="flex-1 bg-gray-200 rounded-full h-2 overflow-hidden">
90
+ {% set pct = (s.approved_count / s.total_approvals * 100)|int %}
91
+ <div class="h-2 rounded-full transition-all {% if s.rejected_count > 0 %}bg-red-500{% else %}bg-green-500{% endif %}"
92
+ style="width: {{ pct }}%"></div>
93
+ </div>
94
+ <span class="text-xs text-gray-500 whitespace-nowrap">
95
+ {{ s.approved_count }}/{{ s.total_approvals }} approved
96
+ </span>
97
+ </div>
98
+ </div>
99
+
100
+ <!-- Approver Details (collapsible) -->
101
+ <details class="text-sm">
102
+ <summary class="cursor-pointer text-blue-600 hover:text-blue-800 text-xs font-medium">
103
+ Show approval details
104
+ </summary>
105
+ <div class="mt-2 border border-gray-100 rounded-lg overflow-hidden">
106
+ <table class="w-full text-xs">
107
+ <thead>
108
+ <tr class="bg-gray-50 text-gray-500">
109
+ <th class="px-3 py-2 text-left font-medium">Role</th>
110
+ <th class="px-3 py-2 text-left font-medium">Name</th>
111
+ <th class="px-3 py-2 text-left font-medium">Status</th>
112
+ <th class="px-3 py-2 text-left font-medium">Date</th>
113
+ </tr>
114
+ </thead>
115
+ <tbody class="divide-y divide-gray-50">
116
+ {% for a in s.approvals %}
117
+ <tr>
118
+ <td class="px-3 py-2 text-gray-700">{{ a.role_label }}</td>
119
+ <td class="px-3 py-2 text-gray-700">{{ a.approver_name }}</td>
120
+ <td class="px-3 py-2">
121
+ {% if a.status == 'approved' %}
122
+ <span class="text-green-600 font-semibold">Approved</span>
123
+ {% elif a.status == 'rejected' %}
124
+ <span class="text-red-600 font-semibold">Rejected</span>
125
+ {% else %}
126
+ <span class="text-yellow-600 font-semibold">Pending</span>
127
+ {% endif %}
128
+ </td>
129
+ <td class="px-3 py-2 text-gray-400">{{ a.responded_at or 'β€”' }}</td>
130
+ </tr>
131
+ {% endfor %}
132
+ </tbody>
133
+ </table>
134
+ </div>
135
+ </details>
136
+ {% endif %}
137
+ </div>
138
+ {% endfor %}
139
+ </div>
140
+ {% else %}
141
+ <div class="px-5 py-8 text-center text-gray-400 text-sm">
142
+ No submissions yet. <a href="/form?token={{ token }}" class="text-blue-500 hover:underline">Submit your first form</a>.
143
+ </div>
144
+ {% endif %}
145
+ </div>
146
+ </div>
147
+ {% endblock %}
148
+
149
+ {% block scripts %}
150
+ <script>
151
+ const authToken = new URLSearchParams(window.location.search).get('token');
152
+
153
+ async function deleteDraft(draftId, btn) {
154
+ if (!confirm('Delete this draft? This cannot be undone.')) return;
155
+ try {
156
+ const resp = await fetch('/api/draft/' + draftId, {
157
+ method: 'DELETE',
158
+ headers: {'Authorization': 'Bearer ' + authToken},
159
+ });
160
+ const data = await resp.json();
161
+ if (data.success) {
162
+ btn.closest('.flex.items-center.justify-between').parentElement.remove();
163
+ } else {
164
+ alert('Failed to delete: ' + (data.error || 'Unknown error'));
165
+ }
166
+ } catch (e) {
167
+ alert('Error: ' + e.message);
168
+ }
169
+ }
170
+ </script>
171
+ {% endblock %}
app/templates/form.html CHANGED
@@ -191,20 +191,33 @@
191
  </div>
192
  </div>
193
 
194
- <!-- Submit Button -->
195
- <div class="flex justify-end">
 
 
 
 
196
  <button id="submit_btn" onclick="submitForm()"
197
  class="bg-green-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-green-700 transition disabled:opacity-50"
198
  disabled>
199
  Submit for Approval
200
  </button>
201
  </div>
 
202
  </div>
203
  {% endblock %}
204
 
205
  {% block scripts %}
206
  <script>
207
  const authToken = new URLSearchParams(window.location.search).get('token');
 
 
 
 
 
 
 
 
208
  // ── Client data for sync ────────────────────────────────
209
  const clientData = {{ clients | tojson }};
210
  const codeToName = {};
@@ -420,9 +433,45 @@ async function refreshSummary() {
420
  }
421
  }
422
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  // ── Submit form ─────────────────────────────────────────
424
  async function submitForm() {
425
  const formData = getFormData();
 
426
  try {
427
  const resp = await fetch('/api/submit-form', {
428
  method: 'POST',
@@ -436,13 +485,17 @@ async function submitForm() {
436
  const btnArea = document.getElementById('submit_btn').parentElement;
437
  btnArea.innerHTML =
438
  '<div class="bg-green-50 border border-green-300 text-green-800 px-6 py-4 rounded-lg text-sm space-y-3">' +
439
- '<div class="font-semibold text-lg">βœ… Form Submitted Successfully!</div>' +
440
  '<div>Submission ID: <strong>' + sid + '</strong></div>' +
441
  '<div>' + (data.approvals_count || 0) + ' approval(s) required.</div>' +
 
442
  (data.pdf_available ?
443
  '<a href="/api/download-pdf/' + sid + '?token=' + authToken + '" ' +
444
- 'class="inline-block bg-blue-600 text-white px-6 py-2 rounded-lg font-semibold hover:bg-blue-700 transition mt-2">' +
445
- 'πŸ“„ Download Draft PDF</a>' : '') +
 
 
 
446
  '</div>';
447
  } else {
448
  alert('Submission failed: ' + (data.error || 'Unknown error'));
@@ -465,5 +518,47 @@ document.getElementById('complex_claims').addEventListener('change', refreshSumm
465
  document.getElementById('revenue').addEventListener('input', () => { refreshSummary(); checkBell(); });
466
  document.getElementById('dept_code').addEventListener('change', () => { refreshSummary(); checkBell(); });
467
  document.getElementById('market_segment').addEventListener('change', () => { refreshSummary(); checkBell(); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
  </script>
469
  {% endblock %}
 
191
  </div>
192
  </div>
193
 
194
+ <!-- Action Buttons -->
195
+ <div class="flex justify-end space-x-3">
196
+ <button id="save_draft_btn" onclick="saveDraft()"
197
+ class="bg-gray-500 text-white px-6 py-3 rounded-lg font-semibold hover:bg-gray-600 transition text-sm">
198
+ Save Draft
199
+ </button>
200
  <button id="submit_btn" onclick="submitForm()"
201
  class="bg-green-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-green-700 transition disabled:opacity-50"
202
  disabled>
203
  Submit for Approval
204
  </button>
205
  </div>
206
+ <div id="draft_msg" class="text-right text-sm text-gray-400 mt-1 hidden"></div>
207
  </div>
208
  {% endblock %}
209
 
210
  {% block scripts %}
211
  <script>
212
  const authToken = new URLSearchParams(window.location.search).get('token');
213
+ let currentDraftId = new URLSearchParams(window.location.search).get('draft_id') || null;
214
+
215
+ // ── Draft data from server (if editing) ─────────────────
216
+ const draftData = {{ (draft.form_data if draft else {})|tojson }};
217
+ {% if draft %}
218
+ currentDraftId = {{ draft.id }};
219
+ {% endif %}
220
+
221
  // ── Client data for sync ────────────────────────────────
222
  const clientData = {{ clients | tojson }};
223
  const codeToName = {};
 
433
  }
434
  }
435
 
436
+ // ── Save draft ──────────────────────────────────────────
437
+ async function saveDraft() {
438
+ const btn = document.getElementById('save_draft_btn');
439
+ const msg = document.getElementById('draft_msg');
440
+ btn.disabled = true;
441
+ btn.textContent = 'Saving...';
442
+ try {
443
+ const resp = await fetch('/api/save-draft', {
444
+ method: 'POST',
445
+ headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authToken},
446
+ body: JSON.stringify({ form_data: getFormData(), draft_id: currentDraftId }),
447
+ });
448
+ const data = await resp.json();
449
+ if (data.success) {
450
+ currentDraftId = data.draft_id;
451
+ // Update URL without reload so subsequent saves update the same draft
452
+ const url = new URL(window.location);
453
+ url.searchParams.set('draft_id', data.draft_id);
454
+ history.replaceState(null, '', url.toString());
455
+ msg.textContent = 'Draft saved at ' + new Date().toLocaleTimeString();
456
+ msg.classList.remove('hidden');
457
+ } else {
458
+ msg.textContent = 'Failed to save draft: ' + (data.error || 'Unknown error');
459
+ msg.classList.remove('hidden');
460
+ msg.classList.add('text-red-500');
461
+ }
462
+ } catch (e) {
463
+ msg.textContent = 'Error saving draft: ' + e.message;
464
+ msg.classList.remove('hidden');
465
+ msg.classList.add('text-red-500');
466
+ }
467
+ btn.disabled = false;
468
+ btn.textContent = 'Save Draft';
469
+ }
470
+
471
  // ── Submit form ─────────────────────────────────────────
472
  async function submitForm() {
473
  const formData = getFormData();
474
+ if (currentDraftId) formData.draft_id = currentDraftId;
475
  try {
476
  const resp = await fetch('/api/submit-form', {
477
  method: 'POST',
 
485
  const btnArea = document.getElementById('submit_btn').parentElement;
486
  btnArea.innerHTML =
487
  '<div class="bg-green-50 border border-green-300 text-green-800 px-6 py-4 rounded-lg text-sm space-y-3">' +
488
+ '<div class="font-semibold text-lg">Form Submitted Successfully!</div>' +
489
  '<div>Submission ID: <strong>' + sid + '</strong></div>' +
490
  '<div>' + (data.approvals_count || 0) + ' approval(s) required.</div>' +
491
+ '<div class="flex items-center space-x-3 mt-3">' +
492
  (data.pdf_available ?
493
  '<a href="/api/download-pdf/' + sid + '?token=' + authToken + '" ' +
494
+ 'class="inline-block bg-blue-600 text-white px-6 py-2 rounded-lg font-semibold hover:bg-blue-700 transition">' +
495
+ 'Download Draft PDF</a>' : '') +
496
+ '<a href="/dashboard?token=' + authToken + '" ' +
497
+ 'class="inline-block bg-gray-600 text-white px-6 py-2 rounded-lg font-semibold hover:bg-gray-700 transition">' +
498
+ 'Go to Dashboard</a></div>' +
499
  '</div>';
500
  } else {
501
  alert('Submission failed: ' + (data.error || 'Unknown error'));
 
518
  document.getElementById('revenue').addEventListener('input', () => { refreshSummary(); checkBell(); });
519
  document.getElementById('dept_code').addEventListener('change', () => { refreshSummary(); checkBell(); });
520
  document.getElementById('market_segment').addEventListener('change', () => { refreshSummary(); checkBell(); });
521
+
522
+ // ── Load draft data into form ───────────────────────────
523
+ if (draftData && Object.keys(draftData).length > 0) {
524
+ const fieldMap = {
525
+ 'client_code': 'client_code',
526
+ 'client_name': 'client_name',
527
+ 'revenue': 'revenue',
528
+ 'dept_code': 'dept_code',
529
+ 'market_segment': 'market_segment',
530
+ 'effective_date': 'effective_date',
531
+ 'association': 'association',
532
+ 'producer1_prefix': 'producer1',
533
+ 'producer2_prefix': 'producer2',
534
+ 'ce_prefix': 'client_exec',
535
+ 'complex_claims': 'complex_claims',
536
+ 'legacy_producer': 'legacy_producer',
537
+ 'agreement_id': 'agreement',
538
+ 'orig_producer_prefix': 'orig_producer',
539
+ 'orig_employee_name': 'orig_employee',
540
+ };
541
+ for (const [key, elId] of Object.entries(fieldMap)) {
542
+ if (draftData[key] !== undefined && draftData[key] !== null) {
543
+ const el = document.getElementById(elId);
544
+ if (el) el.value = draftData[key];
545
+ }
546
+ }
547
+ // Originating type radio
548
+ if (draftData.orig_type) {
549
+ const radio = document.querySelector(`input[name="orig_type"][value="${draftData.orig_type}"]`);
550
+ if (radio) {
551
+ radio.checked = true;
552
+ radio.dispatchEvent(new Event('change'));
553
+ }
554
+ }
555
+ // Orig code suffix
556
+ if (draftData.orig_producer_code) {
557
+ const el = document.getElementById('orig_code');
558
+ if (el) el.value = draftData.orig_producer_code;
559
+ }
560
+ // Trigger summary refresh after loading
561
+ setTimeout(() => { refreshSummary(); checkBell(); }, 100);
562
+ }
563
  </script>
564
  {% endblock %}
app/templates/login.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Login β€” Producer Intake</title>
7
  <script src="https://cdn.tailwindcss.com"></script>
8
  <style>body { font-family: 'Segoe UI', system-ui, sans-serif; }</style>
9
  </head>
@@ -11,7 +11,7 @@
11
  <div class="w-full max-w-md">
12
  <div class="bg-white rounded-lg shadow-lg overflow-hidden">
13
  <div class="bg-[#2e4057] text-white px-8 py-6 text-center">
14
- <h1 class="text-2xl font-bold">Producer Intake Form</h1>
15
  <p class="text-gray-300 text-sm mt-1">Snellings Walters Insurance</p>
16
  </div>
17
  <div class="px-8 py-6">
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Login β€” Commission Agreement Intake</title>
7
  <script src="https://cdn.tailwindcss.com"></script>
8
  <style>body { font-family: 'Segoe UI', system-ui, sans-serif; }</style>
9
  </head>
 
11
  <div class="w-full max-w-md">
12
  <div class="bg-white rounded-lg shadow-lg overflow-hidden">
13
  <div class="bg-[#2e4057] text-white px-8 py-6 text-center">
14
+ <h1 class="text-2xl font-bold">Commission Agreement Intake Form</h1>
15
  <p class="text-gray-300 text-sm mt-1">Snellings Walters Insurance</p>
16
  </div>
17
  <div class="px-8 py-6">