Initial Codes
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Templates - QBO Excel Sync{% endblock %}
|
||||
{% block content %}
|
||||
<div class="templates-page">
|
||||
<div class="page-header">
|
||||
<h2>Mapping Templates</h2>
|
||||
<p>Create and manage field mapping templates for your imports.</p>
|
||||
</div>
|
||||
|
||||
<div class="templates-layout">
|
||||
<aside class="templates-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h3>Templates</h3>
|
||||
<button class="btn btn-primary btn-sm" id="new-template-btn">+ New</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<select id="filter-type">
|
||||
<option value="">All Types</option>
|
||||
<option value="check">Check</option>
|
||||
<option value="invoice">Invoice</option>
|
||||
<option value="bill">Bill</option>
|
||||
<option value="customer">Customer</option>
|
||||
<option value="vendor">Vendor</option>
|
||||
<option value="account">Account</option>
|
||||
</select>
|
||||
</div>
|
||||
<ul class="template-list" id="template-list">
|
||||
{% for template in templates %}
|
||||
<li class="template-item" data-name="{{ template.name }}" data-type="{{ template.data_type }}">
|
||||
<span class="template-name">{{ template.name }}</span>
|
||||
<span class="template-type">{{ template.data_type }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if not templates %}
|
||||
<li class="empty-state">No custom templates yet</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main class="template-detail" id="template-detail">
|
||||
<div class="empty-detail"><p>Select a template or create a new one</p></div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Modal -->
|
||||
<div class="modal" id="template-modal" hidden style="display: none;">
|
||||
<div class="modal-backdrop" id="modal-backdrop"></div>
|
||||
<div class="modal-content modal-lg">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-title">New Template</h3>
|
||||
<button type="button" class="btn btn-icon" id="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Template Name</label>
|
||||
<input type="text" id="template-name" placeholder="e.g., My Payroll Import" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Data Type</label>
|
||||
<select id="template-type">
|
||||
<option value="check">Check</option>
|
||||
<option value="invoice">Invoice</option>
|
||||
<option value="bill">Bill</option>
|
||||
<option value="customer">Customer</option>
|
||||
<option value="vendor">Vendor</option>
|
||||
<option value="account">Account</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mapping-section">
|
||||
<div class="mapping-header">
|
||||
<label>Field Mappings</label>
|
||||
<div class="mapping-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="load-defaults">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Load Defaults
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="excel-columns-note" id="excel-columns-note" style="display: none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
<span>Excel columns available from your last uploaded file</span>
|
||||
</div>
|
||||
|
||||
<table class="data-table mapping-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 25%;">Excel Column</th>
|
||||
<th style="width: 35%;">QBO Field</th>
|
||||
<th style="width: 15%;">Transform</th>
|
||||
<th style="width: 5%;">Req</th>
|
||||
<th style="width: 15%;">Default Value</th>
|
||||
<th style="width: 5%;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="modal-mappings"></tbody>
|
||||
</table>
|
||||
|
||||
<button type="button" class="btn btn-sm" id="add-mapping-row">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Add Field
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" id="modal-cancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="modal-save">Save Template</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-lg {
|
||||
max-width: 900px;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.mapping-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.mapping-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mapping-header label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mapping-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.excel-columns-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mapping-table {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mapping-table select,
|
||||
.mapping-table input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mapping-table input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.mapping-table .qbo-field-select {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.mapping-table .qbo-field-select option {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.mapping-table .qbo-field-select optgroup {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.field-required {
|
||||
color: #dc2626;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.remove-row-btn {
|
||||
padding: 4px 8px;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.remove-row-btn:hover {
|
||||
background: #fecaca;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let currentTemplate = null;
|
||||
let modalMappings = [];
|
||||
let qboFields = [];
|
||||
let excelColumns = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Template list click handlers
|
||||
document.querySelectorAll('.template-item').forEach(item => {
|
||||
item.addEventListener('click', () => loadTemplate(item.dataset.name));
|
||||
});
|
||||
|
||||
// Filter handler
|
||||
document.getElementById('filter-type').addEventListener('change', filterTemplates);
|
||||
|
||||
// New template button
|
||||
document.getElementById('new-template-btn').addEventListener('click', openNewTemplate);
|
||||
|
||||
// Data type change - reload QBO fields
|
||||
document.getElementById('template-type').addEventListener('change', loadQBOFields);
|
||||
|
||||
// Modal close handlers
|
||||
document.getElementById('modal-close').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeModal();
|
||||
});
|
||||
|
||||
document.getElementById('modal-cancel').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeModal();
|
||||
});
|
||||
|
||||
document.getElementById('modal-backdrop').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeModal();
|
||||
});
|
||||
|
||||
// Modal action handlers
|
||||
document.getElementById('modal-save').addEventListener('click', saveTemplate);
|
||||
document.getElementById('load-defaults').addEventListener('click', loadDefaultFields);
|
||||
document.getElementById('add-mapping-row').addEventListener('click', addMappingRow);
|
||||
|
||||
// Close modal on Escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function filterTemplates() {
|
||||
const type = document.getElementById('filter-type').value;
|
||||
document.querySelectorAll('.template-item').forEach(item => {
|
||||
item.style.display = (!type || item.dataset.type === type) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTemplate(name) {
|
||||
try {
|
||||
const response = await fetch('/api/templates');
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const template = result.templates.find(t => t.name === name);
|
||||
if (template) {
|
||||
currentTemplate = template;
|
||||
renderTemplateDetail(template);
|
||||
document.querySelectorAll('.template-item').forEach(i => i.classList.toggle('active', i.dataset.name === name));
|
||||
}
|
||||
}
|
||||
} catch (error) { showToast('Error loading template', 'error'); }
|
||||
}
|
||||
|
||||
function renderTemplateDetail(template) {
|
||||
document.getElementById('template-detail').innerHTML =
|
||||
'<div class="detail-header"><h3>' + escapeHtml(template.name) + '</h3><span class="badge">' + template.data_type + '</span></div>' +
|
||||
'<div class="detail-actions"><button class="btn btn-secondary" onclick="editTemplate()">Edit</button><button class="btn btn-danger" onclick="deleteTemplate()">Delete</button></div>' +
|
||||
'<table class="data-table"><thead><tr><th>Excel Column</th><th>QBO Field</th><th>Transform</th><th>Required</th></tr></thead><tbody>' +
|
||||
template.mappings.map(m => '<tr><td>' + escapeHtml(m.excel_column || '-') + '</td><td>' + escapeHtml(m.qbo_field) + '</td><td>' + (m.transform || '-') + '</td><td>' + (m.required ? 'Yes' : 'No') + '</td></tr>').join('') +
|
||||
'</tbody></table>';
|
||||
}
|
||||
|
||||
async function loadQBOFields() {
|
||||
const type = document.getElementById('template-type').value;
|
||||
try {
|
||||
const response = await fetch('/api/qbo-fields/' + type);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
qboFields = result.qbo_fields || [];
|
||||
excelColumns = result.excel_columns || [];
|
||||
|
||||
// Show/hide Excel columns note
|
||||
const note = document.getElementById('excel-columns-note');
|
||||
if (excelColumns.length > 0) {
|
||||
note.style.display = 'flex';
|
||||
} else {
|
||||
note.style.display = 'none';
|
||||
}
|
||||
|
||||
// Re-render mappings with new fields
|
||||
renderModalMappings();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading QBO fields:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function openNewTemplate() {
|
||||
currentTemplate = null;
|
||||
document.getElementById('modal-title').textContent = 'New Template';
|
||||
document.getElementById('template-name').value = '';
|
||||
document.getElementById('template-type').value = 'check';
|
||||
modalMappings = [];
|
||||
|
||||
// Load QBO fields for default type
|
||||
await loadQBOFields();
|
||||
|
||||
const modal = document.getElementById('template-modal');
|
||||
modal.hidden = false;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
async function editTemplate() {
|
||||
if (!currentTemplate) return;
|
||||
document.getElementById('modal-title').textContent = 'Edit Template';
|
||||
document.getElementById('template-name').value = currentTemplate.name;
|
||||
document.getElementById('template-type').value = currentTemplate.data_type;
|
||||
modalMappings = JSON.parse(JSON.stringify(currentTemplate.mappings));
|
||||
|
||||
// Load QBO fields for this type
|
||||
await loadQBOFields();
|
||||
|
||||
const modal = document.getElementById('template-modal');
|
||||
modal.hidden = false;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const modal = document.getElementById('template-modal');
|
||||
if (modal) {
|
||||
modal.hidden = true;
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderModalMappings() {
|
||||
const tbody = document.getElementById('modal-mappings');
|
||||
|
||||
// Build Excel columns options
|
||||
let excelOptions = '<option value="">-- Select or type --</option>';
|
||||
excelColumns.forEach(col => {
|
||||
excelOptions += '<option value="' + escapeHtml(col) + '">' + escapeHtml(col) + '</option>';
|
||||
});
|
||||
|
||||
// Build QBO fields options grouped by required/optional
|
||||
let qboOptions = '<option value="">-- Select QBO Field --</option>';
|
||||
|
||||
const requiredFields = qboFields.filter(f => f.required);
|
||||
const optionalFields = qboFields.filter(f => !f.required);
|
||||
|
||||
if (requiredFields.length > 0) {
|
||||
qboOptions += '<optgroup label="Required Fields">';
|
||||
requiredFields.forEach(f => {
|
||||
qboOptions += '<option value="' + escapeHtml(f.field) + '" data-transform="' + (f.transform || '') + '">' +
|
||||
escapeHtml(f.label) + ' *</option>';
|
||||
});
|
||||
qboOptions += '</optgroup>';
|
||||
}
|
||||
|
||||
if (optionalFields.length > 0) {
|
||||
qboOptions += '<optgroup label="Optional Fields">';
|
||||
optionalFields.forEach(f => {
|
||||
qboOptions += '<option value="' + escapeHtml(f.field) + '" data-transform="' + (f.transform || '') + '">' +
|
||||
escapeHtml(f.label) + '</option>';
|
||||
});
|
||||
qboOptions += '</optgroup>';
|
||||
}
|
||||
|
||||
tbody.innerHTML = modalMappings.map((m, i) => {
|
||||
// Check if excel_column matches any in the list
|
||||
const excelSelected = excelColumns.includes(m.excel_column);
|
||||
|
||||
// Check if qbo_field matches any in the list
|
||||
const qboMatch = qboFields.find(f => f.field === m.qbo_field);
|
||||
|
||||
return '<tr>' +
|
||||
// Excel Column - combo of select and input
|
||||
'<td>' +
|
||||
'<input type="text" list="excel-list-' + i + '" value="' + escapeHtml(m.excel_column || '') + '" ' +
|
||||
'data-index="' + i + '" data-field="excel_column" placeholder="Type or select...">' +
|
||||
'<datalist id="excel-list-' + i + '">' +
|
||||
excelColumns.map(col => '<option value="' + escapeHtml(col) + '">').join('') +
|
||||
'</datalist>' +
|
||||
'</td>' +
|
||||
// QBO Field - dropdown
|
||||
'<td>' +
|
||||
'<select class="qbo-field-select" data-index="' + i + '" data-field="qbo_field">' +
|
||||
qboOptions.replace(
|
||||
'value="' + escapeHtml(m.qbo_field || '') + '"',
|
||||
'value="' + escapeHtml(m.qbo_field || '') + '" selected'
|
||||
) +
|
||||
'</select>' +
|
||||
'</td>' +
|
||||
// Transform
|
||||
'<td>' +
|
||||
'<select data-index="' + i + '" data-field="transform">' +
|
||||
'<option value="">None</option>' +
|
||||
'<option value="date"' + (m.transform === 'date' ? ' selected' : '') + '>Date</option>' +
|
||||
'<option value="currency"' + (m.transform === 'currency' ? ' selected' : '') + '>Currency</option>' +
|
||||
'<option value="number"' + (m.transform === 'number' ? ' selected' : '') + '>Number</option>' +
|
||||
'</select>' +
|
||||
'</td>' +
|
||||
// Required checkbox
|
||||
'<td style="text-align: center;">' +
|
||||
'<input type="checkbox" data-index="' + i + '" data-field="required"' + (m.required ? ' checked' : '') + '>' +
|
||||
'</td>' +
|
||||
// Default value
|
||||
'<td>' +
|
||||
'<input type="text" value="' + escapeHtml(m.default_value || '') + '" data-index="' + i + '" data-field="default_value" placeholder="Default">' +
|
||||
'</td>' +
|
||||
// Remove button
|
||||
'<td>' +
|
||||
'<button type="button" class="remove-row-btn" onclick="removeMappingRow(' + i + ')">×</button>' +
|
||||
'</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
|
||||
// Add event listeners
|
||||
tbody.querySelectorAll('input, select').forEach(el => {
|
||||
el.addEventListener('change', updateModalMapping);
|
||||
});
|
||||
|
||||
// Auto-set transform when QBO field changes
|
||||
tbody.querySelectorAll('.qbo-field-select').forEach(select => {
|
||||
select.addEventListener('change', function(e) {
|
||||
const idx = parseInt(e.target.dataset.index);
|
||||
const selectedOption = e.target.options[e.target.selectedIndex];
|
||||
const transform = selectedOption.dataset.transform;
|
||||
|
||||
// Auto-set transform if defined for this field
|
||||
if (transform) {
|
||||
const transformSelect = tbody.querySelector('select[data-index="' + idx + '"][data-field="transform"]');
|
||||
if (transformSelect) {
|
||||
transformSelect.value = transform;
|
||||
modalMappings[idx].transform = transform;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-set required if it's a required field
|
||||
const qboField = qboFields.find(f => f.field === e.target.value);
|
||||
if (qboField && qboField.required) {
|
||||
const reqCheckbox = tbody.querySelector('input[data-index="' + idx + '"][data-field="required"]');
|
||||
if (reqCheckbox) {
|
||||
reqCheckbox.checked = true;
|
||||
modalMappings[idx].required = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateModalMapping(e) {
|
||||
const idx = parseInt(e.target.dataset.index);
|
||||
const field = e.target.dataset.field;
|
||||
if (field === 'required') {
|
||||
modalMappings[idx][field] = e.target.checked;
|
||||
} else {
|
||||
modalMappings[idx][field] = e.target.value || null;
|
||||
}
|
||||
}
|
||||
|
||||
function addMappingRow() {
|
||||
modalMappings.push({
|
||||
excel_column: '',
|
||||
qbo_field: '',
|
||||
transform: null,
|
||||
required: false,
|
||||
default_value: null
|
||||
});
|
||||
renderModalMappings();
|
||||
|
||||
// Scroll to the new row
|
||||
const tbody = document.getElementById('modal-mappings');
|
||||
const lastRow = tbody.lastElementChild;
|
||||
if (lastRow) {
|
||||
lastRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
function removeMappingRow(idx) {
|
||||
modalMappings.splice(idx, 1);
|
||||
renderModalMappings();
|
||||
}
|
||||
|
||||
async function loadDefaultFields() {
|
||||
const type = document.getElementById('template-type').value;
|
||||
try {
|
||||
const response = await fetch('/api/templates/default/' + type);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
modalMappings = result.template.mappings;
|
||||
renderModalMappings();
|
||||
showToast('Default fields loaded', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error loading defaults', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTemplate() {
|
||||
const name = document.getElementById('template-name').value.trim();
|
||||
const type = document.getElementById('template-type').value;
|
||||
|
||||
if (!name) {
|
||||
showToast('Please enter a template name', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out empty mappings
|
||||
const validMappings = modalMappings.filter(m => m.qbo_field);
|
||||
|
||||
if (validMappings.length === 0) {
|
||||
showToast('Please add at least one field mapping', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
data_type: type,
|
||||
mappings: validMappings
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
closeModal();
|
||||
showToast('Template saved successfully', 'success');
|
||||
setTimeout(function() {
|
||||
location.reload();
|
||||
}, 500);
|
||||
} else {
|
||||
showToast(result.error || 'Failed to save template', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error saving template: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTemplate() {
|
||||
if (!currentTemplate) return;
|
||||
if (!confirm('Are you sure you want to delete template "' + currentTemplate.name + '"?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/templates/' + encodeURIComponent(currentTemplate.name), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showToast('Template deleted successfully', 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
showToast(result.error || 'Failed to delete template', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error deleting template: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = String(text);
|
||||
return d.innerHTML;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user