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:
2026-04-29 05:30:52 -03:00
parent b886455431
commit 5f9cac1947
78 changed files with 3025 additions and 201 deletions

View File

@@ -0,0 +1,11 @@
FROM node:20-slim
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fixture Invoicing — Soleprint Demo</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
{
"name": "fixture-invoicing-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"vue": "^3.5.12",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^5.4.11"
}
}

View File

@@ -0,0 +1,17 @@
<template>
<div class="fixture-banner">
FIXTURE APP SOLEPRINT DEMO NOT A REAL PRODUCT
</div>
<header class="app-nav">
<h1>Fixture Invoicing</h1>
<nav>
<router-link to="/customers">Customers</router-link>
<router-link to="/invoices">Invoices</router-link>
</nav>
</header>
<main class="app-main">
<router-view />
</main>
</template>

View File

@@ -0,0 +1,42 @@
const apiBase = import.meta.env.VITE_API_URL || "";
async function request(path, options = {}) {
const resp = await fetch(`${apiBase}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
if (resp.status === 204) return null;
return resp.json();
}
export const api = {
listCustomers: () => request("/api/customers"),
createCustomer: (data) =>
request("/api/customers", { method: "POST", body: JSON.stringify(data) }),
deleteCustomer: (id) => request(`/api/customers/${id}`, { method: "DELETE" }),
listInvoices: (params = {}) => {
const qs = new URLSearchParams(params).toString();
return request(`/api/invoices${qs ? "?" + qs : ""}`);
},
getInvoice: (id) => request(`/api/invoices/${id}`),
createInvoice: (data) =>
request("/api/invoices", { method: "POST", body: JSON.stringify(data) }),
deleteInvoice: (id) => request(`/api/invoices/${id}`, { method: "DELETE" }),
addLineItem: (invoiceId, data) =>
request(`/api/line-items/invoices/${invoiceId}`, {
method: "POST",
body: JSON.stringify(data),
}),
deleteLineItem: (id) =>
request(`/api/line-items/${id}`, { method: "DELETE" }),
recordPayment: (invoiceId, data) =>
request(`/api/payments/invoices/${invoiceId}`, {
method: "POST",
body: JSON.stringify(data),
}),
deletePayment: (id) => request(`/api/payments/${id}`, { method: "DELETE" }),
};

View File

@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import { router } from "./router.js";
import "./style.css";
createApp(App).use(router).mount("#app");

View File

@@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from "vue-router";
import Customers from "./views/Customers.vue";
import Invoices from "./views/Invoices.vue";
import InvoiceDetail from "./views/InvoiceDetail.vue";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", redirect: "/invoices" },
{ path: "/customers", component: Customers },
{ path: "/invoices", component: Invoices },
{ path: "/invoices/:id", component: InvoiceDetail, props: true },
],
});

View File

@@ -0,0 +1,119 @@
:root {
--bg: #0d1b2a;
--panel: #152438;
--panel-2: #1b2e45;
--text: #e5e5e5;
--muted: #8ca0b4;
--accent: #6fc3df;
--warn: #ffb454;
--danger: #ff6b6b;
--good: #7dd87d;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
#app { min-height: 100vh; display: flex; flex-direction: column; }
.fixture-banner {
background: repeating-linear-gradient(
45deg, #3a1f00, #3a1f00 10px, #4a2800 10px, #4a2800 20px
);
color: var(--warn);
text-align: center;
padding: 8px;
font-weight: 700;
letter-spacing: 2px;
font-size: 12px;
border-bottom: 2px solid var(--warn);
}
header.app-nav {
background: var(--panel);
padding: 16px 24px;
display: flex;
align-items: center;
gap: 24px;
border-bottom: 1px solid #223;
}
header.app-nav h1 {
font-size: 18px;
color: var(--accent);
}
header.app-nav nav a {
color: var(--muted);
text-decoration: none;
margin-right: 16px;
padding: 4px 8px;
border-radius: 4px;
}
header.app-nav nav a.router-link-active {
color: var(--text);
background: var(--panel-2);
}
main.app-main {
flex: 1;
padding: 24px;
max-width: 1100px;
width: 100%;
margin: 0 auto;
}
h2 { color: var(--accent); margin-bottom: 16px; font-size: 22px; }
h3 { color: var(--text); margin: 16px 0 8px; font-size: 16px; }
table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border-radius: 6px;
overflow: hidden;
}
th, td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #223;
}
th { background: var(--panel-2); color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; }
tr:last-child td { border-bottom: none; }
.status { padding: 2px 8px; border-radius: 10px; font-size: 11px; text-transform: uppercase; }
.status.draft { background: #334; color: var(--muted); }
.status.sent { background: #3a2c10; color: var(--warn); }
.status.paid { background: #1e3a1e; color: var(--good); }
.status.void { background: #3a1e1e; color: var(--danger); }
.card {
background: var(--panel);
padding: 16px;
border-radius: 6px;
margin-bottom: 16px;
}
.muted { color: var(--muted); font-size: 13px; }
button, .btn {
background: var(--accent);
color: #0d1b2a;
border: none;
padding: 8px 14px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
font-size: 13px;
}
button.ghost { background: transparent; color: var(--muted); border: 1px solid #334; }
a.link { color: var(--accent); text-decoration: none; }
a.link:hover { text-decoration: underline; }
.actions { margin-top: 12px; display: flex; gap: 8px; }

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
server: {
host: "0.0.0.0",
port: 5173,
allowedHosts: true,
watch: { usePolling: true },
},
});