mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 09:38:55 -04:00
0e46fdafb2
- Implemented `AdministrationContent` component for managing account roles. - Created `AdministrationPage` to serve as the main entry point for administration tasks. - Added PDF preview functionality with `PdfPreviewFrame` component for invoice generation. - Introduced `InputColor` component for advanced color selection with various formats. - Established color conversion utilities in `color-converter.ts` for handling color formats. - Defined appearance-related schemas and types in `appearance.ts` for consistent theme management.
521 lines
20 KiB
TypeScript
521 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import {
|
|
format,
|
|
startOfWeek,
|
|
endOfWeek,
|
|
eachDayOfInterval,
|
|
isSameDay,
|
|
subWeeks,
|
|
addWeeks,
|
|
subMonths,
|
|
addMonths,
|
|
} from "date-fns";
|
|
import { Calendar } from "~/components/ui/calendar";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
SheetFooter,
|
|
} from "~/components/ui/sheet";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Input } from "~/components/ui/input";
|
|
import { Label } from "~/components/ui/label";
|
|
import { NumberInput } from "~/components/ui/number-input";
|
|
import {
|
|
Plus,
|
|
Trash2,
|
|
Clock,
|
|
Calendar as CalendarIcon,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
} from "lucide-react";
|
|
import { cn } from "~/lib/utils";
|
|
|
|
interface InvoiceItem {
|
|
id: string;
|
|
date: Date;
|
|
description: string;
|
|
hours: number;
|
|
rate: number;
|
|
amount: number;
|
|
}
|
|
|
|
interface InvoiceCalendarViewProps {
|
|
items: InvoiceItem[];
|
|
onUpdateItem: (
|
|
index: number,
|
|
field: string,
|
|
value: string | number | Date,
|
|
) => void;
|
|
onAddItem: (date?: Date) => void;
|
|
onRemoveItem: (index: number) => void;
|
|
className?: string;
|
|
defaultHourlyRate: number | null;
|
|
}
|
|
|
|
export function InvoiceCalendarView({
|
|
items,
|
|
onUpdateItem,
|
|
onAddItem,
|
|
onRemoveItem,
|
|
className,
|
|
defaultHourlyRate: _defaultHourlyRate,
|
|
}: InvoiceCalendarViewProps) {
|
|
const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected
|
|
const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week)
|
|
const [view, setView] = React.useState<"month" | "week">("month");
|
|
const [sheetOpen, setSheetOpen] = React.useState(false);
|
|
// Derived state for selected date items - solves cursor jumping
|
|
const selectedDateItems = React.useMemo(() => {
|
|
if (!date) return [];
|
|
return items
|
|
.map((item, index) => ({ item, index }))
|
|
.filter((wrapper) => {
|
|
const itemDate = new Date(wrapper.item.date);
|
|
return isSameDay(itemDate, date);
|
|
});
|
|
}, [items, date]);
|
|
|
|
// Helper to get items for any date (for calendar view)
|
|
const getItemsForDate = React.useCallback(
|
|
(targetDate: Date) => {
|
|
return items
|
|
.map((item, index) => ({ item, index }))
|
|
.filter((wrapper) => {
|
|
const itemDate = new Date(wrapper.item.date);
|
|
return isSameDay(itemDate, targetDate);
|
|
});
|
|
},
|
|
[items],
|
|
);
|
|
|
|
const handleSelectDate = (newDate: Date | undefined) => {
|
|
if (!newDate) return;
|
|
setDate(newDate);
|
|
setSheetOpen(true);
|
|
};
|
|
|
|
const handleAddNewItem = () => {
|
|
if (date) {
|
|
onAddItem(date);
|
|
}
|
|
};
|
|
|
|
// Week View Logic - Uses viewDate
|
|
const currentWeekStart = startOfWeek(viewDate);
|
|
const currentWeekEnd = endOfWeek(viewDate);
|
|
const weekDays = eachDayOfInterval({
|
|
start: currentWeekStart,
|
|
end: currentWeekEnd,
|
|
});
|
|
|
|
const handleCloseSheet = (isOpen: boolean) => {
|
|
setSheetOpen(isOpen);
|
|
if (!isOpen) {
|
|
setDate(undefined);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={cn("flex h-full w-full flex-col gap-4", className)}>
|
|
<div className="flex w-full items-center justify-between gap-4 px-4 pt-4">
|
|
{/* Navigation Controls */}
|
|
<div className="flex items-center gap-2">
|
|
{view === "week" ? (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setViewDate((d) => subWeeks(d, 1))}
|
|
className="h-8 w-8 rounded-lg"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<span className="w-36 text-center text-sm font-medium">
|
|
{`${format(currentWeekStart, "MMM d")} - ${format(currentWeekEnd, "MMM d")}`}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setViewDate((d) => addWeeks(d, 1))}
|
|
className="h-8 w-8 rounded-lg"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setViewDate((d) => subMonths(d, 1))}
|
|
className="h-8 w-8 rounded-lg"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<span className="w-36 text-center text-sm font-medium">
|
|
{format(viewDate, "MMMM yyyy")}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setViewDate((d) => addMonths(d, 1))}
|
|
className="h-8 w-8 rounded-lg"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="ml-auto flex items-center space-x-2">
|
|
{/* View Switcher */}
|
|
<div className="bg-muted flex rounded-lg p-1 text-sm">
|
|
<button
|
|
type="button"
|
|
onClick={() => setView("month")}
|
|
className={cn(
|
|
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
|
view === "month"
|
|
? "bg-background text-foreground shadow"
|
|
: "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
>
|
|
Month
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setView("week")}
|
|
className={cn(
|
|
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
|
view === "week"
|
|
? "bg-background text-foreground shadow"
|
|
: "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
>
|
|
Week
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full flex-1 overflow-hidden">
|
|
{view === "month" ? (
|
|
<Calendar
|
|
mode="single"
|
|
selected={date}
|
|
onSelect={handleSelectDate}
|
|
month={viewDate}
|
|
onMonthChange={setViewDate}
|
|
className="w-full rounded-md border-0 p-0"
|
|
classNames={{
|
|
root: "w-full p-0",
|
|
months: "flex flex-col w-full",
|
|
month: "flex flex-col w-full space-y-4",
|
|
|
|
// Grid - Revert to Flex but Enforce 1/7th Width
|
|
// table: "w-full border-collapse", // No table-fixed
|
|
head_row: "flex w-full",
|
|
row: "flex w-full mt-2",
|
|
|
|
// Cells & Headers: Explicit width 14.28%
|
|
// Use calc(100%/7) via tailwind arbitrary or just flex bases.
|
|
// Better: w-[14.28%] flex-none (approx 1/7)
|
|
weekdays: "flex w-full border-b",
|
|
weekday:
|
|
"w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4",
|
|
|
|
week: "flex w-full mt-2",
|
|
cell: "w-[14.285%] flex-none h-20 sm:h-28 md:h-32 border-b p-0 relative focus-within:relative focus-within:z-20 text-center text-sm",
|
|
|
|
// Hide internal navigation & caption entirely
|
|
nav: "hidden",
|
|
caption: "hidden",
|
|
|
|
day: cn(
|
|
"w-full h-full p-2 font-normal aria-selected:opacity-100 flex flex-col items-start justify-start gap-1 hover:bg-accent/50 hover:text-accent-foreground align-top transition-colors rounded-xl",
|
|
),
|
|
day_selected: "bg-primary/5 text-primary",
|
|
day_today: "bg-accent/20",
|
|
day_outside: "text-muted-foreground opacity-30",
|
|
}}
|
|
formatters={{
|
|
formatMonthCaption: () => "", // Clear default caption text to prevent duplication
|
|
}}
|
|
components={{
|
|
DayButton: (props) => {
|
|
const { day, modifiers, className, ...buttonProps } = props;
|
|
const DayDate = day.date;
|
|
const dayItems = getItemsForDate(DayDate);
|
|
// const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0); // Unused now
|
|
|
|
return (
|
|
<button
|
|
{...buttonProps}
|
|
type="button"
|
|
className={cn(
|
|
"hover:border-border/50 hover:bg-secondary/30 relative flex h-full w-full flex-col items-start justify-between overflow-hidden rounded-xl border border-transparent p-2 text-left transition-all",
|
|
// Selected State: Filled Box, No Outline
|
|
modifiers.selected &&
|
|
"bg-primary text-primary-foreground hover:bg-primary/90 scale-[0.98] transform shadow-md",
|
|
modifiers.today &&
|
|
!modifiers.selected &&
|
|
"bg-accent/40 rounded-xl",
|
|
className,
|
|
)}
|
|
>
|
|
<span className="z-10 text-sm font-medium">
|
|
{DayDate.getDate()}
|
|
</span>
|
|
{dayItems.length > 0 && (
|
|
<div className="mt-1 flex h-full w-full flex-col justify-end gap-1 overflow-hidden pb-1">
|
|
<div className="mt-1 flex w-full flex-col gap-1">
|
|
{dayItems.slice(0, 4).map((item, idx) => (
|
|
<div
|
|
key={idx}
|
|
className={cn(
|
|
"h-1 w-full rounded-full",
|
|
modifiers.selected
|
|
? "bg-primary-foreground/50"
|
|
: "bg-primary/50",
|
|
)}
|
|
/>
|
|
))}
|
|
{dayItems.length > 4 && (
|
|
<div
|
|
className={cn(
|
|
"h-1 w-1/3 rounded-full",
|
|
modifiers.selected
|
|
? "bg-primary-foreground/30"
|
|
: "bg-muted-foreground/30",
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
},
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="flex w-full gap-3 overflow-x-auto p-4 pb-6">
|
|
{weekDays.map((day) => {
|
|
const isSelected = date && isSameDay(day, date);
|
|
const isToday = isSameDay(day, new Date());
|
|
const dayItems = getItemsForDate(day);
|
|
const totalHours = dayItems.reduce(
|
|
(acc, curr) => acc + curr.item.hours,
|
|
0,
|
|
);
|
|
|
|
return (
|
|
<button
|
|
key={day.toString()}
|
|
type="button"
|
|
onClick={() => handleSelectDate(day)}
|
|
className={cn(
|
|
"hover:bg-accent/30 flex min-h-[260px] w-[120px] flex-shrink-0 flex-col rounded-3xl border p-3 text-left transition-all sm:w-auto sm:flex-1",
|
|
isSelected
|
|
? "ring-primary bg-primary/5 ring-2 ring-offset-2"
|
|
: "bg-background/40",
|
|
isToday && !isSelected ? "bg-accent/40" : "",
|
|
)}
|
|
>
|
|
<div className="mb-4 flex w-full flex-col items-center border-b pb-4">
|
|
<span className="text-muted-foreground text-xs font-bold uppercase">
|
|
{format(day, "EEE")}
|
|
</span>
|
|
<span className="text-2xl font-light">
|
|
{format(day, "d")}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="w-full flex-1 space-y-2 overflow-hidden">
|
|
{dayItems.length > 0 ? (
|
|
dayItems.map(({ item }, i) => (
|
|
<div
|
|
key={i}
|
|
className="bg-background rounded-xl border p-2 text-xs shadow-sm"
|
|
>
|
|
<div className="line-clamp-2 font-medium text-wrap break-words">
|
|
{item.description || "No description"}
|
|
</div>
|
|
<div className="text-muted-foreground whitespace-nowrap">
|
|
{item.hours}h
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-muted-foreground/20 flex h-full items-center justify-center">
|
|
<Plus className="h-8 w-8" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{dayItems.length > 0 && (
|
|
<div className="mt-auto w-full pt-2 text-center">
|
|
<span className="text-sm font-semibold">
|
|
{totalHours}h Total
|
|
</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sheet for Day Details */}
|
|
<Sheet open={sheetOpen} onOpenChange={handleCloseSheet}>
|
|
<SheetContent
|
|
side="right"
|
|
className="flex w-full max-w-full flex-col gap-0 p-0 sm:w-[400px] sm:max-w-[540px]"
|
|
>
|
|
<SheetHeader className="border-b p-6">
|
|
<SheetTitle className="flex flex-wrap items-center gap-3 text-2xl">
|
|
<div className="bg-primary/10 flex-shrink-0 rounded-full p-2.5">
|
|
<CalendarIcon className="text-primary h-6 w-6" />
|
|
</div>
|
|
<span className="text-left break-words">
|
|
{date ? format(date, "EEEE, MMMM do") : "Details"}
|
|
</span>
|
|
</SheetTitle>
|
|
</SheetHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
<div className="space-y-6">
|
|
{date && selectedDateItems.length === 0 ? (
|
|
<div className="bg-secondary/20 border-border/60 flex flex-col items-center justify-center space-y-4 rounded-3xl border border-dashed py-16 text-center">
|
|
<div className="bg-background rounded-full p-4 shadow-sm">
|
|
<Clock className="text-muted-foreground/50 h-8 w-8" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<p className="text-foreground text-lg font-semibold">
|
|
No hours logged
|
|
</p>
|
|
<p className="text-muted-foreground/80 max-w-[200px] text-sm">
|
|
There are no time entries recorded for this day yet.
|
|
</p>
|
|
</div>
|
|
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Log Time
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{selectedDateItems.map(({ item, index }) => (
|
|
<div
|
|
key={item.id}
|
|
className="border-border bg-card group hover:border-primary/50 overflow-hidden rounded-lg border transition-colors"
|
|
>
|
|
<div className="space-y-3 p-4">
|
|
{/* Description */}
|
|
<div className="space-y-1">
|
|
<Label className="text-muted-foreground text-xs">
|
|
Description
|
|
</Label>
|
|
<Input
|
|
value={item.description}
|
|
onChange={(e) =>
|
|
onUpdateItem(index, "description", e.target.value)
|
|
}
|
|
placeholder="Describe the work performed..."
|
|
className="pl-3 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* Hours and Rate in a row */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label className="text-muted-foreground text-xs">
|
|
Hours
|
|
</Label>
|
|
<NumberInput
|
|
value={item.hours}
|
|
onChange={(v) => onUpdateItem(index, "hours", v)}
|
|
step={0.25}
|
|
min={0}
|
|
width="full"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-muted-foreground text-xs">
|
|
Rate
|
|
</Label>
|
|
<NumberInput
|
|
value={item.rate}
|
|
onChange={(v) => onUpdateItem(index, "rate", v)}
|
|
prefix="$"
|
|
min={0}
|
|
step={1}
|
|
width="full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onRemoveItem(index)}
|
|
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="flex-1 px-3 text-center">
|
|
<span className="text-muted-foreground block text-sm font-medium">
|
|
Item #{index + 1}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col items-end">
|
|
<span className="text-muted-foreground text-xs">
|
|
Total
|
|
</span>
|
|
<span className="text-primary text-lg font-bold">
|
|
${(item.hours * item.rate).toFixed(2)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleAddNewItem}
|
|
className="hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary group w-full gap-2 rounded-xl border-dashed py-8 transition-all"
|
|
>
|
|
<div className="bg-muted group-hover:bg-primary/10 rounded-md p-1 transition-colors">
|
|
<Plus className="h-4 w-4" />
|
|
</div>
|
|
<span>Add Another Entry</span>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<SheetFooter className="bg-muted/10 mt-auto border-t p-6">
|
|
<Button
|
|
className="h-12 w-full rounded-xl text-base shadow-md sm:w-full"
|
|
size="lg"
|
|
onClick={() => handleCloseSheet(false)}
|
|
>
|
|
Done
|
|
</Button>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
);
|
|
}
|