Initial commit: TonjumTechRecords - business income/expense tracker with Flask backend and Tailwind frontend
This commit is contained in:
commit
a700774463
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
*.db
|
||||
*.log
|
||||
.env
|
||||
713
app.py
Normal file
713
app.py
Normal file
@ -0,0 +1,713 @@
|
||||
import os
|
||||
import csv
|
||||
import io
|
||||
import datetime
|
||||
import bcrypt
|
||||
import jwt
|
||||
import mysql.connector
|
||||
from functools import wraps
|
||||
from flask import Flask, render_template, request, jsonify, send_file, session
|
||||
from flask_cors import CORS
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = 'tonjumtechrecords-secret-key-change-in-production'
|
||||
CORS(app)
|
||||
|
||||
DB_CONFIG = {
|
||||
'host': '10.7.0.1',
|
||||
'user': 'nick',
|
||||
'password': 'nick9924Tucker9924##',
|
||||
'database': 'TonjumTechRecords',
|
||||
'charset': 'utf8mb4'
|
||||
}
|
||||
JWT_SECRET = 'tonjumtechrecords-jwt-secret-change-in-production'
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = mysql.connector.connect(**DB_CONFIG)
|
||||
return conn
|
||||
|
||||
|
||||
def token_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
token = None
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header and auth_header.startswith('Bearer '):
|
||||
token = auth_header.split(' ')[1]
|
||||
if not token:
|
||||
return jsonify({'message': 'Token is missing'}), 401
|
||||
try:
|
||||
data = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
current_user = data['username']
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'message': 'Token has expired'}), 401
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'message': 'Invalid token'}), 401
|
||||
return f(current_user, *args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def advance_billing_date(next_date, interval):
|
||||
d = next_date
|
||||
if interval == 'Weekly':
|
||||
return d + datetime.timedelta(weeks=1)
|
||||
elif interval == 'Monthly':
|
||||
month = d.month + 1
|
||||
year = d.year
|
||||
if month > 12:
|
||||
month = 1
|
||||
year += 1
|
||||
day = min(d.day, [31, 29 if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) else 28,
|
||||
31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1])
|
||||
return d.replace(year=year, month=month, day=day)
|
||||
elif interval == 'Quarterly':
|
||||
month = d.month + 3
|
||||
year = d.year
|
||||
if month > 12:
|
||||
month -= 12
|
||||
year += 1
|
||||
day = min(d.day, [31, 29 if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) else 28,
|
||||
31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1])
|
||||
return d.replace(year=year, month=month, day=day)
|
||||
elif interval == 'Annually':
|
||||
year = d.year + 1
|
||||
day = min(d.day, [31, 29 if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) else 28,
|
||||
31, 30, 31, 30, 31, 31, 30, 31, 30, 31][d.month - 1])
|
||||
return d.replace(year=year, day=day)
|
||||
|
||||
|
||||
def run_subscription_billing():
|
||||
with app.app_context():
|
||||
try:
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
today = datetime.date.today()
|
||||
cursor.execute("""
|
||||
SELECT s.*, c.Name AS ClientName FROM Subscriptions s
|
||||
JOIN Clients c ON c.Id = s.ClientId
|
||||
WHERE s.IsActive = TRUE AND s.NextBillingDate <= %s
|
||||
""", (today,))
|
||||
due = cursor.fetchall()
|
||||
for sub in due:
|
||||
if sub['EndDate'] and sub['NextBillingDate'] > sub['EndDate']:
|
||||
cursor.execute("UPDATE Subscriptions SET IsActive = FALSE WHERE Id = %s", (sub['Id'],))
|
||||
conn.commit()
|
||||
continue
|
||||
cursor.execute("""
|
||||
INSERT INTO Transactions (Type, TransactionDate, ClientId, Amount, PaymentMethod, Description, SubscriptionId)
|
||||
VALUES ('Income', %s, %s, %s, 'Other', %s, %s)
|
||||
""", (sub['NextBillingDate'], sub['ClientId'], sub['Amount'],
|
||||
f"Subscription: {sub['ServiceName']} (auto-billed)", sub['Id']))
|
||||
next_date = advance_billing_date(sub['NextBillingDate'], sub['Interval'])
|
||||
if sub['EndDate'] and next_date > sub['EndDate']:
|
||||
cursor.execute("UPDATE Subscriptions SET NextBillingDate = %s, IsActive = FALSE WHERE Id = %s",
|
||||
(next_date, sub['Id']))
|
||||
else:
|
||||
cursor.execute("UPDATE Subscriptions SET NextBillingDate = %s WHERE Id = %s",
|
||||
(next_date, sub['Id']))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"[SubscriptionBilling] Error: {e}")
|
||||
|
||||
|
||||
scheduler = None
|
||||
|
||||
def start_scheduler():
|
||||
global scheduler
|
||||
if scheduler is not None:
|
||||
return
|
||||
scheduler = BackgroundScheduler()
|
||||
scheduler.add_job(run_subscription_billing, 'interval', hours=1, id='subscription_billing')
|
||||
scheduler.start()
|
||||
print("[Scheduler] Started subscription billing service")
|
||||
run_subscription_billing()
|
||||
|
||||
|
||||
# --- Auth ---
|
||||
|
||||
@app.route('/api/auth/login', methods=['POST'])
|
||||
def login():
|
||||
data = request.get_json()
|
||||
username = data.get('username', '')
|
||||
password = data.get('password', '')
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM Users WHERE Username = %s", (username,))
|
||||
user = cursor.fetchone()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
if user and bcrypt.checkpw(password.encode(), user['PasswordHash'].encode()):
|
||||
token = jwt.encode({
|
||||
'username': user['Username'],
|
||||
'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=24)
|
||||
}, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
return jsonify({'token': token, 'username': user['Username']})
|
||||
return jsonify({'message': 'Invalid credentials'}), 401
|
||||
|
||||
|
||||
# --- Dashboard ---
|
||||
|
||||
@app.route('/api/dashboard/summary')
|
||||
@token_required
|
||||
def dashboard_summary(current_user):
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
today = datetime.date.today()
|
||||
first_of_month = today.replace(day=1)
|
||||
year_start = today.replace(month=1, day=1)
|
||||
|
||||
cursor.execute("""
|
||||
SELECT COALESCE(SUM(CASE WHEN Type='Income' THEN Amount ELSE -Amount END), 0) AS total
|
||||
FROM Transactions
|
||||
""")
|
||||
total_profit = cursor.fetchone()['total']
|
||||
|
||||
cursor.execute("""
|
||||
SELECT COALESCE(SUM(CASE WHEN Type='Income' THEN Amount ELSE -Amount END), 0) AS total
|
||||
FROM Transactions WHERE TransactionDate >= %s
|
||||
""", (today - datetime.timedelta(days=365),))
|
||||
last_365 = cursor.fetchone()['total']
|
||||
|
||||
cursor.execute("""
|
||||
SELECT COALESCE(SUM(CASE WHEN Type='Income' THEN Amount ELSE -Amount END), 0) AS total
|
||||
FROM Transactions WHERE TransactionDate >= %s
|
||||
""", (today - datetime.timedelta(days=30),))
|
||||
last_30 = cursor.fetchone()['total']
|
||||
|
||||
months_data = []
|
||||
for i in range(11, -1, -1):
|
||||
m = today.month - i
|
||||
y = today.year
|
||||
if m <= 0:
|
||||
m += 12
|
||||
y -= 1
|
||||
month_start = datetime.date(y, m, 1)
|
||||
if m == 12:
|
||||
month_end = datetime.date(y + 1, 1, 1)
|
||||
else:
|
||||
month_end = datetime.date(y, m + 1, 1)
|
||||
cursor.execute("""
|
||||
SELECT COALESCE(SUM(CASE WHEN Type='Income' THEN Amount ELSE 0 END), 0) AS income,
|
||||
COALESCE(SUM(CASE WHEN Type='Expense' THEN Amount ELSE 0 END), 0) AS expense
|
||||
FROM Transactions WHERE TransactionDate >= %s AND TransactionDate < %s
|
||||
""", (month_start, month_end))
|
||||
row = cursor.fetchone()
|
||||
months_data.append({
|
||||
'month': f'{y}-{m:02d}',
|
||||
'income': float(row['income']),
|
||||
'expense': float(row['expense'])
|
||||
})
|
||||
|
||||
cursor.execute("""
|
||||
SELECT COALESCE(SUM(CASE WHEN Type='Income' THEN Amount ELSE -Amount END), 0) AS total
|
||||
FROM Transactions WHERE TransactionDate >= %s
|
||||
""", (year_start,))
|
||||
ytd_profit = cursor.fetchone()['total']
|
||||
|
||||
cursor.execute("""
|
||||
SELECT * FROM Subscriptions WHERE IsActive = TRUE
|
||||
AND (EndDate IS NULL OR EndDate > %s)
|
||||
""", (today,))
|
||||
active_subs = cursor.fetchall()
|
||||
active_subs_count = len(active_subs)
|
||||
mrr_map = {'Weekly': 4.33, 'Monthly': 1, 'Quarterly': 1/3, 'Annually': 1/12}
|
||||
active_subs_total = sum(float(s['Amount']) * mrr_map.get(s['Interval'], 1) for s in active_subs)
|
||||
year_end = datetime.date(today.year, 12, 31)
|
||||
|
||||
future_sub_projected = 0.0
|
||||
for sub in active_subs:
|
||||
bdate = sub['NextBillingDate']
|
||||
while bdate <= year_end:
|
||||
if sub['EndDate'] and bdate > sub['EndDate']:
|
||||
break
|
||||
future_sub_projected += float(sub['Amount'])
|
||||
bdate = advance_billing_date(bdate, sub['Interval'])
|
||||
|
||||
projected = float(ytd_profit) + future_sub_projected
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({
|
||||
'totalProfit': float(total_profit),
|
||||
'last365': float(last_365),
|
||||
'last30': float(last_30),
|
||||
'projected': float(projected),
|
||||
'monthlyHistory': months_data,
|
||||
'activeSubscriptions': active_subs_count,
|
||||
'activeSubscriptionsTotal': float(active_subs_total)
|
||||
})
|
||||
|
||||
|
||||
# --- Transactions ---
|
||||
|
||||
@app.route('/api/transactions', methods=['GET'])
|
||||
@token_required
|
||||
def list_transactions(current_user):
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
page = request.args.get('page', 1, type=int)
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
offset = (page - 1) * limit
|
||||
ttype = request.args.get('type')
|
||||
client_id = request.args.get('client_id', type=int)
|
||||
from_date = request.args.get('from')
|
||||
to_date = request.args.get('to')
|
||||
|
||||
where = []
|
||||
params = []
|
||||
if ttype:
|
||||
where.append("t.Type = %s")
|
||||
params.append(ttype)
|
||||
if client_id:
|
||||
where.append("t.ClientId = %s")
|
||||
params.append(client_id)
|
||||
if from_date:
|
||||
where.append("t.TransactionDate >= %s")
|
||||
params.append(from_date)
|
||||
if to_date:
|
||||
where.append("t.TransactionDate <= %s")
|
||||
params.append(to_date)
|
||||
|
||||
where_clause = ('WHERE ' + ' AND '.join(where)) if where else ''
|
||||
cursor.execute(f"""
|
||||
SELECT t.*, c.Name AS ClientName, s.ServiceName AS SubscriptionName
|
||||
FROM Transactions t
|
||||
LEFT JOIN Clients c ON c.Id = t.ClientId
|
||||
LEFT JOIN Subscriptions s ON s.Id = t.SubscriptionId
|
||||
{where_clause}
|
||||
ORDER BY t.TransactionDate DESC, t.Id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [limit, offset])
|
||||
transactions = cursor.fetchall()
|
||||
for tx in transactions:
|
||||
tx['Amount'] = float(tx['Amount'])
|
||||
tx['HasImage'] = tx['ImageData'] is not None
|
||||
tx['ImageData'] = None
|
||||
tx.pop('UpdatedAt', None)
|
||||
if tx['CreatedAt']:
|
||||
tx['CreatedAt'] = tx['CreatedAt'].isoformat()
|
||||
if tx['TransactionDate']:
|
||||
tx['TransactionDate'] = tx['TransactionDate'].isoformat()
|
||||
|
||||
cursor.execute(f"SELECT COUNT(*) AS cnt FROM Transactions t {where_clause}", params)
|
||||
total = cursor.fetchone()['cnt']
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'transactions': transactions, 'total': total, 'page': page, 'limit': limit})
|
||||
|
||||
|
||||
@app.route('/api/transactions', methods=['POST'])
|
||||
@token_required
|
||||
def create_transaction(current_user):
|
||||
ttype = request.form.get('type')
|
||||
date_str = request.form.get('date')
|
||||
client_name = request.form.get('client_name', '').strip()
|
||||
client_id = request.form.get('client_id', type=int)
|
||||
amount = request.form.get('amount', type=float)
|
||||
payment_method = request.form.get('payment_method')
|
||||
description = request.form.get('description', '')
|
||||
image_file = request.files.get('image')
|
||||
|
||||
if not all([ttype, date_str, amount, payment_method]):
|
||||
return jsonify({'message': 'Missing required fields'}), 400
|
||||
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
if client_name and not client_id:
|
||||
cursor.execute("INSERT IGNORE INTO Clients (Name) VALUES (%s)", (client_name,))
|
||||
conn.commit()
|
||||
cursor.execute("SELECT Id FROM Clients WHERE Name = %s", (client_name,))
|
||||
row = cursor.fetchone()
|
||||
client_id = row['Id'] if row else None
|
||||
elif client_id:
|
||||
pass
|
||||
|
||||
image_data = None
|
||||
image_content_type = None
|
||||
if image_file and image_file.filename:
|
||||
image_data = image_file.read()
|
||||
image_content_type = image_file.content_type or 'image/jpeg'
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO Transactions (Type, TransactionDate, ClientId, Amount, PaymentMethod, Description, ImageData, ImageContentType)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (ttype, date_str, client_id, amount, payment_method, description, image_data, image_content_type))
|
||||
conn.commit()
|
||||
tx_id = cursor.lastrowid
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Transaction created', 'id': tx_id}), 201
|
||||
|
||||
|
||||
@app.route('/api/transactions/<int:tx_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_transaction(current_user, tx_id):
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM Transactions WHERE Id = %s", (tx_id,))
|
||||
existing = cursor.fetchone()
|
||||
if not existing:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Not found'}), 404
|
||||
|
||||
ttype = request.form.get('type', existing['Type'])
|
||||
date_str = request.form.get('date', existing['TransactionDate'].isoformat() if existing['TransactionDate'] else None)
|
||||
client_name = request.form.get('client_name', '').strip()
|
||||
client_id = request.form.get('client_id', type=int)
|
||||
amount = request.form.get('amount', type=float) or float(existing['Amount'])
|
||||
payment_method = request.form.get('payment_method', existing['PaymentMethod'])
|
||||
description = request.form.get('description', existing['Description'] or '')
|
||||
image_file = request.files.get('image')
|
||||
|
||||
if client_name and not client_id:
|
||||
cursor.execute("INSERT IGNORE INTO Clients (Name) VALUES (%s)", (client_name,))
|
||||
conn.commit()
|
||||
cursor.execute("SELECT Id FROM Clients WHERE Name = %s", (client_name,))
|
||||
row = cursor.fetchone()
|
||||
client_id = row['Id'] if row else existing['ClientId']
|
||||
else:
|
||||
client_id = client_id if client_id is not None else existing['ClientId']
|
||||
|
||||
if image_file and image_file.filename:
|
||||
image_data = image_file.read()
|
||||
image_content_type = image_file.content_type or 'image/jpeg'
|
||||
cursor.execute("""
|
||||
UPDATE Transactions SET Type=%s, TransactionDate=%s, ClientId=%s, Amount=%s,
|
||||
PaymentMethod=%s, Description=%s, ImageData=%s, ImageContentType=%s
|
||||
WHERE Id=%s
|
||||
""", (ttype, date_str, client_id, amount, payment_method, description, image_data, image_content_type, tx_id))
|
||||
else:
|
||||
cursor.execute("""
|
||||
UPDATE Transactions SET Type=%s, TransactionDate=%s, ClientId=%s, Amount=%s,
|
||||
PaymentMethod=%s, Description=%s
|
||||
WHERE Id=%s
|
||||
""", (ttype, date_str, client_id, amount, payment_method, description, tx_id))
|
||||
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Transaction updated'})
|
||||
|
||||
|
||||
@app.route('/api/transactions/<int:tx_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_transaction(current_user, tx_id):
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM Transactions WHERE Id = %s", (tx_id,))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Transaction deleted'})
|
||||
|
||||
|
||||
@app.route('/api/transactions/<int:tx_id>/image')
|
||||
def get_image(tx_id):
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT ImageData, ImageContentType FROM Transactions WHERE Id = %s", (tx_id,))
|
||||
row = cursor.fetchone()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
if row and row['ImageData']:
|
||||
return send_file(io.BytesIO(row['ImageData']), mimetype=row['ImageContentType'] or 'image/jpeg')
|
||||
return jsonify({'message': 'No image'}), 404
|
||||
|
||||
|
||||
@app.route('/api/transactions/export/csv')
|
||||
@token_required
|
||||
def export_csv(current_user):
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("""
|
||||
SELECT t.TransactionDate, c.Name AS ClientName, t.Amount, t.Description
|
||||
FROM Transactions t
|
||||
LEFT JOIN Clients c ON c.Id = t.ClientId
|
||||
ORDER BY t.TransactionDate DESC
|
||||
""")
|
||||
rows = cursor.fetchall()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['Date', 'Client', 'Amount', 'Description'])
|
||||
for r in rows:
|
||||
writer.writerow([
|
||||
r['TransactionDate'].isoformat() if r['TransactionDate'] else '',
|
||||
r['ClientName'] or '',
|
||||
float(r['Amount']) if r['Amount'] else 0,
|
||||
r['Description'] or ''
|
||||
])
|
||||
mem = io.BytesIO(output.getvalue().encode('utf-8'))
|
||||
return send_file(mem, mimetype='text/csv', as_attachment=True, download_name='transactions.csv')
|
||||
|
||||
|
||||
# --- Clients ---
|
||||
|
||||
@app.route('/api/clients', methods=['GET'])
|
||||
@token_required
|
||||
def list_clients(current_user):
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM Clients ORDER BY Name ASC")
|
||||
clients = cursor.fetchall()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify(clients)
|
||||
|
||||
|
||||
@app.route('/api/clients', methods=['POST'])
|
||||
@token_required
|
||||
def create_client(current_user):
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'message': 'Name is required'}), 400
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
try:
|
||||
cursor.execute("""
|
||||
INSERT INTO Clients (Name, ContactName, Phone, Email, Address)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""", (name, data.get('contact_name', ''), data.get('phone', ''), data.get('email', ''), data.get('address', '')))
|
||||
conn.commit()
|
||||
client_id = cursor.lastrowid
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Client created', 'id': client_id}), 201
|
||||
except mysql.connector.IntegrityError:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Client already exists'}), 409
|
||||
|
||||
|
||||
@app.route('/api/clients/<int:client_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_client(current_user, client_id):
|
||||
data = request.get_json()
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM Clients WHERE Id = %s", (client_id,))
|
||||
existing = cursor.fetchone()
|
||||
if not existing:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Not found'}), 404
|
||||
cursor.execute("""
|
||||
UPDATE Clients SET Name=%s, ContactName=%s, Phone=%s, Email=%s, Address=%s
|
||||
WHERE Id=%s
|
||||
""", (
|
||||
data.get('name', existing['Name']),
|
||||
data.get('contact_name', existing['ContactName'] or ''),
|
||||
data.get('phone', existing['Phone'] or ''),
|
||||
data.get('email', existing['Email'] or ''),
|
||||
data.get('address', existing['Address'] or ''),
|
||||
client_id
|
||||
))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Client updated'})
|
||||
|
||||
|
||||
@app.route('/api/clients/<int:client_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_client(current_user, client_id):
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM Clients WHERE Id = %s", (client_id,))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Client deleted'})
|
||||
|
||||
|
||||
# --- Subscriptions ---
|
||||
|
||||
@app.route('/api/subscriptions', methods=['GET'])
|
||||
@token_required
|
||||
def list_subscriptions(current_user):
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("""
|
||||
SELECT s.*, c.Name AS ClientName
|
||||
FROM Subscriptions s
|
||||
JOIN Clients c ON c.Id = s.ClientId
|
||||
ORDER BY s.IsActive DESC, s.NextBillingDate ASC
|
||||
""")
|
||||
subs = cursor.fetchall()
|
||||
for s in subs:
|
||||
s['Amount'] = float(s['Amount'])
|
||||
if s['StartDate']:
|
||||
s['StartDate'] = s['StartDate'].isoformat()
|
||||
if s['EndDate']:
|
||||
s['EndDate'] = s['EndDate'].isoformat()
|
||||
if s['NextBillingDate']:
|
||||
s['NextBillingDate'] = s['NextBillingDate'].isoformat()
|
||||
if s['CreatedAt']:
|
||||
s['CreatedAt'] = s['CreatedAt'].isoformat()
|
||||
if s['UpdatedAt']:
|
||||
s['UpdatedAt'] = s['UpdatedAt'].isoformat()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify(subs)
|
||||
|
||||
|
||||
@app.route('/api/subscriptions', methods=['POST'])
|
||||
@token_required
|
||||
def create_subscription(current_user):
|
||||
data = request.get_json()
|
||||
client_id = data.get('client_id')
|
||||
client_name = data.get('client_name', '').strip()
|
||||
service_name = data.get('service_name', '').strip()
|
||||
amount = data.get('amount')
|
||||
if amount is not None:
|
||||
amount = float(amount)
|
||||
interval = data.get('interval')
|
||||
start_date = data.get('start_date')
|
||||
end_date = data.get('end_date')
|
||||
|
||||
if not all([amount, interval, start_date, service_name]):
|
||||
return jsonify({'message': 'Missing required fields'}), 400
|
||||
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
if client_name and not client_id:
|
||||
cursor.execute("INSERT IGNORE INTO Clients (Name) VALUES (%s)", (client_name,))
|
||||
conn.commit()
|
||||
cursor.execute("SELECT Id FROM Clients WHERE Name = %s", (client_name,))
|
||||
row = cursor.fetchone()
|
||||
client_id = row['Id'] if row else None
|
||||
|
||||
if not client_id:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Client is required'}), 400
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO Subscriptions (ClientId, ServiceName, Amount, `Interval`, StartDate, EndDate, NextBillingDate)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""", (client_id, service_name, amount, interval, start_date, end_date or None, start_date))
|
||||
conn.commit()
|
||||
sub_id = cursor.lastrowid
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Subscription created', 'id': sub_id}), 201
|
||||
|
||||
|
||||
@app.route('/api/subscriptions/<int:sub_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_subscription(current_user, sub_id):
|
||||
data = request.get_json()
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM Subscriptions WHERE Id = %s", (sub_id,))
|
||||
existing = cursor.fetchone()
|
||||
if not existing:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Not found'}), 404
|
||||
|
||||
client_name = data.get('client_name', '').strip()
|
||||
client_id = data.get('client_id', existing['ClientId'])
|
||||
if client_name:
|
||||
cursor.execute("INSERT IGNORE INTO Clients (Name) VALUES (%s)", (client_name,))
|
||||
conn.commit()
|
||||
cursor.execute("SELECT Id FROM Clients WHERE Name = %s", (client_name,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
client_id = row['Id']
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE Subscriptions SET ClientId=%s, ServiceName=%s, Amount=%s,
|
||||
`Interval`=%s, StartDate=%s, EndDate=%s, IsActive=%s
|
||||
WHERE Id=%s
|
||||
""", (
|
||||
data.get('client_id', existing['ClientId']),
|
||||
data.get('service_name', existing['ServiceName']),
|
||||
data.get('amount', existing['Amount']),
|
||||
data.get('interval', existing['Interval']),
|
||||
data.get('start_date', existing['StartDate']),
|
||||
data.get('end_date', existing['EndDate']),
|
||||
data.get('is_active', existing['IsActive']),
|
||||
sub_id
|
||||
))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Subscription updated'})
|
||||
|
||||
|
||||
@app.route('/api/subscriptions/<int:sub_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_subscription(current_user, sub_id):
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE Subscriptions SET IsActive = FALSE WHERE Id = %s", (sub_id,))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Subscription deactivated'})
|
||||
|
||||
|
||||
@app.route('/api/subscriptions/<int:sub_id>/record-payment', methods=['POST'])
|
||||
@token_required
|
||||
def record_subscription_payment(current_user, sub_id):
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("""
|
||||
SELECT s.*, c.Name AS ClientName FROM Subscriptions s
|
||||
JOIN Clients c ON c.Id = s.ClientId WHERE s.Id = %s
|
||||
""", (sub_id,))
|
||||
sub = cursor.fetchone()
|
||||
if not sub or not sub['IsActive']:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Subscription not found or inactive'}), 404
|
||||
|
||||
today = datetime.date.today()
|
||||
cursor.execute("""
|
||||
INSERT INTO Transactions (Type, TransactionDate, ClientId, Amount, PaymentMethod, Description, SubscriptionId)
|
||||
VALUES ('Income', %s, %s, %s, 'Other', %s, %s)
|
||||
""", (today, sub['ClientId'], sub['Amount'],
|
||||
f"Subscription: {sub['ServiceName']} (manual payment)", sub_id))
|
||||
|
||||
next_date = advance_billing_date(today, sub['Interval'])
|
||||
if sub['EndDate'] and next_date > sub['EndDate']:
|
||||
cursor.execute("UPDATE Subscriptions SET NextBillingDate = %s, IsActive = FALSE WHERE Id = %s",
|
||||
(next_date, sub_id))
|
||||
else:
|
||||
cursor.execute("UPDATE Subscriptions SET NextBillingDate = %s WHERE Id = %s", (next_date, sub_id))
|
||||
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Payment recorded'})
|
||||
|
||||
|
||||
# --- Frontend Routes ---
|
||||
|
||||
@app.route('/')
|
||||
def login_page():
|
||||
return render_template('login.html')
|
||||
|
||||
|
||||
@app.route('/dashboard')
|
||||
def dashboard_page():
|
||||
return render_template('dashboard.html')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
is_reloader = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'
|
||||
if not app.debug or is_reloader:
|
||||
start_scheduler()
|
||||
app.run(host='0.0.0.0', port=5740, debug=True, use_reloader=True)
|
||||
69
setup_db.py
Normal file
69
setup_db.py
Normal file
@ -0,0 +1,69 @@
|
||||
import mysql.connector, bcrypt
|
||||
|
||||
conn = mysql.connector.connect(host='10.7.0.1', user='nick', password='nick9924Tucker9924##', database='TonjumTechRecords')
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS Users (
|
||||
Id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Username VARCHAR(50) NOT NULL UNIQUE,
|
||||
PasswordHash VARCHAR(255) NOT NULL,
|
||||
CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS Clients (
|
||||
Id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Name VARCHAR(255) NOT NULL UNIQUE,
|
||||
ContactName VARCHAR(255) DEFAULT NULL,
|
||||
Phone VARCHAR(50) DEFAULT NULL,
|
||||
Email VARCHAR(255) DEFAULT NULL,
|
||||
Address TEXT DEFAULT NULL,
|
||||
CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS Subscriptions (
|
||||
Id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ClientId INT NOT NULL,
|
||||
ServiceName VARCHAR(255) NOT NULL,
|
||||
Amount DECIMAL(12,2) NOT NULL,
|
||||
`Interval` ENUM("Weekly","Monthly","Quarterly","Annually") NOT NULL,
|
||||
StartDate DATE NOT NULL,
|
||||
EndDate DATE NULL,
|
||||
NextBillingDate DATE NOT NULL,
|
||||
IsActive BOOLEAN DEFAULT TRUE,
|
||||
CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UpdatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ClientId) REFERENCES Clients(Id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS Transactions (
|
||||
Id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Type ENUM("Income","Expense") NOT NULL,
|
||||
TransactionDate DATE NOT NULL,
|
||||
ClientId INT,
|
||||
Amount DECIMAL(12,2) NOT NULL,
|
||||
PaymentMethod ENUM("Cash","Venmo","Check","Other","Card","Stripe") NOT NULL,
|
||||
Description TEXT,
|
||||
SubscriptionId INT NULL,
|
||||
ImageData LONGBLOB,
|
||||
ImageContentType VARCHAR(100),
|
||||
CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UpdatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ClientId) REFERENCES Clients(Id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (SubscriptionId) REFERENCES Subscriptions(Id) ON DELETE SET NULL
|
||||
)
|
||||
""")
|
||||
|
||||
pw_hash = bcrypt.hashpw('nick9924Tucker9924##'.encode(), bcrypt.gensalt()).decode()
|
||||
cursor.execute("INSERT IGNORE INTO Users (Username, PasswordHash) VALUES (%s, %s)", ('nick', pw_hash))
|
||||
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
print('All tables created and user seeded OK')
|
||||
696
templates/dashboard.html
Normal file
696
templates/dashboard.html
Normal file
@ -0,0 +1,696 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tonjum Technologies - Dashboard</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<style>
|
||||
body { background: #0f172a; min-height: 100vh; }
|
||||
.glass { background: rgba(255,255,255,0.03); backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.06); }
|
||||
.tab-btn.active { border-bottom: 2px solid #6366f1; color: #818cf8; }
|
||||
.modal-overlay { background: rgba(0,0,0,0.6); }
|
||||
input, select, textarea { color-scheme: dark; }
|
||||
.toast { animation: slideIn 0.3s ease; }
|
||||
@keyframes slideIn { from { transform: translateY(-100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
</style>
|
||||
</head>
|
||||
<body class="text-gray-100">
|
||||
<nav class="border-b border-gray-800 bg-gray-900/80 sticky top-0 z-40">
|
||||
<div class="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between">
|
||||
<span class="font-semibold text-lg text-indigo-400">TonjumTech Records</span>
|
||||
<button onclick="logout()" class="text-sm text-gray-400 hover:text-white transition-colors">Sign Out</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 py-6">
|
||||
<div id="toast" class="hidden fixed top-4 right-4 z-50 px-5 py-3 rounded-xl shadow-lg toast text-sm font-medium"></div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-6 border-b border-gray-800 mb-6">
|
||||
<button class="tab-btn active pb-3 text-sm font-medium text-gray-400" data-tab="dashboard">Dashboard</button>
|
||||
<button class="tab-btn pb-3 text-sm font-medium text-gray-400" data-tab="transactions">Transactions</button>
|
||||
<button class="tab-btn pb-3 text-sm font-medium text-gray-400" data-tab="subscriptions">Subscriptions</button>
|
||||
<button class="tab-btn pb-3 text-sm font-medium text-gray-400" data-tab="clients">Clients</button>
|
||||
</div>
|
||||
|
||||
<!-- ========== TAB: DASHBOARD ========== -->
|
||||
<div id="tab-dashboard" class="tab-content">
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="glass rounded-xl p-5">
|
||||
<p class="text-xs text-gray-500 uppercase tracking-wider">Total Profit</p>
|
||||
<p id="stat-total" class="text-2xl font-bold text-white mt-1">$0.00</p>
|
||||
</div>
|
||||
<div class="glass rounded-xl p-5">
|
||||
<p class="text-xs text-gray-500 uppercase tracking-wider">Last 365 Days</p>
|
||||
<p id="stat-365" class="text-2xl font-bold text-white mt-1">$0.00</p>
|
||||
</div>
|
||||
<div class="glass rounded-xl p-5">
|
||||
<p class="text-xs text-gray-500 uppercase tracking-wider">Last 30 Days</p>
|
||||
<p id="stat-30" class="text-2xl font-bold text-white mt-1">$0.00</p>
|
||||
</div>
|
||||
<div class="glass rounded-xl p-5">
|
||||
<p class="text-xs text-gray-500 uppercase tracking-wider">Projected Year</p>
|
||||
<p id="stat-projected" class="text-2xl font-bold text-white mt-1">$0.00</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="lg:col-span-3 glass rounded-xl p-5">
|
||||
<h3 class="text-sm font-medium text-gray-300 mb-4">Monthly Income vs Expenses</h3>
|
||||
<div style="height:250px"><canvas id="monthlyChart"></canvas></div>
|
||||
</div>
|
||||
<div class="glass rounded-xl p-5">
|
||||
<h3 class="text-sm font-medium text-gray-300 mb-3">Subscriptions</h3>
|
||||
<p class="text-xs text-gray-500">Active Subscriptions</p>
|
||||
<p id="sub-count" class="text-2xl font-bold text-white mt-1">0</p>
|
||||
<p class="text-xs text-gray-500 mt-3">Monthly Recurring</p>
|
||||
<p id="sub-mrr" class="text-xl font-bold text-emerald-400 mt-1">$0.00</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== TAB: TRANSACTIONS ========== -->
|
||||
<div id="tab-transactions" class="tab-content hidden">
|
||||
<button onclick="toggleForm()" class="mb-4 text-sm text-indigo-400 hover:text-indigo-300 transition-colors" id="addTxBtn">+ Add Transaction</button>
|
||||
<button onclick="exportCSV()" class="mb-4 ml-3 text-sm text-emerald-400 hover:text-emerald-300 transition-colors">Export CSV</button>
|
||||
|
||||
<div id="txForm" class="hidden glass rounded-xl p-5 mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-300 mb-4">New Transaction</h3>
|
||||
<form id="transactionForm" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Type</label>
|
||||
<select name="type" required class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
<option value="Income">Income</option>
|
||||
<option value="Expense">Expense</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Date</label>
|
||||
<input type="date" name="date" required value="{{ today }}"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Client</label>
|
||||
<div class="flex gap-1">
|
||||
<select name="client_id" class="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
<option value="">Select...</option>
|
||||
</select>
|
||||
<button type="button" onclick="showAddClient()" class="px-3 py-2 bg-indigo-600/20 text-indigo-400 rounded-lg text-sm hover:bg-indigo-600/30">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Amount</label>
|
||||
<input type="number" step="0.01" name="amount" required placeholder="0.00"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Payment Method</label>
|
||||
<select name="payment_method" required class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
<option value="Cash">Cash</option>
|
||||
<option value="Venmo">Venmo</option>
|
||||
<option value="Check">Check</option>
|
||||
<option value="Card">Card</option>
|
||||
<option value="Stripe">Stripe</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Description</label>
|
||||
<input type="text" name="description" placeholder="Optional"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Image (optional)</label>
|
||||
<input type="file" name="image" accept="image/*"
|
||||
class="w-full text-sm text-gray-400 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:bg-indigo-600/20 file:text-indigo-400 hover:file:bg-indigo-600/30">
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="w-full py-2 px-4 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-colors">Add Record</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="glass rounded-xl overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-800 text-gray-500 text-xs uppercase tracking-wider">
|
||||
<th class="text-left px-4 py-3 font-medium">Date</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Type</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Client</th>
|
||||
<th class="text-right px-4 py-3 font-medium">Amount</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Method</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Description</th>
|
||||
<th class="text-center px-4 py-3 font-medium">Image</th>
|
||||
<th class="text-center px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="txTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="txEmpty" class="hidden text-center py-12 text-gray-500">No transactions yet</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== TAB: SUBSCRIPTIONS ========== -->
|
||||
<div id="tab-subscriptions" class="tab-content hidden">
|
||||
<button onclick="toggleSubForm()" class="mb-4 text-sm text-indigo-400 hover:text-indigo-300 transition-colors" id="addSubBtn">+ Add Subscription</button>
|
||||
|
||||
<div id="subForm" class="hidden glass rounded-xl p-5 mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-300 mb-4">New Subscription</h3>
|
||||
<form id="subscriptionForm" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Client</label>
|
||||
<div class="flex gap-1">
|
||||
<select name="client_id" class="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
<option value="">Select...</option>
|
||||
</select>
|
||||
<button type="button" onclick="showAddClient()" class="px-3 py-2 bg-indigo-600/20 text-indigo-400 rounded-lg text-sm hover:bg-indigo-600/30">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Service Name</label>
|
||||
<input type="text" name="service_name" required placeholder="e.g. Web Hosting"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Amount</label>
|
||||
<input type="number" step="0.01" name="amount" required placeholder="0.00"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Interval</label>
|
||||
<select name="interval" required class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
<option value="Weekly">Weekly</option>
|
||||
<option value="Monthly">Monthly</option>
|
||||
<option value="Quarterly">Quarterly</option>
|
||||
<option value="Annually">Annually</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Start Date</label>
|
||||
<input type="date" name="start_date" required
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">End Date (optional)</label>
|
||||
<input type="date" name="end_date"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="w-full py-2 px-4 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-colors">Add Subscription</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="glass rounded-xl overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-800 text-gray-500 text-xs uppercase tracking-wider">
|
||||
<th class="text-left px-4 py-3 font-medium">Client</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Service</th>
|
||||
<th class="text-right px-4 py-3 font-medium">Amount</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Interval</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Next Billing</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th class="text-center px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="subTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="subEmpty" class="hidden text-center py-12 text-gray-500">No subscriptions yet</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Client Modal -->
|
||||
<div id="clientModal" class="hidden fixed inset-0 z-50 modal-overlay flex items-center justify-center p-4">
|
||||
<div class="glass rounded-xl p-6 w-full max-w-sm">
|
||||
<h3 class="text-sm font-medium text-gray-300 mb-4">Add Client</h3>
|
||||
<input type="text" id="newClientName" placeholder="Client name"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm mb-4 focus:outline-none focus:border-indigo-500">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button onclick="closeClientModal()" class="px-4 py-2 text-sm text-gray-400 hover:text-white">Cancel</button>
|
||||
<button onclick="saveClient()" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== TAB: CLIENTS ========== -->
|
||||
<div id="tab-clients" class="tab-content hidden">
|
||||
<button onclick="toggleClientForm()" class="mb-4 text-sm text-indigo-400 hover:text-indigo-300 transition-colors" id="addClientBtn">+ Add Client</button>
|
||||
|
||||
<div id="clientForm" class="hidden glass rounded-xl p-5 mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-300 mb-4">Client</h3>
|
||||
<input type="hidden" name="client_id" value="">
|
||||
<form id="clientDetailForm" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Name *</label>
|
||||
<input type="text" name="name" required
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Contact Name</label>
|
||||
<input type="text" name="contact_name" placeholder="Leave blank if same"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Phone</label>
|
||||
<input type="text" name="phone"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Email</label>
|
||||
<input type="email" name="email"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Address</label>
|
||||
<input type="text" name="address"
|
||||
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<button type="submit" class="py-2 px-4 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-colors">Save</button>
|
||||
<button type="button" onclick="cancelClientForm()" class="py-2 px-4 text-sm text-gray-400 hover:text-white">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="glass rounded-xl overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-800 text-gray-500 text-xs uppercase tracking-wider">
|
||||
<th class="text-left px-4 py-3 font-medium">Name</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Contact</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Phone</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Email</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Address</th>
|
||||
<th class="text-center px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="clientTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="clientEmpty" class="hidden text-center py-12 text-gray-500">No clients yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Modal -->
|
||||
<div id="imageModal" class="hidden fixed inset-0 z-50 modal-overlay flex items-center justify-center p-4" onclick="this.classList.add('hidden')">
|
||||
<img id="imageModalContent" class="max-w-full max-h-[90vh] rounded-xl" src="" alt="Image">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = '';
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) window.location.href = '/';
|
||||
const headers = {'Authorization': 'Bearer ' + token};
|
||||
let monthlyChart = null;
|
||||
|
||||
function getHeaders() { return {'Authorization': 'Bearer ' + localStorage.getItem('token')}; }
|
||||
function getFormHeaders() { const h = getHeaders(); return h; }
|
||||
|
||||
function showToast(msg, type) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'fixed top-4 right-4 z-50 px-5 py-3 rounded-xl shadow-lg toast text-sm font-medium ' +
|
||||
(type === 'error' ? 'bg-red-600 text-white' : 'bg-emerald-600 text-white');
|
||||
t.classList.remove('hidden');
|
||||
setTimeout(() => t.classList.add('hidden'), 3000);
|
||||
}
|
||||
|
||||
function logout() { localStorage.removeItem('token'); window.location.href = '/'; }
|
||||
|
||||
// Tab switching
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden'));
|
||||
document.getElementById('tab-' + btn.dataset.tab).classList.remove('hidden');
|
||||
if (btn.dataset.tab === 'dashboard') loadDashboard();
|
||||
if (btn.dataset.tab === 'transactions') loadTransactions();
|
||||
if (btn.dataset.tab === 'clients') loadClientsTab();
|
||||
if (btn.dataset.tab === 'subscriptions') loadSubscriptions();
|
||||
});
|
||||
});
|
||||
|
||||
// === DASHBOARD ===
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/api/dashboard/summary', {headers: getHeaders()});
|
||||
const d = await res.json();
|
||||
document.getElementById('stat-total').textContent = '$' + d.totalProfit.toFixed(2);
|
||||
document.getElementById('stat-365').textContent = '$' + d.last365.toFixed(2);
|
||||
document.getElementById('stat-30').textContent = '$' + d.last30.toFixed(2);
|
||||
document.getElementById('stat-projected').textContent = '$' + d.projected.toFixed(2);
|
||||
document.getElementById('sub-count').textContent = d.activeSubscriptions;
|
||||
document.getElementById('sub-mrr').textContent = '$' + d.activeSubscriptionsTotal.toFixed(2);
|
||||
|
||||
const ctx = document.getElementById('monthlyChart').getContext('2d');
|
||||
if (monthlyChart) monthlyChart.destroy();
|
||||
const labels = d.monthlyHistory.map(m => m.month);
|
||||
monthlyChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{label: 'Income', data: d.monthlyHistory.map(m => m.income), backgroundColor: '#22c55e', borderRadius: 4},
|
||||
{label: 'Expenses', data: d.monthlyHistory.map(m => m.expense), backgroundColor: '#ef4444', borderRadius: 4}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {legend: {labels: {color: '#9ca3af', boxWidth: 12, padding: 16}}},
|
||||
scales: {
|
||||
x: {ticks: {color: '#6b7280'}, grid: {color: '#1f2937'}},
|
||||
y: {ticks: {color: '#6b7280', callback: v => '$' + v}, grid: {color: '#1f2937'}}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch(e) { showToast('Failed to load dashboard', 'error'); }
|
||||
}
|
||||
|
||||
// === CLIENTS (quick-add modal for dropdowns) ===
|
||||
let clientSelects = [];
|
||||
|
||||
async function loadClients() {
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/api/clients', {headers: getHeaders()});
|
||||
const clients = await res.json();
|
||||
clientSelects = document.querySelectorAll('select[name="client_id"]');
|
||||
const opts = '<option value="">Select...</option>' + clients.map(c =>
|
||||
'<option value="' + c.Id + '">' + c.Name + '</option>').join('');
|
||||
clientSelects.forEach(s => { const v = s.value; s.innerHTML = opts; s.value = v; });
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function showAddClient() {
|
||||
document.getElementById('clientModal').classList.remove('hidden');
|
||||
document.getElementById('newClientName').value = '';
|
||||
document.getElementById('newClientName').focus();
|
||||
}
|
||||
function closeClientModal() { document.getElementById('clientModal').classList.add('hidden'); }
|
||||
async function saveClient() {
|
||||
const name = document.getElementById('newClientName').value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/api/clients', {
|
||||
method: 'POST',
|
||||
headers: Object.assign(getHeaders(), {'Content-Type': 'application/json'}),
|
||||
body: JSON.stringify({name})
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast('Client added', 'success');
|
||||
closeClientModal();
|
||||
loadClients();
|
||||
} else {
|
||||
const d = await res.json();
|
||||
showToast(d.message || 'Error', 'error');
|
||||
}
|
||||
} catch(e) { showToast('Error adding client', 'error'); }
|
||||
}
|
||||
|
||||
// === CLIENTS TAB ===
|
||||
function toggleClientForm() {
|
||||
const f = document.getElementById('clientForm');
|
||||
f.classList.toggle('hidden');
|
||||
document.getElementById('addClientBtn').textContent = f.classList.contains('hidden') ? '+ Add Client' : 'Cancel';
|
||||
if (!f.classList.contains('hidden')) {
|
||||
document.querySelector('#clientDetailForm [name="client_id"]').value = '';
|
||||
document.getElementById('clientDetailForm').reset();
|
||||
}
|
||||
}
|
||||
function cancelClientForm() {
|
||||
document.getElementById('clientForm').classList.add('hidden');
|
||||
document.getElementById('addClientBtn').textContent = '+ Add Client';
|
||||
}
|
||||
|
||||
async function loadClientsTab() {
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/api/clients', {headers: getHeaders()});
|
||||
const clients = await res.json();
|
||||
const tbody = document.getElementById('clientTableBody');
|
||||
const empty = document.getElementById('clientEmpty');
|
||||
if (!clients.length) {
|
||||
tbody.innerHTML = '';
|
||||
empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
empty.classList.add('hidden');
|
||||
tbody.innerHTML = clients.map(c => {
|
||||
const contact = c.ContactName || c.Name;
|
||||
return '<tr class="border-b border-gray-800 hover:bg-gray-800/30">' +
|
||||
'<td class="px-4 py-3 text-gray-300 font-medium">' + (c.Name || '') + '</td>' +
|
||||
'<td class="px-4 py-3 text-gray-400">' + contact + '</td>' +
|
||||
'<td class="px-4 py-3 text-gray-400">' + (c.Phone || '') + '</td>' +
|
||||
'<td class="px-4 py-3 text-gray-400">' + (c.Email || '') + '</td>' +
|
||||
'<td class="px-4 py-3 text-gray-400 max-w-[200px] truncate">' + (c.Address || '') + '</td>' +
|
||||
'<td class="px-4 py-3 text-center">' +
|
||||
'<button onclick="editClient(' + c.Id + ')" class="text-indigo-400 hover:text-indigo-300 text-xs mr-2">Edit</button>' +
|
||||
'<button onclick="deleteClient(' + c.Id + ')" class="text-red-400 hover:text-red-300 text-xs">Delete</button>' +
|
||||
'</td></tr>';
|
||||
}).join('');
|
||||
} catch(e) { showToast('Failed to load clients', 'error'); }
|
||||
}
|
||||
|
||||
async function editClient(id) {
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/api/clients', {headers: getHeaders()});
|
||||
const clients = await res.json();
|
||||
const c = clients.find(x => x.Id === id);
|
||||
if (!c) return;
|
||||
const f = document.getElementById('clientForm');
|
||||
f.classList.remove('hidden');
|
||||
document.getElementById('addClientBtn').textContent = 'Cancel';
|
||||
document.querySelector('#clientDetailForm [name="client_id"]').value = c.Id;
|
||||
document.querySelector('#clientDetailForm [name="name"]').value = c.Name || '';
|
||||
document.querySelector('#clientDetailForm [name="contact_name"]').value = c.ContactName || '';
|
||||
document.querySelector('#clientDetailForm [name="phone"]').value = c.Phone || '';
|
||||
document.querySelector('#clientDetailForm [name="email"]').value = c.Email || '';
|
||||
document.querySelector('#clientDetailForm [name="address"]').value = c.Address || '';
|
||||
} catch(e) { showToast('Error loading client', 'error'); }
|
||||
}
|
||||
|
||||
async function deleteClient(id) {
|
||||
if (!confirm('Delete this client?')) return;
|
||||
try {
|
||||
await fetch(API_BASE + '/api/clients/' + id, {method: 'DELETE', headers: getHeaders()});
|
||||
showToast('Deleted', 'success');
|
||||
loadClientsTab();
|
||||
loadClients();
|
||||
} catch(e) { showToast('Error deleting', 'error'); }
|
||||
}
|
||||
|
||||
document.getElementById('clientDetailForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const data = {};
|
||||
fd.forEach((v, k) => data[k] = v);
|
||||
const clientId = data.client_id;
|
||||
delete data.client_id;
|
||||
try {
|
||||
const url = clientId
|
||||
? API_BASE + '/api/clients/' + clientId
|
||||
: API_BASE + '/api/clients';
|
||||
const method = clientId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: Object.assign(getHeaders(), {'Content-Type': 'application/json'}),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast(clientId ? 'Client updated' : 'Client created', 'success');
|
||||
e.target.reset();
|
||||
document.getElementById('clientForm').classList.add('hidden');
|
||||
document.getElementById('addClientBtn').textContent = '+ Add Client';
|
||||
loadClientsTab();
|
||||
loadClients();
|
||||
} else {
|
||||
const d = await res.json();
|
||||
showToast(d.message || 'Error', 'error');
|
||||
}
|
||||
} catch(e) { showToast('Error saving client', 'error'); }
|
||||
});
|
||||
|
||||
// === TRANSACTIONS ===
|
||||
function toggleForm() {
|
||||
const f = document.getElementById('txForm');
|
||||
f.classList.toggle('hidden');
|
||||
document.getElementById('addTxBtn').textContent = f.classList.contains('hidden') ? '+ Add Transaction' : 'Cancel';
|
||||
if (!f.classList.contains('hidden')) loadClients();
|
||||
}
|
||||
|
||||
async function loadTransactions() {
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/api/transactions?limit=200', {headers: getHeaders()});
|
||||
const d = await res.json();
|
||||
const tbody = document.getElementById('txTableBody');
|
||||
const empty = document.getElementById('txEmpty');
|
||||
if (!d.transactions.length) {
|
||||
tbody.innerHTML = '';
|
||||
empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
empty.classList.add('hidden');
|
||||
tbody.innerHTML = d.transactions.map(tx => {
|
||||
const amt = tx.Type === 'Income'
|
||||
? '<span class="text-emerald-400">+$' + tx.Amount.toFixed(2) + '</span>'
|
||||
: '<span class="text-red-400">-$' + tx.Amount.toFixed(2) + '</span>';
|
||||
const imgHtml = tx.HasImage
|
||||
? '<button onclick="showImage(' + tx.Id + ')" class="text-indigo-400 hover:text-indigo-300 text-xs">View</button>'
|
||||
: '<span class="text-gray-600 text-xs">—</span>';
|
||||
return '<tr class="border-b border-gray-800 hover:bg-gray-800/30">' +
|
||||
'<td class="px-4 py-3 text-gray-300">' + tx.TransactionDate + '</td>' +
|
||||
'<td class="px-4 py-3"><span class="text-xs px-2 py-0.5 rounded-full ' +
|
||||
(tx.Type === 'Income' ? 'bg-emerald-600/20 text-emerald-400' : 'bg-red-600/20 text-red-400') + '">' + tx.Type + '</span></td>' +
|
||||
'<td class="px-4 py-3 text-gray-300">' + (tx.ClientName || '—') + '</td>' +
|
||||
'<td class="px-4 py-3 text-right font-medium">' + amt + '</td>' +
|
||||
'<td class="px-4 py-3 text-gray-300">' + tx.PaymentMethod + '</td>' +
|
||||
'<td class="px-4 py-3 text-gray-400 max-w-[200px] truncate">' + (tx.Description || '') + '</td>' +
|
||||
'<td class="px-4 py-3 text-center">' + imgHtml + '</td>' +
|
||||
'<td class="px-4 py-3 text-center">' +
|
||||
'<button onclick="deleteTx(' + tx.Id + ')" class="text-red-400 hover:text-red-300 text-xs ml-2">Delete</button>' +
|
||||
'</td></tr>';
|
||||
}).join('');
|
||||
} catch(e) { showToast('Failed to load transactions', 'error'); }
|
||||
}
|
||||
|
||||
document.getElementById('transactionForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/api/transactions', {
|
||||
method: 'POST',
|
||||
headers: getFormHeaders(),
|
||||
body: fd
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast('Transaction added', 'success');
|
||||
e.target.reset();
|
||||
document.getElementById('txForm').classList.add('hidden');
|
||||
document.getElementById('addTxBtn').textContent = '+ Add Transaction';
|
||||
loadTransactions();
|
||||
loadDashboard();
|
||||
} else {
|
||||
const d = await res.json();
|
||||
showToast(d.message || 'Error', 'error');
|
||||
}
|
||||
} catch(e) { showToast('Error adding transaction', 'error'); }
|
||||
});
|
||||
|
||||
async function deleteTx(id) {
|
||||
if (!confirm('Delete this transaction?')) return;
|
||||
try {
|
||||
await fetch(API_BASE + '/api/transactions/' + id, {method: 'DELETE', headers: getHeaders()});
|
||||
showToast('Deleted', 'success');
|
||||
loadTransactions();
|
||||
loadDashboard();
|
||||
} catch(e) { showToast('Error deleting', 'error'); }
|
||||
}
|
||||
|
||||
function showImage(id) {
|
||||
const img = document.getElementById('imageModalContent');
|
||||
img.src = API_BASE + '/api/transactions/' + id + '/image';
|
||||
document.getElementById('imageModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function exportCSV() {
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/api/transactions/export/csv', {headers: getHeaders()});
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'transactions.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('CSV exported', 'success');
|
||||
} catch(e) { showToast('Error exporting CSV', 'error'); }
|
||||
}
|
||||
|
||||
// === SUBSCRIPTIONS ===
|
||||
function toggleSubForm() {
|
||||
const f = document.getElementById('subForm');
|
||||
f.classList.toggle('hidden');
|
||||
document.getElementById('addSubBtn').textContent = f.classList.contains('hidden') ? '+ Add Subscription' : 'Cancel';
|
||||
if (!f.classList.contains('hidden')) loadClients();
|
||||
}
|
||||
|
||||
async function loadSubscriptions() {
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/api/subscriptions', {headers: getHeaders()});
|
||||
const subs = await res.json();
|
||||
const tbody = document.getElementById('subTableBody');
|
||||
const empty = document.getElementById('subEmpty');
|
||||
if (!subs.length) {
|
||||
tbody.innerHTML = '';
|
||||
empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
empty.classList.add('hidden');
|
||||
tbody.innerHTML = subs.map(s => {
|
||||
const statusHtml = s.IsActive
|
||||
? '<span class="text-emerald-400 text-xs">Active</span>'
|
||||
: '<span class="text-gray-500 text-xs">Inactive</span>';
|
||||
return '<tr class="border-b border-gray-800 hover:bg-gray-800/30">' +
|
||||
'<td class="px-4 py-3 text-gray-300">' + (s.ClientName || '—') + '</td>' +
|
||||
'<td class="px-4 py-3 text-gray-300">' + s.ServiceName + '</td>' +
|
||||
'<td class="px-4 py-3 text-right font-medium">$' + s.Amount.toFixed(2) + '</td>' +
|
||||
'<td class="px-4 py-3 text-gray-300">' + s.Interval + '</td>' +
|
||||
'<td class="px-4 py-3 text-gray-300">' + s.NextBillingDate + '</td>' +
|
||||
'<td class="px-4 py-3">' + statusHtml + '</td>' +
|
||||
'<td class="px-4 py-3 text-center">' +
|
||||
'<button onclick="deleteSub(' + s.Id + ')" class="text-red-400 hover:text-red-300 text-xs">Deactivate</button>' +
|
||||
'</td></tr>';
|
||||
}).join('');
|
||||
} catch(e) { showToast('Failed to load subscriptions', 'error'); }
|
||||
}
|
||||
|
||||
document.getElementById('subscriptionForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const data = {};
|
||||
fd.forEach((v, k) => data[k] = v);
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/api/subscriptions', {
|
||||
method: 'POST',
|
||||
headers: Object.assign(getHeaders(), {'Content-Type': 'application/json'}),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast('Subscription created', 'success');
|
||||
e.target.reset();
|
||||
document.getElementById('subForm').classList.add('hidden');
|
||||
document.getElementById('addSubBtn').textContent = '+ Add Subscription';
|
||||
loadSubscriptions();
|
||||
loadDashboard();
|
||||
} else {
|
||||
const d = await res.json();
|
||||
showToast(d.message || 'Error', 'error');
|
||||
}
|
||||
} catch(e) { showToast('Error creating subscription', 'error'); }
|
||||
});
|
||||
|
||||
async function deleteSub(id) {
|
||||
if (!confirm('Deactivate this subscription?')) return;
|
||||
try {
|
||||
await fetch(API_BASE + '/api/subscriptions/' + id, {method: 'DELETE', headers: getHeaders()});
|
||||
showToast('Deactivated', 'success');
|
||||
loadSubscriptions();
|
||||
loadDashboard();
|
||||
} catch(e) { showToast('Error', 'error'); }
|
||||
}
|
||||
|
||||
// Initial load
|
||||
loadDashboard();
|
||||
loadClients();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
82
templates/login.html
Normal file
82
templates/login.html
Normal file
@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tonjum Technologies - Login</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-indigo-600/20 border border-indigo-500/30 mb-4">
|
||||
<svg class="w-8 h-8 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-white">Tonjum Technologies</h1>
|
||||
<p class="text-gray-400 mt-1">Business Records</p>
|
||||
</div>
|
||||
<div class="glass-card rounded-2xl p-8 shadow-xl">
|
||||
<form id="loginForm" class="space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Username</label>
|
||||
<input type="text" id="username" required
|
||||
class="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors"
|
||||
placeholder="Enter username">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Password</label>
|
||||
<input type="password" id="password" required
|
||||
class="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors"
|
||||
placeholder="Enter password">
|
||||
</div>
|
||||
<div id="errorMsg" class="hidden text-red-400 text-sm text-center"></div>
|
||||
<button type="submit"
|
||||
class="w-full py-3 px-4 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-xl transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-900">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const API_BASE = '';
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const errorMsg = document.getElementById('errorMsg');
|
||||
errorMsg.classList.add('hidden');
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({username, password})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
localStorage.setItem('token', data.token);
|
||||
window.location.href = '/dashboard';
|
||||
} else {
|
||||
errorMsg.textContent = data.message || 'Invalid credentials';
|
||||
errorMsg.classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg.textContent = 'Connection error';
|
||||
errorMsg.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user