Initial Codes
This commit is contained in:
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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