From c77674b86d9561a8d83a752c98dacf50bf098f3d Mon Sep 17 00:00:00 2001 From: Ben Mosley Date: Tue, 3 Feb 2026 17:41:29 -0600 Subject: [PATCH] Netdeploy2 Babyyyy --- .env 2 | 0 .gitignore | 55 +++++++++++++++++++++++++ app.py | 28 +++++++++++++ extensions.py | 5 +++ models/client.py | 15 +++++++ models/init.py | 4 ++ models/invoice.py | 28 +++++++++++++ models/payment.py | 13 ++++++ models/quote.py | 36 +++++++++++++++++ requirements.txt | 4 ++ routes/clients.py | 22 ++++++++++ routes/invoices.py | 27 +++++++++++++ routes/quotes.py | 67 ++++++++++++++++++++++++++++++ services/invoicing.py | 20 +++++++++ services/totals.py | 6 +++ templates/base.html | 31 ++++++++++++++ templates/clients/list.html | 32 +++++++++++++++ templates/clients/new.html | 23 +++++++++++ templates/invoices/list.html | 34 ++++++++++++++++ templates/invoices/view.html | 41 +++++++++++++++++++ templates/quotes/dashboard.html | 58 ++++++++++++++++++++++++++ templates/quotes/edit.html | 72 +++++++++++++++++++++++++++++++++ templates/quotes/new.html | 38 +++++++++++++++++ 23 files changed, 659 insertions(+) create mode 100644 .env 2 create mode 100644 .gitignore create mode 100644 app.py create mode 100644 extensions.py create mode 100644 models/client.py create mode 100644 models/init.py create mode 100644 models/invoice.py create mode 100644 models/payment.py create mode 100644 models/quote.py create mode 100644 requirements.txt create mode 100644 routes/clients.py create mode 100644 routes/invoices.py create mode 100644 routes/quotes.py create mode 100644 services/invoicing.py create mode 100644 services/totals.py create mode 100644 templates/base.html create mode 100644 templates/clients/list.html create mode 100644 templates/clients/new.html create mode 100644 templates/invoices/list.html create mode 100644 templates/invoices/view.html create mode 100644 templates/quotes/dashboard.html create mode 100644 templates/quotes/edit.html create mode 100644 templates/quotes/new.html diff --git a/.env 2 b/.env 2 new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0eaafc --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Python bytecode +__pycache__/ +*.py[cod] +*$py.class + +# Virtual environments +.venv/ +venv/ +env/ +netdeploy/ +ENV/ + +# Flask / Python cache +instance/ +.cache/ +.pytest_cache/ + +# Environment variables +.env +.env.local +.env.*.local + +# Database files +*.db +*.sqlite3 + +# Flask-Migrate +migrations/ +!migrations/README + +# Logs +*.log + +# OS files +.DS_Store +Thumbs.db + +# Editor files +.vscode/ +.idea/ +*.swp +*.swo + +# Compiled assets (for later if you add frontend tooling) +node_modules/ +dist/ +build/ + +# PDF outputs (when you add them later) +generated_pdfs/ +*.pdf + +# Temporary files +tmp/ +temp/ diff --git a/app.py b/app.py new file mode 100644 index 0000000..c9f6af8 --- /dev/null +++ b/app.py @@ -0,0 +1,28 @@ +from flask import Flask, redirect, url_for +from extensions import db, migrate +from routes.clients import bp as clients_bp +from routes.quotes import bp as quotes_bp +from routes.invoices import bp as invoices_bp +from models import * + +def create_app(): + app = Flask(__name__) + app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///quotes.db" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.secret_key = "dev-secret" + + db.init_app(app) + migrate.init_app(app, db) + + app.register_blueprint(clients_bp) + app.register_blueprint(quotes_bp) + app.register_blueprint(invoices_bp) + + @app.route("/") + def index(): + return redirect(url_for("quotes.dashboard")) + + return app + +if __name__ == "__main__": + create_app().run(debug=True, use_reloader=False) diff --git a/extensions.py b/extensions.py new file mode 100644 index 0000000..378f0df --- /dev/null +++ b/extensions.py @@ -0,0 +1,5 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate + +db = SQLAlchemy() +migrate = Migrate() diff --git a/models/client.py b/models/client.py new file mode 100644 index 0000000..e849552 --- /dev/null +++ b/models/client.py @@ -0,0 +1,15 @@ +from datetime import datetime +from extensions import db + +class Client(db.Model): + id = db.Column(db.Integer, primary_key=True) + + kind = db.Column(db.String(30), default="small_business") + name = db.Column(db.String(120), nullable=False) + company = db.Column(db.String(120)) + + email = db.Column(db.String(255)) + phone = db.Column(db.String(50)) + notes = db.Column(db.Text) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) diff --git a/models/init.py b/models/init.py new file mode 100644 index 0000000..fd050a3 --- /dev/null +++ b/models/init.py @@ -0,0 +1,4 @@ +from .client import Client +from .quote import Quote, QuoteItem +from .invoice import Invoice, InvoiceLine +from .payment import Payment diff --git a/models/invoice.py b/models/invoice.py new file mode 100644 index 0000000..1b586a0 --- /dev/null +++ b/models/invoice.py @@ -0,0 +1,28 @@ +from datetime import datetime +from extensions import db + +class Invoice(db.Model): + id = db.Column(db.Integer, primary_key=True) + + client_id = db.Column(db.Integer, db.ForeignKey("client.id")) + client = db.relationship("Client", backref="invoices") + + quote_id = db.Column(db.Integer, db.ForeignKey("quote.id")) + quote = db.relationship("Quote", backref="invoices") + + status = db.Column(db.String(20), default="unpaid") + + total = db.Column(db.Numeric(10,2), default=0) + amount_paid = db.Column(db.Numeric(10,2), default=0) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + +class InvoiceLine(db.Model): + id = db.Column(db.Integer, primary_key=True) + + invoice_id = db.Column(db.Integer, db.ForeignKey("invoice.id")) + invoice = db.relationship("Invoice", backref="lines") + + description = db.Column(db.String(255)) + qty = db.Column(db.Numeric(10,2)) + unit_price = db.Column(db.Numeric(10,2)) diff --git a/models/payment.py b/models/payment.py new file mode 100644 index 0000000..6c4d7f4 --- /dev/null +++ b/models/payment.py @@ -0,0 +1,13 @@ +from datetime import datetime +from extensions import db + +class Payment(db.Model): + id = db.Column(db.Integer, primary_key=True) + + invoice_id = db.Column(db.Integer, db.ForeignKey("invoice.id")) + invoice = db.relationship("Invoice", backref="payments") + + amount = db.Column(db.Numeric(10,2)) + method = db.Column(db.String(40)) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) diff --git a/models/quote.py b/models/quote.py new file mode 100644 index 0000000..9e0f1cc --- /dev/null +++ b/models/quote.py @@ -0,0 +1,36 @@ +from datetime import datetime +from extensions import db + +class Quote(db.Model): + id = db.Column(db.Integer, primary_key=True) + + category = db.Column(db.String(30)) + status = db.Column(db.String(20), default="draft") + + client_id = db.Column(db.Integer, db.ForeignKey("client.id")) + client = db.relationship("Client", backref="quotes") + + title = db.Column(db.String(200)) + public_notes = db.Column(db.Text) + internal_notes = db.Column(db.Text) + + subtotal = db.Column(db.Numeric(10,2), default=0) + total = db.Column(db.Numeric(10,2), default=0) + + milestones_json = db.Column(db.JSON) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + +class QuoteItem(db.Model): + id = db.Column(db.Integer, primary_key=True) + + quote_id = db.Column(db.Integer, db.ForeignKey("quote.id")) + quote = db.relationship("Quote", backref="items") + + description = db.Column(db.String(255)) + qty = db.Column(db.Numeric(10,2), default=1) + unit_price = db.Column(db.Numeric(10,2), default=0) + + @property + def line_total(self): + return (self.qty or 0) * (self.unit_price or 0) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a374eda --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask +Flask-SQLAlchemy +Flask-Migrate +python-dotenv diff --git a/routes/clients.py b/routes/clients.py new file mode 100644 index 0000000..7de2a11 --- /dev/null +++ b/routes/clients.py @@ -0,0 +1,22 @@ +from flask import Blueprint, render_template, request, redirect, url_for +from extensions import db +from models.client import Client + +bp = Blueprint("clients", __name__, url_prefix="/clients") + +@bp.route("/") +def list_clients(): + return render_template("clients/list.html", clients=Client.query.all()) + +@bp.route("/new", methods=["GET","POST"]) +def new_client(): + if request.method == "POST": + c = Client( + name=request.form["name"], + email=request.form["email"] + ) + db.session.add(c) + db.session.commit() + return redirect(url_for("clients.list_clients")) + + return render_template("clients/new.html") diff --git a/routes/invoices.py b/routes/invoices.py new file mode 100644 index 0000000..324e445 --- /dev/null +++ b/routes/invoices.py @@ -0,0 +1,27 @@ +from flask import Blueprint, render_template, redirect, url_for +from extensions import db +from models.quote import Quote +from models.invoice import Invoice +from models.payment import Payment +from services.invoicing import invoice_from_quote + +bp = Blueprint("invoices", __name__, url_prefix="/invoices") + +@bp.route("/") +def list_invoices(): + return render_template("invoices/list.html", invoices=Invoice.query.all()) + +@bp.route("/from-quote/", methods=["POST"]) +def create_from_quote(quote_id): + q = Quote.query.get_or_404(quote_id) + + inv = invoice_from_quote(q) + db.session.add(inv) + db.session.commit() + + return redirect(url_for("invoices.view_invoice", invoice_id=inv.id)) + +@bp.route("/") +def view_invoice(invoice_id): + inv = Invoice.query.get_or_404(invoice_id) + return render_template("invoices/view.html", inv=inv) diff --git a/routes/quotes.py b/routes/quotes.py new file mode 100644 index 0000000..33e8c1c --- /dev/null +++ b/routes/quotes.py @@ -0,0 +1,67 @@ +from flask import Blueprint, render_template, request, redirect, url_for +from extensions import db +from models.quote import Quote, QuoteItem +from services.totals import compute_quote_totals + +bp = Blueprint("quotes", __name__, url_prefix="/quotes") + +@bp.route("/") +def dashboard(): + quotes = Quote.query.all() + return render_template("quotes/dashboard.html", quotes=quotes) + +@bp.route("/new", methods=["GET","POST"]) +@bp.route("/new", methods=["GET","POST"]) +def new_quote(): + from models.client import Client + + if request.method == "POST": + q = Quote( + title=request.form["title"], + category=request.form["category"], + client_id=request.form["client_id"] + ) + db.session.add(q) + db.session.commit() + return redirect(url_for("quotes.edit_quote", quote_id=q.id)) + + clients = Client.query.all() + return render_template("quotes/new.html", clients=clients) + + +@bp.route("/", methods=["GET", "POST"]) +def edit_quote(quote_id): + q = Quote.query.get_or_404(quote_id) + + if request.method == "POST": + q.title = request.form.get("title", q.title) + + item_ids = request.form.getlist("item_id") + descriptions = request.form.getlist("description") + qtys = request.form.getlist("qty") + prices = request.form.getlist("unit_price") + + from decimal import Decimal + + for idx, item_id in enumerate(item_ids): + item = QuoteItem.query.get(int(item_id)) + if not item or item.quote_id != q.id: + continue + + item.description = descriptions[idx] + item.qty = Decimal(qtys[idx] or "0") + item.unit_price = Decimal(prices[idx] or "0") + + compute_quote_totals(q) + db.session.commit() + return redirect(url_for("quotes.edit_quote", quote_id=q.id)) + + return render_template("quotes/edit.html", q=q) + + +@bp.route("//add-item", methods=["POST"]) +def add_item(quote_id): + item = QuoteItem(quote_id=quote_id, description="New Item", qty=1, unit_price=0) + db.session.add(item) + db.session.commit() + return redirect(url_for("quotes.edit_quote", quote_id=quote_id)) diff --git a/services/invoicing.py b/services/invoicing.py new file mode 100644 index 0000000..dec722b --- /dev/null +++ b/services/invoicing.py @@ -0,0 +1,20 @@ +from models.invoice import Invoice, InvoiceLine +from decimal import Decimal + +def invoice_from_quote(quote): + inv = Invoice( + client_id=quote.client_id, + quote_id=quote.id + ) + + for item in quote.items: + inv.lines.append( + InvoiceLine( + description=item.description, + qty=item.qty, + unit_price=item.unit_price + ) + ) + + inv.total = sum([Decimal(str(l.qty)) * Decimal(str(l.unit_price)) for l in inv.lines]) + return inv diff --git a/services/totals.py b/services/totals.py new file mode 100644 index 0000000..449a1b2 --- /dev/null +++ b/services/totals.py @@ -0,0 +1,6 @@ +from decimal import Decimal + +def compute_quote_totals(quote): + subtotal = sum([Decimal(str(i.line_total)) for i in quote.items]) + quote.subtotal = subtotal + quote.total = subtotal diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..1b81e74 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,31 @@ + + + + + + QuoteApp + + + + + + +
+ +{% block content %}{% endblock %} + + + diff --git a/templates/clients/list.html b/templates/clients/list.html new file mode 100644 index 0000000..3eaab6b --- /dev/null +++ b/templates/clients/list.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block content %} + +

Clients

+ +

+ + New Client +

+ + + + + + + + + + {% for c in clients %} + + + + + + + {% else %} + + + + {% endfor %} +
IDNameEmailKind
{{ c.id }}{{ c.name }}{{ c.email or "" }}{{ c.kind }}
No clients yet.
+ +{% endblock %} diff --git a/templates/clients/new.html b/templates/clients/new.html new file mode 100644 index 0000000..4a886dc --- /dev/null +++ b/templates/clients/new.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block content %} + +

New Client

+ +
+

+ +

+ +

+ +

+ + + Cancel +
+ +{% endblock %} diff --git a/templates/invoices/list.html b/templates/invoices/list.html new file mode 100644 index 0000000..cc5eebf --- /dev/null +++ b/templates/invoices/list.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block content %} + +

Invoices

+ + + + + + + + + + + + {% for inv in invoices %} + + + + + + + + + {% else %} + + + + {% endfor %} +
IDClientStatusTotalPaid
{{ inv.id }}{{ inv.client.name if inv.client else "" }}{{ inv.status }}${{ inv.total }}${{ inv.amount_paid }} + View +
No invoices yet.
+ +{% endblock %} diff --git a/templates/invoices/view.html b/templates/invoices/view.html new file mode 100644 index 0000000..37a9c62 --- /dev/null +++ b/templates/invoices/view.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block content %} + +

Invoice #{{ inv.id }}

+ +

+ Client: {{ inv.client.name if inv.client else "" }}
+ Status: {{ inv.status }}
+ Total: ${{ inv.total }}
+ Paid: ${{ inv.amount_paid }}
+

+ +

Lines

+ + + + + + + + + + {% for l in inv.lines %} + + + + + + + {% else %} + + + + {% endfor %} +
DescriptionQtyUnit PriceLine Total
{{ l.description }}{{ l.qty }}${{ l.unit_price }}${{ (l.qty or 0) * (l.unit_price or 0) }}
No lines found.
+ +

+ Back to invoices +

+ +{% endblock %} diff --git a/templates/quotes/dashboard.html b/templates/quotes/dashboard.html new file mode 100644 index 0000000..99a6dc8 --- /dev/null +++ b/templates/quotes/dashboard.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block content %} + +

Dashboard

+ +

+ + New Quote +

+ +

Draft / Sent Quotes

+ + + + + + + + + + + {% for q in quotes if q.status in ['draft','sent'] %} + + + + + + + + {% else %} + + {% endfor %} +
IDTitleStatusTotal
{{ q.id }}{{ q.title }}{{ q.status }}${{ q.total }}Open
None
+ +
+ +

Approved Quotes (Ready for Invoice)

+ + + + + + + + + + {% for q in quotes if q.status == 'approved' %} + + + + + + + {% else %} + + {% endfor %} +
IDTitleTotal
{{ q.id }}{{ q.title }}${{ q.total }}View
None
+ +{% endblock %} diff --git a/templates/quotes/edit.html b/templates/quotes/edit.html new file mode 100644 index 0000000..52bc8f5 --- /dev/null +++ b/templates/quotes/edit.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% block content %} + +

Edit Quote #{{ q.id }}

+ +
+

+ +

+ +

+ Category: {{ q.category }}
+ Status: {{ q.status }} +

+ +

Line Items

+ + + + + + + + + + {% for item in q.items %} + + + + + + + {% else %} + + + + {% endfor %} +
DescriptionQtyUnit PriceLine Total
+ + + + + + + + + + ${{ ((item.qty or 0) * (item.unit_price or 0)) }} +
No items yet. Click “Add Item”.
+ +

+ + Back +

+
+ +
+ +
+ +
+ +

Total: ${{ q.total }}

+ +
+ +
+ +{% endblock %} diff --git a/templates/quotes/new.html b/templates/quotes/new.html new file mode 100644 index 0000000..67d5824 --- /dev/null +++ b/templates/quotes/new.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block content %} + +

New Quote

+ +
+

+ +

+ +

+ +

+ +

+ +

+ + + + Cancel +
+ +{% endblock %}