diff --git a/backend/src/main.rs b/backend/src/main.rs index c6d085a..56554e5 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -42,7 +42,7 @@ async fn main() -> std::io::Result<()> { .expect("Cannot connect to Redis"); let hmac_secret = std::env::var("HMAC_SECRET").expect("HMAC_SECRET is not set"); let secret_key = Key::from(hmac_secret.as_bytes()); - let rl_config = RateLimitConfig::default().max_requests(3).window_secs(10); + let rl_config = RateLimitConfig::default().max_requests(3).window_secs(1); let rl_store = std::sync::Arc::new(MemoryStore::new()); let port = std::env::var("BACKEND_PORT") diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index c779520..2a212bd 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useSyncExternalStore } from "react" +import { useEffect, useMemo, useState, useSyncExternalStore } from "react" import { useRouter } from "next/navigation" import { User } from "@/lib/session" import { UserPermissions } from "@/lib/userPermissions" @@ -40,23 +40,25 @@ import { BatchesPage } from "@/components/pages/batches" import { CustomersPage } from "@/components/pages/customers" import { FlavorsPage } from "@/components/pages/flavors" import { IngredientsPage } from "@/components/pages/ingredients" -import { OrdersPage } from "@/components/pages/template" +import { OrdersPage } from "@/components/pages/orders" import { SuppliersPage } from "@/components/pages/suppliers" import { UsersPage } from "@/components/pages/users" import { useTheme } from "next-themes" +import { useHashSegment } from "@/lib/useHash" -type Page = - | "batches" - | "customers" - | "flavors" - | "ingredients" - | "orders" - | "suppliers" - | "users" +const PAGE = [ + "batches", + "customers", + "flavors", + "ingredients", + "orders", + "suppliers", + "users", +] const NAV_ITEMS: { label: string - page: Page + page: (typeof PAGE)[number] icon: React.ElementType read_permission: UserPermissions }[] = [ @@ -104,16 +106,23 @@ const NAV_ITEMS: { }, ] -function pageMap(page: Page, userPerms: UserPermissions): React.ReactNode { +function pageMap( + page: (typeof PAGE)[number], + userPerms: UserPermissions +): React.ReactNode { switch (page) { case "batches": - return + // return + break case "customers": - return + // return + break case "flavors": - return + // return + break case "ingredients": - return + // return + break case "orders": return case "suppliers": @@ -138,10 +147,16 @@ interface DashboardProps { } export function Dashboard({ user }: DashboardProps) { - const [activePage, setActivePage] = useState("batches") const router = useRouter() + const hash = useHashSegment() const { resolvedTheme, setTheme } = useTheme() const mounted = useMounted() + const activePage = useMemo(() => { + if (!hash && mounted) { + return PAGE[0] + } + return PAGE.includes(hash) ? hash : PAGE[0] + }, [hash, mounted]) const permissions = UserPermissions.from(user.permissions) const initials = user.full_name ? user.full_name @@ -157,6 +172,12 @@ export function Dashboard({ user }: DashboardProps) { router.refresh() } + useEffect(() => { + if (!hash && mounted) { + window.location.hash = `#/${PAGE[0]}` + } + }, [hash, mounted]) + return ( @@ -180,7 +201,9 @@ export function Dashboard({ user }: DashboardProps) { setActivePage(page)} + onClick={() => { + window.location.hash = `#/${page}` + }} > {label} diff --git a/frontend/components/forms/order.tsx b/frontend/components/forms/order.tsx new file mode 100644 index 0000000..33ea3a3 --- /dev/null +++ b/frontend/components/forms/order.tsx @@ -0,0 +1,171 @@ +"use client" + +import { useState } from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Controller, useForm } from "react-hook-form" +import * as z from "zod" + +import { Button } from "@/components/ui/button" +import { + Field, + FieldError, + FieldGroup, + FieldLabel, +} from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Option, AsyncCombobox } from "../ui/combobox" + +const schema = z.object({ + invoice_number: z.string().min(1, { message: "Invoice Number is required." }), + customer_id: z.string().min(1, { message: "Customer is required." }), + batch_ids: z.array(z.string().min(1, { message: "Batch ID is required." })), +}) + +type FormValues = z.infer + +export interface Order { + id: string + invoice_number: string + customer_id: string + created_at: Date +} + +async function getCustomerOptions( + query: string, + page: number +): Promise { + let url = `/api/customers?page=${page}&limit=10` + if (query) url += `&name=${query}` + const res = await fetch(url) + const data = await res.json() + return data.rows.map((c: { id: string; name: string }) => ({ + value: c.id, + label: c.name, + })) +} + +async function getBatchOptions(query: string, page: number): Promise { + let url = `/api/batches?page=${page}&limit=10` + if (query) url += `&name=${query}` + const res = await fetch(url) + const data = await res.json() + return data.rows.map((c: { id: string; name: string }) => ({ + value: c.id, + label: c.name, + })) +} + +export function OrderForm({ + orderEdit, + setEditOpen, + refetchItems, +}: { + orderEdit: Order | null + setEditOpen: React.Dispatch> + refetchItems: () => void +}) { + const isEditing = orderEdit !== null + const [serverError, setServerError] = useState(null) + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + control, + } = useForm({ + resolver: zodResolver(schema), + }) + + async function onSubmit(values: FormValues) { + setServerError(null) + + const res = await fetch( + `/api/orders${isEditing ? `/${orderEdit.id}` : ""}`, + { + method: isEditing ? "PATCH" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + } + ) + if (!res.ok) { + const data = await res.json() + setServerError(data.error || "An error occurred.") + return + } + setEditOpen(false) + refetchItems() + } + + return ( +
+ + + Invoice Number + + + + + + Customer + ( + + )} + /> + + + + + Batches + ( + + )} + /> + + + + + {serverError && ( +

{serverError}

+ )} + +
+ + +
+
+
+ ) +} diff --git a/frontend/components/pages/orders.tsx b/frontend/components/pages/orders.tsx index e69de29..a583725 100644 --- a/frontend/components/pages/orders.tsx +++ b/frontend/components/pages/orders.tsx @@ -0,0 +1,439 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { + ColumnDef, + flexRender, + getCoreRowModel, + SortingState, + useReactTable, + PaginationState, + ColumnFiltersState, +} from "@tanstack/react-table" +import { + SortableFilterableColumnHeader, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + TableRowSkeleton, +} from "@/components/ui/table" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Card, CardContent } from "@/components/ui/card" +import { Modal } from "@/components/modal" + +import { UserPermissions } from "@/lib/userPermissions" +import { MoreHorizontal, UserPlus2 } from "lucide-react" +import { useQuery } from "@tanstack/react-query" +import { useDebounce } from "use-debounce" +import { TablePaginator } from "../tablePages" +import { Option } from "../ui/combobox" + +const ENTITY_NAME = "Order" +const ENTITY_PLURAL = "orders" +const API_PATH = "/api/orders" + +const SORTABLE_COLUMNS = ["invoice_number"] +const DEFAULT_SORT_BY = "invoice_number" + +const PERM_CREATE = UserPermissions.OrderCreate +const PERM_UPDATE = UserPermissions.OrderUpdate +const PERM_DELETE = UserPermissions.OrderDelete + +import { OrderForm as EntityForm, Order as Entity } from "../forms/order" + +interface PaginatedResponse { + rows: Entity[] + total: number + page_count: number +} + +const DISPLAY_PROP: keyof Entity = "invoice_number" + +interface Props { + userPerms: UserPermissions +} + +function CustomerCell({ customerId }: { customerId: string }) { + const { data: customer, isLoading } = useQuery({ + queryKey: ["customer", customerId], + queryFn: async () => { + const res = await fetch(`/api/customers/${customerId}`) + return res.json() + }, + staleTime: 5 * 60 * 1000, + }) + + if (isLoading) return Loading... + if (!customer) return Unknown + + return {customer.name} +} + +async function getCustomerOptions( + query: string, + page: number +): Promise { + let url = `/api/customers?page=${page}&limit=10` + if (query) url += `&name=${query}` + const res = await fetch(url) + const data = await res.json() + return data.rows.map((c: { id: string; name: string }) => ({ + value: c.id, + label: c.name, + })) +} + +async function getBatchOptions(query: string, page: number): Promise { + let url = `/api/batches?page=${page}&limit=10` + if (query) url += `&name=${query}` + const res = await fetch(url) + const data = await res.json() + return data.rows.map((c: { id: string; name: string }) => ({ + value: c.id, + label: c.name, + })) +} + +export function OrdersPage({ userPerms }: Props) { + const [editOpen, setEditOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + const [deleteError, setDeleteError] = useState(null) + const [entity, setEntity] = useState(null) + + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }) + const [sorting, setSorting] = useState([]) + const [columnFilters, setColumnFilters] = useState([]) + + const [debouncedFilters] = useDebounce(columnFilters, 300) + + const nameFilter = + (debouncedFilters.find((f) => f.id === "name")?.value as string) ?? "" + + useEffect(() => { + setPagination((p) => ({ ...p, pageIndex: 0 })) + }, [debouncedFilters, sorting]) + + const limit = pagination.pageSize + const offset = pagination.pageIndex * pagination.pageSize + + const sort = sorting.find((s) => SORTABLE_COLUMNS.includes(s.id)) + const sortBy = sort?.id ?? DEFAULT_SORT_BY + const sortOrder = sorting.find((s) => SORTABLE_COLUMNS.includes(s.id))?.desc + ? "desc" + : "asc" + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: [ + ENTITY_PLURAL, + limit, + offset, + sortBy, + sortOrder, + nameFilter, + debouncedFilters, + ], + queryFn: async () => { + const params = new URLSearchParams({ + limit: String(limit), + offset: String(offset), + sort_by: sortBy, + sort_order: sortOrder, + }) + + if (nameFilter) params.set("name", nameFilter) + + const res = await fetch(`${API_PATH}?${params.toString()}`) + if (!res.ok) throw new Error(`Failed to fetch ${ENTITY_PLURAL}`) + return res.json() + }, + }) + + const rows = data?.rows ?? [] + const pageCount = data?.page_count ?? 0 + + const columns = useMemo[]>( + () => [ + { + accessorKey: "id", + header: "ID", + size: 150, + cell: ({ row }) => ( + + {row.getValue("id")} + + ), + }, + { + accessorKey: "invoice_number", + header: ({ column }) => ( + + ), + size: 150, + enableSorting: true, + enableColumnFilter: true, + }, + { + accessorKey: "customer_id", + header: ({ column }) => ( + + ), + size: 150, + enableSorting: false, + enableColumnFilter: true, + cell: ({ row }) => { + const customerId = row.getValue("customer_id") as string + return + }, + }, + { + accessorKey: "batch_ids", + header: ({ column }) => ( + + ), + size: 150, + enableSorting: false, + enableColumnFilter: true, + cell: ({ row }) => { + const batchIds = row.getValue("batch_ids") as string[] + return batchIds.map((id) => {id}) + }, + }, + { + accessorKey: "actions", + header: "Actions", + size: 60, + cell: ({ row }) => { + const item = row.original + return ( + + + + + + Actions + + { + setEditOpen(true) + setEntity(item) + }} + disabled={!userPerms.contains(PERM_UPDATE)} + className="cursor-pointer" + > + Edit + + { + setDeleteOpen(true) + setEntity(item) + }} + disabled={!userPerms.contains(PERM_DELETE)} + className="cursor-pointer" + > + Delete + + + + ) + }, + }, + ], + [userPerms] + ) + + // eslint-disable-next-line react-hooks/incompatible-library + const table = useReactTable({ + data: rows, + columns, + pageCount, + state: { pagination, sorting, columnFilters }, + onPaginationChange: setPagination, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + manualSorting: true, + manualFiltering: true, + }) + + useEffect(() => { + if (!editOpen && !deleteOpen) setEntity(null) + }, [editOpen, deleteOpen]) + + async function deleteEntity(id: string) { + const res = await fetch(`${API_PATH}/${id}`, { + method: "DELETE", + }) + if (res.ok) { + refetch() + } else { + const data = await res.json() + setDeleteError(data.error) + } + } + + const displayName = entity?.[DISPLAY_PROP] as string | undefined + const entityTitle = displayName ?? ENTITY_NAME + + return ( + <> + + + {error ? ( +

{error.message}

+ ) : ( +
+
+ {deleteError && ( +

{deleteError}

+ )} + +
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + header.column.getToggleSortingHandler() + } + className={ + header.column.getCanSort() + ? "cursor-pointer select-none" + : "" + } + > + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {isLoading ? ( + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + )} + +
+ +
+ table.setPageIndex(p)} + pageSize={pagination.pageSize} + onPageSizeChange={(s) => table.setPageSize(s)} + /> +
+
+ )} +
+
+ + + + + + +
+ + +
+
+ + ) +} diff --git a/frontend/components/pages/suppliers.tsx b/frontend/components/pages/suppliers.tsx index 3a888d8..98a8658 100644 --- a/frontend/components/pages/suppliers.tsx +++ b/frontend/components/pages/suppliers.tsx @@ -151,6 +151,7 @@ export function SuppliersPage({ userPerms }: { userPerms: UserPermissions }) { /> ), size: 60, + enableSorting: false, enableColumnFilter: true, }, { @@ -163,6 +164,7 @@ export function SuppliersPage({ userPerms }: { userPerms: UserPermissions }) { /> ), size: 180, + enableSorting: false, enableColumnFilter: true, }, { @@ -274,7 +276,7 @@ export function SuppliersPage({ userPerms }: { userPerms: UserPermissions }) { header.column.getToggleSortingHandler() } className={ - "flex flex-col " + header.column.getCanSort() + header.column.getCanSort() ? "cursor-pointer select-none" : "" } diff --git a/frontend/components/pages/template.tsx b/frontend/components/pages/template similarity index 96% rename from frontend/components/pages/template.tsx rename to frontend/components/pages/template index d52cc8e..4a5aca2 100644 --- a/frontend/components/pages/template.tsx +++ b/frontend/components/pages/template @@ -185,6 +185,7 @@ export function ItemsPage({ userPerms }: Props) { [userPerms] ) + // eslint-disable-next-line react-hooks/incompatible-library const table = useReactTable({ data: rows, columns, @@ -240,7 +241,7 @@ export function ItemsPage({ userPerms }: Props) { - +
{table.getHeaderGroups().map((headerGroup) => ( @@ -248,14 +249,11 @@ export function ItemsPage({ userPerms }: Props) { - header.column.getToggleSortingHandler() - } + onClick={header.column.getToggleSortingHandler()} className={ - "flex flex-col " + - (header.column.getCanSort() + header.column.getCanSort() ? "cursor-pointer select-none" - : "") + : undefined } > {header.isPlaceholder diff --git a/frontend/components/pages/users.tsx b/frontend/components/pages/users.tsx index 5c03abf..0fd49ba 100644 --- a/frontend/components/pages/users.tsx +++ b/frontend/components/pages/users.tsx @@ -290,7 +290,7 @@ export function UsersPage({ userPerms }: { userPerms: UserPermissions }) { header.column.getToggleSortingHandler() } className={ - "flex flex-col " + header.column.getCanSort() + header.column.getCanSort() ? "cursor-pointer select-none" : "" } diff --git a/frontend/components/ui/combobox.tsx b/frontend/components/ui/combobox.tsx new file mode 100644 index 0000000..e917628 --- /dev/null +++ b/frontend/components/ui/combobox.tsx @@ -0,0 +1,307 @@ +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown, Loader2, X } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Skeleton } from "@/components/ui/skeleton" + +export interface Option { + value: string + label: string +} + +interface AsyncComboboxBaseProps { + id?: string + "aria-invalid"?: boolean + placeholder?: string + emptyMessage?: string + searchPlaceholder?: string + fetchOptions: (query: string, page: number) => Promise + pageSize?: number +} + +interface AsyncComboboxSingleProps extends AsyncComboboxBaseProps { + multiple?: false + value?: string + onChange: (value: string) => void +} + +interface AsyncComboboxMultiProps extends AsyncComboboxBaseProps { + multiple: true + value?: string[] + onChange: (value: string[]) => void +} + +export type AsyncComboboxProps = + AsyncComboboxSingleProps | AsyncComboboxMultiProps + +export function AsyncCombobox(props: AsyncComboboxProps) { + const { + id, + "aria-invalid": ariaInvalid, + placeholder = "Select...", + emptyMessage = "No results found.", + searchPlaceholder = "Search...", + fetchOptions, + pageSize = 20, + } = props + + const multiple = (props as AsyncComboboxMultiProps).multiple ?? false + const value = props.value + const onChange = props.onChange + + const [open, setOpen] = React.useState(false) + const [searchQuery, setSearchQuery] = React.useState("") + const [debouncedQuery, setDebouncedQuery] = React.useState("") + const [options, setOptions] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(false) + const [page, setPage] = React.useState(1) + const [hasMore, setHasMore] = React.useState(true) + const [selectedLabelsMap, setSelectedLabelsMap] = React.useState< + Record + >({}) + + // Debounce + React.useEffect(() => { + if (searchQuery === debouncedQuery) return + const timer = setTimeout(() => setDebouncedQuery(searchQuery), 300) + return () => clearTimeout(timer) + }, [searchQuery]) + + // Fetch + React.useEffect(() => { + if (!open) return + let cancelled = false + + async function load() { + setIsLoading(true) + try { + const results = await fetchOptions(debouncedQuery, page) + if (cancelled) return + + setSelectedLabelsMap((prev) => { + const next = { ...prev } + results.forEach((r) => { + if (r.value) next[r.value] = r.label + }) + return next + }) + + setOptions((prev) => (page === 1 ? results : [...prev, ...results])) + setHasMore(results.length === pageSize) + } catch (error) { + if (!cancelled) setHasMore(false) + } finally { + if (!cancelled) setIsLoading(false) + } + } + + load() + return () => { + cancelled = true + } + }, [open, debouncedQuery, page, fetchOptions, pageSize]) + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen) + if (nextOpen) { + setSearchQuery("") + setDebouncedQuery("") + setPage(1) + setHasMore(true) + setOptions([]) + setIsLoading(true) + } + } + + const handleSearch = (query: string) => { + setSearchQuery(query) + setPage(1) + setHasMore(true) + setOptions([]) + setIsLoading(true) + } + + const handleLoadMore = () => { + setPage((p) => p + 1) + setIsLoading(true) + } + + const isSelected = (optionValue: string): boolean => { + if (multiple) { + return (value as string[] | undefined)?.includes(optionValue) ?? false + } + return (value as string | undefined) === optionValue + } + + const handleToggle = (option: Option) => { + const selected = isSelected(option.value) + + if (multiple) { + const current = (value as string[] | undefined) ?? [] + const newValues = selected + ? current.filter((v) => v !== option.value) + : [...current, option.value] + + setSelectedLabelsMap((prev) => ({ + ...prev, + [option.value]: option.label, + })) + ;(onChange as (value: string[]) => void)(newValues) + // Stay open for multi-select + } else { + const newValue = selected ? "" : option.value + setSelectedLabelsMap((prev) => ({ + ...prev, + [option.value]: option.label, + })) + ;(onChange as (value: string) => void)(newValue) + setOpen(false) + } + } + + const handleClear = (e: React.MouseEvent) => { + e.stopPropagation() + if (multiple) { + ;(onChange as (value: string[]) => void)([]) + } else { + ;(onChange as (value: string) => void)("") + } + } + + const displayText = React.useMemo(() => { + if (multiple) { + const selectedValues = (value as string[] | undefined) ?? [] + if (selectedValues.length === 0) return placeholder + + const labels = selectedValues.map((v) => selectedLabelsMap[v] || v) + const visible = labels.slice(0, 2).join(", ") + const remaining = labels.length - 2 + if (remaining > 0) return `${visible} +${remaining} more` + return visible + } + + const singleValue = value as string | undefined + if (!singleValue) return placeholder + return selectedLabelsMap[singleValue] || singleValue + }, [value, multiple, selectedLabelsMap, placeholder]) + + const hasValue = multiple + ? ((value as string[] | undefined)?.length ?? 0) > 0 + : !!(value as string | undefined) + + return ( + + + + + + + + + + {isLoading ? ( +
+ + + +
+ ) : ( + emptyMessage + )} +
+ + + {options.map((option) => ( + handleToggle(option)} + className="cursor-pointer" + > + {multiple ? ( +
+ +
+ ) : ( + + )} + {option.label} +
+ ))} +
+ + {hasMore && options.length > 0 && ( +
+ +
+ )} +
+
+
+
+ ) +} diff --git a/frontend/components/ui/command.tsx b/frontend/components/ui/command.tsx new file mode 100644 index 0000000..bf944be --- /dev/null +++ b/frontend/components/ui/command.tsx @@ -0,0 +1,195 @@ +"use client" + +import * as React from "react" +import { Command as CommandPrimitive } from "cmdk" + +import { cn } from "@/lib/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + InputGroup, + InputGroupAddon, +} from "@/components/ui/input-group" +import { SearchIcon, CheckIcon } from "lucide-react" + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = false, + ...props +}: React.ComponentProps & { + title?: string + description?: string + className?: string + showCloseButton?: boolean +}) { + return ( + + + {title} + {description} + + + {children} + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + + + + + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + {children} + + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/frontend/components/ui/input-group.tsx b/frontend/components/ui/input-group.tsx new file mode 100644 index 0000000..beccf14 --- /dev/null +++ b/frontend/components/ui/input-group.tsx @@ -0,0 +1,153 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-2 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-3xl **:data-[slot=kbd]:bg-muted-foreground/10 **:data-[slot=kbd]:px-1.5 [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": "order-first pl-3 has-[>button]:-ml-1 has-[>kbd]:-ml-1", + "inline-end": "order-last pr-3 has-[>button]:-mr-1 has-[>kbd]:-mr-1", + "block-start": + "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-3.5 [.border-b]:pb-3.5", + "block-end": + "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-3.5 [.border-t]:pt-3.5", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return + } + e.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +const inputGroupButtonVariants = cva( + "flex items-center gap-2 rounded-4xl text-sm shadow-none", + { + variants: { + size: { + xs: "h-6 gap-1 rounded-xl px-1.5 [&>svg:not([class*='size-'])]:size-3.5", + sm: "", + "icon-xs": "size-6 rounded-xl p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size"> & + VariantProps) { + return ( +