diff --git a/app/export/transactions/route.ts b/app/export/transactions/route.ts index 79e8e8b..c16fc0a 100644 --- a/app/export/transactions/route.ts +++ b/app/export/transactions/route.ts @@ -15,7 +15,7 @@ export async function GET(request: Request) { const fields = (url.searchParams.get("fields")?.split(",") ?? []) as ExportFields const includeAttachments = url.searchParams.get("includeAttachments") === "true" - const transactions = await getTransactions(filters) + const { transactions } = await getTransactions(filters) const existingFields = await getFields() // Generate CSV file with all transactions diff --git a/app/settings/actions.ts b/app/settings/actions.ts index 5892610..75070f7 100644 --- a/app/settings/actions.ts +++ b/app/settings/actions.ts @@ -1,12 +1,19 @@ "use server" -import { settingsFormSchema } from "@/forms/settings" -import { codeFromName } from "@/lib/utils" +import { + categoryFormSchema, + currencyFormSchema, + fieldFormSchema, + projectFormSchema, + settingsFormSchema, +} from "@/forms/settings" +import { codeFromName, randomHexColor } from "@/lib/utils" import { createCategory, deleteCategory, updateCategory } from "@/models/categories" import { createCurrency, deleteCurrency, updateCurrency } from "@/models/currencies" import { createField, deleteField, updateField } from "@/models/fields" import { createProject, deleteProject, updateProject } from "@/models/projects" import { updateSettings } from "@/models/settings" +import { Prisma } from "@prisma/client" import { revalidatePath } from "next/cache" import { redirect } from "next/navigation" @@ -26,106 +33,180 @@ export async function saveSettingsAction(prevState: any, formData: FormData) { // return { success: true } } -export async function addProjectAction(data: { name: string; llm_prompt?: string; color?: string }) { +export async function addProjectAction(data: Prisma.ProjectCreateInput) { + const validatedForm = projectFormSchema.safeParse(data) + + if (!validatedForm.success) { + return { success: false, error: validatedForm.error.message } + } + const project = await createProject({ - code: codeFromName(data.name), - name: data.name, - llm_prompt: data.llm_prompt || null, - color: data.color || "#000000", + code: codeFromName(validatedForm.data.name), + name: validatedForm.data.name, + llm_prompt: validatedForm.data.llm_prompt || null, + color: validatedForm.data.color || randomHexColor(), }) revalidatePath("/settings/projects") - return project + + return { success: true, project } } -export async function editProjectAction(code: string, data: { name: string; llm_prompt?: string; color?: string }) { +export async function editProjectAction(code: string, data: Prisma.ProjectUpdateInput) { + const validatedForm = projectFormSchema.safeParse(data) + + if (!validatedForm.success) { + return { success: false, error: validatedForm.error.message } + } + const project = await updateProject(code, { - name: data.name, - llm_prompt: data.llm_prompt, - color: data.color, + name: validatedForm.data.name, + llm_prompt: validatedForm.data.llm_prompt, + color: validatedForm.data.color || "", }) revalidatePath("/settings/projects") - return project + + return { success: true, project } } export async function deleteProjectAction(code: string) { - await deleteProject(code) + try { + await deleteProject(code) + } catch (error) { + return { success: false, error: "Failed to delete project" + error } + } revalidatePath("/settings/projects") + return { success: true } } -export async function addCurrencyAction(data: { code: string; name: string }) { +export async function addCurrencyAction(data: Prisma.CurrencyCreateInput) { + const validatedForm = currencyFormSchema.safeParse(data) + + if (!validatedForm.success) { + return { success: false, error: validatedForm.error.message } + } + const currency = await createCurrency({ - code: data.code, - name: data.name, + code: validatedForm.data.code, + name: validatedForm.data.name, }) revalidatePath("/settings/currencies") - return currency + + return { success: true, currency } } -export async function editCurrencyAction(code: string, data: { name: string }) { - const currency = await updateCurrency(code, { name: data.name }) +export async function editCurrencyAction(code: string, data: Prisma.CurrencyUpdateInput) { + const validatedForm = currencyFormSchema.safeParse(data) + + if (!validatedForm.success) { + return { success: false, error: validatedForm.error.message } + } + + const currency = await updateCurrency(code, { name: validatedForm.data.name }) revalidatePath("/settings/currencies") - return currency + return { success: true, currency } } export async function deleteCurrencyAction(code: string) { - await deleteCurrency(code) + try { + await deleteCurrency(code) + } catch (error) { + return { success: false, error: "Failed to delete currency" + error } + } revalidatePath("/settings/currencies") + return { success: true } } -export async function addCategoryAction(data: { name: string; llm_prompt?: string; color?: string }) { +export async function addCategoryAction(data: Prisma.CategoryCreateInput) { + const validatedForm = categoryFormSchema.safeParse(data) + + if (!validatedForm.success) { + return { success: false, error: validatedForm.error.message } + } + const category = await createCategory({ - code: codeFromName(data.name), - name: data.name, - llm_prompt: data.llm_prompt, - color: data.color, + code: codeFromName(validatedForm.data.name), + name: validatedForm.data.name, + llm_prompt: validatedForm.data.llm_prompt, + color: validatedForm.data.color || "", }) revalidatePath("/settings/categories") - return category + + return { success: true, category } } -export async function editCategoryAction(code: string, data: { name: string; llm_prompt?: string; color?: string }) { +export async function editCategoryAction(code: string, data: Prisma.CategoryUpdateInput) { + const validatedForm = categoryFormSchema.safeParse(data) + + if (!validatedForm.success) { + return { success: false, error: validatedForm.error.message } + } + const category = await updateCategory(code, { - name: data.name, - llm_prompt: data.llm_prompt, - color: data.color, + name: validatedForm.data.name, + llm_prompt: validatedForm.data.llm_prompt, + color: validatedForm.data.color || "", }) revalidatePath("/settings/categories") - return category + + return { success: true, category } } export async function deleteCategoryAction(code: string) { - await deleteCategory(code) + try { + await deleteCategory(code) + } catch (error) { + return { success: false, error: "Failed to delete category" + error } + } revalidatePath("/settings/categories") + return { success: true } } -export async function addFieldAction(data: { name: string; type: string; llm_prompt?: string; isRequired?: boolean }) { +export async function addFieldAction(data: Prisma.FieldCreateInput) { + const validatedForm = fieldFormSchema.safeParse(data) + + if (!validatedForm.success) { + return { success: false, error: validatedForm.error.message } + } + const field = await createField({ - code: codeFromName(data.name), - name: data.name, - type: data.type, - llm_prompt: data.llm_prompt, - isRequired: data.isRequired, + code: codeFromName(validatedForm.data.name), + name: validatedForm.data.name, + type: validatedForm.data.type, + llm_prompt: validatedForm.data.llm_prompt, + isVisibleInList: validatedForm.data.isVisibleInList, + isVisibleInAnalysis: validatedForm.data.isVisibleInAnalysis, isExtra: true, }) revalidatePath("/settings/fields") - return field + + return { success: true, field } } -export async function editFieldAction( - code: string, - data: { name: string; type: string; llm_prompt?: string; isRequired?: boolean } -) { +export async function editFieldAction(code: string, data: Prisma.FieldUpdateInput) { + const validatedForm = fieldFormSchema.safeParse(data) + + if (!validatedForm.success) { + return { success: false, error: validatedForm.error.message } + } + const field = await updateField(code, { - name: data.name, - type: data.type, - llm_prompt: data.llm_prompt, - isRequired: data.isRequired, + name: validatedForm.data.name, + type: validatedForm.data.type, + llm_prompt: validatedForm.data.llm_prompt, + isVisibleInList: validatedForm.data.isVisibleInList, + isVisibleInAnalysis: validatedForm.data.isVisibleInAnalysis, }) revalidatePath("/settings/fields") - return field + + return { success: true, field } } export async function deleteFieldAction(code: string) { - await deleteField(code) + try { + await deleteField(code) + } catch (error) { + return { success: false, error: "Failed to delete field" + error } + } revalidatePath("/settings/fields") + return { success: true } } diff --git a/app/settings/categories/page.tsx b/app/settings/categories/page.tsx index 741eb32..70e528d 100644 --- a/app/settings/categories/page.tsx +++ b/app/settings/categories/page.tsx @@ -2,6 +2,7 @@ import { addCategoryAction, deleteCategoryAction, editCategoryAction } from "@/a import { CrudTable } from "@/components/settings/crud" import { randomHexColor } from "@/lib/utils" import { getCategories } from "@/models/categories" +import { Prisma } from "@prisma/client" export default async function CategoriesSettingsPage() { const categories = await getCategories() @@ -13,7 +14,12 @@ export default async function CategoriesSettingsPage() { return (
-

Categories

+

Categories

+

+ Create your own categories that better reflect the type of income and expenses you have. Define an LLM Prompt so + that AI can determine this category automatically. +

+ { "use server" - await deleteCategoryAction(code) + return await deleteCategoryAction(code) }} onAdd={async (data) => { "use server" - await addCategoryAction( - data as { - code: string - name: string - llm_prompt?: string - color: string - } - ) + return await addCategoryAction(data as Prisma.CategoryCreateInput) }} onEdit={async (code, data) => { "use server" - await editCategoryAction( - code, - data as { - name: string - llm_prompt?: string - color?: string - } - ) + return await editCategoryAction(code, data as Prisma.CategoryUpdateInput) }} />
diff --git a/app/settings/currencies/page.tsx b/app/settings/currencies/page.tsx index 299c9af..d68c34a 100644 --- a/app/settings/currencies/page.tsx +++ b/app/settings/currencies/page.tsx @@ -12,7 +12,10 @@ export default async function CurrenciesSettingsPage() { return (
-

Currencies

+

Currencies

+

+ Custom currencies would not be automatically converted but you still can have them. +

{ "use server" - await deleteCurrencyAction(code) + return await deleteCurrencyAction(code) }} onAdd={async (data) => { "use server" - await addCurrencyAction(data as { code: string; name: string }) + return await addCurrencyAction(data as { code: string; name: string }) }} onEdit={async (code, data) => { "use server" - await editCurrencyAction(code, data as { name: string }) + return await editCurrencyAction(code, data as { name: string }) }} />
diff --git a/app/settings/fields/page.tsx b/app/settings/fields/page.tsx index baae4c5..f5b3e36 100644 --- a/app/settings/fields/page.tsx +++ b/app/settings/fields/page.tsx @@ -1,6 +1,7 @@ import { addFieldAction, deleteFieldAction, editFieldAction } from "@/app/settings/actions" import { CrudTable } from "@/components/settings/crud" import { getFields } from "@/models/fields" +import { Prisma } from "@prisma/client" export default async function FieldsSettingsPage() { const fields = await getFields() @@ -12,38 +13,50 @@ export default async function FieldsSettingsPage() { return (
-

Custom Fields

+

Custom Fields

+

+ You can add new fields to your transactions. Standard fields can't be removed but you can tweak their prompts or + hide them. If you don't want a field to be analyzed by AI but filled in by hand, leave the "LLM prompt" empty. +

{ "use server" - await deleteFieldAction(code) + return await deleteFieldAction(code) }} onAdd={async (data) => { "use server" - await addFieldAction( - data as { - name: string - type: string - llm_prompt?: string - } - ) + return await addFieldAction(data as Prisma.FieldCreateInput) }} onEdit={async (code, data) => { "use server" - await editFieldAction( - code, - data as { - name: string - type: string - llm_prompt?: string - } - ) + return await editFieldAction(code, data as Prisma.FieldUpdateInput) }} />
diff --git a/app/settings/projects/page.tsx b/app/settings/projects/page.tsx index 430783d..72e83ad 100644 --- a/app/settings/projects/page.tsx +++ b/app/settings/projects/page.tsx @@ -2,6 +2,7 @@ import { addProjectAction, deleteProjectAction, editProjectAction } from "@/app/ import { CrudTable } from "@/components/settings/crud" import { randomHexColor } from "@/lib/utils" import { getProjects } from "@/models/projects" +import { Prisma } from "@prisma/client" export default async function ProjectsSettingsPage() { const projects = await getProjects() @@ -13,7 +14,11 @@ export default async function ProjectsSettingsPage() { return (
-

Projects

+

Projects

+

+ Use projects to differentiate between the type of activities you do For example: Freelancing, YouTube channel, + Blogging. Projects are just a convenient way to separate statistics. +

{ "use server" - await deleteProjectAction(code) + return await deleteProjectAction(code) }} onAdd={async (data) => { "use server" - await addProjectAction(data as { code: string; name: string; llm_prompt: string; color: string }) + return await addProjectAction(data as Prisma.ProjectCreateInput) }} onEdit={async (code, data) => { "use server" - await editProjectAction(code, data as { name: string; llm_prompt: string; color: string }) + return await editProjectAction(code, data as Prisma.ProjectUpdateInput) }} />
diff --git a/app/transactions/[transactionId]/page.tsx b/app/transactions/[transactionId]/page.tsx index fcd3323..580fae9 100644 --- a/app/transactions/[transactionId]/page.tsx +++ b/app/transactions/[transactionId]/page.tsx @@ -57,7 +57,7 @@ export default async function TransactionPage({ params }: { params: Promise<{ tr -
+
diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx index c88fa00..867c2ec 100644 --- a/app/transactions/page.tsx +++ b/app/transactions/page.tsx @@ -3,6 +3,7 @@ import { UploadButton } from "@/components/files/upload-button" import { TransactionSearchAndFilters } from "@/components/transactions/filters" import { TransactionList } from "@/components/transactions/list" import { NewTransactionDialog } from "@/components/transactions/new" +import { Pagination } from "@/components/transactions/pagination" import { Button } from "@/components/ui/button" import { getCategories } from "@/models/categories" import { getFields } from "@/models/fields" @@ -10,25 +11,37 @@ import { getProjects } from "@/models/projects" import { getTransactions, TransactionFilters } from "@/models/transactions" import { Download, Plus, Upload } from "lucide-react" import { Metadata } from "next" +import { redirect } from "next/navigation" export const metadata: Metadata = { title: "Transactions", description: "Manage your transactions", } +const TRANSACTIONS_PER_PAGE = 1000 + export default async function TransactionsPage({ searchParams }: { searchParams: Promise }) { - const filters = await searchParams - const transactions = await getTransactions(filters) + const { page, ...filters } = await searchParams + const { transactions, total } = await getTransactions(filters, { + limit: TRANSACTIONS_PER_PAGE, + offset: ((page ?? 1) - 1) * TRANSACTIONS_PER_PAGE, + }) const categories = await getCategories() const projects = await getProjects() const fields = await getFields() + // Reset page if user clicks a filter and no transactions are found + if (page && page > 1 && transactions.length === 0) { + const params = new URLSearchParams(filters as Record) + redirect(`?${params.toString()}`) + } + return ( <>

Transactions - {transactions.length} + {total}

@@ -50,6 +63,8 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
+ {total > TRANSACTIONS_PER_PAGE && } + {transactions.length === 0 && (

diff --git a/components/export/transactions.tsx b/components/export/transactions.tsx index dc51cdf..fdb1c6b 100644 --- a/components/export/transactions.tsx +++ b/components/export/transactions.tsx @@ -43,7 +43,12 @@ export function ExportTransactionsDialog({ const handleSubmit = () => { router.push( `/export/transactions?${new URLSearchParams({ - ...exportFilters, + search: exportFilters?.search || "", + dateFrom: exportFilters?.dateFrom || "", + dateTo: exportFilters?.dateTo || "", + ordering: exportFilters?.ordering || "", + categoryCode: exportFilters?.categoryCode || "", + projectCode: exportFilters?.projectCode || "", fields: exportFields.join(","), includeAttachments: includeAttachments.toString(), }).toString()}` diff --git a/components/files/preview.tsx b/components/files/preview.tsx index 2b3ac74..c52bd4c 100644 --- a/components/files/preview.tsx +++ b/components/files/preview.tsx @@ -41,12 +41,12 @@ export function FilePreview({ file }: { file: File }) {

Type: {file.mimetype}

+ {/*

+ Uploaded: {format(file.createdAt, "MMM d, yyyy")} +

*/}

Size: {fileSize < 1 ? (fileSize * 1024).toFixed(2) + " KB" : fileSize.toFixed(2) + " MB"}

-

- Path: {file.path} -

diff --git a/components/forms/select-category.tsx b/components/forms/select-category.tsx index 0a4bce1..1d0a37c 100644 --- a/components/forms/select-category.tsx +++ b/components/forms/select-category.tsx @@ -10,11 +10,27 @@ export const FormSelectCategory = ({ categories, emptyValue, placeholder, + hideIfEmpty = false, ...props -}: { title: string; categories: Category[]; emptyValue?: string; placeholder?: string } & SelectProps) => { +}: { + title: string + categories: Category[] + emptyValue?: string + placeholder?: string + hideIfEmpty?: boolean +} & SelectProps) => { const items = useMemo( () => categories.map((category) => ({ code: category.code, name: category.name, color: category.color })), [categories] ) - return + return ( + + ) } diff --git a/components/forms/select-currency.tsx b/components/forms/select-currency.tsx index f4d2fc7..8299b10 100644 --- a/components/forms/select-currency.tsx +++ b/components/forms/select-currency.tsx @@ -8,11 +8,27 @@ export const FormSelectCurrency = ({ currencies, emptyValue, placeholder, + hideIfEmpty = false, ...props -}: { title: string; currencies: Currency[]; emptyValue?: string; placeholder?: string } & SelectProps) => { +}: { + title: string + currencies: Currency[] + emptyValue?: string + placeholder?: string + hideIfEmpty?: boolean +} & SelectProps) => { const items = useMemo( () => currencies.map((currency) => ({ code: currency.code, name: `${currency.code} - ${currency.name}` })), [currencies] ) - return + return ( + + ) } diff --git a/components/forms/select-project.tsx b/components/forms/select-project.tsx index 56c4ffd..e578d99 100644 --- a/components/forms/select-project.tsx +++ b/components/forms/select-project.tsx @@ -7,14 +7,22 @@ export const FormSelectProject = ({ projects, emptyValue, placeholder, + hideIfEmpty = false, ...props -}: { title: string; projects: Project[]; emptyValue?: string; placeholder?: string } & SelectProps) => { +}: { + title: string + projects: Project[] + emptyValue?: string + placeholder?: string + hideIfEmpty?: boolean +} & SelectProps) => { return ( ({ code: project.code, name: project.name, color: project.color }))} emptyValue={emptyValue} placeholder={placeholder} + hideIfEmpty={hideIfEmpty} {...props} /> ) diff --git a/components/forms/select-type.tsx b/components/forms/select-type.tsx index e63f67c..310e1f5 100644 --- a/components/forms/select-type.tsx +++ b/components/forms/select-type.tsx @@ -5,8 +5,9 @@ export const FormSelectType = ({ title, emptyValue, placeholder, + hideIfEmpty = false, ...props -}: { title: string; emptyValue?: string; placeholder?: string } & SelectProps) => { +}: { title: string; emptyValue?: string; placeholder?: string; hideIfEmpty?: boolean } & SelectProps) => { const items = [ { code: "expense", name: "Expense" }, { code: "income", name: "Income" }, @@ -14,5 +15,14 @@ export const FormSelectType = ({ { code: "other", name: "Other" }, ] - return + return ( + + ) } diff --git a/components/forms/simple.tsx b/components/forms/simple.tsx index 47ef3f3..6d72eee 100644 --- a/components/forms/simple.tsx +++ b/components/forms/simple.tsx @@ -53,13 +53,19 @@ export const FormSelect = ({ items, emptyValue, placeholder, + hideIfEmpty = false, ...props }: { title: string items: Array<{ code: string; name: string; color?: string }> emptyValue?: string placeholder?: string + hideIfEmpty?: boolean } & SelectProps) => { + if (hideIfEmpty && (!props.defaultValue || props.defaultValue.toString().trim() === "") && !props.value) { + return null + } + return ( {title} diff --git a/components/settings/crud.tsx b/components/settings/crud.tsx index f7533a3..0d9cd94 100644 --- a/components/settings/crud.tsx +++ b/components/settings/crud.tsx @@ -3,35 +3,146 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { CircleCheck, Edit, Trash2 } from "lucide-react" +import { Check, Edit, Trash2 } from "lucide-react" import { useOptimistic, useState } from "react" +interface CrudColumn { + key: keyof T + label: string + type?: "text" | "number" | "checkbox" | "select" + options?: string[] + defaultValue?: string | boolean + editable?: boolean +} + interface CrudProps { items: T[] - columns: { - key: keyof T - label: string - type?: "text" | "number" | "checkbox" - defaultValue?: string - editable?: boolean - }[] - onDelete: (id: string) => Promise - onAdd: (data: Partial) => Promise - onEdit?: (id: string, data: Partial) => Promise + columns: CrudColumn[] + onDelete: (id: string) => Promise<{ success: boolean; error?: string }> + onAdd: (data: Partial) => Promise<{ success: boolean; error?: string }> + onEdit?: (id: string, data: Partial) => Promise<{ success: boolean; error?: string }> } export function CrudTable({ items, columns, onDelete, onAdd, onEdit }: CrudProps) { const [isAdding, setIsAdding] = useState(false) const [editingId, setEditingId] = useState(null) - const [newItem, setNewItem] = useState>({}) - const [editingItem, setEditingItem] = useState>({}) + const [newItem, setNewItem] = useState>(itemDefaults(columns)) + const [editingItem, setEditingItem] = useState>(itemDefaults(columns)) const [optimisticItems, addOptimisticItem] = useOptimistic(items, (state, newItem: T) => [...state, newItem]) + const FormCell = (item: T, column: CrudColumn) => { + if (column.type === "checkbox") { + return item[column.key] ? : "" + } + return item[column.key] + } + + const EditFormCell = (item: T, column: CrudColumn) => { + if (column.type === "checkbox") { + return ( + + setEditingItem({ + ...editingItem, + [column.key]: e.target.checked, + }) + } + /> + ) + } else if (column.type === "select") { + return ( + + ) + } + + return ( + + setEditingItem({ + ...editingItem, + [column.key]: e.target.value, + }) + } + /> + ) + } + + const AddFormCell = (column: CrudColumn) => { + if (column.type === "checkbox") { + return ( + + setNewItem({ + ...newItem, + [column.key]: e.target.checked, + }) + } + /> + ) + } else if (column.type === "select") { + return ( + + ) + } + return ( + + setNewItem({ + ...newItem, + [column.key]: e.target.value, + }) + } + /> + ) + } + const handleAdd = async () => { try { - await onAdd(newItem) - setIsAdding(false) - setNewItem({}) + const result = await onAdd(newItem) + if (result.success) { + setIsAdding(false) + setNewItem(itemDefaults(columns)) + } else { + alert(result.error) + } } catch (error) { console.error("Failed to add item:", error) } @@ -40,9 +151,13 @@ export function CrudTable({ items, columns, on const handleEdit = async (id: string) => { if (!onEdit) return try { - await onEdit(id, editingItem) - setEditingId(null) - setEditingItem({}) + const result = await onEdit(id, editingItem) + if (result.success) { + setEditingId(null) + setEditingItem({}) + } else { + alert(result.error) + } } catch (error) { console.error("Failed to edit item:", error) } @@ -55,7 +170,10 @@ export function CrudTable({ items, columns, on const handleDelete = async (id: string) => { try { - await onDelete(id) + const result = await onDelete(id) + if (!result.success) { + alert(result.error) + } } catch (error) { console.error("Failed to delete item:", error) } @@ -77,26 +195,9 @@ export function CrudTable({ items, columns, on {columns.map((column) => ( - {editingId === (item.code || item.id) && column.editable ? ( - - setEditingItem({ - ...editingItem, - [column.key]: column.type === "checkbox" ? e.target.checked : e.target.value, - }) - } - /> - ) : column.type === "checkbox" ? ( - item[column.key] ? ( - - ) : ( - "" - ) - ) : ( - item[column.key] - )} + {editingId === (item.code || item.id) && column.editable + ? EditFormCell(item, column) + : FormCell(item, column)} ))} @@ -113,7 +214,14 @@ export function CrudTable({ items, columns, on ) : ( <> {onEdit && ( - )} @@ -132,18 +240,7 @@ export function CrudTable({ items, columns, on {columns.map((column) => ( - {column.editable && ( - - setNewItem({ - ...newItem, - [column.key]: column.type === "checkbox" ? e.target.checked : e.target.value, - }) - } - /> - )} + {column.editable && AddFormCell(column)} ))} @@ -160,7 +257,23 @@ export function CrudTable({ items, columns, on )} - {!isAdding && } + {!isAdding && ( + + )} ) } + +function itemDefaults(columns: CrudColumn[]) { + return columns.reduce((acc, column) => { + acc[column.key] = column.defaultValue as T[keyof T] + return acc + }, {} as Partial) +} diff --git a/components/settings/llm-settings-form.tsx b/components/settings/llm-settings-form.tsx index f6d67ed..66bd4e7 100644 --- a/components/settings/llm-settings-form.tsx +++ b/components/settings/llm-settings-form.tsx @@ -27,7 +27,7 @@ export default function LLMSettingsForm({ settings, fields }: { settings: Record { + const params = new URLSearchParams(searchParams.toString()) + params.set("page", page.toString()) + router.push(`?${params.toString()}`) + } + + const getPageNumbers = () => { + const pageNumbers = [] + + // Show all page numbers if total pages is small + if (totalPages <= MAX_VISIBLE_PAGES) { + for (let i = 1; i <= totalPages; i++) { + pageNumbers.push(i) + } + } else { + // Always include the first page + pageNumbers.push(1) + + // Calculate the range around the current page + let startPage = Math.max(2, currentPage - 1) + let endPage = Math.min(totalPages - 1, currentPage + 1) + + // Adjust if we're near the start + if (currentPage <= 3) { + endPage = Math.min(totalPages - 1, 4) + } + + // Adjust if we're near the end + if (currentPage >= totalPages - 2) { + startPage = Math.max(2, totalPages - 3) + } + + // Add ellipsis after first page if needed + if (startPage > 2) { + pageNumbers.push("ellipsis-start") + } + + // Add middle page numbers + for (let i = startPage; i <= endPage; i++) { + pageNumbers.push(i) + } + + // Add ellipsis before last page if needed + if (endPage < totalPages - 1) { + pageNumbers.push("ellipsis-end") + } + + // Always include the last page + pageNumbers.push(totalPages) + } + + return pageNumbers + } + + return ( +
+ + + {/* + currentPage > 1 && onPageChange(currentPage - 1)} + className={currentPage <= 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + */} + + {getPageNumbers().map((pageNumber, index) => + pageNumber === "ellipsis-start" || pageNumber === "ellipsis-end" ? ( + + + + ) : ( + + onPageChange(pageNumber as number)} + className="cursor-pointer" + > + {pageNumber} + + + ) + )} + + {/* + currentPage < totalPages && onPageChange(currentPage + 1)} + className={currentPage >= totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + */} + + +
+ ) +} diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx index c6fdd07..e40d744 100644 --- a/components/ui/checkbox.tsx +++ b/components/ui/checkbox.tsx @@ -1,8 +1,8 @@ "use client" -import * as React from "react" import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import { Check } from "lucide-react" +import * as React from "react" import { cn } from "@/lib/utils" @@ -13,14 +13,12 @@ const Checkbox = React.forwardRef< - + diff --git a/components/ui/pagination.tsx b/components/ui/pagination.tsx new file mode 100644 index 0000000..d331105 --- /dev/null +++ b/components/ui/pagination.tsx @@ -0,0 +1,117 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" +import { ButtonProps, buttonVariants } from "@/components/ui/button" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +