add fixture-invoicing example, sample-room wrap, kind cluster support
- examples/fixture-invoicing/: FastAPI + Vue + Postgres demo (4-entity invoice fixture)
- cfg/sample/: wraps the fixture (managed.repos points at examples/)
- ctrl/kind-{up,down,status}.sh + per-room k8s render in soleprint/ctrl/k8s/
- build.py: relative repo paths, resilient rmtree, optional k8s render hook
- cfg/.gitignore: stop ignoring sample/ and standalone/ template rooms
Manifests render cleanly but kind cluster has not been run end-to-end yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
71
examples/fixture-invoicing/frontend/src/views/Customers.vue
Normal file
71
examples/fixture-invoicing/frontend/src/views/Customers.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<h2>Customers</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Add customer</h3>
|
||||
<div class="actions">
|
||||
<input v-model="draft.name" placeholder="Name" />
|
||||
<input v-model="draft.email" placeholder="Email" />
|
||||
<button @click="add" :disabled="!draft.name || !draft.email">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table v-if="customers.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in customers" :key="c.id">
|
||||
<td>{{ c.id }}</td>
|
||||
<td>{{ c.name }}</td>
|
||||
<td>{{ c.email }}</td>
|
||||
<td>
|
||||
<button class="ghost" @click="remove(c.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">No customers yet.</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive } from "vue";
|
||||
import { api } from "../api.js";
|
||||
|
||||
const customers = ref([]);
|
||||
const draft = reactive({ name: "", email: "" });
|
||||
|
||||
async function load() {
|
||||
customers.value = await api.listCustomers();
|
||||
}
|
||||
|
||||
async function add() {
|
||||
await api.createCustomer({ name: draft.name, email: draft.email });
|
||||
draft.name = "";
|
||||
draft.email = "";
|
||||
await load();
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
await api.deleteCustomer(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actions input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #334;
|
||||
border-radius: 4px;
|
||||
background: #0d1b2a;
|
||||
color: var(--text);
|
||||
min-width: 180px;
|
||||
}
|
||||
</style>
|
||||
148
examples/fixture-invoicing/frontend/src/views/InvoiceDetail.vue
Normal file
148
examples/fixture-invoicing/frontend/src/views/InvoiceDetail.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div v-if="invoice">
|
||||
<h2>
|
||||
Invoice {{ invoice.number }}
|
||||
<span :class="['status', invoice.status]">{{ invoice.status }}</span>
|
||||
</h2>
|
||||
<p class="muted">
|
||||
Customer: <strong>{{ invoice.customer.name }}</strong> ({{ invoice.customer.email }})
|
||||
· Issued {{ invoice.issued_at?.slice(0, 10) }}
|
||||
<span v-if="invoice.due_at"> · Due {{ invoice.due_at.slice(0, 10) }}</span>
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<h3>Line items</h3>
|
||||
<table v-if="invoice.line_items.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Qty</th>
|
||||
<th>Unit price</th>
|
||||
<th>Total</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="li in invoice.line_items" :key="li.id">
|
||||
<td>{{ li.description }}</td>
|
||||
<td>{{ li.quantity }}</td>
|
||||
<td>${{ li.unit_price }}</td>
|
||||
<td>${{ (li.quantity * li.unit_price).toFixed(2) }}</td>
|
||||
<td><button class="ghost" @click="removeLine(li.id)">Remove</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">No line items.</p>
|
||||
|
||||
<div class="actions" style="margin-top: 12px;">
|
||||
<input v-model="lineDraft.description" placeholder="Description" />
|
||||
<input type="number" v-model.number="lineDraft.quantity" style="width: 80px;" />
|
||||
<input type="number" step="0.01" v-model.number="lineDraft.unit_price" style="width: 120px;" />
|
||||
<button @click="addLine" :disabled="!lineDraft.description">Add line</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Payments</h3>
|
||||
<table v-if="invoice.payments.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Amount</th>
|
||||
<th>Method</th>
|
||||
<th>Paid at</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in invoice.payments" :key="p.id">
|
||||
<td>${{ p.amount }}</td>
|
||||
<td>{{ p.method }}</td>
|
||||
<td>{{ p.paid_at?.slice(0, 10) }}</td>
|
||||
<td><button class="ghost" @click="removePayment(p.id)">Remove</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">No payments.</p>
|
||||
|
||||
<div class="actions" style="margin-top: 12px;">
|
||||
<input type="number" step="0.01" v-model.number="payDraft.amount" placeholder="Amount" />
|
||||
<select v-model="payDraft.method">
|
||||
<option value="cash">cash</option>
|
||||
<option value="card">card</option>
|
||||
<option value="transfer">transfer</option>
|
||||
</select>
|
||||
<button @click="addPayment" :disabled="!payDraft.amount">Record payment</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="muted">
|
||||
Total billed: <strong>${{ totalBilled.toFixed(2) }}</strong>
|
||||
· Total paid: <strong>${{ totalPaid.toFixed(2) }}</strong>
|
||||
· Balance: <strong>${{ (totalBilled - totalPaid).toFixed(2) }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="muted">Loading…</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, watch } from "vue";
|
||||
import { api } from "../api.js";
|
||||
|
||||
const props = defineProps({ id: { type: [String, Number], required: true } });
|
||||
|
||||
const invoice = ref(null);
|
||||
const lineDraft = reactive({ description: "", quantity: 1, unit_price: 0 });
|
||||
const payDraft = reactive({ amount: 0, method: "cash" });
|
||||
|
||||
const totalBilled = computed(() =>
|
||||
(invoice.value?.line_items || []).reduce(
|
||||
(sum, li) => sum + Number(li.quantity) * Number(li.unit_price), 0
|
||||
)
|
||||
);
|
||||
const totalPaid = computed(() =>
|
||||
(invoice.value?.payments || []).reduce(
|
||||
(sum, p) => sum + Number(p.amount), 0
|
||||
)
|
||||
);
|
||||
|
||||
async function load() {
|
||||
invoice.value = await api.getInvoice(props.id);
|
||||
}
|
||||
|
||||
async function addLine() {
|
||||
await api.addLineItem(props.id, { ...lineDraft });
|
||||
lineDraft.description = "";
|
||||
lineDraft.quantity = 1;
|
||||
lineDraft.unit_price = 0;
|
||||
await load();
|
||||
}
|
||||
|
||||
async function removeLine(id) {
|
||||
await api.deleteLineItem(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function addPayment() {
|
||||
await api.recordPayment(props.id, { ...payDraft });
|
||||
payDraft.amount = 0;
|
||||
await load();
|
||||
}
|
||||
|
||||
async function removePayment(id) {
|
||||
await api.deletePayment(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
watch(() => props.id, load);
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actions input, .actions select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #334;
|
||||
border-radius: 4px;
|
||||
background: #0d1b2a;
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
93
examples/fixture-invoicing/frontend/src/views/Invoices.vue
Normal file
93
examples/fixture-invoicing/frontend/src/views/Invoices.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<h2>Invoices</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Create invoice</h3>
|
||||
<div class="actions">
|
||||
<input v-model="draft.number" placeholder="Number (e.g. DEMO-2026-004)" />
|
||||
<select v-model="draft.customer_id">
|
||||
<option :value="null" disabled>Customer…</option>
|
||||
<option v-for="c in customers" :key="c.id" :value="c.id">
|
||||
{{ c.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button @click="add" :disabled="!draft.number || !draft.customer_id">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table v-if="invoices.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Number</th>
|
||||
<th>Customer</th>
|
||||
<th>Issued</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="inv in invoices" :key="inv.id">
|
||||
<td>{{ inv.id }}</td>
|
||||
<td>
|
||||
<router-link :to="`/invoices/${inv.id}`" class="link">
|
||||
{{ inv.number }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ customerName(inv.customer_id) }}</td>
|
||||
<td>{{ inv.issued_at?.slice(0, 10) }}</td>
|
||||
<td><span :class="['status', inv.status]">{{ inv.status }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">No invoices yet.</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive, computed } from "vue";
|
||||
import { api } from "../api.js";
|
||||
|
||||
const invoices = ref([]);
|
||||
const customers = ref([]);
|
||||
const draft = reactive({ number: "", customer_id: null });
|
||||
|
||||
const customerById = computed(() =>
|
||||
Object.fromEntries(customers.value.map((c) => [c.id, c]))
|
||||
);
|
||||
|
||||
function customerName(id) {
|
||||
return customerById.value[id]?.name || `#${id}`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
[invoices.value, customers.value] = await Promise.all([
|
||||
api.listInvoices(),
|
||||
api.listCustomers(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function add() {
|
||||
await api.createInvoice({
|
||||
number: draft.number,
|
||||
customer_id: draft.customer_id,
|
||||
status: "draft",
|
||||
});
|
||||
draft.number = "";
|
||||
draft.customer_id = null;
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actions input, .actions select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #334;
|
||||
border-radius: 4px;
|
||||
background: #0d1b2a;
|
||||
color: var(--text);
|
||||
min-width: 200px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user