Add 'apps/web/' from commit '1e7174fa604b11e7c3983cd8ad01c596f6e77e96'

git-subtree-dir: apps/web
git-subtree-mainline: 068a51b46b
git-subtree-split: 1e7174fa60
This commit is contained in:
2026-08-16 21:42:59 -04:00
350 changed files with 62192 additions and 0 deletions
@@ -0,0 +1,218 @@
"use client";
import { cn } from "~/lib/utils";
import { Label } from "~/components/ui/label";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import { DatePicker } from "~/components/ui/date-picker";
import { NumberInput } from "~/components/ui/number-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { STATUS_OPTIONS } from "./types";
import type { InvoiceFormData, ClientType, BusinessType } from "./types";
interface InvoiceMetaSidebarProps {
formData: InvoiceFormData;
updateField: <K extends keyof InvoiceFormData>(
field: K,
value: InvoiceFormData[K],
) => void;
clients: ClientType[] | undefined;
businesses: BusinessType[] | undefined;
className?: string;
}
export function InvoiceMetaSidebar({
formData,
updateField,
clients,
businesses,
className,
}: InvoiceMetaSidebarProps) {
return (
<div className={cn("flex h-full flex-col gap-6 p-4", className)}>
<div className="space-y-4">
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Invoice Details
</h3>
{/* Status */}
<div className="space-y-1.5">
<Label htmlFor="status" className="text-xs">
Status
</Label>
<Select
value={formData.status}
onValueChange={(value: "draft" | "sent" | "paid") =>
updateField("status", value)
}
>
<SelectTrigger className="bg-background/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Invoice Number */}
<div className="space-y-1.5">
<Label htmlFor="invoiceNumber" className="text-xs">
Invoice Number
</Label>
<Input
id="invoiceNumber"
value={formData.invoiceNumber}
placeholder="INV-..."
disabled
className="bg-muted/50 font-mono text-sm"
/>
</div>
</div>
<div className="space-y-4">
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Involved Parties
</h3>
{/* From (Business) */}
<div className="space-y-1.5">
<Label htmlFor="business" className="text-xs">
From (Business)
</Label>
<Select
value={formData.businessId}
onValueChange={(value) => updateField("businessId", value)}
>
<SelectTrigger
aria-label="From Business"
className="bg-background/50 text-sm"
>
<span className="truncate">
<SelectValue placeholder="Select business" />
</span>
</SelectTrigger>
<SelectContent>
{businesses?.map((business) => (
<SelectItem key={business.id} value={business.id}>
{business.name}
{business.nickname ? ` (${business.nickname})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Bill To (Client) */}
<div className="space-y-1.5">
<Label htmlFor="client" className="text-xs">
Bill To (Client)
</Label>
<Select
value={formData.clientId}
onValueChange={(value) => updateField("clientId", value)}
>
<SelectTrigger
aria-label="Bill To Client"
className="bg-background/50 text-sm"
>
<span className="truncate">
<SelectValue placeholder="Select client" />
</span>
</SelectTrigger>
<SelectContent>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4">
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Dates
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Issued</Label>
<DatePicker
date={formData.issueDate}
onDateChange={(date) =>
updateField("issueDate", date ?? new Date())
}
className="bg-background/50 w-full"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Due</Label>
<DatePicker
date={formData.dueDate}
onDateChange={(date) =>
updateField("dueDate", date ?? new Date())
}
className="bg-background/50 w-full"
/>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
Config
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Tax Rate</Label>
<NumberInput
value={formData.taxRate}
onChange={(v) => updateField("taxRate", v)}
min={0}
max={100}
step={1}
suffix="%"
className="bg-background/50"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Hourly Rate</Label>
<NumberInput
value={formData.defaultHourlyRate ?? 0}
onChange={(v) => updateField("defaultHourlyRate", v)}
min={0}
prefix="$"
placeholder={!formData.clientId ? "Select client" : "Rate"}
disabled={!formData.clientId}
className={cn(
"bg-background/50",
!formData.clientId && "opacity-50",
)}
/>
</div>
</div>
</div>
<div className="flex-1 space-y-1.5">
<Label className="text-xs">Notes</Label>
<Textarea
value={formData.notes}
onChange={(e) => updateField("notes", e.target.value)}
placeholder="Notes for client..."
className="bg-background/50 h-24 resize-none"
/>
</div>
</div>
);
}
@@ -0,0 +1,114 @@
"use client";
import { FileText, Loader2 } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import { api } from "~/trpc/react";
export type InvoicePdfPreviewInput = {
invoiceNumber: string;
invoicePrefix: string;
businessId: string;
clientId: string;
issueDate: Date;
dueDate: Date;
status: "draft" | "sent" | "paid";
notes: string;
emailMessage: string;
taxRate: number;
currency: string;
items: Array<{
date: Date;
description: string;
hours: number;
rate: number;
}>;
};
function canPreview(input: InvoicePdfPreviewInput | null): input is InvoicePdfPreviewInput {
if (!input?.clientId) return false;
if (input.items.length === 0) return false;
return input.items.every((item) => item.description.trim().length > 0);
}
type InvoicePdfPreviewPanelProps = {
input: InvoicePdfPreviewInput | null;
enabled?: boolean;
className?: string;
heightClassName?: string;
/** Renders only the preview body (no card/header) for embedding in a parent pane. */
embedded?: boolean;
};
export function InvoicePdfPreviewPanel({
input,
enabled = true,
className,
heightClassName = "h-[min(80vh,760px)]",
embedded = false,
}: InvoicePdfPreviewPanelProps) {
const previewReady = canPreview(input);
const { data: pdfPreview, isFetching, error, refetch } =
api.invoices.previewPdf.useQuery(input!, {
enabled: enabled && previewReady,
refetchOnWindowFocus: false,
staleTime: 5_000,
});
const previewBody = (
<div
className={cn(
"bg-muted/20 overflow-hidden border-t",
heightClassName,
)}
>
{!previewReady ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Select a client and add descriptions for all line items to generate the
PDF preview.
</div>
) : error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-destructive text-sm">{error.message}</p>
<Button type="button" variant="outline" size="sm" onClick={() => void refetch()}>
Try again
</Button>
</div>
) : isFetching && !pdfPreview ? (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 p-6 text-center text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Generating preview
</div>
) : pdfPreview ? (
<iframe
title="Invoice PDF preview"
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
className="h-full w-full border-0"
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
PDF preview will appear here.
</div>
)}
</div>
);
if (embedded) {
return <div className={cn("overflow-hidden", className)}>{previewBody}</div>;
}
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
PDF preview
{isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null}
</CardTitle>
</CardHeader>
<CardContent className="p-0">{previewBody}</CardContent>
</Card>
);
}
@@ -0,0 +1,101 @@
"use client";
import * as React from "react";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { List, Calendar as CalendarIcon } from "lucide-react";
import { InvoiceLineItems } from "../invoice-line-items";
import { InvoiceCalendarView } from "../invoice-calendar-view";
import type { InvoiceFormData } from "./types";
interface InvoiceWorkspaceProps {
formData: InvoiceFormData;
viewMode: "list" | "calendar";
setViewMode: (mode: "list" | "calendar") => void;
addItem: (date?: Date) => void;
removeItem: (index: number) => void;
updateItem: (
index: number,
field: string,
value: string | number | Date,
) => void;
className?: string;
}
export function InvoiceWorkspace({
formData,
viewMode,
setViewMode,
addItem,
removeItem,
updateItem,
className,
}: InvoiceWorkspaceProps) {
return (
<div className={cn("flex h-full flex-col", className)}>
{/* Workspace Header / View Toggle */}
<div className="bg-background/50 sticky top-0 z-10 flex items-center justify-between border-b p-4 backdrop-blur-sm">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">
{viewMode === "list" ? "Line Items" : "Timesheet"}
</h2>
<div className="text-muted-foreground ml-2 text-sm">
{formData.items.length}{" "}
{formData.items.length === 1 ? "entry" : "entries"}
</div>
</div>
<div className="bg-secondary/50 flex items-center rounded-lg p-1">
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("list")}
className="h-8 gap-2 text-xs"
>
<List className="h-3.5 w-3.5" />
List
</Button>
<Button
variant={viewMode === "calendar" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("calendar")}
className="h-8 gap-2 text-xs"
>
<CalendarIcon className="h-3.5 w-3.5" />
Calendar
</Button>
</div>
</div>
{/* Workspace Content */}
<div className="relative flex-1 overflow-hidden">
<div className="absolute inset-0 overflow-y-auto p-6 md:p-8">
{viewMode === "list" ? (
<div className="mx-auto max-w-4xl space-y-6">
<div className="bg-background/40 rounded-xl border border-white/10 p-1 backdrop-blur-md">
<InvoiceLineItems
items={formData.items}
onAddItem={() => addItem()}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
className="p-4"
/>
</div>
</div>
) : (
<div className="h-full">
<InvoiceCalendarView
items={formData.items}
onAddItem={addItem}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
defaultHourlyRate={formData.defaultHourlyRate}
className="h-full"
/>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,38 @@
import { type RouterOutputs } from "~/trpc/react";
export type ClientType = RouterOutputs["clients"]["getAll"][number];
export type BusinessType = RouterOutputs["businesses"]["getAll"][number];
import type { LineItemBillingType } from "~/lib/invoice-line-item";
export interface InvoiceItem {
id: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
billingType: LineItemBillingType;
}
export interface InvoiceFormData {
invoiceNumber: string;
invoicePrefix: string;
businessId: string;
clientId: string;
issueDate: Date;
dueDate: Date;
status: "draft" | "sent" | "paid";
notes: string;
emailMessage: string;
taxRate: number;
currency: string;
defaultHourlyRate: number | null;
items: InvoiceItem[];
}
export const STATUS_OPTIONS = [
{ value: "draft", label: "Draft" },
{ value: "sent", label: "Sent" },
{ value: "paid", label: "Paid" },
] as const;