Phase 2: implement template form editor

This commit is contained in:
2026-02-20 12:57:44 -05:00
parent 225ec9000a
commit b62df442b5
8 changed files with 2419 additions and 284 deletions
+40
View File
@@ -0,0 +1,40 @@
"""Add form_schema column to inspection_templates
Run this once on your server:
python add_form_schema.py
Or via Flask-Migrate:
flask db migrate -m "add form_schema to inspection_templates"
flask db upgrade
"""
# If you prefer to run this as a standalone script:
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app, db
from sqlalchemy import text
app = create_app()
with app.app_context():
with db.engine.connect() as conn:
# Check if column already exists
result = conn.execute(text("""
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'inspection_templates'
AND column_name = 'form_schema'
"""))
exists = result.scalar()
if not exists:
conn.execute(text("""
ALTER TABLE inspection_templates
ADD COLUMN form_schema JSON NULL COMMENT 'JSON schema for the dynamic form builder'
"""))
conn.commit()
print("✓ Column 'form_schema' added to inspection_templates.")
else:
print("✓ Column 'form_schema' already exists — no changes made.")
+20 -1
View File
@@ -1,5 +1,7 @@
from app import db from app import db
from datetime import datetime from datetime import datetime
import json
class InspectionTemplate(db.Model): class InspectionTemplate(db.Model):
__tablename__ = 'inspection_templates' __tablename__ = 'inspection_templates'
@@ -11,13 +13,28 @@ class InspectionTemplate(db.Model):
created_by = db.Column(db.Integer, db.ForeignKey('users.id')) created_by = db.Column(db.Integer, db.ForeignKey('users.id'))
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Form builder schema — stores the full dynamic field layout as JSON
form_schema = db.Column(db.JSON, nullable=True)
# Relationships # Relationships
checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan') checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan')
inspections = db.relationship('Inspection', backref='template', lazy='dynamic') inspections = db.relationship('Inspection', backref='template', lazy='dynamic')
def get_form_schema(self):
"""Return the form schema as a Python list, or empty list if not set."""
if self.form_schema is None:
return []
if isinstance(self.form_schema, str):
try:
return json.loads(self.form_schema)
except (json.JSONDecodeError, TypeError):
return []
return self.form_schema
def __repr__(self): def __repr__(self):
return f'<InspectionTemplate {self.name}>' return f'<InspectionTemplate {self.name}>'
class ChecklistItem(db.Model): class ChecklistItem(db.Model):
__tablename__ = 'checklist_items' __tablename__ = 'checklist_items'
@@ -36,6 +53,7 @@ class ChecklistItem(db.Model):
def __repr__(self): def __repr__(self):
return f'<ChecklistItem {self.item_description[:30]}>' return f'<ChecklistItem {self.item_description[:30]}>'
class Inspection(db.Model): class Inspection(db.Model):
__tablename__ = 'inspections' __tablename__ = 'inspections'
@@ -57,6 +75,7 @@ class Inspection(db.Model):
def __repr__(self): def __repr__(self):
return f'<Inspection {self.id} - {self.inspection_date}>' return f'<Inspection {self.id} - {self.inspection_date}>'
class InspectionResult(db.Model): class InspectionResult(db.Model):
__tablename__ = 'inspection_results' __tablename__ = 'inspection_results'
@@ -69,4 +88,4 @@ class InspectionResult(db.Model):
photo_path = db.Column(db.String(255)) photo_path = db.Column(db.String(255))
def __repr__(self): def __repr__(self):
return f'<InspectionResult {self.id}>' return f'<InspectionResult {self.id}>'
+221 -43
View File
@@ -1,24 +1,32 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf.csrf import generate_csrf
from app import db from app import db
from app.models.inspection import InspectionTemplate, ChecklistItem from app.models.inspection import InspectionTemplate, ChecklistItem
from app.utils.forms import InspectionTemplateForm, ChecklistItemForm from app.utils.forms import InspectionTemplateForm, ChecklistItemForm
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
import json
bp = Blueprint('templates', __name__, url_prefix='/templates') bp = Blueprint('templates', __name__, url_prefix='/templates')
# ---------------------------------------------------------------------------
# Template CRUD
# ---------------------------------------------------------------------------
@bp.route('/') @bp.route('/')
@login_required @login_required
def index(): def index():
templates = InspectionTemplate.query.order_by(InspectionTemplate.name).all() templates = InspectionTemplate.query.order_by(InspectionTemplate.name).all()
return render_template('templates/list.html', templates=templates) return render_template('templates/list.html', templates=templates)
@bp.route('/new', methods=['GET', 'POST']) @bp.route('/new', methods=['GET', 'POST'])
@login_required @login_required
@supervisor_required @supervisor_required
def create_template(): def create_template():
form = InspectionTemplateForm() form = InspectionTemplateForm()
if form.validate_on_submit(): if form.validate_on_submit():
template = InspectionTemplate( template = InspectionTemplate(
name=form.name.data, name=form.name.data,
@@ -26,30 +34,26 @@ def create_template():
frequency=form.frequency.data, frequency=form.frequency.data,
created_by=current_user.id created_by=current_user.id
) )
db.session.add(template) db.session.add(template)
db.session.commit() db.session.commit()
flash(f'Template "{template.name}" created successfully.', 'success') flash(f'Template "{template.name}" created successfully.', 'success')
return redirect(url_for('templates.edit_template', template_id=template.id)) return redirect(url_for('templates.form_editor', template_id=template.id))
return render_template('templates/form.html', form=form, title='Create Inspection Template') return render_template('templates/form.html', form=form, title='Create Inspection Template')
@bp.route('/<int:template_id>') @bp.route('/<int:template_id>')
@login_required @login_required
def view_template(template_id): def view_template(template_id):
template = InspectionTemplate.query.get_or_404(template_id) template = InspectionTemplate.query.get_or_404(template_id)
checklist_items = template.checklist_items.order_by(ChecklistItem.display_order, ChecklistItem.category).all() form_fields = template.get_form_schema()
return render_template(
# Group items by category 'templates/view.html',
items_by_category = {} template=template,
for item in checklist_items: form_fields=form_fields
category = item.category or 'General' )
if category not in items_by_category:
items_by_category[category] = []
items_by_category[category].append(item)
return render_template('templates/view.html', template=template, items_by_category=items_by_category)
@bp.route('/<int:template_id>/edit', methods=['GET', 'POST']) @bp.route('/<int:template_id>/edit', methods=['GET', 'POST'])
@login_required @login_required
@@ -57,39 +61,204 @@ def view_template(template_id):
def edit_template(template_id): def edit_template(template_id):
template = InspectionTemplate.query.get_or_404(template_id) template = InspectionTemplate.query.get_or_404(template_id)
form = InspectionTemplateForm(obj=template) form = InspectionTemplateForm(obj=template)
if form.validate_on_submit(): if form.validate_on_submit():
template.name = form.name.data template.name = form.name.data
template.description = form.description.data template.description = form.description.data
template.frequency = form.frequency.data template.frequency = form.frequency.data
db.session.commit() db.session.commit()
flash(f'Template "{template.name}" updated successfully.', 'success') flash(f'Template "{template.name}" updated successfully.', 'success')
return redirect(url_for('templates.view_template', template_id=template.id)) return redirect(url_for('templates.view_template', template_id=template.id))
checklist_items = template.checklist_items.order_by(ChecklistItem.display_order, ChecklistItem.category).all() form_fields = template.get_form_schema()
return render_template(
return render_template('templates/edit.html', form=form, template=template, checklist_items=checklist_items) 'templates/edit.html',
form=form,
template=template,
form_fields=form_fields
)
@bp.route('/<int:template_id>/rename', methods=['POST'])
@login_required
@supervisor_required
def rename_template(template_id):
template = InspectionTemplate.query.get_or_404(template_id)
new_name = request.form.get('name', '').strip()
if not new_name:
flash('Template name cannot be empty.', 'danger')
return redirect(url_for('templates.index'))
if len(new_name) > 255:
flash('Template name is too long (max 255 characters).', 'danger')
return redirect(url_for('templates.index'))
valid_frequencies = {'daily', 'weekly', 'monthly', 'quarterly'}
new_frequency = request.form.get('frequency', '').strip()
if new_frequency not in valid_frequencies:
flash('Invalid frequency value.', 'danger')
return redirect(url_for('templates.index'))
template.name = new_name
template.description = request.form.get('description', '').strip() or None
template.frequency = new_frequency
db.session.commit()
flash(f'Template "{template.name}" updated successfully.', 'success')
return redirect(url_for('templates.index'))
@bp.route('/<int:template_id>/delete', methods=['POST']) @bp.route('/<int:template_id>/delete', methods=['POST'])
@login_required @login_required
@supervisor_required @supervisor_required
def delete_template(template_id): def delete_template(template_id):
template = InspectionTemplate.query.get_or_404(template_id) template = InspectionTemplate.query.get_or_404(template_id)
# Check if template has inspections
if template.inspections.count() > 0: if template.inspections.count() > 0:
flash('Cannot delete template with existing inspections.', 'danger') flash('Cannot delete template with existing inspections.', 'danger')
return redirect(url_for('templates.view_template', template_id=template.id)) return redirect(url_for('templates.index'))
template_name = template.name template_name = template.name
db.session.delete(template) db.session.delete(template)
db.session.commit() db.session.commit()
flash(f'Template "{template_name}" deleted successfully.', 'success') flash(f'Template "{template_name}" deleted successfully.', 'success')
return redirect(url_for('templates.index')) return redirect(url_for('templates.index'))
# Checklist Item Management
@bp.route('/<int:template_id>/duplicate', methods=['POST'])
@login_required
@supervisor_required
def duplicate_template(template_id):
src = InspectionTemplate.query.get_or_404(template_id)
# Duplicate the template header
new_tpl = InspectionTemplate(
name=f'{src.name} (Copy)',
description=src.description,
frequency=src.frequency,
created_by=current_user.id
)
db.session.add(new_tpl)
db.session.flush() # get new_tpl.id before committing
# Duplicate all checklist items
for item in src.checklist_items.order_by(ChecklistItem.display_order).all():
new_item = ChecklistItem(
template_id=new_tpl.id,
category=item.category,
item_description=item.item_description,
scoring_type=item.scoring_type,
weight=item.weight,
requires_photo=item.requires_photo,
display_order=item.display_order
)
db.session.add(new_item)
# Duplicate form schema if present
if src.form_schema:
new_tpl.form_schema = src.form_schema
db.session.commit()
flash(f'Template "{src.name}" duplicated successfully.', 'success')
return redirect(url_for('templates.index'))
# ---------------------------------------------------------------------------
# Form Editor
# ---------------------------------------------------------------------------
@bp.route('/<int:template_id>/form-editor')
@login_required
@supervisor_required
def form_editor(template_id):
template = InspectionTemplate.query.get_or_404(template_id)
form_schema = template.get_form_schema()
return render_template(
'templates/form_editor.html',
template=template,
form_schema_json=json.dumps(form_schema),
csrf_token=generate_csrf()
)
@bp.route('/<int:template_id>/form-editor/save', methods=['POST'])
@login_required
@supervisor_required
def save_form_schema(template_id):
"""AJAX endpoint — receives the full form schema as JSON and persists it."""
template = InspectionTemplate.query.get_or_404(template_id)
data = request.get_json(silent=True)
if data is None:
return jsonify({'success': False, 'error': 'Invalid JSON payload'}), 400
fields = data.get('fields', [])
# Basic sanitisation — ensure each field has the minimum required keys
sanitised = []
for field in fields:
if not isinstance(field, dict):
continue
if not field.get('id') or not field.get('type'):
continue
ftype = str(field.get('type', 'text'))
entry = {
'id': str(field.get('id', '')),
'type': ftype,
'label': str(field.get('label', 'Untitled'))[:255],
'placeholder': str(field.get('placeholder', ''))[:255],
'required': bool(field.get('required', False)),
'options': field.get('options', []) if ftype in ('radio', 'checkbox_group', 'select') else [],
'help_text': str(field.get('help_text', ''))[:500],
'order': int(field.get('order', 0)),
# Grid position & size
'col': max(1, min(12, int(field.get('col', 1)))),
'row': max(1, min(9999, int(field.get('row', 1)))),
'colSpan': max(1, min(12, int(field.get('colSpan', 6)))),
'rowSpan': max(1, min(20, int(field.get('rowSpan', 2)))),
}
# Table-specific fields
if ftype == 'table':
raw_hdrs = field.get('col_headers', ['Column 1', 'Column 2', 'Column 3'])
col_headers = [str(h)[:100] for h in raw_hdrs if isinstance(h, str)][:20] or ['Column 1']
entry['col_headers'] = col_headers
entry['table_cols'] = len(col_headers)
entry['table_rows'] = max(1, min(30, int(field.get('table_rows', 3))))
# Label-specific fields
if ftype == 'label':
entry['text_content'] = str(field.get('text_content', 'Label text'))[:2000]
entry['font_size'] = field.get('font_size', 'normal') if field.get('font_size') in ('small','normal','large','x-large') else 'normal'
entry['font_weight'] = 'bold' if field.get('font_weight') == 'bold' else 'normal'
# Button-specific fields
if ftype in ('button_submit', 'button_print', 'button_email'):
defaults = {'button_submit':'Submit Form','button_print':'Print Form','button_email':'Email Form'}
entry['btn_label'] = str(field.get('btn_label', defaults[ftype]))[:100]
sanitised.append(entry)
template.form_schema = sanitised
db.session.commit()
return jsonify({'success': True, 'field_count': len(sanitised)})
@bp.route('/<int:template_id>/form-editor/preview')
@login_required
def form_preview(template_id):
"""Renders a read-only preview of the dynamic form."""
template = InspectionTemplate.query.get_or_404(template_id)
form_fields = template.get_form_schema()
return render_template(
'templates/form_preview.html',
template=template,
form_fields=form_fields
)
# ---------------------------------------------------------------------------
# Checklist Item Management (legacy, kept for backwards compatibility)
# ---------------------------------------------------------------------------
@bp.route('/<int:template_id>/items/new', methods=['GET', 'POST']) @bp.route('/<int:template_id>/items/new', methods=['GET', 'POST'])
@login_required @login_required
@@ -97,12 +266,11 @@ def delete_template(template_id):
def create_checklist_item(template_id): def create_checklist_item(template_id):
template = InspectionTemplate.query.get_or_404(template_id) template = InspectionTemplate.query.get_or_404(template_id)
form = ChecklistItemForm() form = ChecklistItemForm()
if form.validate_on_submit(): if form.validate_on_submit():
# Get the highest display order
max_order = db.session.query(db.func.max(ChecklistItem.display_order))\ max_order = db.session.query(db.func.max(ChecklistItem.display_order))\
.filter_by(template_id=template.id).scalar() or 0 .filter_by(template_id=template.id).scalar() or 0
item = ChecklistItem( item = ChecklistItem(
template_id=template.id, template_id=template.id,
category=form.category.data, category=form.category.data,
@@ -112,14 +280,19 @@ def create_checklist_item(template_id):
requires_photo=form.requires_photo.data, requires_photo=form.requires_photo.data,
display_order=max_order + 1 display_order=max_order + 1
) )
db.session.add(item) db.session.add(item)
db.session.commit() db.session.commit()
flash('Checklist item added successfully.', 'success') flash('Checklist item added successfully.', 'success')
return redirect(url_for('templates.edit_template', template_id=template.id)) return redirect(url_for('templates.edit_template', template_id=template.id))
return render_template('templates/item_form.html', form=form, template=template, title='Add Checklist Item') return render_template(
'templates/item_form.html',
form=form,
template=template,
title='Add Checklist Item'
)
@bp.route('/items/<int:item_id>/edit', methods=['GET', 'POST']) @bp.route('/items/<int:item_id>/edit', methods=['GET', 'POST'])
@login_required @login_required
@@ -127,19 +300,25 @@ def create_checklist_item(template_id):
def edit_checklist_item(item_id): def edit_checklist_item(item_id):
item = ChecklistItem.query.get_or_404(item_id) item = ChecklistItem.query.get_or_404(item_id)
form = ChecklistItemForm(obj=item) form = ChecklistItemForm(obj=item)
if form.validate_on_submit(): if form.validate_on_submit():
item.category = form.category.data item.category = form.category.data
item.item_description = form.item_description.data item.item_description = form.item_description.data
item.scoring_type = form.scoring_type.data item.scoring_type = form.scoring_type.data
item.weight = form.weight.data item.weight = form.weight.data
item.requires_photo = form.requires_photo.data item.requires_photo = form.requires_photo.data
db.session.commit() db.session.commit()
flash('Checklist item updated successfully.', 'success') flash('Checklist item updated successfully.', 'success')
return redirect(url_for('templates.edit_template', template_id=item.template_id)) return redirect(url_for('templates.edit_template', template_id=item.template_id))
return render_template('templates/item_form.html', form=form, item=item, template=item.template, title='Edit Checklist Item') return render_template(
'templates/item_form.html',
form=form,
item=item,
template=item.template,
title='Edit Checklist Item'
)
@bp.route('/items/<int:item_id>/delete', methods=['POST']) @bp.route('/items/<int:item_id>/delete', methods=['POST'])
@login_required @login_required
@@ -147,24 +326,23 @@ def edit_checklist_item(item_id):
def delete_checklist_item(item_id): def delete_checklist_item(item_id):
item = ChecklistItem.query.get_or_404(item_id) item = ChecklistItem.query.get_or_404(item_id)
template_id = item.template_id template_id = item.template_id
db.session.delete(item) db.session.delete(item)
db.session.commit() db.session.commit()
flash('Checklist item deleted successfully.', 'success') flash('Checklist item deleted successfully.', 'success')
return redirect(url_for('templates.edit_template', template_id=template_id)) return redirect(url_for('templates.edit_template', template_id=template_id))
@bp.route('/<int:template_id>/items/reorder', methods=['POST']) @bp.route('/<int:template_id>/items/reorder', methods=['POST'])
@login_required @login_required
@supervisor_required @supervisor_required
def reorder_items(template_id): def reorder_items(template_id):
template = InspectionTemplate.query.get_or_404(template_id) template = InspectionTemplate.query.get_or_404(template_id)
item_order = request.json.get('item_order', []) item_order = request.json.get('item_order', [])
for index, item_id in enumerate(item_order): for index, item_id in enumerate(item_order):
item = ChecklistItem.query.get(item_id) item = ChecklistItem.query.get(item_id)
if item and item.template_id == template.id: if item and item.template_id == template.id:
item.display_order = index item.display_order = index
db.session.commit() db.session.commit()
return jsonify({'success': True}) return jsonify({'success': True})
+229 -115
View File
@@ -1,132 +1,246 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Edit Template - {{ template.name }}{% endblock %} {% block title %}Edit Template {{ template.name }}{% endblock %}
{% block extra_css %} {% block extra_css %}
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style> <style>
.checklist-item { cursor: move; } body { font-family: 'DM Sans', sans-serif; }
.checklist-item:hover { background-color: #f8f9fa; }
.page-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1.5rem; gap: 1rem;
}
.page-title { font-size: 1.3rem; font-weight: 700; color: #0f172a; margin: 0; }
/* two-column layout */
.edit-layout {
display: grid;
grid-template-columns: 340px 1fr;
gap: 1.5rem;
align-items: start;
}
/* left: settings card */
.settings-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
position: sticky;
top: 1rem;
}
.settings-card-header {
background: #1a1d23;
color: #fff;
padding: .85rem 1.25rem;
font-weight: 600;
font-size: .9rem;
display: flex;
align-items: center;
gap: .5rem;
}
.settings-card-body { padding: 1.25rem; }
/* right: form canvas preview */
.canvas-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
.canvas-card-header {
display: flex; align-items: center; justify-content: space-between;
padding: .85rem 1.25rem;
border-bottom: 1px solid #e2e8f0;
background: #fafbfc;
}
.canvas-card-header h6 { margin: 0; font-weight: 700; font-size: .88rem; color: #0f172a; }
.canvas-card-body { padding: 1.25rem; overflow-x: auto; }
/* grid minimap — same constants as editor */
.form-grid {
display: grid;
grid-template-columns: repeat(12, 72px);
grid-auto-rows: 52px;
gap: 8px;
width: calc(12 * 72px + 11 * 8px);
}
.fg-cell {
overflow: hidden; display: flex; flex-direction: column;
background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 8px; padding: .4rem .6rem;
cursor: default;
transition: border-color .15s, box-shadow .15s;
}
.fg-cell:hover { border-color: #93c5fd; box-shadow: 0 2px 8px rgba(37,99,235,.1); }
.fg-cell .fl {
font-size: .72rem; font-weight: 600; color: #374151;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: .15rem;
}
.fg-cell .ft {
font-size: .6rem; font-family: monospace;
color: #2563eb; background: #eff6ff;
padding: .05rem .28rem; border-radius: 10px;
display: inline-block; width: fit-content;
text-transform: uppercase; letter-spacing: .04em;
}
.req-dot { color: #dc2626; }
.fg-section {
border-top: 2px solid #e2e8f0; padding-top: .35rem;
display: flex; align-items: center; height: 100%;
}
.fg-section span { font-weight: 700; font-size: .82rem; color: #374151; }
/* empty / open-editor CTA */
.editor-cta {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 3rem 2rem; text-align: center; color: #94a3b8;
gap: .75rem;
}
.editor-cta i { font-size: 2.2rem; opacity: .3; }
.editor-cta p { margin: 0; font-size: .85rem; }
/* field count badge */
.field-count {
font-size: .72rem; font-weight: 600;
background: #eff6ff; color: #2563eb;
padding: .2rem .6rem; border-radius: 20px;
}
/* open editor banner */
.editor-banner {
margin: 1rem 1.25rem 0;
padding: .75rem 1rem;
background: #eff6ff; border: 1px solid #bfdbfe;
border-radius: 8px;
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
font-size: .83rem; color: #1e40af;
}
.editor-banner .left { display: flex; align-items: center; gap: .5rem; }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="row mb-4"> <div class="page-header">
<div class="col-md-8"> <h2 class="page-title"><i class="bi bi-file-earmark-text text-primary"></i> Edit Template</h2>
<h2><i class="bi bi-file-earmark-text"></i> {{ template.name }}</h2> <a href="{{ url_for('templates.view_template', template_id=template.id) }}"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back
</a>
</div>
<div class="edit-layout">
<!-- LEFT: template metadata settings -->
<div class="settings-card">
<div class="settings-card-header">
<i class="bi bi-sliders"></i> Template Settings
</div> </div>
<div class="col-md-4 text-end"> <div class="settings-card-body">
<a href="{{ url_for('templates.create_checklist_item', template_id=template.id) }}" class="btn btn-primary"> <form method="POST">
<i class="bi bi-plus-circle"></i> Add Item {{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class="form-label fw-semibold small") }}
{{ form.name(class="form-control form-control-sm") }}
{% if form.name.errors %}
<div class="text-danger small mt-1">{{ form.name.errors[0] }}</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.description.label(class="form-label fw-semibold small") }}
{{ form.description(class="form-control form-control-sm", rows=3) }}
</div>
<div class="mb-4">
{{ form.frequency.label(class="form-label fw-semibold small") }}
{{ form.frequency(class="form-select form-select-sm") }}
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-save"></i> Save Settings
</button>
</div>
</form>
</div>
<!-- open form editor banner -->
<div class="editor-banner">
<div class="left">
<i class="bi bi-grid-3x3-gap"></i>
<span>Edit fields &amp; layout in the Form Editor</span>
</div>
<a href="{{ url_for('templates.form_editor', template_id=template.id) }}"
class="btn btn-primary btn-sm flex-shrink-0">
<i class="bi bi-pencil-square"></i> Open Editor
</a>
</div>
<div style="height:.85rem;"></div>
</div>
<!-- RIGHT: form canvas preview -->
<div class="canvas-card">
<div class="canvas-card-header">
<h6><i class="bi bi-grid-3x3-gap me-1 text-primary"></i> Form Layout</h6>
<div class="d-flex align-items: center; gap: .75rem;">
<span class="field-count">{{ form_fields|length }} field{{ 's' if form_fields|length != 1 else '' }}</span>
<a href="{{ url_for('templates.form_preview', template_id=template.id) }}"
target="_blank" class="btn btn-outline-secondary btn-sm ms-2">
<i class="bi bi-eye"></i> Preview
</a> </a>
<a href="{{ url_for('templates.form_editor', template_id=template.id) }}"
class="btn btn-primary btn-sm">
<i class="bi bi-pencil-square"></i> Edit Form
</a>
</div>
</div> </div>
</div>
<div class="row"> <div class="canvas-card-body">
<div class="col-md-4"> {% if form_fields %}
<div class="card shadow-sm mb-4"> <div class="form-grid">
<div class="card-header bg-light"> {% for f in form_fields %}
<h5 class="mb-0">Template Settings</h5> <div class="fg-cell"
</div> style="grid-column: {{ f.col }} / span {{ f.colSpan }};
<div class="card-body"> grid-row: {{ f.row }} / span {{ f.rowSpan }};"
<form method="POST"> title="{{ f.label }} ({{ f.type.replace('_', ' ') }})">
{{ form.hidden_tag() }} {% if f.type == 'section' %}
<div class="fg-section"><span>{{ f.label }}</span></div>
<div class="mb-3"> {% elif f.type == 'table' %}
{{ form.name.label(class="form-label") }} <div class="fl"><i class="bi bi-table" style="color:#2563eb;margin-right:.2rem;"></i>{{ f.label }}</div>
{{ form.name(class="form-control") }} <span class="ft">table · {{ f.col_headers|length if f.col_headers else 3 }} cols × {{ f.table_rows or 3 }} rows</span>
</div> {% elif f.type == 'label' %}
<div class="fl" style="font-size:.7rem;color:#374151;overflow:hidden;line-height:1.3;white-space:nowrap;text-overflow:ellipsis;">{{ f.text_content or 'Label text' }}</div>
<div class="mb-3"> <span class="ft">label</span>
{{ form.description.label(class="form-label") }} {% elif f.type.startswith('button_') %}
{{ form.description(class="form-control", rows=3) }} <div class="fl"><i class="bi bi-{% if f.type == 'button_submit' %}send-fill{% elif f.type == 'button_print' %}printer-fill{% else %}envelope-fill{% endif %}" style="color:#2563eb;margin-right:.2rem;"></i>{{ f.btn_label or f.type.replace('button_','') | title }}</div>
</div> <span class="ft">{{ f.type.replace('_',' ') }}</span>
{% else %}
<div class="mb-3"> <div class="fl">
{{ form.frequency.label(class="form-label") }} {{ f.label }}{% if f.required %}<span class="req-dot"> *</span>{% endif %}
{{ form.frequency(class="form-select") }}
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-save"></i> Update Template
</button>
</form>
</div> </div>
<span class="ft">{{ f.type.replace('_', ' ') }}</span>
{% endif %}
</div> </div>
</div> {% endfor %}
</div>
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="bi bi-check2-square"></i> Checklist Items ({{ checklist_items|length }})</h5>
</div>
<div class="card-body">
{% if checklist_items %}
<div id="checklist-items" class="list-group">
{% for item in checklist_items %}
<div class="list-group-item checklist-item" data-item-id="{{ item.id }}">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="d-flex align-items-center mb-1">
<i class="bi bi-grip-vertical text-muted me-2"></i>
<span class="badge bg-secondary me-2">{{ item.category }}</span>
<span class="badge bg-info">{{ item.scoring_type.replace('_', ' ')|title }}</span>
{% if item.requires_photo %}
<span class="badge bg-warning ms-2"><i class="bi bi-camera"></i></span>
{% endif %}
</div>
<p class="mb-1">{{ item.item_description }}</p>
<small class="text-muted">Weight: {{ item.weight }}</small>
</div>
<div class="ms-3">
<a href="{{ url_for('templates.edit_checklist_item', item_id=item.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
<form method="POST" action="{{ url_for('templates.delete_checklist_item', item_id=item.id) }}" class="d-inline" onsubmit="return confirm('Delete this item?');">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle"></i> No checklist items yet. Click "Add Item" to get started.
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %} {% else %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script> <div class="editor-cta">
<script> <i class="bi bi-layout-text-sidebar-reverse"></i>
document.addEventListener('DOMContentLoaded', function() { <p>No fields yet. Open the Form Editor to start building your inspection form.</p>
const el = document.getElementById('checklist-items'); <a href="{{ url_for('templates.form_editor', template_id=template.id) }}"
if (el) { class="btn btn-primary btn-sm">
new Sortable(el, { <i class="bi bi-plus-circle"></i> Open Form Editor
animation: 150, </a>
handle: '.bi-grip-vertical', </div>
onEnd: function(evt) { {% endif %}
const itemOrder = []; </div>
el.querySelectorAll('.checklist-item').forEach(item => { </div>
itemOrder.push(item.getAttribute('data-item-id'));
}); </div>
fetch('{{ url_for("templates.reorder_items", template_id=template.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ item_order: itemOrder })
});
}
});
}
});
</script>
{% endblock %} {% endblock %}
File diff suppressed because it is too large Load Diff
+382
View File
@@ -0,0 +1,382 @@
{% extends "base.html" %}
{% block title %}Preview — {{ template.name }}{% endblock %}
{% block extra_css %}
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
body { font-family: 'DM Sans', sans-serif; background: #eef0f4; }
.preview-wrap { max-width: 960px; margin: 2rem auto; padding: 0 1rem 3rem; }
.preview-header {
background: #1a1d23; color: #fff;
padding: 1.25rem 1.75rem;
border-radius: 12px 12px 0 0;
display: flex; align-items: center; justify-content: space-between;
}
.preview-header h4 { margin: 0; font-weight: 600; font-size: 1.05rem; }
.preview-header .sub { font-size: .78rem; color: #94a3b8; margin-top: .2rem; }
.freq-badge {
font-size: .72rem;
background: rgba(255,255,255,.15);
padding: .28rem .65rem; border-radius: 20px; font-weight: 500;
}
.preview-notice {
background: #eff6ff; border: 1px solid #bfdbfe;
border-radius: 8px; padding: .65rem 1rem;
font-size: .82rem; color: #1d4ed8;
margin-bottom: 1.5rem;
display: flex; align-items: center; gap: .45rem;
}
.preview-body {
background: #fff;
border: 1px solid #e2e8f0; border-top: none;
border-radius: 0 0 12px 12px;
padding: 1.75rem;
overflow-x: auto;
}
/* 12-col grid — same columns as editor, rows shrink to content in preview */
.form-grid {
display: grid;
grid-template-columns: repeat(12, 72px);
grid-auto-rows: auto; /* no fixed row height — rows are only as tall as their content */
gap: 4px 8px; /* 4px row-gap (tight), 8px col-gap (matches editor) */
width: max-content;
}
/* ── Cell ── */
.fg-cell {
overflow: hidden;
display: flex; flex-direction: column;
padding: .22rem .55rem; /* top/bottom halved from .45rem */
}
/* Label shown above the input — same style as editor card header label */
.fg-cell .field-lbl {
font-size: .74rem; font-weight: 500;
color: #64748b; margin-bottom: .2rem; display: block;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
flex-shrink: 0;
}
.required-mark { color: #dc2626; }
.help-text { font-size: .72rem; color: #64748b; margin-top: .18rem; flex-shrink: 0; }
/* Inputs/selects: compact to match editor body sizing */
.fg-cell .form-control,
.fg-cell .form-select {
font-size: .76rem;
padding: .2rem .4rem;
border-color: #e2e8f0;
background: #f8fafc;
/* NO flex/height stretch — inputs must be their natural intrinsic height,
exactly as in the editor where no flex is applied to .fcard-body inputs */
}
.fg-cell textarea.form-control { resize: vertical; flex: 1; min-height: 4; }
.fg-cell .upload-zone,
.fg-cell .signature-box { flex: 1; min-height: 0; }
.fg-cell .form-check-label { font-size: .76rem; color: #64748b; }
.fg-cell .form-check-input { margin-top: .18rem; }
.fg-cell .form-check { margin-bottom: .1rem; }
.upload-zone {
border: 2px dashed #cbd5e1; border-radius: 6px;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: #64748b; background: #f8fafc;
cursor: pointer; font-size: .74rem; gap: .2rem;
transition: border-color .18s, background .18s;
}
.upload-zone:hover { border-color: #2563eb; background: #eff6ff; color: #2563eb; }
.upload-zone i { font-size: 1.1rem; }
.signature-box {
border-bottom: 2px solid #e2e8f0;
flex: 1; min-height: 0;
display: flex; align-items: flex-end;
padding: .75rem .4rem .15rem; /* same as editor .mock-sig */
color: #94a3b8; font-size: .72rem; font-style: italic;
}
.rating-stars { display: flex; gap: .25rem; align-items: center; padding-top: .08rem; }
.rating-stars button {
border: none; background: none;
font-size: 1.45rem; color: #cbd5e1;
cursor: pointer; padding: 0; line-height: 1;
transition: color .12s;
}
.rating-stars button:hover,
.rating-stars button.on { color: #f59e0b; }
.section-divider {
border-top: 2px solid #e2e8f0; padding-top: .4rem;
height: 100%; display: flex; align-items: center;
padding: 0; /* cell already has .45rem .55rem padding */
}
.section-divider h5 { font-weight: 700; color: #374151; margin: 0; font-size: .95rem; }
.preview-footer {
display: flex; align-items: center; justify-content: space-between;
margin-top: 1.5rem; padding-top: 1.25rem;
border-top: 1px solid #e2e8f0;
}
.btn-back {
display: inline-flex; align-items: center; gap: .4rem;
padding: .42rem .95rem; border-radius: 7px;
background: #1a1d23; color: #fff;
font-size: .83rem; font-weight: 600;
text-decoration: none; transition: background .18s;
}
.btn-back:hover { background: #374151; color: #fff; }
.empty-form {
text-align: center; padding: 3rem 1rem; color: #94a3b8;
}
.empty-form i { font-size: 2.25rem; display: block; margin-bottom: .65rem; opacity: .38; }
/* table field */
.tbl-field {
width: 100%; border-collapse: collapse; font-size: .8rem;
}
.tbl-field th {
background: #f1f5f9; font-weight: 600; color: #374151;
padding: .3rem .5rem; border: 1px solid #e2e8f0;
white-space: nowrap; font-size: .78rem;
}
.tbl-field td {
border: 1px solid #e2e8f0; padding: .15rem .25rem;
}
.tbl-input {
width: 100%; border: none; outline: none;
font-size: .8rem; padding: .1rem .2rem;
background: transparent; color: #0f172a;
}
.tbl-input:focus { background: #eff6ff; }
</style>
{% endblock %}
{% block content %}
<div class="preview-wrap">
<div class="preview-header">
<div>
<h4><i class="bi bi-file-earmark-check"></i> {{ template.name }}</h4>
<div class="sub">{{ template.description or 'Inspection form preview' }}</div>
</div>
<span class="freq-badge">{{ template.frequency|title }}</span>
</div>
<div class="preview-body">
<div class="preview-notice">
<i class="bi bi-eye"></i>
<span>This is a <strong>read-only preview</strong>. Layout matches the grid editor exactly.</span>
</div>
{% if form_fields %}
<div class="form-grid">
{% for field in form_fields %}
<div class="fg-cell"
style="grid-column: {{ field.col }} / span {{ field.colSpan }};
grid-row: {{ field.row }} / span {{ field.rowSpan }};">
{% if field.type == 'section' %}
<div class="section-divider"><strong>{{ field.label }}</strong></div>
{% elif field.type == 'text' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<input type="text" class="form-control" placeholder="{{ field.placeholder or '' }}">
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'textarea' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<textarea class="form-control" style="flex:1;resize:vertical;" placeholder="{{ field.placeholder or '' }}"></textarea>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'number' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<input type="number" class="form-control" placeholder="{{ field.placeholder or '' }}">
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'date' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<input type="date" class="form-control">
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'email' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<input type="email" class="form-control" placeholder="{{ field.placeholder or 'name@example.com' }}">
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'checkbox' %}
<div class="form-check">
<input class="form-check-input" type="checkbox" id="f_{{ loop.index }}">
<label class="form-check-label" for="f_{{ loop.index }}">
{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}
</label>
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'checkbox_group' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
{% for opt in field.options %}
<div class="form-check">
<input class="form-check-input" type="checkbox" id="cg_{{ loop.index }}_{{ loop.index0 }}">
<label class="form-check-label" for="cg_{{ loop.index }}_{{ loop.index0 }}">{{ opt }}</label>
</div>
{% endfor %}
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'radio' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
{% for opt in field.options %}
<div class="form-check">
<input class="form-check-input" type="radio" name="rg_{{ loop.index }}" id="rg_{{ loop.index }}_{{ loop.index0 }}">
<label class="form-check-label" for="rg_{{ loop.index }}_{{ loop.index0 }}">{{ opt }}</label>
</div>
{% endfor %}
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'select' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<select class="form-select">
<option value="">-- Select --</option>
{% for opt in field.options %}<option>{{ opt }}</option>{% endfor %}
</select>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'image' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<div class="upload-zone">
<i class="bi bi-cloud-upload"></i>
<span>Upload photo</span>
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'signature' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<div class="signature-box">Sign here…</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'rating' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<div class="rating-stars" data-rating="0">
{% for i in range(1, 6) %}
<button type="button" data-val="{{ i }}" onclick="setRating(this)"></button>
{% endfor %}
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'label' %}
{% set fs_map = {'small':'0.78rem','normal':'0.9rem','large':'1.05rem','x-large':'1.25rem'} %}
<div style="font-size:{{ fs_map.get(field.font_size or 'normal', '0.9rem') }};
font-weight:{{ field.font_weight or 'normal' }};
color:#374151;line-height:1.5;white-space:pre-wrap;overflow:hidden;height:100%;">{{ field.text_content or 'Label text' }}</div>
{% elif field.type == 'button_submit' %}
<button type="button" class="btn btn-primary w-100" onclick="handleSubmit(this)">
<i class="bi bi-send-fill"></i> {{ field.btn_label or 'Submit Form' }}
</button>
{% elif field.type == 'button_print' %}
<button type="button" class="btn btn-outline-secondary w-100" onclick="window.print()">
<i class="bi bi-printer-fill"></i> {{ field.btn_label or 'Print Form' }}
</button>
{% elif field.type == 'button_email' %}
<button type="button" class="btn btn-outline-primary w-100" onclick="handleEmail(this)">
<i class="bi bi-envelope-fill"></i> {{ field.btn_label or 'Email Form' }}
</button>
{% elif field.type == 'table' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<div style="overflow:auto;flex:1;min-height:0;">
<table class="tbl-field">
<thead>
<tr>
{% for hdr in (field.col_headers or ['Column 1','Column 2','Column 3']) %}
<th>{{ hdr }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for r in range(field.table_rows or 3) %}
<tr>
{% for hdr in (field.col_headers or ['Column 1','Column 2','Column 3']) %}
<td><input type="text" class="tbl-input"></td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
<div class="preview-footer">
<button onclick="window.close()" class="btn-back">
<i class="bi bi-x-lg"></i> Close Preview
</button>
<button class="btn btn-primary" disabled>
<i class="bi bi-send"></i> Submit Inspection
</button>
</div>
{% else %}
<div class="empty-form">
<i class="bi bi-layout-text-sidebar-reverse"></i>
<strong>No fields yet</strong>
<p class="mb-3" style="font-size:.84rem;">
This template has no form fields. Open the editor to add some.
</p>
<button onclick="window.close()" class="btn-back">
<i class="bi bi-x-lg"></i> Close Preview
</button>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function setRating(btn) {
const group = btn.closest('.rating-stars');
const val = parseInt(btn.dataset.val);
group.dataset.rating = val;
group.querySelectorAll('button').forEach(b => {
b.classList.toggle('on', parseInt(b.dataset.val) <= val);
});
}
function handleSubmit(btn) {
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-check-circle-fill"></i> Submitted!';
btn.classList.replace('btn-primary', 'btn-success');
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = btn.dataset.orig || btn.innerHTML;
btn.classList.replace('btn-success', 'btn-primary');
}, 2500);
}
function handleEmail(btn) {
// In a real inspection this would POST and email; here it shows a toast.
const toast = document.getElementById('emailToast');
toast.style.display = 'flex';
setTimeout(() => { toast.style.display = 'none'; }, 3000);
}
</script>
<!-- email toast -->
<div id="emailToast" style="display:none;position:fixed;bottom:1.5rem;right:1.5rem;
background:#16a34a;color:#fff;padding:.65rem 1.1rem;border-radius:8px;
align-items:center;gap:.5rem;font-size:.85rem;font-weight:600;
box-shadow:0 4px 16px rgba(0,0,0,.15);z-index:9999;">
<i class="bi bi-check-circle-fill"></i> Form emailed successfully
</div>
{% endblock %}
+197 -5
View File
@@ -26,9 +26,7 @@
{{ template.name }} {{ template.name }}
</a> </a>
</h5> </h5>
<p class="card-text text-muted small">{{ template.description or 'No description' }}</p> <p class="card-text text-muted small">{{ template.description or 'No description' }}</p>
<div class="mt-3"> <div class="mt-3">
<span class="badge bg-info">{{ template.frequency|title }}</span> <span class="badge bg-info">{{ template.frequency|title }}</span>
<small class="text-muted ms-2"> <small class="text-muted ms-2">
@@ -36,10 +34,50 @@
</small> </small>
</div> </div>
</div> </div>
<div class="card-footer bg-transparent">
<a href="{{ url_for('templates.view_template', template_id=template.id) }}" class="btn btn-sm btn-outline-primary"> <div class="card-footer bg-transparent d-flex gap-2 align-items-center flex-wrap">
<i class="bi bi-eye"></i> View Template <a href="{{ url_for('templates.view_template', template_id=template.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View
</a> </a>
{% if current_user.role in ['admin', 'supervisor'] %}
<!-- Rename -->
<button type="button"
class="btn btn-sm btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#renameModal"
data-template-id="{{ template.id }}"
data-template-name="{{ template.name }}"
data-template-description="{{ template.description or '' }}"
data-template-frequency="{{ template.frequency or 'daily' }}"
title="Edit template details">
<i class="bi bi-pencil"></i> Edit
</button>
<!-- Duplicate -->
<form method="POST"
action="{{ url_for('templates.duplicate_template', template_id=template.id) }}"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-sm btn-outline-secondary"
title="Duplicate template">
<i class="bi bi-copy"></i> Duplicate
</button>
</form>
<!-- Delete -->
<button type="button"
class="btn btn-sm btn-outline-danger ms-auto"
data-bs-toggle="modal"
data-bs-target="#deleteModal"
data-template-id="{{ template.id }}"
data-template-name="{{ template.name }}"
data-inspection-count="{{ template.inspections.count() }}"
title="Delete template">
<i class="bi bi-trash"></i> Delete
</button>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -51,4 +89,158 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% if current_user.role in ['admin', 'supervisor'] %}
<!-- Edit Template Modal -->
<div class="modal fade" id="renameModal" tabindex="-1" aria-labelledby="renameModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="renameModalLabel">
<i class="bi bi-pencil"></i> Edit Template
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form id="renameTemplateForm" method="POST" action="">
<div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3">
<label for="renameInput" class="form-label fw-semibold">Name <span class="text-danger">*</span></label>
<input type="text" id="renameInput" name="name"
class="form-control" maxlength="255"
placeholder="Enter template name" required>
</div>
<div class="mb-3">
<label for="editDescription" class="form-label fw-semibold">Description</label>
<textarea id="editDescription" name="description"
class="form-control" rows="3"
placeholder="Optional description"></textarea>
</div>
<div class="mb-1">
<label for="editFrequency" class="form-label fw-semibold">Frequency</label>
<select id="editFrequency" name="frequency" class="form-select">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Cancel
</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteModalLabel">
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="mb-1">You are about to permanently delete:</p>
<p class="fw-bold fs-5 mb-3" id="modalTemplateName"></p>
<!-- Shown when template has inspections -->
<div id="modalWarningBlock" class="alert alert-danger d-none mb-0">
<i class="bi bi-x-circle-fill"></i>
<strong>Cannot delete this template.</strong>
It has existing inspection records linked to it.
Remove all associated inspections first.
</div>
<!-- Shown when safe to delete -->
<div id="modalConfirmBlock">
<p class="text-muted mb-0">
This action is <strong>irreversible</strong>.
All checklist items and form schema for this template will be permanently removed.
</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Cancel
</button>
<form id="deleteTemplateForm" method="POST" action="" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" id="confirmDeleteBtn" class="btn btn-danger">
<i class="bi bi-trash-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
{% if current_user.role in ['admin', 'supervisor'] %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// Edit template modal
const renameModal = document.getElementById('renameModal');
renameModal.addEventListener('show.bs.modal', function (event) {
const btn = event.relatedTarget;
const templateId = btn.getAttribute('data-template-id');
const templateName = btn.getAttribute('data-template-name');
const templateDesc = btn.getAttribute('data-template-description');
const templateFreq = btn.getAttribute('data-template-frequency');
document.getElementById('renameInput').value = templateName;
document.getElementById('editDescription').value = templateDesc;
document.getElementById('editFrequency').value = templateFreq;
document.getElementById('renameTemplateForm').action =
'/templates/' + templateId + '/rename';
renameModal.addEventListener('shown.bs.modal', function focusInput() {
const input = document.getElementById('renameInput');
input.select();
renameModal.removeEventListener('shown.bs.modal', focusInput);
});
});
const deleteModal = document.getElementById('deleteModal');
deleteModal.addEventListener('show.bs.modal', function (event) {
const btn = event.relatedTarget;
const templateId = btn.getAttribute('data-template-id');
const templateName = btn.getAttribute('data-template-name');
const inspectionCount = parseInt(btn.getAttribute('data-inspection-count'), 10);
document.getElementById('modalTemplateName').textContent = templateName;
document.getElementById('deleteTemplateForm').action =
'/templates/' + templateId + '/delete';
const warningBlock = document.getElementById('modalWarningBlock');
const confirmBlock = document.getElementById('modalConfirmBlock');
const confirmBtn = document.getElementById('confirmDeleteBtn');
if (inspectionCount > 0) {
warningBlock.classList.remove('d-none');
confirmBlock.classList.add('d-none');
confirmBtn.disabled = true;
} else {
warningBlock.classList.add('d-none');
confirmBlock.classList.remove('d-none');
confirmBtn.disabled = false;
}
});
});
</script>
{% endif %}
{% endblock %} {% endblock %}
+171 -120
View File
@@ -2,127 +2,178 @@
{% block title %}{{ template.name }}{% endblock %} {% block title %}{{ template.name }}{% endblock %}
{% block extra_css %}
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
body { font-family: 'DM Sans', sans-serif; }
.tpl-header {
display: flex; align-items: flex-start;
justify-content: space-between; gap: 1rem;
margin-bottom: 1.5rem;
}
.tpl-title { font-size: 1.45rem; font-weight: 700; color: #0f172a; margin: 0; }
.tpl-desc { color: #64748b; font-size: .9rem; margin-top: .25rem; }
/* meta card */
.meta-card {
background: #fff; border: 1px solid #e2e8f0;
border-radius: 10px; padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
display: flex; gap: 2rem; flex-wrap: wrap;
align-items: center;
}
.meta-item { display: flex; align-items: center; gap: .45rem; font-size: .85rem; color: #374151; }
.meta-item i { color: #2563eb; font-size: 1rem; }
.meta-item strong { color: #0f172a; }
/* form grid preview */
.grid-preview-wrap {
background: #fff; border: 1px solid #e2e8f0;
border-radius: 10px; padding: 1.5rem;
overflow-x: auto;
}
.grid-preview-title {
font-size: .75rem; font-weight: 700; letter-spacing: .08em;
text-transform: uppercase; color: #64748b;
margin-bottom: 1rem;
}
/* mirrors editor constants */
.form-grid {
display: grid;
grid-template-columns: repeat(12, 72px);
grid-auto-rows: 52px;
gap: 8px;
width: calc(12 * 72px + 11 * 8px);
}
.fg-cell {
overflow: hidden; display: flex; flex-direction: column;
background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 8px; padding: .4rem .6rem;
}
.fg-cell .fl {
font-size: .72rem; font-weight: 600; color: #374151;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: .15rem;
}
.fg-cell .ft {
font-size: .62rem; font-family: monospace;
color: #2563eb; background: #eff6ff;
padding: .05rem .3rem; border-radius: 10px;
display: inline-block; width: fit-content;
text-transform: uppercase; letter-spacing: .04em;
}
.req-dot { color: #dc2626; }
.fg-section {
border-top: 2px solid #e2e8f0; padding-top: .35rem;
display: flex; align-items: center; height: 100%;
}
.fg-section span { font-weight: 700; font-size: .82rem; color: #374151; }
/* empty state */
.empty-state {
text-align: center; padding: 2.5rem 1rem; color: #94a3b8;
}
.empty-state i { font-size: 2rem; display: block; margin-bottom: .6rem; opacity: .35; }
.empty-state p { font-size: .85rem; margin: 0; }
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="row mb-4"> <div class="tpl-header">
<div class="col-md-8"> <div>
<h2><i class="bi bi-file-earmark-text"></i> {{ template.name }}</h2> <h2 class="tpl-title"><i class="bi bi-file-earmark-text text-primary"></i> {{ template.name }}</h2>
<p class="text-muted">{{ template.description or 'No description provided' }}</p> {% if template.description %}
</div> <p class="tpl-desc">{{ template.description }}</p>
<div class="col-md-4 text-end"> {% endif %}
{% if current_user.role in ['admin', 'supervisor'] %} </div>
<a href="{{ url_for('templates.edit_template', template_id=template.id) }}" class="btn btn-primary"> <div class="d-flex gap-2 flex-shrink-0 mt-1">
<i class="bi bi-pencil"></i> Edit Template {% if current_user.role in ['admin', 'supervisor'] %}
</a> <a href="{{ url_for('templates.form_editor', template_id=template.id) }}" class="btn btn-primary btn-sm">
<form method="POST" action="{{ url_for('templates.delete_template', template_id=template.id) }}" class="d-inline" onsubmit="return confirm('Delete this template? This action cannot be undone.');"> <i class="bi bi-pencil-square"></i> Edit Form
<button type="submit" class="btn btn-outline-danger">
<i class="bi bi-trash"></i> Delete
</button>
</form>
{% endif %}
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">Template Details</h5>
</div>
<div class="card-body">
<table class="table table-sm table-borderless mb-0">
<tr>
<th width="40%">Frequency:</th>
<td>
<span class="badge bg-info">{{ template.frequency|title }}</span>
</td>
</tr>
<tr>
<th>Total Items:</th>
<td>{{ template.checklist_items.count() }}</td>
</tr>
<tr>
<th>Created:</th>
<td>{{ template.created_at.strftime('%Y-%m-%d %H:%M') if template.created_at else 'N/A' }}</td>
</tr>
<tr>
<th>Total Inspections:</th>
<td>{{ template.inspections.count() }}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">Statistics</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6">
<h3 class="text-primary">{{ items_by_category|length }}</h3>
<small class="text-muted">Categories</small>
</div>
<div class="col-6">
<h3 class="text-success">{{ template.checklist_items.count() }}</h3>
<small class="text-muted">Checklist Items</small>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="bi bi-check2-square"></i> Checklist Preview</h5>
</div>
<div class="card-body">
{% if items_by_category %}
{% for category, items in items_by_category.items() %}
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-folder"></i> {{ category }}
<span class="badge bg-secondary">{{ items|length }} items</span>
</h5>
<div class="list-group">
{% for item in items %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="mb-2">
<span class="badge bg-info me-2">{{ item.scoring_type.replace('_', ' ')|title }}</span>
{% if item.requires_photo %}
<span class="badge bg-warning text-dark">
<i class="bi bi-camera"></i> Photo Required
</span>
{% endif %}
<span class="badge bg-secondary">Weight: {{ item.weight }}</span>
</div>
<p class="mb-0">{{ item.item_description }}</p>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle"></i> No checklist items defined for this template yet.
{% if current_user.role in ['admin', 'supervisor'] %}
<a href="{{ url_for('templates.edit_template', template_id=template.id) }}" class="alert-link">Click here to add items.</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
<div class="mt-3">
<a href="{{ url_for('templates.index') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to Templates
</a> </a>
<form method="POST" action="{{ url_for('templates.delete_template', template_id=template.id) }}"
class="d-inline" onsubmit="return confirm('Delete this template? This cannot be undone.');">
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash"></i> Delete
</button>
</form>
{% endif %}
<a href="{{ url_for('templates.form_preview', template_id=template.id) }}"
target="_blank" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-eye"></i> Preview
</a>
<a href="{{ url_for('templates.index') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back
</a>
</div>
</div>
<!-- Meta strip -->
<div class="meta-card">
<div class="meta-item">
<i class="bi bi-arrow-repeat"></i>
<span>Frequency: <strong>{{ template.frequency|title }}</strong></span>
</div>
<div class="meta-item">
<i class="bi bi-layout-text-sidebar-reverse"></i>
<span>Fields: <strong>{{ form_fields|length }}</strong></span>
</div>
<div class="meta-item">
<i class="bi bi-clipboard-check"></i>
<span>Inspections: <strong>{{ template.inspections.count() }}</strong></span>
</div>
{% if template.created_at %}
<div class="meta-item">
<i class="bi bi-calendar3"></i>
<span>Created: <strong>{{ template.created_at.strftime('%Y-%m-%d') }}</strong></span>
</div>
{% endif %}
</div>
<!-- Form layout preview -->
<div class="grid-preview-wrap">
<div class="grid-preview-title"><i class="bi bi-grid-3x3-gap"></i> Form Layout</div>
{% if form_fields %}
<div class="form-grid">
{% for f in form_fields %}
<div class="fg-cell"
style="grid-column: {{ f.col }} / span {{ f.colSpan }};
grid-row: {{ f.row }} / span {{ f.rowSpan }};">
{% if f.type == 'section' %}
<div class="fg-section"><span>{{ f.label }}</span></div>
{% elif f.type == 'table' %}
<div class="fl"><i class="bi bi-table" style="color:#2563eb;margin-right:.2rem;"></i>{{ f.label }}</div>
<span class="ft">table · {{ f.col_headers|length if f.col_headers else 3 }} cols × {{ f.table_rows or 3 }} rows</span>
{% elif f.type == 'label' %}
<div class="fl" style="font-size:.7rem;color:#374151;overflow:hidden;line-height:1.3;white-space:nowrap;text-overflow:ellipsis;">{{ f.text_content or 'Label text' }}</div>
<span class="ft">label</span>
{% elif f.type.startswith('button_') %}
<div class="fl"><i class="bi bi-{% if f.type == 'button_submit' %}send-fill{% elif f.type == 'button_print' %}printer-fill{% else %}envelope-fill{% endif %}" style="color:#2563eb;margin-right:.2rem;"></i>{{ f.btn_label or f.type.replace('button_','') | title }}</div>
<span class="ft">{{ f.type.replace('_',' ') }}</span>
{% else %}
<div class="fl">
{{ f.label }}{% if f.required %}<span class="req-dot"> *</span>{% endif %}
</div>
<span class="ft">{{ f.type.replace('_', ' ') }}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-layout-text-sidebar-reverse"></i>
<p>No fields defined yet.</p>
{% if current_user.role in ['admin', 'supervisor'] %}
<a href="{{ url_for('templates.form_editor', template_id=template.id) }}"
class="btn btn-primary btn-sm mt-3">
<i class="bi bi-plus-circle"></i> Open Form Editor
</a>
{% endif %}
</div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}