Add attachment support for fuel and recurring expenses with camera capture
Co-authored-by: aiulian25 <17886483+aiulian25@users.noreply.github.com>
This commit is contained in:
parent
339cd94b26
commit
8a5b916a39
8 changed files with 487 additions and 9 deletions
394
ATTACHMENT_API.md
Normal file
394
ATTACHMENT_API.md
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
# Attachment API Documentation
|
||||
|
||||
## Overview
|
||||
The Masina-Dock application supports file attachments for fuel records, service records, and recurring expenses (taxes). This document describes the API endpoints, data models, and usage patterns for working with attachments.
|
||||
|
||||
## Security
|
||||
- All attachment operations require authentication via `@login_required` decorator
|
||||
- Users can only access their own attachments through vehicle ownership validation
|
||||
- File paths are validated to prevent directory traversal attacks
|
||||
- Uploaded files are stored in `/app/uploads/attachments/` with secure filenames
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Upload Attachment
|
||||
Upload a file attachment.
|
||||
|
||||
**Endpoint:** `POST /api/upload/attachment`
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Request:**
|
||||
- Content-Type: `multipart/form-data`
|
||||
- Body parameter: `attachment` (file)
|
||||
|
||||
**Allowed File Types:**
|
||||
- PDF: `.pdf`
|
||||
- Images: `.png`, `.jpg`, `.jpeg`
|
||||
- Documents: `.txt`, `.doc`, `.docx`
|
||||
- Spreadsheets: `.xls`, `.xlsx`, `.csv`
|
||||
- OpenDocument: `.odt`, `.ods`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"file_path": "attachments/20231112153045_a1b2c3d4_receipt.pdf"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- 400: No attachment provided / No file selected / Invalid file type
|
||||
- 500: Server error during upload
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
const formData = new FormData();
|
||||
formData.append('attachment', fileInput.files[0]);
|
||||
|
||||
const response = await fetch('/api/upload/attachment', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const filePath = data.file_path;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Download Attachment
|
||||
Download or view an attachment.
|
||||
|
||||
**Endpoint:** `GET /api/attachments/download`
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Query Parameters:**
|
||||
- `path` (required): Relative path to the attachment (e.g., `attachments/filename.pdf`)
|
||||
|
||||
**Response:**
|
||||
- File content with appropriate Content-Type header
|
||||
- Downloads as attachment
|
||||
|
||||
**Error Responses:**
|
||||
- 400: No file path provided
|
||||
- 403: Invalid file path (security violation)
|
||||
- 404: File not found
|
||||
|
||||
**Example:**
|
||||
```html
|
||||
<a href="/api/attachments/download?path=attachments/20231112153045_a1b2c3d4_receipt.pdf">
|
||||
Download Receipt
|
||||
</a>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
### FuelRecord
|
||||
```python
|
||||
class FuelRecord(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
odometer = db.Column(db.Integer, nullable=False)
|
||||
fuel_amount = db.Column(db.Float, nullable=False)
|
||||
cost = db.Column(db.Float, nullable=False)
|
||||
unit_cost = db.Column(db.Float)
|
||||
distance = db.Column(db.Integer)
|
||||
fuel_economy = db.Column(db.Float)
|
||||
unit = db.Column(db.String(20), default='MPG')
|
||||
notes = db.Column(db.Text)
|
||||
document_path = db.Column(db.String(255)) # NEW: Stores attachment path
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
```
|
||||
|
||||
### RecurringExpense
|
||||
```python
|
||||
class RecurringExpense(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False)
|
||||
expense_type = db.Column(db.String(50), nullable=False)
|
||||
description = db.Column(db.String(200), nullable=False)
|
||||
amount = db.Column(db.Float, nullable=False)
|
||||
frequency = db.Column(db.String(20), nullable=False)
|
||||
start_date = db.Column(db.Date, nullable=False)
|
||||
next_due_date = db.Column(db.Date, nullable=False)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
notes = db.Column(db.Text)
|
||||
document_path = db.Column(db.String(255)) # NEW: Stores attachment path
|
||||
```
|
||||
|
||||
### ServiceRecord
|
||||
```python
|
||||
class ServiceRecord(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'), nullable=False)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
odometer = db.Column(db.Integer, nullable=False)
|
||||
description = db.Column(db.String(200), nullable=False)
|
||||
cost = db.Column(db.Float, default=0.0)
|
||||
notes = db.Column(db.Text)
|
||||
category = db.Column(db.String(50))
|
||||
document_path = db.Column(db.String(255)) # EXISTING: Already supported
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using Attachments with Records
|
||||
|
||||
### Fuel Records
|
||||
|
||||
**Create with Attachment:**
|
||||
```javascript
|
||||
// 1. Upload attachment first
|
||||
const formData = new FormData();
|
||||
formData.append('attachment', fileInput.files[0]);
|
||||
const uploadResponse = await fetch('/api/upload/attachment', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
const { file_path } = await uploadResponse.json();
|
||||
|
||||
// 2. Create fuel record with attachment path
|
||||
await fetch(`/api/vehicles/${vehicleId}/fuel-records`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
date: '2023-11-12',
|
||||
odometer: 45000,
|
||||
fuel_amount: 12.5,
|
||||
cost: 45.00,
|
||||
unit: 'MPG',
|
||||
notes: 'Regular fill-up',
|
||||
document_path: file_path // Attach the uploaded file
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
**Retrieve with Attachment:**
|
||||
```javascript
|
||||
const records = await fetch(`/api/vehicles/${vehicleId}/fuel-records`, {
|
||||
credentials: 'include'
|
||||
}).then(r => r.json());
|
||||
|
||||
// Each record includes document_path
|
||||
records.forEach(record => {
|
||||
if (record.document_path) {
|
||||
console.log(`Attachment: ${record.document_path}`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Recurring Expenses (Taxes)
|
||||
|
||||
**Create with Attachment:**
|
||||
```javascript
|
||||
// 1. Upload attachment
|
||||
const formData = new FormData();
|
||||
formData.append('attachment', fileInput.files[0]);
|
||||
const uploadResponse = await fetch('/api/upload/attachment', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
const { file_path } = await uploadResponse.json();
|
||||
|
||||
// 2. Create recurring expense with attachment
|
||||
await fetch(`/api/vehicles/${vehicleId}/recurring-expenses`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
expense_type: 'Insurance',
|
||||
description: 'Car Insurance',
|
||||
amount: 1200.00,
|
||||
frequency: 'yearly',
|
||||
start_date: '2023-11-12',
|
||||
notes: 'Annual premium',
|
||||
document_path: file_path // Attach the policy document
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
### Service Records
|
||||
|
||||
**Create with Attachment:**
|
||||
```javascript
|
||||
// Same pattern as fuel records
|
||||
const formData = new FormData();
|
||||
formData.append('attachment', fileInput.files[0]);
|
||||
const uploadResponse = await fetch('/api/upload/attachment', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
const { file_path } = await uploadResponse.json();
|
||||
|
||||
await fetch(`/api/vehicles/${vehicleId}/service-records`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
date: '2023-11-12',
|
||||
odometer: 45000,
|
||||
description: 'Oil Change',
|
||||
category: 'Maintenance',
|
||||
cost: 45.00,
|
||||
notes: 'Synthetic oil',
|
||||
document_path: file_path // Attach the invoice
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mobile Camera Support
|
||||
|
||||
All attachment input fields support camera capture on mobile devices via the `capture="environment"` attribute:
|
||||
|
||||
```html
|
||||
<input type="file"
|
||||
id="fuel-attachment"
|
||||
name="attachment"
|
||||
accept="image/*,.pdf,.txt"
|
||||
capture="environment">
|
||||
```
|
||||
|
||||
On mobile devices, this will prompt the user to:
|
||||
1. Take a photo with the camera, OR
|
||||
2. Select a file from the device
|
||||
|
||||
The `accept="image/*"` attribute allows any image format, while specific formats like `.pdf` and `.txt` are also accepted.
|
||||
|
||||
---
|
||||
|
||||
## Migration
|
||||
|
||||
To add attachment support to an existing database, run the migration script:
|
||||
|
||||
```bash
|
||||
# Inside Docker container
|
||||
python /app/backend/migrate_attachments.py
|
||||
|
||||
# Or locally
|
||||
cd backend
|
||||
python migrate_attachments.py
|
||||
```
|
||||
|
||||
This will add the `document_path` column to:
|
||||
- `fuel_record` table
|
||||
- `recurring_expense` table
|
||||
|
||||
---
|
||||
|
||||
## File Storage
|
||||
|
||||
**Location:** `/app/uploads/attachments/`
|
||||
|
||||
**Filename Format:** `{timestamp}_{random}_{original_filename}`
|
||||
- Example: `20231112153045_a1b2c3d4_receipt.pdf`
|
||||
|
||||
**Permissions:** Files are saved with `0o644` permissions (read for all, write for owner)
|
||||
|
||||
**Directory Creation:** The attachments directory is created automatically if it doesn't exist
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always upload the file first**, then reference it in the record
|
||||
2. **Handle upload failures gracefully** - allow users to save records without attachments
|
||||
3. **Validate file types** on both client and server side
|
||||
4. **Use relative paths** - store only `attachments/filename.ext`, not absolute paths
|
||||
5. **Check file existence** before displaying download links
|
||||
6. **Implement cleanup** - consider removing orphaned attachments when records are deleted
|
||||
|
||||
---
|
||||
|
||||
## Example: Complete Flow
|
||||
|
||||
```javascript
|
||||
async function addFuelRecordWithAttachment(vehicleId, recordData, file) {
|
||||
let documentPath = null;
|
||||
|
||||
// Step 1: Upload attachment if present
|
||||
if (file) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('attachment', file);
|
||||
|
||||
const uploadResponse = await fetch('/api/upload/attachment', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
console.error('Attachment upload failed');
|
||||
// Continue without attachment
|
||||
} else {
|
||||
const { file_path } = await uploadResponse.json();
|
||||
documentPath = file_path;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Attachment upload error:', error);
|
||||
// Continue without attachment
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Create fuel record
|
||||
const response = await fetch(`/api/vehicles/${vehicleId}/fuel-records`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
...recordData,
|
||||
document_path: documentPath
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create fuel record');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
```javascript
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('attachment', file);
|
||||
|
||||
const response = await fetch('/api/upload/attachment', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
console.error('Upload failed:', error.error);
|
||||
// Handle specific error cases
|
||||
if (response.status === 400) {
|
||||
alert('Invalid file type or no file selected');
|
||||
} else if (response.status === 500) {
|
||||
alert('Server error during upload');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Network error:', error);
|
||||
alert('Failed to connect to server');
|
||||
}
|
||||
```
|
||||
|
|
@ -505,7 +505,8 @@ def fuel_records(vehicle_id):
|
|||
'distance': r.distance,
|
||||
'fuel_economy': r.fuel_economy,
|
||||
'unit': r.unit,
|
||||
'notes': r.notes
|
||||
'notes': r.notes,
|
||||
'document_path': r.document_path
|
||||
} for r in records]), 200
|
||||
|
||||
elif request.method == 'POST':
|
||||
|
|
@ -625,7 +626,8 @@ def recurring_expenses(vehicle_id):
|
|||
'start_date': e.start_date.isoformat(),
|
||||
'next_due_date': e.next_due_date.isoformat(),
|
||||
'is_active': e.is_active,
|
||||
'notes': e.notes
|
||||
'notes': e.notes,
|
||||
'document_path': e.document_path
|
||||
} for e in expenses]), 200
|
||||
|
||||
elif request.method == 'POST':
|
||||
|
|
@ -651,7 +653,8 @@ def recurring_expenses(vehicle_id):
|
|||
frequency=frequency,
|
||||
start_date=start_date,
|
||||
next_due_date=next_due,
|
||||
notes=data.get('notes')
|
||||
notes=data.get('notes'),
|
||||
document_path=data.get('document_path')
|
||||
)
|
||||
|
||||
db.session.add(expense)
|
||||
|
|
@ -847,7 +850,8 @@ def fuel_record_operations(vehicle_id, record_id):
|
|||
'odometer': record.odometer,
|
||||
'fuel_amount': record.fuel_amount,
|
||||
'cost': record.cost,
|
||||
'notes': record.notes or ''
|
||||
'notes': record.notes or '',
|
||||
'document_path': record.document_path or ''
|
||||
})
|
||||
|
||||
elif request.method == 'PUT':
|
||||
|
|
@ -857,6 +861,7 @@ def fuel_record_operations(vehicle_id, record_id):
|
|||
record.fuel_amount = data.get('fuel_amount', record.fuel_amount)
|
||||
record.cost = data.get('cost', record.cost)
|
||||
record.notes = data.get('notes', record.notes)
|
||||
record.document_path = data.get('document_path', record.document_path)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Fuel record updated successfully'})
|
||||
|
||||
|
|
|
|||
75
backend/migrate_attachments.py
Normal file
75
backend/migrate_attachments.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database migration script to add document_path columns to FuelRecord and RecurringExpense tables.
|
||||
This migration adds support for file attachments to fuel and tax (recurring expense) entries.
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
import sys
|
||||
|
||||
def migrate_database():
|
||||
"""Add document_path columns to FuelRecord and RecurringExpense tables."""
|
||||
# Support both Docker and local paths
|
||||
db_paths = [
|
||||
'/app/data/masina_dock.db',
|
||||
'./data/masina_dock.db',
|
||||
'../data/masina_dock.db'
|
||||
]
|
||||
|
||||
db_path = None
|
||||
for path in db_paths:
|
||||
if os.path.exists(path):
|
||||
db_path = path
|
||||
break
|
||||
|
||||
if not db_path:
|
||||
print("Database does not exist yet. Migration will be applied on first run.")
|
||||
return True
|
||||
|
||||
print(f"Migrating database at: {db_path}")
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check FuelRecord table
|
||||
cursor.execute("PRAGMA table_info(fuel_record)")
|
||||
fuel_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'document_path' not in fuel_columns:
|
||||
print("Adding document_path column to fuel_record table...")
|
||||
cursor.execute("ALTER TABLE fuel_record ADD COLUMN document_path VARCHAR(255)")
|
||||
print("✓ Added document_path to fuel_record")
|
||||
else:
|
||||
print("✓ fuel_record.document_path already exists")
|
||||
|
||||
# Check RecurringExpense table
|
||||
cursor.execute("PRAGMA table_info(recurring_expense)")
|
||||
expense_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'document_path' not in expense_columns:
|
||||
print("Adding document_path column to recurring_expense table...")
|
||||
cursor.execute("ALTER TABLE recurring_expense ADD COLUMN document_path VARCHAR(255)")
|
||||
print("✓ Added document_path to recurring_expense")
|
||||
else:
|
||||
print("✓ recurring_expense.document_path already exists")
|
||||
|
||||
conn.commit()
|
||||
print("\n✓ Database migration completed successfully!")
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"✗ Migration error: {e}", file=sys.stderr)
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"✗ Unexpected error: {e}", file=sys.stderr)
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
success = migrate_database()
|
||||
sys.exit(0 if success else 1)
|
||||
|
|
@ -111,6 +111,7 @@ class FuelRecord(db.Model):
|
|||
fuel_economy = db.Column(db.Float)
|
||||
unit = db.Column(db.String(20), default='MPG')
|
||||
notes = db.Column(db.Text)
|
||||
document_path = db.Column(db.String(255))
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
class Reminder(db.Model):
|
||||
|
|
@ -151,3 +152,4 @@ class RecurringExpense(db.Model):
|
|||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
notes = db.Column(db.Text)
|
||||
document_path = db.Column(db.String(255))
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ function displayFuelRecords(records) {
|
|||
<td>${formatCurrency(r.cost, settings.currency)}</td>
|
||||
<td>${r.distance || 'N/A'}</td>
|
||||
<td>${r.fuel_economy ? r.fuel_economy.toFixed(2) : 'N/A'} ${r.unit}</td>
|
||||
<td>${r.notes && r.notes.startsWith('attachment:') ? `<a href="/api/attachments/download?path=${encodeURIComponent(r.notes.replace('attachment:', ''))}" class="btn" style="padding: 5px 10px; font-size: 12px;">Download</a>` : 'None'}</td>
|
||||
<td>${r.document_path ? `<a href="/api/attachments/download?path=${encodeURIComponent(r.document_path)}" class="btn" style="padding: 5px 10px; font-size: 12px;">Download</a>` : 'None'}</td>
|
||||
<td>
|
||||
<button onclick="editFuelRecord(${vehicleId}, ${r.id})" class="btn" style="padding: 5px 10px; font-size: 12px; margin-right: 5px;">Edit</button>
|
||||
<button onclick="deleteFuelRecord(${vehicleId}, ${r.id})" class="btn" style="padding: 5px 10px; font-size: 12px; background: #dc3545;">Delete</button>
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fuel-attachment" data-translate="optional">Receipt (Optional)</label>
|
||||
<input type="file" id="fuel-attachment" name="attachment" accept=".pdf,.png,.jpg,.jpeg,.txt">
|
||||
<input type="file" id="fuel-attachment" name="attachment" accept="image/*,.pdf,.txt" capture="environment">
|
||||
<small data-translate="supported_files">Supported: PDF, Images, Text</small>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px; margin-top: 20px;">
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<label for="service-attachment" data-translate="optional">Atasament (Optional)</label>
|
||||
<input type="file" id="service-attachment" name="attachment" accept=".pdf,.png,.jpg,.jpeg,.txt,.doc,.docx,.xls,.xlsx">
|
||||
<input type="file" id="service-attachment" name="attachment" accept="image/*,.pdf,.txt,.doc,.docx,.xls,.xlsx" capture="environment">
|
||||
<small data-translate="supported_files">Suportat: PDF, Imagini, Text, Word, Excel</small>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px; margin-top: 20px;">
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tax-attachment" data-translate="optional">Document (Optional)</label>
|
||||
<input type="file" id="tax-attachment" name="attachment" accept=".pdf,.png,.jpg,.jpeg,.txt,.doc,.docx">
|
||||
<input type="file" id="tax-attachment" name="attachment" accept="image/*,.pdf,.txt,.doc,.docx" capture="environment">
|
||||
<small data-translate="supported_files">Supported: PDF, Images, Text, Word</small>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px; margin-top: 20px;">
|
||||
|
|
@ -193,6 +193,7 @@
|
|||
<p><strong>Frequency:</strong> ${e.frequency}</p>
|
||||
<p><strong>Next Due:</strong> ${formatDate(e.next_due_date)}</p>
|
||||
<p style="color: var(--text-secondary); font-size: 14px;">${e.notes || ''}</p>
|
||||
${e.document_path ? `<p><strong>Attachment:</strong> <a href="/api/attachments/download?path=${encodeURIComponent(e.document_path)}" class="btn" style="padding: 5px 10px; font-size: 12px; display: inline-block; margin-top: 5px;">Download</a></p>` : ''}
|
||||
<button class="btn btn-danger" onclick="cancelRecurringExpense(${e.id})" style="margin-top: 10px;">
|
||||
Cancel Recurring
|
||||
</button>
|
||||
|
|
@ -291,7 +292,8 @@
|
|||
amount: parseFloat(document.getElementById('tax-amount').value),
|
||||
frequency: document.getElementById('tax-frequency').value,
|
||||
start_date: document.getElementById('tax-date').value,
|
||||
notes: document.getElementById('tax-notes').value || null
|
||||
notes: document.getElementById('tax-notes').value || null,
|
||||
document_path: attachmentPath
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue