Phase 2: implement template form editor
This commit is contained in:
@@ -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.")
|
||||
@@ -1,5 +1,7 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
|
||||
class InspectionTemplate(db.Model):
|
||||
__tablename__ = 'inspection_templates'
|
||||
@@ -11,13 +13,28 @@ class InspectionTemplate(db.Model):
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
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
|
||||
checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan')
|
||||
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):
|
||||
return f'<InspectionTemplate {self.name}>'
|
||||
|
||||
|
||||
class ChecklistItem(db.Model):
|
||||
__tablename__ = 'checklist_items'
|
||||
|
||||
@@ -36,6 +53,7 @@ class ChecklistItem(db.Model):
|
||||
def __repr__(self):
|
||||
return f'<ChecklistItem {self.item_description[:30]}>'
|
||||
|
||||
|
||||
class Inspection(db.Model):
|
||||
__tablename__ = 'inspections'
|
||||
|
||||
@@ -57,6 +75,7 @@ class Inspection(db.Model):
|
||||
def __repr__(self):
|
||||
return f'<Inspection {self.id} - {self.inspection_date}>'
|
||||
|
||||
|
||||
class InspectionResult(db.Model):
|
||||
__tablename__ = 'inspection_results'
|
||||
|
||||
@@ -69,4 +88,4 @@ class InspectionResult(db.Model):
|
||||
photo_path = db.Column(db.String(255))
|
||||
|
||||
def __repr__(self):
|
||||
return f'<InspectionResult {self.id}>'
|
||||
return f'<InspectionResult {self.id}>'
|
||||
+221
-43
@@ -1,24 +1,32 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf.csrf import generate_csrf
|
||||
from app import db
|
||||
from app.models.inspection import InspectionTemplate, ChecklistItem
|
||||
from app.utils.forms import InspectionTemplateForm, ChecklistItemForm
|
||||
from app.utils.decorators import supervisor_required
|
||||
import json
|
||||
|
||||
bp = Blueprint('templates', __name__, url_prefix='/templates')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
templates = InspectionTemplate.query.order_by(InspectionTemplate.name).all()
|
||||
return render_template('templates/list.html', templates=templates)
|
||||
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def create_template():
|
||||
form = InspectionTemplateForm()
|
||||
|
||||
|
||||
if form.validate_on_submit():
|
||||
template = InspectionTemplate(
|
||||
name=form.name.data,
|
||||
@@ -26,30 +34,26 @@ def create_template():
|
||||
frequency=form.frequency.data,
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.session.add(template)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
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')
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>')
|
||||
@login_required
|
||||
def view_template(template_id):
|
||||
template = InspectionTemplate.query.get_or_404(template_id)
|
||||
checklist_items = template.checklist_items.order_by(ChecklistItem.display_order, ChecklistItem.category).all()
|
||||
|
||||
# Group items by category
|
||||
items_by_category = {}
|
||||
for item in checklist_items:
|
||||
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)
|
||||
form_fields = template.get_form_schema()
|
||||
return render_template(
|
||||
'templates/view.html',
|
||||
template=template,
|
||||
form_fields=form_fields
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@@ -57,39 +61,204 @@ def view_template(template_id):
|
||||
def edit_template(template_id):
|
||||
template = InspectionTemplate.query.get_or_404(template_id)
|
||||
form = InspectionTemplateForm(obj=template)
|
||||
|
||||
|
||||
if form.validate_on_submit():
|
||||
template.name = form.name.data
|
||||
template.description = form.description.data
|
||||
template.frequency = form.frequency.data
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Template "{template.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('templates.view_template', template_id=template.id))
|
||||
|
||||
checklist_items = template.checklist_items.order_by(ChecklistItem.display_order, ChecklistItem.category).all()
|
||||
|
||||
return render_template('templates/edit.html', form=form, template=template, checklist_items=checklist_items)
|
||||
|
||||
form_fields = template.get_form_schema()
|
||||
return render_template(
|
||||
'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'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def delete_template(template_id):
|
||||
template = InspectionTemplate.query.get_or_404(template_id)
|
||||
|
||||
# Check if template has inspections
|
||||
|
||||
if template.inspections.count() > 0:
|
||||
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
|
||||
db.session.delete(template)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
flash(f'Template "{template_name}" deleted successfully.', 'success')
|
||||
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'])
|
||||
@login_required
|
||||
@@ -97,12 +266,11 @@ def delete_template(template_id):
|
||||
def create_checklist_item(template_id):
|
||||
template = InspectionTemplate.query.get_or_404(template_id)
|
||||
form = ChecklistItemForm()
|
||||
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Get the highest display order
|
||||
max_order = db.session.query(db.func.max(ChecklistItem.display_order))\
|
||||
.filter_by(template_id=template.id).scalar() or 0
|
||||
|
||||
|
||||
item = ChecklistItem(
|
||||
template_id=template.id,
|
||||
category=form.category.data,
|
||||
@@ -112,14 +280,19 @@ def create_checklist_item(template_id):
|
||||
requires_photo=form.requires_photo.data,
|
||||
display_order=max_order + 1
|
||||
)
|
||||
|
||||
db.session.add(item)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
flash('Checklist item added successfully.', 'success')
|
||||
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'])
|
||||
@login_required
|
||||
@@ -127,19 +300,25 @@ def create_checklist_item(template_id):
|
||||
def edit_checklist_item(item_id):
|
||||
item = ChecklistItem.query.get_or_404(item_id)
|
||||
form = ChecklistItemForm(obj=item)
|
||||
|
||||
|
||||
if form.validate_on_submit():
|
||||
item.category = form.category.data
|
||||
item.item_description = form.item_description.data
|
||||
item.scoring_type = form.scoring_type.data
|
||||
item.weight = form.weight.data
|
||||
item.requires_photo = form.requires_photo.data
|
||||
|
||||
db.session.commit()
|
||||
flash('Checklist item updated successfully.', 'success')
|
||||
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'])
|
||||
@login_required
|
||||
@@ -147,24 +326,23 @@ def edit_checklist_item(item_id):
|
||||
def delete_checklist_item(item_id):
|
||||
item = ChecklistItem.query.get_or_404(item_id)
|
||||
template_id = item.template_id
|
||||
|
||||
db.session.delete(item)
|
||||
db.session.commit()
|
||||
|
||||
flash('Checklist item deleted successfully.', 'success')
|
||||
return redirect(url_for('templates.edit_template', template_id=template_id))
|
||||
|
||||
|
||||
@bp.route('/<int:template_id>/items/reorder', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def reorder_items(template_id):
|
||||
template = InspectionTemplate.query.get_or_404(template_id)
|
||||
item_order = request.json.get('item_order', [])
|
||||
|
||||
|
||||
for index, item_id in enumerate(item_order):
|
||||
item = ChecklistItem.query.get(item_id)
|
||||
if item and item.template_id == template.id:
|
||||
item.display_order = index
|
||||
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
+229
-115
@@ -1,132 +1,246 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Template - {{ template.name }}{% endblock %}
|
||||
{% block title %}Edit Template — {{ 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>
|
||||
.checklist-item { cursor: move; }
|
||||
.checklist-item:hover { background-color: #f8f9fa; }
|
||||
body { font-family: 'DM Sans', sans-serif; }
|
||||
|
||||
.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>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h2><i class="bi bi-file-earmark-text"></i> {{ template.name }}</h2>
|
||||
<div class="page-header">
|
||||
<h2 class="page-title"><i class="bi bi-file-earmark-text text-primary"></i> Edit Template</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 class="col-md-4 text-end">
|
||||
<a href="{{ url_for('templates.create_checklist_item', template_id=template.id) }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add Item
|
||||
<div class="settings-card-body">
|
||||
<form method="POST">
|
||||
{{ 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 & 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 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 class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">Template Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.name.label(class="form-label") }}
|
||||
{{ form.name(class="form-control") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.description.label(class="form-label") }}
|
||||
{{ form.description(class="form-control", rows=3) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.frequency.label(class="form-label") }}
|
||||
{{ 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 class="canvas-card-body">
|
||||
{% 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 }};"
|
||||
title="{{ f.label }} ({{ f.type.replace('_', ' ') }})">
|
||||
{% 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>
|
||||
</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 %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const el = document.getElementById('checklist-items');
|
||||
if (el) {
|
||||
new Sortable(el, {
|
||||
animation: 150,
|
||||
handle: '.bi-grip-vertical',
|
||||
onEnd: function(evt) {
|
||||
const itemOrder = [];
|
||||
el.querySelectorAll('.checklist-item').forEach(item => {
|
||||
itemOrder.push(item.getAttribute('data-item-id'));
|
||||
});
|
||||
|
||||
fetch('{{ url_for("templates.reorder_items", template_id=template.id) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ item_order: itemOrder })
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="editor-cta">
|
||||
<i class="bi bi-layout-text-sidebar-reverse"></i>
|
||||
<p>No fields yet. Open the Form Editor to start building your inspection form.</p>
|
||||
<a href="{{ url_for('templates.form_editor', template_id=template.id) }}"
|
||||
class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-circle"></i> Open Form Editor
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 %}
|
||||
@@ -26,9 +26,7 @@
|
||||
{{ template.name }}
|
||||
</a>
|
||||
</h5>
|
||||
|
||||
<p class="card-text text-muted small">{{ template.description or 'No description' }}</p>
|
||||
|
||||
<div class="mt-3">
|
||||
<span class="badge bg-info">{{ template.frequency|title }}</span>
|
||||
<small class="text-muted ms-2">
|
||||
@@ -36,10 +34,50 @@
|
||||
</small>
|
||||
</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">
|
||||
<i class="bi bi-eye"></i> View Template
|
||||
|
||||
<div class="card-footer bg-transparent d-flex gap-2 align-items-center flex-wrap">
|
||||
<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>
|
||||
|
||||
{% 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>
|
||||
@@ -51,4 +89,158 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</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 %}
|
||||
+171
-120
@@ -2,127 +2,178 @@
|
||||
|
||||
{% 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 %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h2><i class="bi bi-file-earmark-text"></i> {{ template.name }}</h2>
|
||||
<p class="text-muted">{{ template.description or 'No description provided' }}</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
{% if current_user.role in ['admin', 'supervisor'] %}
|
||||
<a href="{{ url_for('templates.edit_template', template_id=template.id) }}" class="btn btn-primary">
|
||||
<i class="bi bi-pencil"></i> Edit Template
|
||||
</a>
|
||||
<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.');">
|
||||
<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
|
||||
<div class="tpl-header">
|
||||
<div>
|
||||
<h2 class="tpl-title"><i class="bi bi-file-earmark-text text-primary"></i> {{ template.name }}</h2>
|
||||
{% if template.description %}
|
||||
<p class="tpl-desc">{{ template.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-shrink-0 mt-1">
|
||||
{% if current_user.role in ['admin', 'supervisor'] %}
|
||||
<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>
|
||||
<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>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user