diff --git a/CLAUDE.md b/CLAUDE.md index c82d362..e0bb94e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -245,9 +245,23 @@ issues: id, inspection_id (nullable), area_id, facility_id (nullable), severity mobile_local_id VARCHAR(64) nullable indexed, ← Phase B vendor_name VARCHAR(100) nullable, ← Phase 26 vendor_contact VARCHAR(200) nullable, ← Phase 26 - vendor_notes TEXT nullable ← Phase 26 + vendor_notes TEXT nullable, ← Phase 26 + handler_type ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal', ← Phase 35 + facility_handler_name VARCHAR(100) nullable, ← Phase 35 + facility_handler_contact VARCHAR(200) nullable, ← Phase 35 + facility_handler_notes TEXT nullable ← Phase 35 ``` +**Handler (`handler_type`, Phase 35) — who is doing the work:** + +| Value | Meaning | Detail fields | `assigned_to` role | +|---|---|---|---| +| `internal` (default) | Our staff | — (the assignee IS the handler) | the handler | +| `facility` | The facility's own staff | `facility_handler_name/contact/notes` (free text) | internal **follow-up owner** | +| `vendor` | External contractor | `vendor_name/contact/notes` (Phase 26) | internal **follow-up owner** | + +`assigned_to` (a JQC User) is **always** available: it is the handler for `internal`, and the internal follow-up owner (e.g. the inspector who verifies/updates) for `facility`/`vendor`. Set via the **Update Issue** panel on the issue detail page — the "Handled By" selector reveals the facility or vendor sub-fields via JS. Triage of `handler_type` + facility/vendor detail fields is **admin/director/project_manager only** (same gate as vendor fields); `assigned_to` remains admin/director. Issue list is filterable by `?handler_type=` and shows a Facility/Vendor badge. `Issue.handler_label` gives the display string. Not yet exposed in the mobile API. + **Photo columns — three distinct fields with different semantics:** | Column | Type | Populated by | Displayed as | @@ -714,7 +728,8 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif → phase31_device_registry → phase32_device_token_columns → phase33_contract_recipients - → phase34_facility_qr ← HEAD + → phase34_facility_qr + → phase35_issue_handler ← HEAD ``` ### phase21_performance_indexes @@ -810,6 +825,16 @@ flask db upgrade # adds + backfills public_token sudo systemctl restart gunicorn ``` +### phase35_issue_handler + +Revision id `phase35_issue_handler` (file `phase35_issue_handler_type.py`). Adds to `issues`: `handler_type ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal'` and `facility_handler_name/contact/notes`. **Backfills** existing rows with a non-empty `vendor_name` to `handler_type='vendor'`. Separates WHO handles an issue (see §5 Issue + the Handler section). `INFORMATION_SCHEMA` checks — safe to re-run. + +**Deploy order:** +```bash +flask db upgrade +sudo systemctl restart gunicorn +``` + **Deploy order for phases 24–32:** ```bash flask db upgrade diff --git a/app/models/issue.py b/app/models/issue.py index be56177..de1f113 100644 --- a/app/models/issue.py +++ b/app/models/issue.py @@ -84,6 +84,18 @@ class Issue(db.Model): vendor_contact = db.Column(db.String(200), nullable=True) # phone or email vendor_notes = db.Column(db.Text, nullable=True) + # ── Who handles the issue (phase35) ────────────────────────────────── + # internal = our staff (assigned_to); facility = the facility's own staff + # (facility_handler_* below); vendor = external contractor (vendor_* above). + # assigned_to remains the internal follow-up owner in ALL cases. + handler_type = db.Column( + db.Enum('internal', 'facility', 'vendor'), + nullable=False, default='internal', + ) + facility_handler_name = db.Column(db.String(100), nullable=True) + facility_handler_contact = db.Column(db.String(200), nullable=True) # phone or email + facility_handler_notes = db.Column(db.Text, nullable=True) + # Relationships # NOTE: Issue.area is provided by the backref on Area.issues (facility.py). # Do NOT add a second explicit db.relationship('Area') here — it conflicts @@ -102,6 +114,17 @@ class Issue(db.Model): """Return True if the given user is currently following this issue.""" return self.followers.filter_by(user_id=user.id).first() is not None + # Human-readable label for the handler category (phase35). + HANDLER_LABELS = { + 'internal': 'Our Staff', + 'facility': 'Facility Staff', + 'vendor': 'External Vendor', + } + + @property + def handler_label(self): + return self.HANDLER_LABELS.get(self.handler_type or 'internal', 'Our Staff') + @property def resolved_facility(self): """Returns the Facility for this issue regardless of which path was used to create it. diff --git a/app/routes/issues.py b/app/routes/issues.py index 2a5144c..d993c1a 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -112,9 +112,12 @@ def export_list_pdf(): date_from_filter = request.args.get('date_from', '') date_to_filter = request.args.get('date_to', '') reporter_filter = request.args.get('reporter_id', '') + handler_filter = request.args.get('handler_type', '') if issue_id_filter.isdigit(): q = q.filter(Issue.id == int(issue_id_filter)) + if handler_filter in ('internal', 'facility', 'vendor'): + q = q.filter(Issue.handler_type == handler_filter) if severity_filter: q = q.filter(Issue.severity == severity_filter) if status_filter: @@ -243,9 +246,12 @@ def index(): date_from_filter = request.args.get('date_from', '') date_to_filter = request.args.get('date_to', '') reporter_filter = request.args.get('reporter_id', '') + handler_filter = request.args.get('handler_type', '') if issue_id_filter.isdigit(): q = q.filter(Issue.id == int(issue_id_filter)) + if handler_filter in ('internal', 'facility', 'vendor'): + q = q.filter(Issue.handler_type == handler_filter) if severity_filter: q = q.filter(Issue.severity == severity_filter) if status_filter: @@ -352,6 +358,7 @@ def index(): date_from_filter=date_from_filter, date_to_filter=date_to_filter, reporter_filter=reporter_filter, + handler_filter=handler_filter, facilities=facilities, projects=projects, staff=staff, @@ -432,12 +439,22 @@ def view(issue_id): issue.result_notes = form.result_notes.data or None - # Vendor / contractor assignment — admin, director, project_manager only + # Handler assignment (who handles it) + vendor/facility details — + # admin, director, project_manager only. if current_user.role in ('admin', 'director', 'project_manager'): + handler = form.handler_type.data or 'internal' + if handler not in ('internal', 'facility', 'vendor'): + handler = 'internal' + issue.handler_type = handler + issue.vendor_name = (form.vendor_name.data or '').strip() or None issue.vendor_contact = (form.vendor_contact.data or '').strip() or None issue.vendor_notes = (form.vendor_notes.data or '').strip() or None + issue.facility_handler_name = (form.facility_handler_name.data or '').strip() or None + issue.facility_handler_contact = (form.facility_handler_contact.data or '').strip() or None + issue.facility_handler_notes = (form.facility_handler_notes.data or '').strip() or None + from app.routes.inspections import _save_photo new_photos = [] for file_obj in request.files.getlist('result_photos'): @@ -595,7 +612,7 @@ def view(issue_id): db.session.commit() # Single atomic commit: issue fields + comment + all notification rows log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}', - f'status={issue.status}; assigned_to={issue.assigned_to}') + f'status={issue.status}; handler={issue.handler_type}; assigned_to={issue.assigned_to}') flash('Issue updated.', 'success') return redirect(url_for('issues.view', issue_id=issue_id)) diff --git a/app/templates/issues/list.html b/app/templates/issues/list.html index de97f07..0671103 100644 --- a/app/templates/issues/list.html +++ b/app/templates/issues/list.html @@ -82,6 +82,15 @@ {% endfor %} +
+ + +
Clear @@ -172,6 +181,11 @@ {% if issue.assigned_user %}{{ issue.assigned_user.display_name }} {% else %}{% endif %} {% endif %} + {% if issue.handler_type == 'facility' %} +
Facility
+ {% elif issue.handler_type == 'vendor' %} +
Vendor
+ {% endif %} {# Following badge + inline unfollow #} @@ -184,7 +198,7 @@ class="d-inline" title="Unfollow this issue"> - +
- {% if current_user.role in ['admin','director'] %} + {% if current_user.role in ['admin','director','project_manager'] %} + {# ── Who handles this issue ── #}
- {{ form.assigned_to.label(class="form-label fw-semibold") }} - {{ form.assigned_to(class="form-select") }} + {{ form.handler_type.label(class="form-label fw-semibold") }} + {{ form.handler_type(class="form-select", id="handler_type_select") }}
{% endif %} + + {% if current_user.role in ['admin','director'] %} +
+ + {{ form.assigned_to(class="form-select") }} + +
+ {% endif %} + + {% if current_user.role in ['admin','director','project_manager'] %} + {# ── Facility-staff handler (shown when Handled By = Facility Staff) ── #} + + + {# ── External vendor (shown when Handled By = External Vendor) ── #} + + {% endif %} + +
{{ form.result_notes.label(class="form-label fw-semibold") }} {{ form.result_notes(class="form-control", rows=3, @@ -364,29 +456,6 @@
{% endif %} - {% if current_user.role in ['admin','director','project_manager'] %} -
-

- External Contractor -

-
- {{ form.vendor_name.label(class="form-label small fw-semibold mb-1") }} - {{ form.vendor_name(class="form-control form-control-sm", - placeholder="Contractor or vendor name", - value=issue.vendor_name or '') }} -
-
- {{ form.vendor_contact.label(class="form-label small fw-semibold mb-1") }} - {{ form.vendor_contact(class="form-control form-control-sm", - placeholder="Phone or email", - value=issue.vendor_contact or '') }} -
-
- {{ form.vendor_notes.label(class="form-label small fw-semibold mb-1") }} - {{ form.vendor_notes(class="form-control form-control-sm", rows=2, - placeholder="Notes about what the contractor is handling…") }} -
- {% endif %} {% if issue.status in ['in_progress', 'resolved'] and current_user.role not in ['customer'] %} @@ -476,6 +545,32 @@ section.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } + + // ── "Handled By" — show the relevant sub-block (facility vs vendor) and + // relabel the assignee as a follow-up owner for facility/vendor. ── + var handlerSelect = document.getElementById('handler_type_select'); + function syncHandlerUI() { + if (!handlerSelect) { return; } + var v = handlerSelect.value; + var facBlock = document.getElementById('facility_handler_block'); + var venBlock = document.getElementById('vendor_block'); + if (facBlock) { facBlock.style.display = (v === 'facility') ? '' : 'none'; } + if (venBlock) { venBlock.style.display = (v === 'vendor') ? '' : 'none'; } + + var label = document.getElementById('assigned_to_label'); + var help = document.getElementById('assigned_to_help'); + if (label) { + label.textContent = (v === 'facility' || v === 'vendor') + ? 'Follow-up Owner' : 'Assign To'; + } + if (help) { + help.style.display = (v === 'facility' || v === 'vendor') ? '' : 'none'; + } + } + if (handlerSelect) { + handlerSelect.addEventListener('change', syncHandlerUI); + syncHandlerUI(); // set initial state on load + } })(); {% endblock %} diff --git a/app/utils/forms.py b/app/utils/forms.py index eb5fb2f..f23ef49 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -185,7 +185,17 @@ class IssueUpdateForm(FlaskForm): Optional(), FileAllowed(['jpg','jpeg','png','gif'], 'Images only.') ]) - # External contractor / vendor fields (phase26) + # Who handles the issue (phase35) + handler_type = SelectField('Handled By', choices=[ + ('internal', 'Our Staff'), + ('facility', 'Facility Staff'), + ('vendor', 'External Vendor'), + ], validators=[Optional()]) + # Facility-staff handler (free text) — used when handler_type == 'facility' + facility_handler_name = StringField('Facility Contact Name', validators=[Optional(), Length(max=100)]) + facility_handler_contact = StringField('Facility Contact', validators=[Optional(), Length(max=200)]) + facility_handler_notes = TextAreaField('Facility Handling Notes', validators=[Optional(), Length(max=1000)]) + # External contractor / vendor fields (phase26) — used when handler_type == 'vendor' vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)]) vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)]) vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)]) diff --git a/migrations/versions/phase35_issue_handler_type.py b/migrations/versions/phase35_issue_handler_type.py new file mode 100644 index 0000000..7203f2a --- /dev/null +++ b/migrations/versions/phase35_issue_handler_type.py @@ -0,0 +1,68 @@ +"""phase35 — issue handler_type + facility-staff handler fields + +Separates WHO handles an issue into three categories: + internal — one of our staff (existing assigned_to User) + facility — the facility's own staff (new free-text facility_handler_* fields) + vendor — an external contractor (existing vendor_* fields, phase26) + +`assigned_to` remains the internal follow-up owner in all cases. + +Backfills existing rows that already have a vendor_name to handler_type='vendor'. +Uses INFORMATION_SCHEMA column-existence checks — safe to re-run. +""" + +revision = 'phase35_issue_handler' +down_revision = 'phase34_facility_qr' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def _column_exists(conn, table, column): + result = conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = :t AND COLUMN_NAME = :c" + ), {"t": table, "c": column}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + + if not _column_exists(bind, 'issues', 'handler_type'): + op.execute(sa.text( + "ALTER TABLE issues ADD COLUMN handler_type " + "ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal'" + )) + + if not _column_exists(bind, 'issues', 'facility_handler_name'): + op.execute(sa.text( + "ALTER TABLE issues ADD COLUMN facility_handler_name VARCHAR(100) NULL" + )) + if not _column_exists(bind, 'issues', 'facility_handler_contact'): + op.execute(sa.text( + "ALTER TABLE issues ADD COLUMN facility_handler_contact VARCHAR(200) NULL" + )) + if not _column_exists(bind, 'issues', 'facility_handler_notes'): + op.execute(sa.text( + "ALTER TABLE issues ADD COLUMN facility_handler_notes TEXT NULL" + )) + + # Backfill: rows already carrying a vendor become handler_type='vendor' + # so existing contractor assignments keep their meaning. + op.execute(sa.text( + "UPDATE issues SET handler_type = 'vendor' " + "WHERE handler_type = 'internal' " + "AND vendor_name IS NOT NULL AND TRIM(vendor_name) <> ''" + )) + + +def downgrade(): + bind = op.get_bind() + for col in ('facility_handler_notes', 'facility_handler_contact', + 'facility_handler_name', 'handler_type'): + if _column_exists(bind, 'issues', col): + op.execute(sa.text(f"ALTER TABLE issues DROP COLUMN {col}"))