remove reordering controls, add auto sort

This commit is contained in:
2026-04-09 23:27:45 -04:00
parent 74f9696023
commit af392e1bc9
4 changed files with 133 additions and 223 deletions
+11 -5
View File
@@ -108,10 +108,10 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
} }
}; };
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number, currency = "USD") => {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency,
}).format(amount); }).format(amount);
}; };
@@ -233,7 +233,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
onClick={handlePDFExport} onClick={handlePDFExport}
disabled={isExportingPDF} disabled={isExportingPDF}
variant="default" variant="default"
className="transform-none flex-shrink-0" className="flex-shrink-0 transform-none"
> >
{isExportingPDF ? ( {isExportingPDF ? (
<> <>
@@ -432,7 +432,10 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
Tax ({((invoice.taxRate ?? 0) * 100).toFixed(1)}%) Tax ({((invoice.taxRate ?? 0) * 100).toFixed(1)}%)
</span> </span>
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">
{formatCurrency(invoice.totalAmount * (invoice.taxRate ?? 0), invoice.currency)} {formatCurrency(
invoice.totalAmount * (invoice.taxRate ?? 0),
invoice.currency,
)}
</span> </span>
</div> </div>
)} )}
@@ -440,7 +443,10 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
<div className="flex justify-between text-lg font-bold"> <div className="flex justify-between text-lg font-bold">
<span className="text-foreground">Total</span> <span className="text-foreground">Total</span>
<span className="text-primary"> <span className="text-primary">
{formatCurrency(invoice.totalAmount * (1 + (invoice.taxRate ?? 0)), invoice.currency)} {formatCurrency(
invoice.totalAmount * (1 + (invoice.taxRate ?? 0)),
invoice.currency,
)}
</span> </span>
</div> </div>
</div> </div>
+32 -36
View File
@@ -21,7 +21,15 @@ import { InvoiceLineItems } from "./invoice-line-items";
import { InvoiceCalendarView } from "./invoice-calendar-view"; import { InvoiceCalendarView } from "./invoice-calendar-view";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Save, Calendar as CalendarIcon, Tag, User, List, FileText, ChevronDown } from "lucide-react"; import {
Save,
Calendar as CalendarIcon,
Tag,
User,
List,
FileText,
ChevronDown,
} from "lucide-react";
import { SUPPORTED_CURRENCIES } from "~/lib/currency"; import { SUPPORTED_CURRENCIES } from "~/lib/currency";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { import {
@@ -101,7 +109,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
// Queries (Same as before) // Queries (Same as before)
const { data: clients, isLoading: loadingClients } = const { data: clients, isLoading: loadingClients } =
api.clients.getAll.useQuery(); api.clients.getAll.useQuery();
const { data: noteTemplates } = api.invoiceTemplates.getByType.useQuery({ type: "notes" }); const { data: noteTemplates } = api.invoiceTemplates.getByType.useQuery({
type: "notes",
});
const { data: businesses, isLoading: loadingBusinesses } = const { data: businesses, isLoading: loadingBusinesses } =
api.businesses.getAll.useQuery(); api.businesses.getAll.useQuery();
const { data: existingInvoice, isLoading: loadingInvoice } = const { data: existingInvoice, isLoading: loadingInvoice } =
@@ -231,32 +241,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}), }),
})); }));
}; };
const moveItemUp = (idx: number) => {
if (idx === 0) return;
setFormData((prev) => {
const newItems = [...prev.items];
if (newItems[idx] && newItems[idx - 1]) {
const temp = newItems[idx - 1]!;
newItems[idx - 1] = newItems[idx];
newItems[idx] = temp;
}
return { ...prev, items: newItems };
});
};
const moveItemDown = (idx: number) => {
if (idx === formData.items.length - 1) return;
setFormData((prev) => {
const newItems = [...prev.items];
if (newItems[idx] && newItems[idx + 1]) {
const temp = newItems[idx + 1]!;
newItems[idx + 1] = newItems[idx];
newItems[idx] = temp;
}
return { ...prev, items: newItems };
});
};
const reorderItems = (newItems: InvoiceItem[]) =>
setFormData((prev) => ({ ...prev, items: newItems }));
const createInvoice = api.invoices.create.useMutation({ const createInvoice = api.invoices.create.useMutation({
onSuccess: (inv) => { onSuccess: (inv) => {
@@ -453,13 +437,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
? selectedClient.defaultHourlyRate ? selectedClient.defaultHourlyRate
: null; : null;
const businessRate = const businessRate =
currentBusiness && "defaultHourlyRate" in currentBusiness currentBusiness &&
"defaultHourlyRate" in currentBusiness
? currentBusiness.defaultHourlyRate ? currentBusiness.defaultHourlyRate
: null; : null;
updateField("defaultHourlyRate", (clientRate ?? businessRate ?? 0) as number); updateField(
"defaultHourlyRate",
(clientRate ?? businessRate ?? 0) as number,
);
// Auto-fill currency from client // Auto-fill currency from client
if (selectedClient && "currency" in selectedClient && selectedClient.currency) { if (
updateField("currency", selectedClient.currency as string); selectedClient &&
"currency" in selectedClient &&
selectedClient.currency
) {
updateField(
"currency",
selectedClient.currency as string,
);
} }
}} }}
> >
@@ -599,7 +594,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
{noteTemplates && noteTemplates.length > 0 && ( {noteTemplates && noteTemplates.length > 0 && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs"> <Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
>
Use template <ChevronDown className="h-3 w-3" /> Use template <ChevronDown className="h-3 w-3" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -674,9 +673,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
onAddItem={addItem} onAddItem={addItem}
onRemoveItem={removeItem} onRemoveItem={removeItem}
onUpdateItem={updateItem} onUpdateItem={updateItem}
onMoveUp={moveItemUp}
onMoveDown={moveItemDown}
onReorderItems={reorderItems}
/> />
</CardContent> </CardContent>
</Card> </Card>
+6 -93
View File
@@ -1,11 +1,6 @@
"use client"; "use client";
import { import { Plus, Trash2 } from "lucide-react";
ChevronDown,
ChevronUp,
Plus,
Trash2,
} from "lucide-react";
import * as React from "react"; import * as React from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -33,9 +28,6 @@ interface InvoiceLineItemsProps {
field: string, field: string,
value: string | number | Date, value: string | number | Date,
) => void; ) => void;
onMoveUp: (index: number) => void;
onMoveDown: (index: number) => void;
onReorderItems: (items: InvoiceItem[]) => void;
className?: string; className?: string;
} }
@@ -49,61 +41,18 @@ interface LineItemRowProps {
field: string, field: string,
value: string | number | Date, value: string | number | Date,
) => void; ) => void;
onMoveUp: (index: number) => void;
onMoveDown: (index: number) => void;
isFirst: boolean;
isLast: boolean;
} }
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>( const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
( ({ item, index, canRemove, onRemove, onUpdate }, ref) => {
{
item,
index,
canRemove,
onRemove,
onUpdate,
onMoveUp,
onMoveDown,
isFirst,
isLast,
},
ref,
) => {
return ( return (
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"bg-card border hidden rounded-xl p-4 md:block transition-all shadow-sm group hover:border-primary/20", "bg-card group hover:border-primary/20 hidden rounded-xl border p-4 shadow-sm transition-all md:block",
)} )}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Arrow Controls */}
<div className="flex flex-col gap-0.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveUp(index)}
className="h-6 w-6 p-0"
disabled={isFirst}
aria-label="Move up"
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveDown(index)}
className="h-6 w-6 p-0"
disabled={isLast}
aria-label="Move down"
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
{/* Main Content */} {/* Main Content */}
<div className="flex-1 space-y-3"> <div className="flex-1 space-y-3">
{/* Description */} {/* Description */}
@@ -136,7 +85,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
min={0} min={0}
step={0.25} step={0.25}
width="auto" width="auto"
className="h-9 flex-1 min-w-[100px] font-mono" className="h-9 min-w-[100px] flex-1 font-mono"
suffix="h" suffix="h"
/> />
@@ -148,7 +97,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
step={1} step={1}
prefix="$" prefix="$"
width="auto" width="auto"
className="h-9 flex-1 min-w-[100px] font-mono" className="h-9 min-w-[100px] flex-1 font-mono"
/> />
{/* Amount */} {/* Amount */}
@@ -185,10 +134,6 @@ function MobileLineItem({
canRemove, canRemove,
onRemove, onRemove,
onUpdate, onUpdate,
onMoveUp,
onMoveDown,
isFirst,
isLast,
}: LineItemRowProps) { }: LineItemRowProps) {
return ( return (
<motion.div <motion.div
@@ -253,28 +198,6 @@ function MobileLineItem({
{/* Bottom section with controls, item name, and total */} {/* Bottom section with controls, item name, and total */}
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2"> <div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveUp(index)}
className="h-8 w-8 p-0"
disabled={isFirst}
aria-label="Move up"
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveDown(index)}
className="h-8 w-8 p-0"
disabled={isLast}
aria-label="Move down"
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -310,8 +233,6 @@ export function InvoiceLineItems({
onAddItem, onAddItem,
onRemoveItem, onRemoveItem,
onUpdateItem, onUpdateItem,
onMoveUp,
onMoveDown,
className, className,
}: InvoiceLineItemsProps) { }: InvoiceLineItemsProps) {
const canRemoveItems = items.length > 1; const canRemoveItems = items.length > 1;
@@ -337,10 +258,6 @@ export function InvoiceLineItems({
canRemove={canRemoveItems} canRemove={canRemoveItems}
onRemove={onRemoveItem} onRemove={onRemoveItem}
onUpdate={onUpdateItem} onUpdate={onUpdateItem}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
isFirst={index === 0}
isLast={index === items.length - 1}
/> />
</motion.div> </motion.div>
@@ -351,10 +268,6 @@ export function InvoiceLineItems({
canRemove={canRemoveItems} canRemove={canRemoveItems}
onRemove={onRemoveItem} onRemove={onRemoveItem}
onUpdate={onUpdateItem} onUpdate={onUpdateItem}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
isFirst={index === 0}
isLast={index === items.length - 1}
/> />
</React.Fragment> </React.Fragment>
))} ))}
@@ -368,7 +281,7 @@ export function InvoiceLineItems({
type="button" type="button"
variant="outline" variant="outline"
onClick={onAddItem} onClick={onAddItem}
className="w-full border-dashed border-border py-8 text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 transition-all" className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 w-full border-dashed py-8 transition-all"
> >
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Line Item Add Line Item
@@ -9,98 +9,93 @@ import { InvoiceCalendarView } from "../invoice-calendar-view";
import type { InvoiceFormData } from "./types"; import type { InvoiceFormData } from "./types";
interface InvoiceWorkspaceProps { interface InvoiceWorkspaceProps {
formData: InvoiceFormData; formData: InvoiceFormData;
viewMode: "list" | "calendar"; viewMode: "list" | "calendar";
setViewMode: (mode: "list" | "calendar") => void; setViewMode: (mode: "list" | "calendar") => void;
addItem: (date?: Date) => void; addItem: (date?: Date) => void;
removeItem: (index: number) => void; removeItem: (index: number) => void;
updateItem: (index: number, field: string, value: string | number | Date) => void; updateItem: (
moveItemUp: (index: number) => void; index: number,
moveItemDown: (index: number) => void; field: string,
reorderItems: (items: InvoiceFormData['items']) => void; value: string | number | Date,
className?: string; ) => void;
className?: string;
} }
export function InvoiceWorkspace({ export function InvoiceWorkspace({
formData, formData,
viewMode, viewMode,
setViewMode, setViewMode,
addItem, addItem,
removeItem, removeItem,
updateItem, updateItem,
moveItemUp, className,
moveItemDown,
reorderItems,
className,
}: InvoiceWorkspaceProps) { }: InvoiceWorkspaceProps) {
return (
return ( <div className={cn("flex h-full flex-col", className)}>
<div className={cn("flex flex-col h-full", className)}> {/* Workspace Header / View Toggle */}
{/* 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 justify-between p-4 border-b bg-background/50 backdrop-blur-sm sticky top-0 z-10"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <h2 className="text-lg font-semibold tracking-tight">
<h2 className="text-lg font-semibold tracking-tight"> {viewMode === "list" ? "Line Items" : "Timesheet"}
{viewMode === 'list' ? 'Line Items' : 'Timesheet'} </h2>
</h2> <div className="text-muted-foreground ml-2 text-sm">
<div className="text-sm text-muted-foreground ml-2"> {formData.items.length}{" "}
{formData.items.length} {formData.items.length === 1 ? 'entry' : 'entries'} {formData.items.length === 1 ? "entry" : "entries"}
</div> </div>
</div>
<div className="flex items-center bg-secondary/50 p-1 rounded-lg">
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
className="h-8 gap-2 text-xs"
>
<List className="w-3.5 h-3.5" />
List
</Button>
<Button
variant={viewMode === 'calendar' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('calendar')}
className="h-8 gap-2 text-xs"
>
<CalendarIcon className="w-3.5 h-3.5" />
Calendar
</Button>
</div>
</div>
{/* Workspace Content */}
<div className="flex-1 overflow-hidden relative">
<div className="absolute inset-0 overflow-y-auto p-6 md:p-8">
{viewMode === 'list' ? (
<div className="max-w-4xl mx-auto space-y-6">
<div className="bg-background/40 backdrop-blur-md rounded-xl border border-white/10 p-1">
<InvoiceLineItems
items={formData.items}
onAddItem={() => addItem()}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
onMoveUp={moveItemUp}
onMoveDown={moveItemDown}
onReorderItems={reorderItems}
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> </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>
);
} }