mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-02-05 00:06:36 -05:00
feat: Implement a new CountUp component and refactor calendar day details to use a Sheet instead of a Dialog.
This commit is contained in:
@@ -4,12 +4,12 @@ import * as React from "react";
|
|||||||
import { format, startOfWeek, endOfWeek, eachDayOfInterval, isSameDay, subWeeks, addWeeks, subMonths, addMonths } from "date-fns";
|
import { format, startOfWeek, endOfWeek, eachDayOfInterval, isSameDay, subWeeks, addWeeks, subMonths, addMonths } from "date-fns";
|
||||||
import { Calendar } from "~/components/ui/calendar";
|
import { Calendar } from "~/components/ui/calendar";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Sheet,
|
||||||
DialogContent,
|
SheetContent,
|
||||||
DialogHeader,
|
SheetHeader,
|
||||||
DialogTitle,
|
SheetTitle,
|
||||||
DialogFooter,
|
SheetFooter,
|
||||||
} from "~/components/ui/dialog";
|
} from "~/components/ui/sheet";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
@@ -51,7 +51,7 @@ export function InvoiceCalendarView({
|
|||||||
const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected
|
const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected
|
||||||
const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week)
|
const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week)
|
||||||
const [view, setView] = React.useState<"month" | "week">("month");
|
const [view, setView] = React.useState<"month" | "week">("month");
|
||||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
const [sheetOpen, setSheetOpen] = React.useState(false);
|
||||||
// Derived state for selected date items - solves cursor jumping
|
// Derived state for selected date items - solves cursor jumping
|
||||||
const selectedDateItems = React.useMemo(() => {
|
const selectedDateItems = React.useMemo(() => {
|
||||||
if (!date) return [];
|
if (!date) return [];
|
||||||
@@ -76,9 +76,7 @@ export function InvoiceCalendarView({
|
|||||||
const handleSelectDate = (newDate: Date | undefined) => {
|
const handleSelectDate = (newDate: Date | undefined) => {
|
||||||
if (!newDate) return;
|
if (!newDate) return;
|
||||||
setDate(newDate);
|
setDate(newDate);
|
||||||
// Optionally update viewDate to match selection if desired, but user wants them decoupled during nav
|
setSheetOpen(true);
|
||||||
// setViewDate(newDate);
|
|
||||||
setDialogOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddNewItem = () => {
|
const handleAddNewItem = () => {
|
||||||
@@ -92,8 +90,8 @@ export function InvoiceCalendarView({
|
|||||||
const currentWeekEnd = endOfWeek(viewDate);
|
const currentWeekEnd = endOfWeek(viewDate);
|
||||||
const weekDays = eachDayOfInterval({ start: currentWeekStart, end: currentWeekEnd });
|
const weekDays = eachDayOfInterval({ start: currentWeekStart, end: currentWeekEnd });
|
||||||
|
|
||||||
const handleCloseDialog = (isOpen: boolean) => {
|
const handleCloseSheet = (isOpen: boolean) => {
|
||||||
setDialogOpen(isOpen);
|
setSheetOpen(isOpen);
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setDate(undefined);
|
setDate(undefined);
|
||||||
}
|
}
|
||||||
@@ -280,95 +278,104 @@ export function InvoiceCalendarView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dialog for Day Details - Now consistently used and rounded */}
|
{/* Sheet for Day Details */}
|
||||||
<Dialog
|
<Sheet
|
||||||
open={dialogOpen}
|
open={sheetOpen}
|
||||||
onOpenChange={handleCloseDialog}
|
onOpenChange={handleCloseSheet}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-[600px] rounded-3xl">
|
<SheetContent side="right" className="w-[400px] sm:w-[540px] flex flex-col gap-0 p-0 sm:max-w-[540px]">
|
||||||
<DialogHeader>
|
<SheetHeader className="p-6 border-b">
|
||||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
<SheetTitle className="flex items-center gap-3 text-2xl">
|
||||||
<div className="bg-primary/10 p-2 rounded-full">
|
<div className="bg-primary/10 p-2.5 rounded-full">
|
||||||
<CalendarIcon className="w-5 h-5 text-primary" />
|
<CalendarIcon className="w-6 h-6 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
{date ? format(date, "EEEE, MMMM do") : "Details"}
|
{date ? format(date, "EEEE, MMMM do") : "Details"}
|
||||||
</DialogTitle>
|
</SheetTitle>
|
||||||
</DialogHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="space-y-6 py-4">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
{date && selectedDateItems.length === 0 ? (
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center space-y-3 bg-secondary/20 rounded-3xl border border-dashed border-border">
|
{date && selectedDateItems.length === 0 ? (
|
||||||
<Clock className="w-12 h-12 text-muted-foreground/30" />
|
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4 bg-secondary/20 rounded-3xl border border-dashed border-border/60">
|
||||||
<div className="space-y-1">
|
<div className="bg-background p-4 rounded-full shadow-sm">
|
||||||
<p className="font-medium text-foreground">No hours logged</p>
|
<Clock className="w-8 h-8 text-muted-foreground/50" />
|
||||||
<p className="text-sm text-muted-foreground">Add time entries for this day.</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleAddNewItem} variant="secondary" className="mt-2 text-primary">
|
|
||||||
Start Logging
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{selectedDateItems.map(({ item, index }) => (
|
|
||||||
<div key={item.id} className="group relative bg-card hover:bg-accent/10 transition-colors p-4 rounded-2xl border shadow-sm space-y-3">
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<div className="flex-1 space-y-1.5">
|
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">Description</Label>
|
|
||||||
<Input
|
|
||||||
value={item.description}
|
|
||||||
onChange={(e) => onUpdateItem(index, "description", e.target.value)}
|
|
||||||
placeholder="What did you work on?"
|
|
||||||
className="bg-background/50 border-transparent focus:border-input focus:bg-background transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end gap-3">
|
|
||||||
<div className="w-24 space-y-1.5">
|
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">Hours</Label>
|
|
||||||
<NumberInput
|
|
||||||
value={item.hours}
|
|
||||||
onChange={v => onUpdateItem(index, "hours", v)}
|
|
||||||
step={0.25}
|
|
||||||
min={0}
|
|
||||||
className="bg-background/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-28 space-y-1.5">
|
|
||||||
<Label className="text-xs font-semibold text-muted-foreground">Rate ($/hr)</Label>
|
|
||||||
<NumberInput
|
|
||||||
value={item.rate}
|
|
||||||
onChange={v => onUpdateItem(index, "rate", v)}
|
|
||||||
prefix="$"
|
|
||||||
min={0}
|
|
||||||
className="bg-background/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 flex justify-end items-center pb-2 text-sm font-medium text-muted-foreground">
|
|
||||||
<span>${(item.hours * item.rate).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-10 w-10 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-xl"
|
|
||||||
onClick={() => onRemoveItem(index)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="space-y-1">
|
||||||
<Button variant="outline" onClick={handleAddNewItem} className="w-full border-dashed py-6 rounded-xl hover:bg-accent/40 hover:border-primary/50 text-muted-foreground hover:text-primary transition-all">
|
<p className="font-semibold text-lg text-foreground">No hours logged</p>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<p className="text-sm text-muted-foreground/80 max-w-[200px]">There are no time entries recorded for this day yet.</p>
|
||||||
Add Another Entry
|
</div>
|
||||||
</Button>
|
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
|
||||||
</div>
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
)}
|
Log Time
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{selectedDateItems.map(({ item, index }) => (
|
||||||
|
<div key={item.id} className="group relative bg-card hover:bg-accent/10 transition-colors p-5 rounded-2xl border shadow-sm space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Description</Label>
|
||||||
|
<Input
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) => onUpdateItem(index, "description", e.target.value)}
|
||||||
|
placeholder="What did you work on?"
|
||||||
|
className="bg-muted/30 border-transparent focus:border-input focus:bg-background transition-all font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div className="w-28 space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Hours</Label>
|
||||||
|
<NumberInput
|
||||||
|
value={item.hours}
|
||||||
|
onChange={v => onUpdateItem(index, "hours", v)}
|
||||||
|
step={0.25}
|
||||||
|
min={0}
|
||||||
|
className="bg-muted/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32 space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Rate</Label>
|
||||||
|
<NumberInput
|
||||||
|
value={item.rate}
|
||||||
|
onChange={v => onUpdateItem(index, "rate", v)}
|
||||||
|
prefix="$"
|
||||||
|
min={0}
|
||||||
|
className="bg-muted/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex justify-end items-center pb-2 text-sm font-medium text-muted-foreground">
|
||||||
|
<span className="bg-primary/10 text-primary px-3 py-1 rounded-full text-xs font-bold">
|
||||||
|
${(item.hours * item.rate).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-xl"
|
||||||
|
onClick={() => onRemoveItem(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" onClick={handleAddNewItem} className="w-full border-dashed py-8 rounded-xl hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary transition-all gap-2 group">
|
||||||
|
<div className="bg-muted group-hover:bg-primary/10 p-1 rounded-md transition-colors">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<span>Add Another Entry</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<SheetFooter className="p-6 border-t bg-muted/10 mt-auto">
|
||||||
<Button className="w-full sm:w-auto rounded-xl" onClick={() => handleCloseDialog(false)}>Done</Button>
|
<Button className="w-full sm:w-full rounded-xl h-12 text-base shadow-md" size="lg" onClick={() => handleCloseSheet(false)}>Done</Button>
|
||||||
</DialogFooter>
|
</SheetFooter>
|
||||||
</DialogContent>
|
</SheetContent>
|
||||||
</Dialog>
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ import {
|
|||||||
import { STATUS_OPTIONS } from "./invoice/types";
|
import { STATUS_OPTIONS } from "./invoice/types";
|
||||||
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
|
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
|
||||||
|
|
||||||
// ... (Imports and Interfaces identical to previous)
|
import { CountUp } from "~/components/ui/count-up";
|
||||||
|
|
||||||
|
|
||||||
interface InvoiceFormProps {
|
interface InvoiceFormProps {
|
||||||
invoiceId?: string;
|
invoiceId?: string;
|
||||||
@@ -314,9 +315,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
{/* ITEMS TAB */}
|
{/* ITEMS TAB */}
|
||||||
<TabsContent value="items" className="focus-visible:outline-none mt-6">
|
<TabsContent value="items" className="focus-visible:outline-none mt-6">
|
||||||
<div className="mb-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="mb-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<Card className="bg-primary/5 border-primary/20"><CardContent className="p-4 flex justify-between items-center"><span className="text-muted-foreground">Total</span><span className="text-2xl font-bold">${totals.total.toFixed(2)}</span></CardContent></Card>
|
<Card className="bg-primary/5 border-primary/20"><CardContent className="p-4 flex justify-between items-center"><span className="text-muted-foreground">Total</span><span className="text-2xl font-bold font-mono"><CountUp value={totals.total} prefix="$" /></span></CardContent></Card>
|
||||||
<Card><CardContent className="p-4 flex justify-between items-center"><span className="text-muted-foreground">Subtotal</span><span className="text-xl font-semibold">${totals.subtotal.toFixed(2)}</span></CardContent></Card>
|
<Card><CardContent className="p-4 flex justify-between items-center"><span className="text-muted-foreground">Subtotal</span><span className="text-xl font-semibold font-mono"><CountUp value={totals.subtotal} prefix="$" /></span></CardContent></Card>
|
||||||
<Card><CardContent className="p-4 flex justify-between items-center"><span className="text-muted-foreground">Hours</span><span className="text-xl font-semibold">{formData.items.reduce((s, i) => s + i.hours, 0)}h</span></CardContent></Card>
|
<Card><CardContent className="p-4 flex justify-between items-center"><span className="text-muted-foreground">Hours</span><span className="text-xl font-semibold font-mono"><CountUp value={formData.items.reduce((s, i) => s + i.hours, 0)} suffix="h" /></span></CardContent></Card>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle className="flex gap-2"><List className="w-5 h-5" /> Invoice Items</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="flex gap-2"><List className="w-5 h-5" /> Invoice Items</CardTitle></CardHeader>
|
||||||
|
|||||||
@@ -126,19 +126,19 @@ function SortableLineItem({
|
|||||||
exit={{ opacity: 0, y: -20 }}
|
exit={{ opacity: 0, y: -20 }}
|
||||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-secondary hidden rounded-lg p-4 md:block transition-all",
|
"bg-card border hidden rounded-xl p-4 md:block transition-all shadow-sm group hover:border-primary/20",
|
||||||
isDragging && "opacity-50",
|
isDragging && "opacity-50 shadow-md rotate-1 scale-[1.01] z-50 ring-2 ring-primary/20",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{/* Drag Handle and Arrow Controls */}
|
{/* Drag Handle and Arrow Controls */}
|
||||||
<div className="mt-1 flex flex-col items-center gap-1">
|
<div className="mt-1 flex flex-col items-center gap-1">
|
||||||
<div
|
<div
|
||||||
className="cursor-grab active:cursor-grabbing"
|
className="cursor-grab active:cursor-grabbing p-2 hover:bg-muted rounded-md transition-colors mt-2"
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
>
|
>
|
||||||
<GripVertical className="text-muted-foreground h-4 w-4" />
|
<GripVertical className="text-muted-foreground/50 hover:text-foreground h-4 w-4 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<Button
|
<Button
|
||||||
@@ -174,7 +174,7 @@ function SortableLineItem({
|
|||||||
value={item.description}
|
value={item.description}
|
||||||
onChange={(e) => onUpdate(index, "description", e.target.value)}
|
onChange={(e) => onUpdate(index, "description", e.target.value)}
|
||||||
placeholder="Describe the work performed..."
|
placeholder="Describe the work performed..."
|
||||||
className="w-full text-sm font-medium"
|
className="w-full text-sm font-medium border-transparent bg-transparent hover:bg-muted/50 focus:bg-background focus:ring-1 focus:ring-ring transition-all rounded-md px-2 -ml-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -262,7 +262,7 @@ function MobileLineItem({
|
|||||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
className="border-border bg-card overflow-hidden rounded-lg border md:hidden"
|
className="border-border bg-card overflow-hidden rounded-lg border md:hidden"
|
||||||
>
|
>
|
||||||
<div className="bg-secondary space-y-3 p-4">
|
<div className="space-y-3 p-4">
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-muted-foreground text-xs">Description</Label>
|
<Label className="text-muted-foreground text-xs">Description</Label>
|
||||||
@@ -447,17 +447,15 @@ export function InvoiceLineItems({
|
|||||||
{/* Add Item Button */}
|
{/* Add Item Button */}
|
||||||
<div className="px-3 pt-3">
|
<div className="px-3 pt-3">
|
||||||
<div className="border-t pt-6">
|
<div className="border-t pt-6">
|
||||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
<Button
|
||||||
<Button
|
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="w-full"
|
>
|
||||||
>
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
Add Line Item
|
||||||
Add Line Item
|
</Button>
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
15
src/components/ui/count-up.tsx
Normal file
15
src/components/ui/count-up.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion, useSpring, useTransform } from "framer-motion";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function CountUp({ value, prefix = "", suffix = "" }: { value: number, prefix?: string, suffix?: string }) {
|
||||||
|
const spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 });
|
||||||
|
const display = useTransform(spring, (current) => `${prefix}${current.toFixed(2)}${suffix}`);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
spring.set(value);
|
||||||
|
}, [spring, value]);
|
||||||
|
|
||||||
|
return <motion.span>{display}</motion.span>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user