wip
This commit is contained in:
82
src/components/detailsInvoice.ts
Normal file
82
src/components/detailsInvoice.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
42
src/components/helpers/animatedText.ts
Normal file
42
src/components/helpers/animatedText.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
5
src/components/helpers/boxBorders.ts
Normal file
5
src/components/helpers/boxBorders.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const doubleBoxWithTitle = (
|
||||
title: string,
|
||||
): [string, string, string, string, string, string, string, string] => {
|
||||
return ["═", "║", `╔═ ${title} `, "╗", "╚", "╝", "═", "║"];
|
||||
};
|
||||
21
src/components/helpers/dates.ts
Normal file
21
src/components/helpers/dates.ts
Normal 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);
|
||||
53
src/components/helpers/tableCellBox.ts
Normal file
53
src/components/helpers/tableCellBox.ts
Normal 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 ? "|" : "",
|
||||
],
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
],
|
||||
}),
|
||||
]
|
||||
})
|
||||
104
src/components/listClients.ts
Normal file
104
src/components/listClients.ts
Normal 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())],
|
||||
});
|
||||
112
src/components/listInvoices.ts
Normal file
112
src/components/listInvoices.ts
Normal 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
38
src/components/mainNav.ts
Normal 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 ? "━" : "─",
|
||||
"",
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user