Initial Codes

This commit is contained in:
2026-02-13 10:38:45 -05:00
parent 0dd81218e0
commit 40a151787f
24 changed files with 7627 additions and 1 deletions
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block title %}Page Not Found - QBO Excel Sync{% endblock %}
{% block content %}
<div class="error-page">
<div class="error-content">
<div class="error-icon">
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<path d="M16 16s-1.5-2-4-2-4 2-4 2"/>
<line x1="9" y1="9" x2="9.01" y2="9"/>
<line x1="15" y1="9" x2="15.01" y2="9"/>
</svg>
</div>
<h1>404</h1>
<h2>Page Not Found</h2>
<p>The page you're looking for doesn't exist or has been moved.</p>
<a href="{{ url_for('index') }}" class="btn btn-primary">Back to Dashboard</a>
</div>
</div>
<style>
.error-page { display: flex; align-items: center; justify-content: center; min-height: 60vh; }
.error-content { text-align: center; }
.error-icon { color: #64748b; margin-bottom: 24px; }
.error-page h1 { font-size: 72px; font-weight: 800; color: #e2e8f0; margin: 0; }
.error-page h2 { font-size: 24px; margin: 8px 0 16px; }
.error-page p { color: #64748b; margin-bottom: 24px; }
</style>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}Server Error - QBO Excel Sync{% endblock %}
{% block content %}
<div class="error-page">
<div class="error-content">
<div class="error-icon">
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</div>
<h1>500</h1>
<h2>Server Error</h2>
<p>Something went wrong on our end. Please try again later.</p>
<div class="button-group" style="justify-content: center;">
<a href="{{ url_for('index') }}" class="btn btn-primary">Back to Dashboard</a>
<button onclick="location.reload()" class="btn btn-secondary">Try Again</button>
</div>
</div>
</div>
<style>
.error-page { display: flex; align-items: center; justify-content: center; min-height: 60vh; }
.error-content { text-align: center; }
.error-icon { color: #dc2626; margin-bottom: 24px; }
.error-page h1 { font-size: 72px; font-weight: 800; color: #fee2e2; margin: 0; }
.error-page h2 { font-size: 24px; margin: 8px 0 16px; }
.error-page p { color: #64748b; margin-bottom: 24px; }
</style>
{% endblock %}
+108
View File
@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}QBO Excel Sync{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- Header -->
<header class="main-header">
<div class="header-content">
<div class="logo-section">
<div class="logo">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" fill="url(#gradient)"/>
<path d="M8 12H24M8 16H20M8 20H16" stroke="white" stroke-width="2" stroke-linecap="round"/>
<defs>
<linearGradient id="gradient" x1="0" y1="0" x2="32" y2="32">
<stop offset="0%" stop-color="#2563eb"/>
<stop offset="100%" stop-color="#7c3aed"/>
</linearGradient>
</defs>
</svg>
</div>
<div class="title-group">
<h1>QBO Excel Sync</h1>
<span class="subtitle">Import Excel data to QuickBooks Online</span>
</div>
</div>
<div class="header-status">
<div class="connection-status {% if is_connected %}connected{% else %}disconnected{% endif %}">
<span class="status-dot"></span>
<span class="status-text">
{% if is_connected %}
Connected: {{ company_name or 'Unknown' }}
{% else %}
Not Connected
{% endif %}
</span>
</div>
<div class="environment-badge {{ environment }}">
{{ environment|upper }}
</div>
</div>
</div>
</header>
<!-- Navigation -->
<nav class="main-nav">
<div class="nav-content">
<a href="{{ url_for('index') }}" class="nav-link {% if request.endpoint == 'index' %}active{% endif %}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
Dashboard
</a>
<a href="{{ url_for('connection') }}" class="nav-link {% if request.endpoint == 'connection' %}active{% endif %}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
Connection
</a>
<a href="{{ url_for('import_page') }}" class="nav-link {% if request.endpoint == 'import_page' %}active{% endif %}">
<svg width="18" height="18" 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="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Import
</a>
<a href="{{ url_for('templates_page') }}" class="nav-link {% if request.endpoint == 'templates_page' %}active{% endif %}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
Templates
</a>
<a href="{{ url_for('settings_page') }}" class="nav-link {% if request.endpoint == 'settings_page' %}active{% endif %}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Settings
</a>
</div>
</nav>
<!-- Main Content -->
<main class="main-content">
{% block content %}{% endblock %}
</main>
<!-- Toast Container -->
<div id="toast-container"></div>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
+508
View File
@@ -0,0 +1,508 @@
{% extends "base.html" %}
{% block title %}Connection - QBO Excel Sync{% endblock %}
{% block content %}
<div class="connection-page">
<div class="page-header">
<h2>QuickBooks Connection</h2>
<p>Connect to your QuickBooks Online account to start importing data.</p>
</div>
<div class="connection-layout">
<!-- Credentials Section -->
<section class="card collapsible">
<div class="card-header" onclick="toggleSection(this)">
<h3>API Credentials</h3>
<span class="collapse-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</span>
</div>
<div class="card-body collapsible-content">
<form id="credentials-form" class="form">
<div class="form-group">
<label for="client_id">Client ID</label>
<input type="text" id="client_id" name="client_id"
value="{{ credentials.client_id }}"
placeholder="Enter your Intuit Developer Client ID">
</div>
<div class="form-group">
<label for="client_secret">Client Secret</label>
<input type="password" id="client_secret" name="client_secret"
value="{{ credentials.client_secret }}"
placeholder="Enter your Client Secret">
</div>
<div class="form-group">
<label for="redirect_uri">Redirect URI</label>
<input type="text" id="redirect_uri" name="redirect_uri"
value="{{ credentials.redirect_uri or 'http://localhost:5000/callback' }}"
placeholder="http://localhost:5000/callback">
<small class="form-help">This must match exactly in your Intuit Developer app settings.</small>
</div>
<div class="form-group">
<label for="environment">Environment</label>
<select id="environment" name="environment">
<option value="sandbox" {% if environment == 'sandbox' %}selected{% endif %}>Sandbox (Testing)</option>
<option value="production" {% if environment == 'production' %}selected{% endif %}>Production (Live)</option>
</select>
</div>
<button type="submit" class="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save Credentials
</button>
</form>
</div>
</section>
<!-- Connection Status Section -->
<section class="card collapsible">
<div class="card-header" onclick="toggleSection(this)">
<h3>Connection Status</h3>
<span class="collapse-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</span>
</div>
<div class="card-body collapsible-content">
<div class="connection-status-display {% if is_connected %}connected{% else %}disconnected{% endif %}">
<div class="status-icon">
{% if is_connected %}
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
{% else %}
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
{% endif %}
</div>
<div class="status-details">
<h4>{% if is_connected %}Connected{% else %}Not Connected{% endif %}</h4>
{% if is_connected and company_name %}
<p class="company-name">{{ company_name }}</p>
{% endif %}
</div>
</div>
<div class="connection-actions">
{% if is_connected %}
<button type="button" id="test-connection" class="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Test Connection
</button>
<button type="button" id="disconnect" class="btn btn-danger">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/>
<line x1="12" y1="2" x2="12" y2="12"/>
</svg>
Disconnect
</button>
{% else %}
<button type="button" id="connect-oauth" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
Connect to QuickBooks
</button>
{% endif %}
</div>
</div>
</section>
<!-- Manual Token Entry -->
<section class="card collapsible collapsed">
<div class="card-header" onclick="toggleSection(this)">
<h3>Alternative: Manual Token Entry</h3>
<div class="header-right">
<span class="card-badge">Advanced</span>
<span class="collapse-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</span>
</div>
</div>
<div class="card-body collapsible-content">
<p class="card-description">
If OAuth redirect isn't working, you can manually enter tokens from Intuit's OAuth 2.0 Playground.
</p>
<form id="manual-token-form" class="form">
<div class="form-group">
<label for="access_token">Access Token</label>
<textarea id="access_token" name="access_token" rows="3"
placeholder="Paste access token from OAuth Playground"></textarea>
</div>
<div class="form-group">
<label for="refresh_token">Refresh Token</label>
<textarea id="refresh_token" name="refresh_token" rows="2"
placeholder="Paste refresh token (optional but recommended)"></textarea>
</div>
<div class="form-group">
<label for="realm_id">Realm ID (Company ID)</label>
<input type="text" id="realm_id" name="realm_id"
placeholder="Enter the Realm ID / Company ID">
</div>
<button type="submit" class="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save Tokens & Connect
</button>
</form>
</div>
</section>
<!-- Help Section -->
<section class="card help-card collapsible collapsed">
<div class="card-header" onclick="toggleSection(this)">
<h3>Setup Instructions</h3>
<span class="collapse-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</span>
</div>
<div class="card-body collapsible-content">
<div class="help-section">
<h4>Option 1: OAuth Flow (Recommended)</h4>
<ol>
<li>Go to <a href="https://developer.intuit.com" target="_blank">developer.intuit.com</a></li>
<li>Create or select your app → "Keys & OAuth" section</li>
<li>Add redirect URI: <code>http://localhost:5000/callback</code></li>
<li>Copy your Client ID and Client Secret</li>
<li>Save credentials above, then click "Connect to QuickBooks"</li>
</ol>
</div>
<div class="help-section">
<h4>Option 2: Manual Token Entry</h4>
<ol>
<li>In the developer portal, click "OAuth 2.0 Playground"</li>
<li>Authorize your sandbox/production company</li>
<li>Copy the Access Token, Refresh Token, and Realm ID</li>
<li>Paste them in the "Manual Token Entry" section above</li>
</ol>
</div>
<div class="help-note">
<strong>Note:</strong> Access tokens expire after 1 hour. The app will automatically refresh using the refresh token.
</div>
</div>
</section>
</div>
</div>
<style>
/* Collapsible Sections */
.card.collapsible .card-header {
cursor: pointer;
user-select: none;
display: flex;
justify-content: space-between;
align-items: center;
}
.card.collapsible .card-header:hover {
background: var(--bg);
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.collapse-icon {
transition: transform 0.3s ease;
display: flex;
align-items: center;
color: var(--text-muted);
}
.card.collapsible.collapsed .collapse-icon {
transform: rotate(-90deg);
}
.collapsible-content {
max-height: 2000px;
overflow: hidden;
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
opacity: 1;
}
.card.collapsible.collapsed .collapsible-content {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
}
/* Ensure card-body padding transitions smoothly */
.card.collapsible .card-body {
transition: padding 0.3s ease;
}
.card.collapsible.collapsed .card-body {
padding: 0 20px;
}
</style>
{% endblock %}
{% block extra_js %}
<script>
// Toggle collapsible section
function toggleSection(header) {
const card = header.closest('.card.collapsible');
card.classList.toggle('collapsed');
// Save state to localStorage
const sectionId = header.querySelector('h3').textContent.trim();
const isCollapsed = card.classList.contains('collapsed');
localStorage.setItem('section_' + sectionId, isCollapsed ? 'collapsed' : 'expanded');
}
// Global variable to track OAuth polling
let oauthPollTimer = null;
document.addEventListener('DOMContentLoaded', function() {
// Check if we need to handle OAuth completion on page load
const oauthComplete = localStorage.getItem('oauth_complete');
if (oauthComplete) {
localStorage.removeItem('oauth_complete');
// Page was just reloaded after OAuth - show success message
showToast('Successfully connected to QuickBooks!', 'success');
}
// Restore section states from localStorage
document.querySelectorAll('.card.collapsible').forEach(card => {
const sectionId = card.querySelector('.card-header h3').textContent.trim();
const savedState = localStorage.getItem('section_' + sectionId);
if (savedState === 'collapsed') {
card.classList.add('collapsed');
} else if (savedState === 'expanded') {
card.classList.remove('collapsed');
}
});
// Save credentials form
document.getElementById('credentials-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = {
client_id: document.getElementById('client_id').value,
client_secret: document.getElementById('client_secret').value,
redirect_uri: document.getElementById('redirect_uri').value,
environment: document.getElementById('environment').value
};
try {
const response = await fetch('/api/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.success) {
showToast('Credentials saved successfully', 'success');
} else {
showToast(result.error || 'Failed to save credentials', 'error');
}
} catch (error) {
showToast('Error saving credentials: ' + error.message, 'error');
}
});
// Connect via OAuth
const connectBtn = document.getElementById('connect-oauth');
if (connectBtn) {
connectBtn.addEventListener('click', async function() {
try {
const response = await fetch('/api/oauth/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const result = await response.json();
if (result.success && result.auth_url) {
// Clear any previous OAuth flag
localStorage.removeItem('oauth_complete');
// Open OAuth in new window
const oauthPopup = window.open(result.auth_url, 'QBO_OAuth', 'width=600,height=700');
// Start polling for OAuth completion
startOAuthPolling(oauthPopup);
} else {
showToast(result.error || 'Failed to start OAuth flow', 'error');
}
} catch (error) {
showToast('Error starting OAuth: ' + error.message, 'error');
}
});
}
// Test connection
const testBtn = document.getElementById('test-connection');
if (testBtn) {
testBtn.addEventListener('click', async function() {
try {
const response = await fetch('/api/connection/test');
const result = await response.json();
if (result.success) {
showToast(`Connected to ${result.company_name}. Found ${result.customers_count} customers, ${result.vendors_count} vendors, ${result.accounts_count} accounts.`, 'success');
} else {
showToast(result.error || 'Connection test failed', 'error');
}
} catch (error) {
showToast('Error testing connection: ' + error.message, 'error');
}
});
}
// Disconnect
const disconnectBtn = document.getElementById('disconnect');
if (disconnectBtn) {
disconnectBtn.addEventListener('click', async function() {
if (!confirm('Are you sure you want to disconnect from QuickBooks?')) {
return;
}
try {
const response = await fetch('/api/disconnect', {
method: 'POST'
});
const result = await response.json();
if (result.success) {
showToast('Disconnected successfully', 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(result.error || 'Failed to disconnect', 'error');
}
} catch (error) {
showToast('Error disconnecting: ' + error.message, 'error');
}
});
}
// Manual token form
document.getElementById('manual-token-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = {
access_token: document.getElementById('access_token').value,
refresh_token: document.getElementById('refresh_token').value,
realm_id: document.getElementById('realm_id').value
};
if (!formData.access_token || !formData.realm_id) {
showToast('Please enter at least the Access Token and Realm ID', 'warning');
return;
}
try {
const response = await fetch('/api/oauth/manual', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.success) {
showToast(`Connected to ${result.company_name}`, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(result.error || 'Failed to connect with tokens', 'error');
}
} catch (error) {
showToast('Error connecting: ' + error.message, 'error');
}
});
});
// Function to poll for OAuth completion
function startOAuthPolling(popup) {
// Clear any existing timer
if (oauthPollTimer) {
clearInterval(oauthPollTimer);
}
let checkCount = 0;
const maxChecks = 300; // 5 minutes max (300 * 1000ms)
oauthPollTimer = setInterval(async function() {
checkCount++;
// Check localStorage for OAuth completion signal
const oauthComplete = localStorage.getItem('oauth_complete');
if (oauthComplete) {
clearInterval(oauthPollTimer);
localStorage.removeItem('oauth_complete');
showToast('Successfully connected to QuickBooks!', 'success');
location.reload();
return;
}
// Check if popup is closed
if (popup && popup.closed) {
clearInterval(oauthPollTimer);
// Wait a moment then check connection status
setTimeout(async function() {
try {
const response = await fetch('/api/connection/test');
const result = await response.json();
if (result.connected === true) {
showToast('Successfully connected to QuickBooks!', 'success');
location.reload();
} else {
showToast('Popup closed. Click "Connect to QuickBooks" to try again.', 'warning');
}
} catch (err) {
showToast('Popup closed. Click "Connect to QuickBooks" to try again.', 'warning');
}
}, 1500);
return;
}
// Stop polling after max time
if (checkCount >= maxChecks) {
clearInterval(oauthPollTimer);
showToast('OAuth timeout. Please try again.', 'warning');
}
}, 1000);
}
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
{% extends "base.html" %}
{% block title %}Dashboard - QBO Excel Sync{% endblock %}
{% block content %}
<div class="dashboard">
<div class="page-header">
<h2>Dashboard</h2>
<p>Welcome to QBO Excel Sync. Import your Excel data to QuickBooks Online with ease.</p>
</div>
<!-- Quick Stats -->
<div class="stats-grid">
<div class="stat-card {% if is_connected %}connected{% else %}disconnected{% endif %}">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</div>
<div class="stat-content">
<h3>Connection Status</h3>
<p class="stat-value">{% if is_connected %}Connected{% else %}Not Connected{% endif %}</p>
{% if is_connected and company_name %}
<p class="stat-detail">{{ company_name }}</p>
{% endif %}
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<line x1="10" y1="9" x2="8" y2="9"/>
</svg>
</div>
<div class="stat-content">
<h3>Environment</h3>
<p class="stat-value">{{ environment|title }}</p>
<p class="stat-detail">{% if environment == 'sandbox' %}Test mode{% else %}Live data{% endif %}</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
</div>
<div class="stat-content">
<h3>Supported Types</h3>
<p class="stat-value">6 Types</p>
<p class="stat-detail">Checks, Invoices, Bills, & more</p>
</div>
</div>
</div>
<!-- Quick Actions -->
<section class="section">
<h3 class="section-title">Quick Actions</h3>
<div class="action-grid">
{% if not is_connected %}
<a href="{{ url_for('connection') }}" class="action-card primary">
<div class="action-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</div>
<div class="action-content">
<h4>Connect to QuickBooks</h4>
<p>Set up your QuickBooks Online connection to start importing data.</p>
</div>
<svg class="action-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
</a>
{% else %}
<a href="{{ url_for('import_page') }}" class="action-card primary">
<div class="action-icon">
<svg width="32" height="32" 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="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<div class="action-content">
<h4>Import Excel File</h4>
<p>Upload and import an Excel file to QuickBooks Online.</p>
</div>
<svg class="action-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
</a>
{% endif %}
<a href="{{ url_for('templates_page') }}" class="action-card">
<div class="action-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
</div>
<div class="action-content">
<h4>Manage Templates</h4>
<p>Create and edit field mapping templates for your imports.</p>
</div>
<svg class="action-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
</a>
<a href="{{ url_for('settings_page') }}" class="action-card">
<div class="action-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</div>
<div class="action-content">
<h4>Configure Settings</h4>
<p>Customize import behavior, date formats, and more.</p>
</div>
<svg class="action-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
</a>
</div>
</section>
</div>
{% endblock %}
+127
View File
@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if success %}Authorization Successful{% else %}Authorization Failed{% endif %} - QBO Excel Sync</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.card {
background: white;
border-radius: 16px;
padding: 48px;
max-width: 480px;
width: 100%;
text-align: center;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.icon {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
}
.icon.success { background: #d1fae5; color: #059669; }
.icon.error { background: #fee2e2; color: #dc2626; }
.icon svg { width: 40px; height: 40px; }
h1 { font-size: 24px; margin-bottom: 12px; color: #1f2937; }
p { color: #6b7280; margin-bottom: 24px; line-height: 1.6; }
.error-msg { background: #fef2f2; color: #dc2626; padding: 12px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 24px; }
.btn {
display: inline-block;
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
color: white;
padding: 12px 32px;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
cursor: pointer;
border: none;
font-size: 16px;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn:hover { transform: translateY(-2px); box-shadow: 0 10px 20px -5px rgba(37, 99, 235, 0.4); }
.auto-close { font-size: 12px; color: #9ca3af; margin-top: 24px; }
.countdown { font-weight: bold; }
</style>
</head>
<body>
<div class="card">
{% if success %}
<div class="icon success">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<h1>Authorization Successful!</h1>
<p>You have successfully connected to QuickBooks Online.</p>
<button type="button" class="btn" onclick="closeAndRefresh()">Close & Continue</button>
<p class="auto-close">This window will close automatically in <span class="countdown" id="countdown">5</span> seconds...</p>
<script>
// Signal OAuth success via localStorage
try {
localStorage.setItem('oauth_complete', Date.now().toString());
console.log('OAuth complete flag set in localStorage');
} catch (e) {
console.error('Failed to set localStorage:', e);
}
// Countdown timer
let seconds = 5;
const countdownEl = document.getElementById('countdown');
const countdownTimer = setInterval(function() {
seconds--;
countdownEl.textContent = seconds;
if (seconds <= 0) {
clearInterval(countdownTimer);
closeAndRefresh();
}
}, 1000);
// Close popup and trigger parent refresh
function closeAndRefresh() {
// Try to close the popup
try {
window.close();
} catch (e) {
console.log('Could not close window:', e);
}
// If window didn't close, redirect to connection page
setTimeout(function() {
window.location.href = '/connection';
}, 500);
}
</script>
{% else %}
<div class="icon error">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</div>
<h1>Authorization Failed</h1>
<p>We couldn't complete the authorization with QuickBooks Online.</p>
{% if error %}
<div class="error-msg">{{ error }}</div>
{% endif %}
<a href="/connection" class="btn">Try Again</a>
{% endif %}
</div>
</body>
</html>
+176
View File
@@ -0,0 +1,176 @@
{% extends "base.html" %}
{% block title %}Settings - QBO Excel Sync{% endblock %}
{% block content %}
<div class="settings-page">
<div class="page-header">
<h2>Settings</h2>
<p>Configure import behavior and application preferences.</p>
</div>
<div class="settings-layout">
<section class="card">
<div class="card-header"><h3>Import Settings</h3></div>
<div class="card-body">
<form id="settings-form" class="form">
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="skip_duplicates" {% if import_settings.skip_duplicates %}checked{% endif %}>
<span>Skip duplicate records</span>
</label>
</div>
<div class="form-group">
<label for="duplicate_fields">Duplicate check fields</label>
<input type="text" id="duplicate_fields" value="{{ import_settings.duplicate_check_fields | join(', ') }}" placeholder="DocNumber, RefNumber">
<small>Comma-separated list of fields to check for duplicates</small>
</div>
<div class="form-group">
<label for="batch_size">Batch size</label>
<input type="number" id="batch_size" value="{{ import_settings.batch_size }}" min="1" max="200">
<small>Number of records to process per batch (1-200)</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="validate_before" {% if import_settings.validate_before_import %}checked{% endif %}>
<span>Validate data before import</span>
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="create_missing" {% if import_settings.create_missing_references %}checked{% endif %}>
<span>Auto-create missing customers/vendors</span>
</label>
</div>
</form>
</div>
</section>
<section class="card">
<div class="card-header"><h3>Format Settings</h3></div>
<div class="card-body">
<div class="form-group">
<label for="date_format">Date format</label>
<select id="date_format">
<option value="%Y-%m-%d" {% if import_settings.date_format == '%Y-%m-%d' %}selected{% endif %}>YYYY-MM-DD (2024-01-15)</option>
<option value="%m/%d/%Y" {% if import_settings.date_format == '%m/%d/%Y' %}selected{% endif %}>MM/DD/YYYY (01/15/2024)</option>
<option value="%d/%m/%Y" {% if import_settings.date_format == '%d/%m/%Y' %}selected{% endif %}>DD/MM/YYYY (15/01/2024)</option>
</select>
</div>
<div class="form-group">
<label for="decimal_sep">Decimal separator</label>
<select id="decimal_sep">
<option value="." {% if import_settings.decimal_separator == '.' %}selected{% endif %}>Period (1234.56)</option>
<option value="," {% if import_settings.decimal_separator == ',' %}selected{% endif %}>Comma (1234,56)</option>
</select>
</div>
<div class="form-group">
<label for="thousand_sep">Thousand separator</label>
<select id="thousand_sep">
<option value="," {% if import_settings.thousand_separator == ',' %}selected{% endif %}>Comma (1,234)</option>
<option value="." {% if import_settings.thousand_separator == '.' %}selected{% endif %}>Period (1.234)</option>
<option value=" " {% if import_settings.thousand_separator == ' ' %}selected{% endif %}>Space (1 234)</option>
<option value="" {% if import_settings.thousand_separator == '' %}selected{% endif %}>None (1234)</option>
</select>
</div>
</div>
</section>
<section class="card">
<div class="card-header"><h3>API Settings</h3></div>
<div class="card-body">
<div class="form-group">
<label for="environment">Environment</label>
<select id="environment">
<option value="sandbox" {% if environment == 'sandbox' %}selected{% endif %}>Sandbox (Testing)</option>
<option value="production" {% if environment == 'production' %}selected{% endif %}>Production (Live)</option>
</select>
<small>Sandbox uses test data. Production uses real QuickBooks data.</small>
</div>
</div>
</section>
<section class="card">
<div class="card-header"><h3>Data Management</h3></div>
<div class="card-body">
<div class="button-group">
<button class="btn btn-danger" id="clear-credentials">Clear Saved Credentials</button>
<button class="btn btn-secondary" id="clear-templates">Reset All Templates</button>
</div>
</div>
</section>
<div class="settings-actions">
<button class="btn btn-primary" id="save-settings">Save Settings</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('save-settings').addEventListener('click', saveSettings);
document.getElementById('clear-credentials').addEventListener('click', clearCredentials);
document.getElementById('clear-templates').addEventListener('click', clearTemplates);
});
async function saveSettings() {
const fields = document.getElementById('duplicate_fields').value;
const duplicateFields = fields.split(',').map(f => f.trim()).filter(f => f);
const settings = {
skip_duplicates: document.getElementById('skip_duplicates').checked,
duplicate_check_fields: duplicateFields,
batch_size: parseInt(document.getElementById('batch_size').value) || 50,
validate_before_import: document.getElementById('validate_before').checked,
create_missing_references: document.getElementById('create_missing').checked,
date_format: document.getElementById('date_format').value,
decimal_separator: document.getElementById('decimal_sep').value,
thousand_separator: document.getElementById('thousand_sep').value,
environment: document.getElementById('environment').value
};
try {
const response = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
const result = await response.json();
if (result.success) showToast('Settings saved successfully', 'success');
else showToast(result.error || 'Failed to save settings', 'error');
} catch (error) {
showToast('Error saving settings: ' + error.message, 'error');
}
}
async function clearCredentials() {
if (!confirm('This will remove all saved API credentials. Continue?')) return;
try {
const response = await fetch('/api/settings/clear-credentials', { method: 'POST' });
const result = await response.json();
if (result.success) { showToast('Credentials cleared', 'success'); location.reload(); }
else showToast(result.error || 'Failed to clear', 'error');
} catch (error) { showToast('Error: ' + error.message, 'error'); }
}
async function clearTemplates() {
if (!confirm('This will delete ALL custom templates. Continue?')) return;
try {
const response = await fetch('/api/templates');
const result = await response.json();
if (result.success) {
for (const t of result.templates) {
await fetch('/api/templates/' + encodeURIComponent(t.name), { method: 'DELETE' });
}
showToast('Templates cleared', 'success');
}
} catch (error) { showToast('Error: ' + error.message, 'error'); }
}
</script>
{% endblock %}
+597
View File
@@ -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">&times;</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 + ')">&times;</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 %}