Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"flatted": "^3.3.1",
"formik": "^2.4.6",
"graphql": "^16.11.0",
"html-to-image": "^1.11.13",
"html-to-text": "^9.0.5",
"human-crypto-keys": "^0.1.4",
"ic-mops": "^1.0.1",
Expand Down
4 changes: 3 additions & 1 deletion src/alex_frontend/core/config/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ export interface App {
path: string;
logo?: string;
comingSoon?: boolean;
hideFromHeader?: boolean;
}

export const appsData: App[] = [
{ name: 'Perpetua', description: 'Write', path: '/app/perpetua', logo: '/logos/Perpetua.svg', comingSoon: false },
{ name: 'Alexandrian', description: 'Library', path: '/app/alexandrian', logo: '/logos/Alexandrian.svg' },
{ name: 'Permasearch', description: 'Explore', path: '/app/permasearch', logo: '/logos/Permasearch.svg' },
{ name: 'Emporium', description: 'Trade', path: '/app/emporium', logo: '/logos/Emporium.svg' },
{ name: 'Pinax', description: 'Upload', path: '/app/pinax', logo: '/logos/Pinax.svg', comingSoon: false },
{ name: 'Sonora', description: 'Audio', path: '/app/sonora', logo: '/logos/Sonora.svg' },
{ name: 'Pinax', description: 'Upload', path: '/app/pinax', logo: '/logos/Pinax.svg', comingSoon: false, hideFromHeader: true },
{ name: 'Pearl', description: 'Receipt', path: '/app/pearl', logo: '/logos/Pearl.svg', comingSoon: false, hideFromHeader: true },
{ name: 'Syllogos', description: 'Aggregate', path: '/app/syllogos', logo: '/logos/Syllogos.svg', comingSoon: true },
{ name: 'Bibliotheca', description: 'Library', path: '/app/bibliotheca', comingSoon: true },
{ name: 'Dialectica', description: 'Debate', path: '/app/dialectica', comingSoon: true },
Expand Down
19 changes: 19 additions & 0 deletions src/alex_frontend/core/features/pearl/components/FieldError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from "react";
import { useFormikContext, getIn } from "formik";

/**
* Renders a Yup/Formik error for a (possibly nested) field path, gated on the
* field being touched. Safe for paths like `items[0].description` or
* `taxLines[2].ratePercent` via Formik's `getIn`.
*/
export function FieldError({ name }: { name: string }) {
const { errors, touched } = useFormikContext<unknown>();
const error = getIn(errors, name);
const isTouched = getIn(touched, name);
if (!isTouched || !error) return null;
return (
<p className="text-xs text-red-500 dark:text-red-400 mt-1">
{String(error)}
</p>
);
}
129 changes: 129 additions & 0 deletions src/alex_frontend/core/features/pearl/components/HeaderFields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import React, { useState } from "react";
import { useFormikContext } from "formik";
import { ChevronDown, Check } from "lucide-react";
import { Input } from "@/lib/components/input";
import { Button } from "@/lib/components/button";
import { Label } from "@/lib/components/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/lib/components/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/lib/components/command";
import type { Receipt } from "../schema";
import { CURRENCIES } from "../constants";
import { FieldError } from "./FieldError";

const labelClass =
"text-xs font-medium text-gray-700 dark:text-gray-300 font-roboto-condensed";

export function HeaderFields() {
const { values, handleChange, setFieldValue } = useFormikContext<Receipt>();

return (
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
<div className="sm:col-span-2 space-y-1.5">
<Label htmlFor="merchant" className={labelClass}>
Merchant
</Label>
<Input
id="merchant"
name="merchant"
value={values.merchant}
onChange={handleChange}
placeholder="e.g. Tartine Bakery"
autoComplete="off"
scale="sm"
rounded="md"
className="h-8"
/>
<FieldError name="merchant" />
</div>

<div className="sm:col-span-1 space-y-1.5">
<Label htmlFor="date" className={labelClass}>
Date
</Label>
<Input
id="date"
name="date"
type="date"
value={values.date}
onChange={handleChange}
scale="sm"
rounded="md"
className="h-8"
/>
<FieldError name="date" />
</div>

<div className="sm:col-span-1 space-y-1.5">
<Label className={labelClass}>Currency</Label>
<CurrencyPicker
value={values.currency}
onChange={code => setFieldValue("currency", code)}
/>
</div>
</div>
);
}

/** Currency combobox, styled to match the surrounding inputs (rounded-md,
* same border + fill) so it reads as part of the header row. */
function CurrencyPicker({
value,
onChange,
}: {
value: string;
onChange: (code: string) => void;
}) {
const [open, setOpen] = useState(false);

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
scale="sm"
rounded="md"
className="h-8 w-full justify-between font-mono font-semibold border-gray-400 bg-white dark:bg-gray-800"
>
<span>{value}</span>
<ChevronDown size={14} className="opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-64" align="end">
<Command>
<CommandInput placeholder="Search currency..." />
<CommandList>
<CommandEmpty>No currency found.</CommandEmpty>
<CommandGroup>
{CURRENCIES.map(c => (
<CommandItem
key={c.code}
value={`${c.code} ${c.name}`}
onSelect={() => {
onChange(c.code);
setOpen(false);
}}
>
<span className="font-mono font-semibold w-12">{c.code}</span>
<span className="flex-1 text-xs text-gray-500">{c.name}</span>
{c.code === value && <Check size={14} className="ml-2" />}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
193 changes: 193 additions & 0 deletions src/alex_frontend/core/features/pearl/components/LineItemRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import React from "react";
import { FieldArray, useFormikContext } from "formik";
import { nanoid } from "nanoid";
import { Plus, X } from "lucide-react";
import { Input } from "@/lib/components/input";
import { Button } from "@/lib/components/button";
import type { Receipt, LineItem, LineItemChild } from "../schema";
import { useComputedTotals } from "../hooks/useComputedTotals";
import { FieldError } from "./FieldError";

interface LineItemRowProps {
item: LineItem;
index: number;
onRemove: () => void;
}

const numericInputClass = "font-mono tabular-nums text-right";

export function LineItemRow({ item, index, onRemove }: LineItemRowProps) {
const { handleChange, setFieldValue } = useFormikContext<Receipt>();
const totals = useComputedTotals();
const hasChildren = item.children.length > 0;
const namePrefix = `items[${index}]`;

return (
<div className="space-y-1.5 pt-2 first:pt-0">
<div className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-5">
<Input
name={`${namePrefix}.description`}
value={item.description}
onChange={handleChange}
placeholder="Item description"
scale="sm"
rounded="md"
/>
<FieldError name={`${namePrefix}.description`} />
</div>
<div className="col-span-2">
<Input
name={`${namePrefix}.quantity`}
type="number"
min={0}
step="any"
value={item.quantity}
onChange={handleChange}
placeholder="Qty"
scale="sm"
rounded="md"
className={numericInputClass}
/>
<FieldError name={`${namePrefix}.quantity`} />
</div>
<div className="col-span-2">
<Input
name={`${namePrefix}.unitPrice`}
type="number"
min={0}
step="any"
value={item.unitPrice ?? ""}
onChange={handleChange}
disabled={hasChildren}
placeholder={hasChildren ? "bundle" : "0.00"}
scale="sm"
rounded="md"
className={numericInputClass}
/>
{!hasChildren && <FieldError name={`${namePrefix}.unitPrice`} />}
</div>
<div className="col-span-2 flex items-center justify-end pr-1 text-sm font-mono tabular-nums text-gray-900 dark:text-gray-100">
{totals.itemTotal(item).toFixed(2)}
</div>
<div className="col-span-1 flex items-center justify-end">
<Button type="button" variant="ghost" scale="icon" onClick={onRemove} aria-label="Remove item">
<X size={14} />
</Button>
</div>
</div>

<FieldArray name={`${namePrefix}.children`}>
{({ push, remove }) => {
if (item.children.length === 0) {
return (
<Button
type="button"
variant="muted"
scale="sm"
onClick={() => {
setFieldValue(`${namePrefix}.unitPrice`, null);
push({ id: nanoid(), description: "", quantity: 1, unitPrice: 0 });
}}
>
<Plus size={12} /> add sub-item
</Button>
);
}
return (
<div className="relative">
{/* Left rule spans the whole sub-items block; only the
description column is visually indented. Qty / Unit / Total /
actions stay in the same x-position as the parent row. */}
<div
aria-hidden
className="absolute left-2 top-0 bottom-7 border-l-2 border-gray-200 dark:border-gray-800"
/>
<div className="space-y-1.5">
{item.children.map((child: LineItemChild, ci: number) => {
const cPrefix = `${namePrefix}.children[${ci}]`;
return (
<div key={child.id} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-5 pl-6">
<Input
name={`${cPrefix}.description`}
value={child.description}
onChange={handleChange}
placeholder="Sub-item"
scale="sm"
rounded="md"
/>
<FieldError name={`${cPrefix}.description`} />
</div>
<div className="col-span-2">
<Input
name={`${cPrefix}.quantity`}
type="number"
min={0}
step="any"
value={child.quantity}
onChange={handleChange}
placeholder="Qty"
scale="sm"
rounded="md"
className={numericInputClass}
/>
<FieldError name={`${cPrefix}.quantity`} />
</div>
<div className="col-span-2">
<Input
name={`${cPrefix}.unitPrice`}
type="number"
min={0}
step="any"
value={child.unitPrice}
onChange={handleChange}
placeholder="0.00"
scale="sm"
rounded="md"
className={numericInputClass}
/>
<FieldError name={`${cPrefix}.unitPrice`} />
</div>
<div className="col-span-2 flex items-center justify-end pr-1 text-sm font-mono tabular-nums text-gray-700 dark:text-gray-300">
{(child.quantity * child.unitPrice).toFixed(2)}
</div>
<div className="col-span-1 flex items-center justify-end">
<Button
type="button"
variant="ghost"
scale="icon"
onClick={() => {
remove(ci);
if (item.children.length === 1) {
setFieldValue(`${namePrefix}.unitPrice`, 0);
}
}}
aria-label="Remove sub-item"
>
<X size={12} />
</Button>
</div>
</div>
);
})}
</div>
<div className="pl-6">
<Button
type="button"
variant="muted"
scale="sm"
onClick={() =>
push({ id: nanoid(), description: "", quantity: 1, unitPrice: 0 })
}
>
<Plus size={12} /> add sub-item
</Button>
</div>
</div>
);
}}
</FieldArray>
</div>
);
}
Loading
Loading