This commit is contained in:
Tim Bendt
2024-11-01 11:32:37 -05:00
parent 22cb743835
commit fb36fb8303
12 changed files with 751 additions and 159 deletions

View File

@@ -0,0 +1,82 @@
import * as w from "wretched";
import Table from "cli-table3";
import {
getOneInvoice,
markInvoicePaid,
updateInvoice,
} from "../services/pancakeApi.js";
import { doubleBoxWithTitle } from "./helpers/boxBorders.js";
import { isNaN, omit, values } from "lodash-es";
import { fromUnixTime, format } from "date-fns";
import { InvoiceDetailsResponse } from "../services/invoiceResponse.js";
export const renderInvoiceDetails = async (invoiceId: string | number) => {
const { invoice } = await getOneInvoice(invoiceId.toString());
if (process.env.DEBUG) {
console.log("🚀 ~ renderInvoiceDetails ~ details:", invoice);
}
const mainWindow = new w.Scrollable({
children: [],
});
const mainTable = new Table();
for (const key in omit(invoice, "items", "partial_payments")) {
const rawValue = invoice[key as keyof InvoiceDetailsResponse];
if (typeof rawValue !== "object") {
let value: Table.Cell = rawValue;
if (typeof rawValue !== "boolean") {
if (key.indexOf("date") >= 0) {
value = Number(rawValue);
if (!isNaN(value)) {
value = format(fromUnixTime(value), "y-M-d");
}
}
}
mainTable.push({
[key]: value,
} as Table.VerticalTableRow);
}
}
mainWindow.addAll([
new w.Flow({
direction: "topToBottom",
children: [
new w.Text({ text: `Unpaid: $${invoice.unpaid_amount}` }),
new w.Button({
text: `Mark as ${invoice.unpaid_amount > 0 ? "Paid" : "Unpaid"}`,
onClick: async () => {
const isPaid = invoice.unpaid_amount > 0;
if (process.env.DEBUG) {
console.log("Switching paid status from", {
isPaid,
invoiceId,
});
}
if (isPaid) {
mainWindow.removeAllChildren();
mainWindow.addAll([
new w.Flow({
direction: "leftToRight",
children: [
new w.Text({ text: "Paid Amount" }),
new w.Input({ text: "Paid Amount" }),
],
}),
]);
} else {
if (!process.env.DEBUG) {
await markInvoicePaid(invoiceId);
}
}
},
}),
new w.Text({ text: mainTable.toString() }),
],
}),
]);
return new w.Box({
border: doubleBoxWithTitle(
`${invoice.client_name} 💸 Invoice: ${invoice.id}`,
),
child: mainWindow,
});
};

View File

@@ -0,0 +1,42 @@
import { Size, Text, View, Viewport } from "wretched";
export class AnimatedText extends View {
#frameTime = 0;
#frame = 0;
#frames: string[];
static FRAME = 32;
constructor({ frames }: { frames: string[] }) {
super({
x: 10,
y: 4,
width: 14,
height: 2,
});
this.#frames = frames;
}
naturalSize() {
return new Size(14, 2);
}
receiveTick(dt: number): boolean {
this.#frameTime += dt;
if (this.#frameTime > AnimatedText.FRAME) {
this.#frameTime %= AnimatedText.FRAME;
this.#frame = (this.#frame + 1) % this.#frames.length;
return true;
}
return false;
}
render(viewport: Viewport) {
viewport.registerTick();
const t = new Text({ text: this.#frames[this.#frame] });
t.render(viewport);
}
}

View File

@@ -0,0 +1,5 @@
export const doubleBoxWithTitle = (
title: string,
): [string, string, string, string, string, string, string, string] => {
return ["═", "║", `╔═ ${title} `, "╗", "╚", "╝", "═", "║"];
};

View File

@@ -0,0 +1,21 @@
import { differenceInBusinessDays, format, parse, startOfDay } from "date-fns";
const now = format(new Date(), "yyyy-MM-dd");
const customTime = "18:00:00";
const customDate = new Date(`${now} ${customTime}`);
export const simpleFormat = (date: Date) => {
return format(startOfDay(date), "MMM d, yyyy");
};
console.log("🚀 ~ customDate:", simpleFormat(customDate));
export const distanceInBizDays = (date: Date) =>
differenceInBusinessDays(date, customDate);
/**
*
* @param dateString Ex. 2017-07-16 23:00:00
* @returns Date
*/
export const parseServiceDateString = (dateString: string) =>
parse(dateString, "yyyy-MM-dd hh:mm:ss", customDate);

View File

@@ -0,0 +1,53 @@
import { Box, Text } from "wretched";
import { InvoiceResponse } from "../../services/pancakeApi.js";
import { Theme } from "wretched/dist/Theme.js";
import { Style } from "wretched/dist/Style.js";
export type CellProps = {
width?: number;
padding?: { left: number; right: number; top: number; bottom: number };
borders?: { left?: boolean; right?: boolean };
alignment?: "left" | "right" | "center";
theme?: Theme;
};
export const tableCellBox = (text: string, opts: CellProps) => {
opts = Object.assign(
{
padding: { left: 1, right: 1, top: 0, bottom: 0 },
borders: { left: true, right: true },
alignment: "left",
theme: Theme.plain,
},
opts,
);
const innerWidth = opts.width
? opts.padding
? opts.width + opts.padding.left + opts.padding.right
: "natural"
: "natural";
return new Box({
width: innerWidth,
padding: opts.padding,
theme: opts.theme,
child: new Text({
wrap: false,
style: new Style({
background: opts.theme?.background,
foreground: opts.theme?.textColor,
}),
text,
width: opts.width,
alignment: opts.alignment,
}),
border: [
"",
opts.borders?.left ? "|" : "",
"",
"",
"",
"",
"",
opts.borders?.right ? "|" : "",
],
});
};

View File

@@ -1,71 +0,0 @@
import { Box, Button, Container, Flex, Screen, ScrollableList, Text, } from "wretched";
import { ClientResponse, InvoiceResponse, getAllClients, getAllInvoices, getAllProjects } from "../services/pancakeApi.js";
import { compact, find, reverse, sortBy } from "lodash-es";
const clientsResp = await getAllClients({ limit: 100, sort_by: "created", sort_dir: "desc" });
console.log("🚀 ~ clientsResp:", clientsResp)
const sorted = reverse(sortBy(clientsResp.clients, ["unpaid_total"]))
console.log("🚀 ~ sorted:", sorted)
let selectedClientId: string | undefined;
const currentClientName = () => {
const curr = find(sorted, { id: selectedClientId });
return `🏙️ ${curr?.company} | ${curr?.first_name} ${curr?.last_name}`
}
let invoiceList = async (clientId?: string) => {
if (clientId === undefined) {
console.warn("No Client Id");
return new Text({ text: " --" });
}
const invResp = await getAllInvoices({ client_id: clientId });
const invoices: Array<InvoiceResponse> = invResp.invoices;
if (!invoices?.length) {
console.warn("No invoices")
return new Text({ text: " --" });
}
return new ScrollableList({ cellForItem: (item) => new Button({ text: item.invoice_number }), items: invoices })
}
let Invoices = new Box({ width: "fill", height: "fill", child: await invoiceList(selectedClientId), border: "rounded" })
let Clients = new ScrollableList({
minWidth: 20,
width: "natural",
cellForItem: (item: ClientResponse) => {
if (!item || !item.company) {
console.log(item)
return new Text({ text: "Nothing Here" })
}
return new Button({
text: item.company || item.id, onClick: async () => {
console.log("🚀 ~ clientInvoicesView ~ clicked company:", item);
selectedClientId = item.id;
//TODO: Need a spinner for loading indicator
Invoices.removeAllChildren();
Invoices.add(await invoiceList(selectedClientId))
ClientTitle.text = currentClientName();
},
});
}, items: sorted
})
const ClientTitle = new Text({
text: "No Client Selected Yet",
})
export const clientInvoicesView = Flex.right({
children: [
Clients,
Flex.down({
width: "fill",
height: "fill",
padding: 1,
children: [
ClientTitle,
Invoices
],
}),
]
})

View File

@@ -0,0 +1,104 @@
import {
Box,
Button,
Container,
Flex,
Screen,
ScrollableList,
Text,
} from "wretched";
import {
ClientResponse,
InvoiceResponse,
getAllClients,
getAllInvoices,
getAllProjects,
} from "../services/pancakeApi.js";
import spinners from "cli-spinners";
import { compact, find, isNil, isString, reverse, sortBy } from "lodash-es";
import { renderInvoiceList } from "./listInvoices.js";
import { renderInvoiceDetails } from "./detailsInvoice.js";
import { doubleBoxWithTitle } from "./helpers/boxBorders.js";
import { AnimatedText } from "./helpers/animatedText.js";
const clientsResp = await getAllClients({
limit: 100,
sort_by: "created",
sort_dir: "desc",
});
// console.log("🚀 ~ clientsResp:", clientsResp);
const sorted = reverse(sortBy(clientsResp.clients, ["unpaid_total"]));
// console.log("🚀 ~ sorted:", sorted);
// let selectedClientId: string | undefined;
// let selectedInvoiceId: string | undefined;
const reDrawMain = async (
selectedClientId?: string,
selectedInvoiceId?: string,
) => {
clientInvoicesView.removeChild(1);
clientInvoicesView.add(
new AnimatedText({ frames: spinners.aesthetic.frames }),
);
const newBox = await renderInvoicesBox(selectedClientId, selectedInvoiceId);
clientInvoicesView.removeChild(1);
clientInvoicesView.add(renderRightPanel(newBox));
};
const renderInvoicesBox = async (
selectedClientId?: string,
selectedInvoiceId?: string,
) => {
let InvoicesBox = new Box({ child: new Text({ text: "« Select a Client" }) });
if (selectedInvoiceId && selectedClientId) {
InvoicesBox = await renderInvoiceDetails(selectedInvoiceId);
} else if (selectedClientId) {
const currClient = find(sorted, { id: selectedClientId });
const currentClientName = `${currClient?.company} | ${currClient?.first_name} ${currClient?.last_name}`;
InvoicesBox = new Box({
width: "fill",
height: "fill",
child: await renderInvoiceList(selectedClientId, (id) => {
console.log("clicked invoice", id);
reDrawMain(selectedClientId, id.toString());
}),
border: doubleBoxWithTitle(currentClientName),
});
}
return InvoicesBox;
};
let Clients = new ScrollableList({
minWidth: 20,
width: "natural",
cellForItem: (client: ClientResponse) => {
if (!client) {
// console.log("empty client company name", item);
return new Text({ text: `Undefined Client` });
}
return new Button({
border: "none",
width: "natural",
text: `${client.company}`,
onClick: async () => {
console.log("🚀 ~ clientInvoicesView ~ clicked client:", client);
reDrawMain(client.id);
},
});
},
items: sorted,
});
const renderRightPanel = (box: Box) =>
Flex.down({
width: "fill",
height: "fill",
padding: { left: 1, right: 1 },
children: [box],
});
export const clientInvoicesView = Flex.right({
children: [Clients, renderRightPanel(await renderInvoicesBox())],
});

View File

@@ -0,0 +1,112 @@
import {
Box,
Button,
Container,
Flex,
Flow,
Screen,
ScrollableList,
Text,
} from "wretched";
import {
ClientResponse,
InvoiceResponse,
getAllClients,
getAllInvoices,
getAllProjects,
} from "../services/pancakeApi.js";
import { compact, find, reverse, sortBy } from "lodash-es";
import Table from "cli-table3";
import { differenceInBusinessDays, format, parse } from "date-fns";
import { tableCellBox } from "./helpers/tableCellBox.js";
import { Theme } from "wretched/dist/Theme.js";
import {
distanceInBizDays,
parseServiceDateString,
simpleFormat,
} from "./helpers/dates.js";
export const renderInvoiceList = async (
clientId: string,
onSelect: (selectedInvoiceId: string | number) => void,
) => {
if (clientId === undefined) {
console.warn("No Client Id");
return new Text({ text: " --" });
}
const invResp = await getAllInvoices({ client_id: clientId });
const invoices: Array<InvoiceResponse> = invResp.invoices
.sort((a, b) => Number(a.invoice_number) - Number(b.invoice_number))
.reverse();
if (!invoices?.length) {
console.warn("No invoices");
return new Text({ text: " --" });
}
type FormattedInvoice = InvoiceResponse & { dueDistance: string };
const transformInvoice = (invoice: InvoiceResponse): FormattedInvoice => {
const ret: FormattedInvoice = { ...invoice, dueDistance: "" };
try {
if (invoice.due_date?.length > 0) {
const due = parseServiceDateString(invoice.due_date);
ret.due_date = simpleFormat(due);
ret.dueDistance = `Due in ${distanceInBizDays(due)} biz days.`;
}
if (invoice.payment_date?.length > 0) {
ret.payment_date = simpleFormat(
parseServiceDateString(invoice.payment_date),
);
}
ret.amount = "$" + Number(invoice.amount).toFixed(2).padEnd(2, "0");
} catch (error) {
console.error("Error transforming date", error);
} finally {
return ret;
}
};
// const detailsTable = new Table({
// head: ["Invoice#", "Amount", "Due", "Paid?", "Overdue?"],
// });
// const detailsTable: string[] | undefined = new Array();
// detailsTable.concat(
// ...invoices.map((x) => [
// x.invoice_number,
// Number(x.amount).toFixed(2),
// x.due_date?.substring(0, 10),
// x.is_paid ? "💰" : "⏳",
// x.overdue ? "🔥" : "✅",
// ]),
// );
return new ScrollableList({
cellForItem: (item, row) => {
return Flex.right({
children: [
new Button({
onClick: () => onSelect(item.id),
border: "none",
width: 6,
text: item.invoice_number,
}),
tableCellBox(item.amount, {
theme: item.is_paid ? Theme.green : Theme.secondary,
padding: { left: 1, right: 1, top: 0, bottom: 0 },
borders: { right: false },
width: 15,
alignment: "right",
}),
tableCellBox(item.dueDistance, {
theme: !item.is_paid && item.overdue ? Theme.red : Theme.secondary,
borders: { right: false },
padding: { left: 1, right: 1, top: 0, bottom: 0 },
width: 20,
alignment: "center",
}),
],
});
},
items: invoices.map(transformInvoice),
showScrollbars: undefined,
});
};

38
src/components/mainNav.ts Normal file
View File

@@ -0,0 +1,38 @@
import { Box, Button, Flex, Text } from "wretched";
import { Style } from "wretched/dist/Style.js";
const tabs = {
Home: "/",
Clients: "/",
Invoices: "/",
Projects: "/",
} as const;
export const mainNav = (props: { activeTabName: keyof typeof tabs }) => {
return new Flex({
direction: "leftToRight",
children: Object.keys(tabs).map(
(x) =>
new Box({
padding: { top: 0, bottom: 0, left: 2, right: 2 },
// child: new Text({ text: x, style: new Style({ bold: true }) }),
child: new Button({
text: x,
border: "none",
theme: props.activeTabName === x ? "plain" : "selected",
onClick: () => {
console.log({ clicked: x });
},
}),
border: [
"",
"",
"",
"",
"",
"",
props.activeTabName === x ? "━" : "─",
"",
],
}),
),
});
};