Netdeploy2 Babyyyy
This commit is contained in:
55
.gitignore
vendored
Normal file
55
.gitignore
vendored
Normal file
@@ -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/
|
||||||
28
app.py
Normal file
28
app.py
Normal file
@@ -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)
|
||||||
5
extensions.py
Normal file
5
extensions.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
migrate = Migrate()
|
||||||
15
models/client.py
Normal file
15
models/client.py
Normal file
@@ -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)
|
||||||
4
models/init.py
Normal file
4
models/init.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .client import Client
|
||||||
|
from .quote import Quote, QuoteItem
|
||||||
|
from .invoice import Invoice, InvoiceLine
|
||||||
|
from .payment import Payment
|
||||||
28
models/invoice.py
Normal file
28
models/invoice.py
Normal file
@@ -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))
|
||||||
13
models/payment.py
Normal file
13
models/payment.py
Normal file
@@ -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)
|
||||||
36
models/quote.py
Normal file
36
models/quote.py
Normal file
@@ -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)
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Flask
|
||||||
|
Flask-SQLAlchemy
|
||||||
|
Flask-Migrate
|
||||||
|
python-dotenv
|
||||||
22
routes/clients.py
Normal file
22
routes/clients.py
Normal file
@@ -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")
|
||||||
27
routes/invoices.py
Normal file
27
routes/invoices.py
Normal file
@@ -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/<int:quote_id>", 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("/<int:invoice_id>")
|
||||||
|
def view_invoice(invoice_id):
|
||||||
|
inv = Invoice.query.get_or_404(invoice_id)
|
||||||
|
return render_template("invoices/view.html", inv=inv)
|
||||||
67
routes/quotes.py
Normal file
67
routes/quotes.py
Normal file
@@ -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("/<int:quote_id>", 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("/<int:quote_id>/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))
|
||||||
20
services/invoicing.py
Normal file
20
services/invoicing.py
Normal file
@@ -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
|
||||||
6
services/totals.py
Normal file
6
services/totals.py
Normal file
@@ -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
|
||||||
31
templates/base.html
Normal file
31
templates/base.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>QuoteApp</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; margin: 20px; color:#111; }
|
||||||
|
nav a { margin-right: 10px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; max-width: 900px; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 8px; }
|
||||||
|
th { background: #f5f5f5; text-align: left; }
|
||||||
|
input, select, button { padding: 6px; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('quotes.dashboard') }}">Dashboard</a> |
|
||||||
|
<a href="{{ url_for('clients.list_clients') }}">Clients</a> |
|
||||||
|
<a href="{{ url_for('quotes.dashboard') }}">Quotes</a> |
|
||||||
|
<a href="{{ url_for('invoices.list_invoices') }}">Invoices</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
templates/clients/list.html
Normal file
32
templates/clients/list.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h2>Clients</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="{{ url_for('clients.new_client') }}">+ New Client</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table border="1" cellpadding="6">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Kind</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{% for c in clients %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ c.id }}</td>
|
||||||
|
<td>{{ c.name }}</td>
|
||||||
|
<td>{{ c.email or "" }}</td>
|
||||||
|
<td>{{ c.kind }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">No clients yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
23
templates/clients/new.html
Normal file
23
templates/clients/new.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h2>New Client</h2>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<p>
|
||||||
|
<label>Name<br>
|
||||||
|
<input name="name" required style="width: 360px;">
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<label>Email<br>
|
||||||
|
<input name="email" style="width: 360px;">
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button type="submit">Create Client</button>
|
||||||
|
<a href="{{ url_for('clients.list_clients') }}" style="margin-left: 10px;">Cancel</a>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
34
templates/invoices/list.html
Normal file
34
templates/invoices/list.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h2>Invoices</h2>
|
||||||
|
|
||||||
|
<table border="1" cellpadding="6">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th>Paid</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{% for inv in invoices %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ inv.id }}</td>
|
||||||
|
<td>{{ inv.client.name if inv.client else "" }}</td>
|
||||||
|
<td>{{ inv.status }}</td>
|
||||||
|
<td>${{ inv.total }}</td>
|
||||||
|
<td>${{ inv.amount_paid }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('invoices.view_invoice', invoice_id=inv.id) }}">View</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6">No invoices yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
41
templates/invoices/view.html
Normal file
41
templates/invoices/view.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h2>Invoice #{{ inv.id }}</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Client:</strong> {{ inv.client.name if inv.client else "" }}<br>
|
||||||
|
<strong>Status:</strong> {{ inv.status }}<br>
|
||||||
|
<strong>Total:</strong> ${{ inv.total }}<br>
|
||||||
|
<strong>Paid:</strong> ${{ inv.amount_paid }}<br>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Lines</h3>
|
||||||
|
|
||||||
|
<table border="1" cellpadding="6">
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Qty</th>
|
||||||
|
<th>Unit Price</th>
|
||||||
|
<th>Line Total</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{% for l in inv.lines %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ l.description }}</td>
|
||||||
|
<td>{{ l.qty }}</td>
|
||||||
|
<td>${{ l.unit_price }}</td>
|
||||||
|
<td>${{ (l.qty or 0) * (l.unit_price or 0) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">No lines found.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin-top: 16px;">
|
||||||
|
<a href="{{ url_for('invoices.list_invoices') }}">Back to invoices</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
58
templates/quotes/dashboard.html
Normal file
58
templates/quotes/dashboard.html
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="{{ url_for('quotes.new_quote') }}">+ New Quote</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Draft / Sent Quotes</h3>
|
||||||
|
|
||||||
|
<table border="1" cellpadding="6">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{% for q in quotes if q.status in ['draft','sent'] %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ q.id }}</td>
|
||||||
|
<td>{{ q.title }}</td>
|
||||||
|
<td>{{ q.status }}</td>
|
||||||
|
<td>${{ q.total }}</td>
|
||||||
|
<td><a href="{{ url_for('quotes.edit_quote', quote_id=q.id) }}">Open</a></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5">None</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h3>Approved Quotes (Ready for Invoice)</h3>
|
||||||
|
|
||||||
|
<table border="1" cellpadding="6">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{% for q in quotes if q.status == 'approved' %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ q.id }}</td>
|
||||||
|
<td>{{ q.title }}</td>
|
||||||
|
<td>${{ q.total }}</td>
|
||||||
|
<td><a href="{{ url_for('quotes.edit_quote', quote_id=q.id) }}">View</a></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="4">None</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
72
templates/quotes/edit.html
Normal file
72
templates/quotes/edit.html
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h2>Edit Quote #{{ q.id }}</h2>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<p>
|
||||||
|
<label>
|
||||||
|
Title<br>
|
||||||
|
<input name="title" value="{{ q.title or '' }}" style="width: 420px;">
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Category:</strong> {{ q.category }}<br>
|
||||||
|
<strong>Status:</strong> {{ q.status }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Line Items</h3>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
<th style="width:110px;">Qty</th>
|
||||||
|
<th style="width:140px;">Unit Price</th>
|
||||||
|
<th style="width:140px;">Line Total</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{% for item in q.items %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<!-- THIS is what makes saving work -->
|
||||||
|
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||||
|
|
||||||
|
<input name="description" value="{{ item.description or '' }}" style="width: 100%;">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input name="qty" type="number" step="0.01" value="{{ item.qty or 0 }}" style="width: 100px;">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input name="unit_price" type="number" step="0.01" value="{{ item.unit_price or 0 }}" style="width: 120px;">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
${{ ((item.qty or 0) * (item.unit_price or 0)) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">No items yet. Click “Add Item”.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin-top: 10px;">
|
||||||
|
<button type="submit">Save Quote</button>
|
||||||
|
<a href="{{ url_for('quotes.dashboard') }}" style="margin-left:10px;">Back</a>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('quotes.add_item', quote_id=q.id) }}">
|
||||||
|
<button type="submit">Add Item</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h3>Total: ${{ q.total }}</h3>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('invoices.create_from_quote', quote_id=q.id) }}">
|
||||||
|
<button type="submit">Create Invoice from Quote</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
38
templates/quotes/new.html
Normal file
38
templates/quotes/new.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h2>New Quote</h2>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<p>
|
||||||
|
<label>Title<br>
|
||||||
|
<input name="title" required style="width: 360px;">
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<label>Category<br>
|
||||||
|
<select name="category" required>
|
||||||
|
<option value="web_design">Web Design</option>
|
||||||
|
<option value="it_services">IT Services</option>
|
||||||
|
<option value="pc_repair">PC Repair</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<label>Client<br>
|
||||||
|
<select name="client_id" required>
|
||||||
|
{% for c in clients %}
|
||||||
|
<option value="{{ c.id }}">{{ c.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<button type="submit">Create Quote</button>
|
||||||
|
<a href="{{ url_for('quotes.dashboard') }}" style="margin-left: 10px;">Cancel</a>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user