init
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { BILL_BODY_LIMIT, readLimited } from "../../../../lib/body";
|
||||
import { BillStoreError, readBillPayload, storePublishedBill } from "../../../../lib/bills";
|
||||
import { isUuid } from "../../../../lib/ids";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteContext = { params: Promise<{ token: string }> };
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
const { token } = await context.params;
|
||||
if (!isUuid(token)) return Response.json({ error: "not found" }, { status: 404 });
|
||||
try {
|
||||
const payload = await readBillPayload(token);
|
||||
if (!payload) return Response.json({ error: "not found" }, { status: 404 });
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
} catch {
|
||||
console.error("bill read failed");
|
||||
return Response.json({ error: "not found" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
const { token } = await context.params;
|
||||
if (!isUuid(token)) return Response.json({ error: "invalid token" }, { status: 400 });
|
||||
const raw = await readLimited(request, BILL_BODY_LIMIT);
|
||||
if (raw === null) return Response.json({ error: "body too large" }, { status: 413 });
|
||||
try {
|
||||
await storePublishedBill(token, raw);
|
||||
return Response.json({ ok: true }, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof BillStoreError) {
|
||||
return Response.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
console.error("bill publish failed");
|
||||
return Response.json({ error: "publish failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isAuthorized } from "../../../../lib/auth";
|
||||
import { pullChanges, SyncInputError } from "../../../../lib/sync";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAuthorized(request.headers.get("authorization"))) {
|
||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid json" }, { status: 400 });
|
||||
}
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
return Response.json({ error: "invalid body" }, { status: 400 });
|
||||
}
|
||||
const cursors = (body as { cursors?: unknown }).cursors;
|
||||
try {
|
||||
const result = await pullChanges(cursors);
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof SyncInputError) {
|
||||
return Response.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("sync pull failed");
|
||||
return Response.json({ error: "sync failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isAuthorized } from "../../../../lib/auth";
|
||||
import { pushChanges, SyncInputError } from "../../../../lib/sync";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAuthorized(request.headers.get("authorization"))) {
|
||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid json" }, { status: 400 });
|
||||
}
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
return Response.json({ error: "invalid body" }, { status: 400 });
|
||||
}
|
||||
const tables = (body as { tables?: unknown }).tables ?? {};
|
||||
try {
|
||||
const result = await pushChanges(tables);
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof SyncInputError) {
|
||||
return Response.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("sync push failed");
|
||||
return Response.json({ error: "sync failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { cache } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { readBillPayload } from "../../../lib/bills";
|
||||
import { BILL_NOT_FOUND, formatBillDate, parseBill, type BillView } from "../../../lib/bill-view";
|
||||
import { money } from "../../../lib/money";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const loadBill = cache(async (token: string): Promise<BillView | null> => {
|
||||
const raw = await readBillPayload(token);
|
||||
if (!raw) return null;
|
||||
return parseBill(raw);
|
||||
});
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { token } = await params;
|
||||
const bill = await loadBill(token);
|
||||
if (!bill) return { title: "100kmph invoice" };
|
||||
return { title: `${bill.number || "Invoice"} · 100kmph` };
|
||||
}
|
||||
|
||||
export default async function BillPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params;
|
||||
const bill = await loadBill(token);
|
||||
if (!bill) return <main id="bill">{BILL_NOT_FOUND}</main>;
|
||||
|
||||
const when = formatBillDate(bill.createdAt);
|
||||
return (
|
||||
<main id="bill">
|
||||
<h1>{bill.shopName || "100kmph"}</h1>
|
||||
<p className="muted">{bill.address}</p>
|
||||
<p className="muted">{bill.phone ? `Ph: ${bill.phone}` : ""}</p>
|
||||
<hr />
|
||||
<p>
|
||||
<strong>Invoice {bill.number}</strong>
|
||||
<br />
|
||||
{when}
|
||||
</p>
|
||||
<p>
|
||||
Customer: {bill.customerName || "-"}
|
||||
<br />
|
||||
Phone: {bill.customerPhone || "-"}
|
||||
{bill.vehicleReg ? (
|
||||
<>
|
||||
<br />
|
||||
Vehicle: {bill.vehicleReg} {bill.vehicleModel}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th className="num">Qty</th>
|
||||
<th className="num">Rate</th>
|
||||
<th className="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bill.lines.map((line, index) => (
|
||||
<tr key={index}>
|
||||
<td>{line.description}</td>
|
||||
<td className="num">{line.qty ?? ""}</td>
|
||||
<td className="num">{money(line.unitPrice)}</td>
|
||||
<td className="num">{money(line.lineTotal)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="total">Total {money(bill.total)}</p>
|
||||
<p>Paid by: {bill.paymentMethod.toUpperCase()}</p>
|
||||
<p className="foot">Digital invoice from 100kmph. You can print this page if you need paper.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
:root {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.home {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
#bill {
|
||||
max-width: 720px;
|
||||
margin: 16px auto;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#bill h1 {
|
||||
text-align: center;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
text-align: center;
|
||||
color: #444;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#bill table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
#bill th,
|
||||
#bill td {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
#bill th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.foot {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.status {
|
||||
max-width: 720px;
|
||||
margin: 16px auto;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.status h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.status label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status button {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.status .error {
|
||||
color: #900;
|
||||
}
|
||||
|
||||
.status li {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
#bill {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "100kmph",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function HomePage() {
|
||||
return <main className="home">100kmph</main>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use server";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { allowRequest, clientIp } from "../../lib/rate-limit";
|
||||
import { lookupBikes } from "../../lib/status";
|
||||
import type { StatusResult } from "../../lib/workshop";
|
||||
|
||||
export async function lookupStatus(_prev: StatusResult | null, formData: FormData): Promise<StatusResult> {
|
||||
const headerList = await headers();
|
||||
const ip = clientIp(headerList.get("x-forwarded-for"));
|
||||
if (!allowRequest(ip)) {
|
||||
return {
|
||||
message: null,
|
||||
error: "Too many lookups. Wait a minute and try again.",
|
||||
bikes: [],
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await lookupBikes(String(formData.get("phone") ?? ""));
|
||||
} catch {
|
||||
console.error("status lookup failed");
|
||||
return { message: null, error: "Lookup failed. Try again.", bikes: [] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StatusForm } from "./status-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function StatusPage() {
|
||||
return (
|
||||
<main className="status">
|
||||
<h1>100kmph</h1>
|
||||
<StatusForm />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { lookupStatus } from "./actions";
|
||||
import { NOT_IN_WORKSHOP, type StatusResult } from "../../lib/workshop";
|
||||
|
||||
function formatWhen(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleString("en-LK", { timeZone: "Asia/Colombo" });
|
||||
}
|
||||
|
||||
export function StatusForm() {
|
||||
const [state, action, pending] = useActionState<StatusResult | null, FormData>(lookupStatus, null);
|
||||
|
||||
return (
|
||||
<form action={action}>
|
||||
<label>
|
||||
Phone
|
||||
<input name="phone" inputMode="tel" autoComplete="tel" required />
|
||||
</label>
|
||||
<button type="submit" disabled={pending}>
|
||||
Check
|
||||
</button>
|
||||
{state?.error ? <p className="error">{state.error}</p> : null}
|
||||
{state?.message ? <p>{state.message}</p> : null}
|
||||
{state && state.bikes.length > 0 ? (
|
||||
<ul>
|
||||
{state.bikes.map((bike, index) => {
|
||||
const when = formatWhen(bike.stageAt);
|
||||
return (
|
||||
<li key={`${bike.registration}-${index}`}>
|
||||
<div>{bike.registration}</div>
|
||||
<div>
|
||||
{bike.make} {bike.model}
|
||||
</div>
|
||||
{bike.inWorkshop ? (
|
||||
<div>
|
||||
{bike.stageLabel}
|
||||
{when ? ` · ${when}` : ""}
|
||||
</div>
|
||||
) : (
|
||||
<div>{NOT_IN_WORKSHOP}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user