You've already forked double-entry-accounting
586 lines
26 KiB
HTML
586 lines
26 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Accounting System</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12"></script>
|
|
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
|
.section { margin-bottom: 30px; padding: 15px; border: 1px solid #eee; border-radius: 5px; }
|
|
table { border-collapse: collapse; width: 100%; margin-bottom: 20px; }
|
|
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
|
th { background-color: #f2f2f2; }
|
|
.tabs { margin-bottom: 20px; }
|
|
.tab { padding: 10px 15px; cursor: pointer; background: #f1f1f1; display: inline-block; margin-right: 5px; }
|
|
.tab.active { background: #4CAF50; color: white; }
|
|
input, select { padding: 8px; margin: 5px 0; width: 200px; }
|
|
button { padding: 8px 15px; background: #4CAF50; color: white; border: none; cursor: pointer; margin: 5px; }
|
|
button:hover { background: #45a049; }
|
|
button:disabled { background: #cccccc; }
|
|
.file-input { margin: 15px 0; }
|
|
.score-bar { height: 10px; background-color: #4CAF50; margin: 5px 0; }
|
|
.match-quality-filters { margin: 15px 0; }
|
|
.match-quality-filters label { margin-right: 15px; }
|
|
.report-actions { margin-top: 20px; }
|
|
.primary { background-color: #2196F3; }
|
|
.danger { background-color: #f44336; }
|
|
.bank-section { margin-bottom: 30px; }
|
|
.form-group { margin-bottom: 15px; }
|
|
label { display: block; margin-bottom: 5px; font-weight: bold; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<h1>Accounting System</h1>
|
|
|
|
<div class="tabs">
|
|
<div class="tab" :class="{active: activeTab === 'accounts'}" @click="activeTab = 'accounts'">Accounts</div>
|
|
<div class="tab" :class="{active: activeTab === 'trial-balance'}" @click="activeTab = 'trial-balance'">Trial Balance</div>
|
|
<div class="tab" :class="{active: activeTab === 'banking'}" @click="activeTab = 'banking'">Bank Reconciliation</div>
|
|
<div class="tab" :class="{active: activeTab === 'reports'}" @click="activeTab = 'reports'">Reports</div>
|
|
</div>
|
|
|
|
<!-- Accounts Tab -->
|
|
<div class="section" v-if="activeTab === 'accounts'">
|
|
<h2>Accounts</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Code</th>
|
|
<th>Name</th>
|
|
<th>Type</th>
|
|
<th>Balance</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="account in accounts">
|
|
<td>{{ account.code }}</td>
|
|
<td>{{ account.name }}</td>
|
|
<td>{{ account.type }}</td>
|
|
<td>{{ formatCurrency(account.balance) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h3>Add New Account</h3>
|
|
<div class="form-group">
|
|
<label>Account Name</label>
|
|
<input v-model="newAccount.name" placeholder="Account Name">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Account Code</label>
|
|
<input v-model="newAccount.code" placeholder="Account Code">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Account Type</label>
|
|
<select v-model="newAccount.type">
|
|
<option value="Asset">Asset</option>
|
|
<option value="Liability">Liability</option>
|
|
<option value="Equity">Equity</option>
|
|
<option value="Revenue">Revenue</option>
|
|
<option value="Expense">Expense</option>
|
|
</select>
|
|
</div>
|
|
<button @click="addAccount">Add Account</button>
|
|
</div>
|
|
|
|
<!-- Trial Balance Tab -->
|
|
<div class="section" v-if="activeTab === 'trial-balance'">
|
|
<h2>Trial Balance</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Account</th>
|
|
<th>Debit</th>
|
|
<th>Credit</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="item in trialBalance">
|
|
<td>{{ item.account }}</td>
|
|
<td>{{ formatCurrency(item.debit) }}</td>
|
|
<td>{{ formatCurrency(item.credit) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Banking Tab -->
|
|
<div class="section" v-if="activeTab === 'banking'">
|
|
<h2>Bank Reconciliation</h2>
|
|
|
|
<div class="bank-section">
|
|
<h3>Bank Accounts</h3>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Bank</th>
|
|
<th>Account Number</th>
|
|
<th>Name</th>
|
|
<th>Currency</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="account in bankAccounts">
|
|
<td>{{ account.bank_name }}</td>
|
|
<td>{{ account.account_number }}</td>
|
|
<td>{{ account.name }}</td>
|
|
<td>{{ account.currency }}</td>
|
|
<td>
|
|
<button @click="viewReconciliation(account.id)">Reconcile</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h3>Add New Bank Account</h3>
|
|
<div class="form-group">
|
|
<label>Account Name</label>
|
|
<input v-model="newBankAccount.name" placeholder="Account Name">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Bank Name</label>
|
|
<input v-model="newBankAccount.bank_name" placeholder="Bank Name">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Account Number</label>
|
|
<input v-model="newBankAccount.account_number" placeholder="Account Number">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Currency</label>
|
|
<input v-model="newBankAccount.currency" placeholder="Currency" value="USD">
|
|
</div>
|
|
<button @click="addBankAccount">Add Bank Account</button>
|
|
</div>
|
|
|
|
<div class="bank-section" v-if="selectedBankAccount">
|
|
<h3>Import Transactions</h3>
|
|
<div class="form-group">
|
|
<label>Select Bank Account</label>
|
|
<select v-model="selectedBankAccount">
|
|
<option value="">Select Bank Account</option>
|
|
<option v-for="account in bankAccounts" :value="account.id">
|
|
{{ account.bank_name }} - {{ account.account_number }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>CSV File</label>
|
|
<input type="file" @change="handleFileUpload" accept=".csv">
|
|
</div>
|
|
|
|
<button @click="importTransactions" :disabled="!selectedBankAccount || !fileData">Import</button>
|
|
|
|
<div v-if="importResult">
|
|
<p>Imported {{ importResult.imported.length }} transactions</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bank-section" v-if="selectedBankAccountForReconciliation">
|
|
<h3>Unreconciled Transactions</h3>
|
|
<div class="form-group">
|
|
<label>Select Bank Account</label>
|
|
<select v-model="selectedBankAccountForReconciliation" @change="fetchUnreconciledTransactions">
|
|
<option value="">Select Bank Account</option>
|
|
<option v-for="account in bankAccounts" :value="account.id">
|
|
{{ account.bank_name }} - {{ account.account_number }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<table v-if="unreconciledTransactions.length > 0">
|
|
<thead>
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Description</th>
|
|
<th>Amount</th>
|
|
<th>Match With Journal Entry</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="txn in unreconciledTransactions">
|
|
<td>{{ txn.date }}</td>
|
|
<td>{{ txn.description }}</td>
|
|
<td>{{ formatCurrency(txn.amount) }}</td>
|
|
<td>
|
|
<select v-model="selectedJournalEntries[txn.id]">
|
|
<option value="">Select matching entry</option>
|
|
<option v-for="entry in potentialMatches(txn)" :value="entry.id">
|
|
{{ entry.date }} - {{ entry.description }} ({{ formatCurrency(entry.amount) }})
|
|
</option>
|
|
</select>
|
|
</td>
|
|
<td>
|
|
<button @click="reconcile(txn.id)" :disabled="!selectedJournalEntries[txn.id]">Reconcile</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p v-else>No unreconciled transactions found</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Reports Tab -->
|
|
<div class="section" v-if="activeTab === 'reports'">
|
|
<h2>Reconciliation Reports</h2>
|
|
|
|
<div v-if="!activeReport">
|
|
<h3>Create New Report</h3>
|
|
<div class="form-group">
|
|
<label>Bank Account</label>
|
|
<select v-model="newReport.bank_account_id">
|
|
<option value="">Select Bank Account</option>
|
|
<option v-for="account in bankAccounts" :value="account.id">
|
|
{{ account.bank_name }} - {{ account.account_number }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Start Date</label>
|
|
<input type="date" v-model="newReport.start_date">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>End Date</label>
|
|
<input type="date" v-model="newReport.end_date">
|
|
</div>
|
|
<button @click="createReconciliationReport"
|
|
:disabled="!newReport.bank_account_id || !newReport.start_date || !newReport.end_date">
|
|
Generate Report
|
|
</button>
|
|
|
|
<h3>Recent Reports</h3>
|
|
<table v-if="reconciliationReports.length > 0">
|
|
<thead>
|
|
<tr>
|
|
<th>Bank Account</th>
|
|
<th>Date Range</th>
|
|
<th>Status</th>
|
|
<th>Created</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="report in reconciliationReports">
|
|
<td>{{ getBankAccountName(report.bank_account_id) }}</td>
|
|
<td>{{ report.start_date }} to {{ report.end_date }}</td>
|
|
<td>{{ report.status }}</td>
|
|
<td>{{ report.created_at }}</td>
|
|
<td>
|
|
<button @click="loadReport(report.id)">View</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p v-else>No reconciliation reports found</p>
|
|
</div>
|
|
|
|
<div v-if="activeReport">
|
|
<h3>Reconciliation Report - {{ activeReport.start_date }} to {{ activeReport.end_date }}</h3>
|
|
<p>Bank Account: {{ getBankAccountName(activeReport.bank_account_id) }}</p>
|
|
<p>Status: {{ activeReport.status }}</p>
|
|
|
|
<div class="match-quality-filters">
|
|
<label>
|
|
<input type="checkbox" v-model="filterMatches" value="high"> High Confidence (>80%)
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" v-model="filterMatches" value="medium"> Medium Confidence (50-80%)
|
|
</label>
|
|
</div>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Bank Transaction</th>
|
|
<th>Match Score</th>
|
|
<th>Journal Entry</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="match in filteredMatches">
|
|
<td>
|
|
{{ match.transaction_date }}<br>
|
|
{{ match.transaction_description }}<br>
|
|
<strong>{{ formatCurrency(match.transaction_amount) }}</strong>
|
|
</td>
|
|
<td>
|
|
<div class="score-bar" :style="{width: match.match_score + '%'}"></div>
|
|
{{ match.match_score }}%
|
|
</td>
|
|
<td>
|
|
{{ match.journal_entry_date }}<br>
|
|
{{ match.journal_entry_description }}
|
|
</td>
|
|
<td>
|
|
<button @click="acceptMatch(match)">Accept</button>
|
|
<button @click="rejectMatch(match)" class="danger">Reject</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<div class="report-actions">
|
|
<button @click="finalizeReconciliation" class="primary" v-if="activeReport.status === 'draft'">Finalize Reconciliation</button>
|
|
<button @click="cancelReconciliation">Back to Reports</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
new Vue({
|
|
el: '#app',
|
|
data() {
|
|
return {
|
|
activeTab: 'accounts',
|
|
accounts: [],
|
|
trialBalance: [],
|
|
newAccount: {
|
|
name: '',
|
|
code: '',
|
|
type: 'Asset'
|
|
},
|
|
bankAccounts: [],
|
|
newBankAccount: {
|
|
name: '',
|
|
bank_name: '',
|
|
account_number: '',
|
|
currency: 'USD'
|
|
},
|
|
selectedBankAccount: null,
|
|
selectedBankAccountForReconciliation: null,
|
|
fileData: null,
|
|
importResult: null,
|
|
unreconciledTransactions: [],
|
|
selectedJournalEntries: {},
|
|
journalEntries: [],
|
|
reconciliationReports: [],
|
|
newReport: {
|
|
bank_account_id: null,
|
|
start_date: '',
|
|
end_date: ''
|
|
},
|
|
activeReport: null,
|
|
filterMatches: ['high', 'medium']
|
|
}
|
|
},
|
|
computed: {
|
|
filteredMatches() {
|
|
if (!this.activeReport) return [];
|
|
|
|
return this.activeReport.matches.filter(match => {
|
|
if (this.filterMatches.includes('high') && match.match_score >= 80) return true;
|
|
if (this.filterMatches.includes('medium') && match.match_score >= 50 && match.match_score < 80) return true;
|
|
return false;
|
|
});
|
|
}
|
|
},
|
|
mounted() {
|
|
this.fetchAccounts();
|
|
this.fetchTrialBalance();
|
|
this.fetchBankAccounts();
|
|
this.fetchJournalEntries();
|
|
this.fetchReconciliationReports();
|
|
},
|
|
methods: {
|
|
formatCurrency(value) {
|
|
if (value === undefined || value === null) return '$0.00';
|
|
return '$' + parseFloat(value).toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,');
|
|
},
|
|
fetchAccounts() {
|
|
axios.get('/api/accounts')
|
|
.then(response => {
|
|
this.accounts = response.data;
|
|
});
|
|
},
|
|
fetchTrialBalance() {
|
|
axios.get('/api/trial_balance')
|
|
.then(response => {
|
|
this.trialBalance = response.data;
|
|
});
|
|
},
|
|
addAccount() {
|
|
axios.post('/api/accounts', this.newAccount)
|
|
.then(response => {
|
|
if (response.data.status === 'success') {
|
|
this.fetchAccounts();
|
|
this.newAccount = { name: '', code: '', type: 'Asset' };
|
|
}
|
|
});
|
|
},
|
|
fetchBankAccounts() {
|
|
axios.get('/api/bank_accounts')
|
|
.then(response => {
|
|
this.bankAccounts = response.data;
|
|
});
|
|
},
|
|
addBankAccount() {
|
|
axios.post('/api/bank_accounts', this.newBankAccount)
|
|
.then(response => {
|
|
if (response.data.status === 'success') {
|
|
this.fetchBankAccounts();
|
|
this.newBankAccount = { name: '', bank_name: '', account_number: '', currency: 'USD' };
|
|
}
|
|
});
|
|
},
|
|
handleFileUpload(event) {
|
|
this.fileData = event.target.files[0];
|
|
},
|
|
importTransactions() {
|
|
if (!this.fileData || !this.selectedBankAccount) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const content = e.target.result;
|
|
const lines = content.split('\n');
|
|
const headers = lines[0].split(',').map(h => h.trim());
|
|
const transactions = [];
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
if (!lines[i].trim()) continue;
|
|
|
|
const values = lines[i].split(',');
|
|
const txn = {};
|
|
for (let j = 0; j < headers.length; j++) {
|
|
txn[headers[j]] = values[j] ? values[j].trim() : '';
|
|
}
|
|
// Convert amount to number
|
|
if (txn.amount) {
|
|
txn.amount = parseFloat(txn.amount);
|
|
}
|
|
transactions.push(txn);
|
|
}
|
|
|
|
axios.post(`/api/bank_accounts/${this.selectedBankAccount}/transactions`, {
|
|
transactions: transactions
|
|
}).then(response => {
|
|
this.importResult = response.data;
|
|
this.fileData = null;
|
|
document.querySelector('input[type="file"]').value = '';
|
|
if (this.selectedBankAccount === this.selectedBankAccountForReconciliation) {
|
|
this.fetchUnreconciledTransactions();
|
|
}
|
|
});
|
|
};
|
|
reader.readAsText(this.fileData);
|
|
},
|
|
fetchUnreconciledTransactions() {
|
|
if (this.selectedBankAccountForReconciliation) {
|
|
axios.get(`/api/bank_accounts/${this.selectedBankAccountForReconciliation}/unreconciled`)
|
|
.then(response => {
|
|
this.unreconciledTransactions = response.data;
|
|
});
|
|
}
|
|
},
|
|
fetchJournalEntries() {
|
|
axios.get('/api/journal_entries')
|
|
.then(response => {
|
|
this.journalEntries = response.data.map(entry => {
|
|
const debitLines = entry.lines.filter(line => line.is_debit);
|
|
const creditLines = entry.lines.filter(line => !line.is_debit);
|
|
return {
|
|
id: entry.id,
|
|
date: entry.date,
|
|
description: entry.description,
|
|
amount: debitLines.reduce((sum, line) => sum + line.amount, 0),
|
|
lines: entry.lines
|
|
};
|
|
});
|
|
});
|
|
},
|
|
potentialMatches(txn) {
|
|
// Simple matching by amount and date
|
|
const txnDate = new Date(txn.date);
|
|
return this.journalEntries.filter(entry => {
|
|
const entryDate = new Date(entry.date);
|
|
return Math.abs(entry.amount - txn.amount) < 0.01 &&
|
|
txnDate.toDateString() === entryDate.toDateString();
|
|
});
|
|
},
|
|
reconcile(txnId) {
|
|
const journalEntryId = this.selectedJournalEntries[txnId];
|
|
if (!journalEntryId) return;
|
|
|
|
axios.post('/api/reconciliation', {
|
|
transaction_id: txnId,
|
|
journal_entry_id: journalEntryId
|
|
}).then(response => {
|
|
if (response.data.status === 'success') {
|
|
this.fetchUnreconciledTransactions();
|
|
this.selectedJournalEntries[txnId] = null;
|
|
} else {
|
|
alert(response.data.message);
|
|
}
|
|
});
|
|
},
|
|
fetchReconciliationReports() {
|
|
axios.get('/api/reconciliation/reports')
|
|
.then(response => {
|
|
this.reconciliationReports = response.data;
|
|
});
|
|
},
|
|
createReconciliationReport() {
|
|
axios.post('/api/reconciliation/reports', this.newReport)
|
|
.then(response => {
|
|
if (response.data.status === 'success') {
|
|
this.loadReport(response.data.report_id);
|
|
this.fetchReconciliationReports();
|
|
}
|
|
});
|
|
},
|
|
loadReport(reportId) {
|
|
axios.get(`/api/reconciliation/reports/${reportId}`)
|
|
.then(response => {
|
|
if (response.data.status === 'success') {
|
|
this.activeReport = response.data.report;
|
|
this.activeTab = 'reports';
|
|
}
|
|
});
|
|
},
|
|
acceptMatch(match) {
|
|
axios.post('/api/reconciliation', {
|
|
transaction_id: match.transaction_id,
|
|
journal_entry_id: match.journal_entry_id
|
|
}).then(response => {
|
|
if (response.data.status === 'success') {
|
|
this.loadReport(this.activeReport.id);
|
|
}
|
|
});
|
|
},
|
|
rejectMatch(match) {
|
|
axios.delete(`/api/reconciliation/matches/${match.id}`)
|
|
.then(() => {
|
|
this.loadReport(this.activeReport.id);
|
|
});
|
|
},
|
|
finalizeReconciliation() {
|
|
axios.post(`/api/reconciliation/reports/${this.activeReport.id}/finalize`)
|
|
.then(response => {
|
|
if (response.data.status === 'success') {
|
|
this.activeReport = null;
|
|
this.fetchReconciliationReports();
|
|
this.fetchUnreconciledTransactions();
|
|
}
|
|
});
|
|
},
|
|
cancelReconciliation() {
|
|
this.activeReport = null;
|
|
},
|
|
getBankAccountName(accountId) {
|
|
const account = this.bankAccounts.find(a => a.id === accountId);
|
|
return account ? `${account.bank_name} - ${account.account_number}` : '';
|
|
},
|
|
viewReconciliation(accountId) {
|
|
this.selectedBankAccountForReconciliation = accountId;
|
|
this.fetchUnreconciledTransactions();
|
|
this.activeTab = 'banking';
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |