diff --git a/package.json b/package.json index bc7f076..ccc10dc 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-toast": "^1.2.4", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4d6307..5edcf1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@radix-ui/react-dialog': specifier: ^1.1.4 version: 1.1.4(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-progress': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-slot': specifier: ^1.1.1 version: 1.1.1(@types/react@19.0.2)(react@19.0.0) @@ -529,6 +532,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-progress@1.1.1': + resolution: {integrity: sha512-6diOawA84f/eMxFHcWut0aE1C2kyE9dOyCTQOMRR2C/qPiXz/X0SaiA/RLbapQaXUCmy0/hLMf9meSccD1N0pA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.1.1': resolution: {integrity: sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==} peerDependencies: @@ -2397,6 +2413,16 @@ snapshots: '@types/react': 19.0.2 '@types/react-dom': 19.0.2(@types/react@19.0.2) + '@radix-ui/react-progress@1.1.1(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-context': 1.1.1(@types/react@19.0.2)(react@19.0.0) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.2 + '@types/react-dom': 19.0.2(@types/react@19.0.2) + '@radix-ui/react-slot@1.1.1(@types/react@19.0.2)(react@19.0.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.2)(react@19.0.0) diff --git a/src/app/page.tsx b/src/app/page.tsx index 296097f..60d747f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,18 +5,12 @@ import TableWrapper from "@/components/table-wrapper"; import Header from "@/components/header"; import TotalDisplay from "@/components/total-display"; import { toast } from "sonner"; -interface Budgetable { - id: string; - title: string; - price: number; - link: string; - note?: string; - status: "Paid" | "Unpaid"; -} +import { Budgetable, areRowsEqual } from "@/lib/utils"; export default function App() { - const [data, setData] = useState([]); + const [data, setData] = useState(() => []); const [isEditing, setIsEditing] = useState(false); + const [loading, setLoading] = useState(false); const [newRow, setNewRow] = useState({ id: "", title: "", @@ -28,7 +22,6 @@ export default function App() { const [recentlyUpdatedRowId, setRecentlyUpdatedRowId] = useState< string | null >(null); - const [loading, setLoading] = useState(false); useEffect(() => { async function fetchData() { @@ -53,32 +46,46 @@ export default function App() { updatedRow: Budgetable, originalRow: Budgetable, ) => { - setLoading(true); - try { - await new Promise((resolve) => setTimeout(resolve, 250)); + if (areRowsEqual(updatedRow, originalRow)) { + return; + } + try { + const res = await fetch(`/pocketbase/${updatedRow.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updatedRow), + }); + if (!res.ok) throw new Error("Failed to update row"); + const updatedData = await res.json(); setData((prev) => - prev.map((item) => (item.id === originalRow.id ? updatedRow : item)), + prev.map((row) => (row.id === updatedRow.id ? updatedData : row)), ); + setRecentlyUpdatedRowId(updatedRow.id); - setTimeout(() => setRecentlyUpdatedRowId(null), 250); - toast.success("Row saved successfully"); + setTimeout(() => setRecentlyUpdatedRowId(null), 500); + toast.success("Row updated successfully!"); } catch (err) { - toast.error("Error saving row. Please try again."); - console.error("Error saving row:", err); - } finally { - setLoading(false); + toast.error("Error updating row. Please try again."); + console.error("Error updating row:", err); } }; const handleAddRow = async () => { - if (!newRow.title || newRow.price <= 0) return; + if (!newRow.title || newRow.price <= 0) { + toast("Title and price are required."); + return; + } - setLoading(true); try { - await new Promise((resolve) => setTimeout(resolve, 250)); - - setData((prev) => [...prev, { ...newRow, id: `${Date.now()}` }]); + const res = await fetch("/pocketbase", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newRow), + }); + if (!res.ok) throw new Error("Failed to add row"); + const record: Budgetable = await res.json(); + setData((prev) => [...prev, record]); setNewRow({ id: "", title: "", @@ -87,48 +94,39 @@ export default function App() { note: "", status: "Unpaid", }); - toast.success("Row added successfully"); + toast.success("Row added successfully!"); } catch (err) { toast.error("Error adding row. Please try again."); console.error("Error adding row:", err); - } finally { - setLoading(false); } }; const handleDeleteRow = async (id: string) => { - setLoading(true); try { - await new Promise((resolve) => setTimeout(resolve, 250)); - - setData((prev) => prev.filter((item) => item.id !== id)); - toast.success("Row deleted successfully"); + const res = await fetch(`/pocketbase/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Failed to delete row"); + setData((prev) => prev.filter((row) => row.id !== id)); + toast.success("Row deleted successfully!"); } catch (err) { toast.error("Error deleting row. Please try again."); console.error("Error deleting row:", err); - } finally { - setLoading(false); } }; const toggleStatus = async (row: Budgetable) => { - setLoading(true); - try { - await new Promise((resolve) => setTimeout(resolve, 250)); - - setData((prev) => - prev.map((item) => - item.id === row.id - ? { ...item, status: item.status === "Paid" ? "Unpaid" : "Paid" } - : item, - ), - ); - } finally { - setLoading(false); - } + const updatedStatus: "Paid" | "Unpaid" = + row.status === "Paid" ? "Unpaid" : "Paid"; + const updatedRow: Budgetable = { ...row, status: updatedStatus }; + await handleSave(updatedRow, row); + setData((prev) => + prev.map((item) => (item.id === row.id ? updatedRow : item)), + ); }; - const total = data.reduce((sum, item) => sum + item.price, 0); + const total = data.reduce( + (sum, item) => sum + (item.status === "Unpaid" ? item.price : 0), + 0, + ); return (
diff --git a/src/app/pocketbase/[id]/route.ts b/src/app/pocketbase/[id]/route.ts index 7128f58..16a56c8 100644 --- a/src/app/pocketbase/[id]/route.ts +++ b/src/app/pocketbase/[id]/route.ts @@ -12,7 +12,7 @@ async function authenticateSuperuser() { } export async function GET( - req: Request, + _req: Request, context: { params: Promise<{ id: string }> }, ) { try { @@ -20,27 +20,36 @@ export async function GET( const id = (await context.params)?.id; if (!id) { - return new Response( - JSON.stringify({ error: { message: "Missing ID in request" } }), - { status: 400, headers: { "Content-Type": "application/json" } }, - ); + return Response.json({ + error: { + message: "Missing ID in request", + }, + }, { + status: 400, + headers: { "Content-Type": "application/json" }, + }); } const record = await pb.collection("budgetable").getOne(id); - return new Response(JSON.stringify(record), { + return Response.json(record, { + status: 200, headers: { "Content-Type": "application/json" }, }); } catch (error) { console.error("Error fetching data:", error); - return new Response( - JSON.stringify({ error: { message: "Failed to fetch data" } }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ); + return Response.json({ + error: { + message: "Failed to fetch data", + }, + }, { + status: 500, + headers: { "Content-Type": "application/json" }, + }) } } export async function DELETE( - req: Request, + _req: Request, context: { params: Promise<{ id: string }> }, ) { try { @@ -48,22 +57,33 @@ export async function DELETE( const id = (await context.params)?.id; if (!id) { - return new Response( - JSON.stringify({ error: { message: "Missing ID in request" } }), - { status: 400, headers: { "Content-Type": "application/json" } }, - ); + return Response.json({ + error: { + message: "Missing ID in request", + }, + }, { + status: 400, + headers: { "Content-Type": "application/json" }, + }); } await pb.collection("budgetable").delete(id); - return new Response(JSON.stringify({ success: true }), { + return Response.json({ + success: true, + }, { + status: 200, headers: { "Content-Type": "application/json" }, }); } catch (error) { console.error("Error deleting data:", error); - return new Response( - JSON.stringify({ error: { message: "Failed to delete data" } }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ); + return Response.json({ + error: { + message: "Failed to delete data", + }, + }, { + status: 500, + headers: { "Content-Type": "application/json" }, + }); } } @@ -74,31 +94,44 @@ export async function PUT( try { await authenticateSuperuser(); - const id = (await context.params)?.id; // Use `context.params?.id` + const id = (await context.params)?.id; if (!id) { - return new Response( - JSON.stringify({ error: { message: "Missing ID in request" } }), - { status: 400, headers: { "Content-Type": "application/json" } }, - ); + return Response.json({ + error: { + message: "Missing ID in request", + }, + }, { + status: 400, + headers: { "Content-Type": "application/json" }, + }); } const body = await req.json(); if (!body.title || typeof body.price !== "number") { - return new Response( - JSON.stringify({ error: { message: "Invalid data provided" } }), - { status: 400, headers: { "Content-Type": "application/json" } }, - ); + return Response.json({ + error: { + message: "Invalid data provided", + }, + }, { + status: 400, + headers: { "Content-Type": "application/json" }, + }); } const updatedRecord = await pb.collection("budgetable").update(id, body); - return new Response(JSON.stringify(updatedRecord), { + return Response.json(updatedRecord, { + status: 200, headers: { "Content-Type": "application/json" }, }); } catch (error) { console.error("Error updating data:", error); - return new Response( - JSON.stringify({ error: { message: "Failed to update data" } }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ); + return Response.json({ + error: { + message: "Failed to update data", + }, + }, { + status: 500, + headers: { "Content-Type": "application/json" }, + }); } } diff --git a/src/app/pocketbase/route.ts b/src/app/pocketbase/route.ts index 1ad7743..9a50805 100644 --- a/src/app/pocketbase/route.ts +++ b/src/app/pocketbase/route.ts @@ -15,22 +15,20 @@ export async function GET() { try { await authenticateSuperuser(); const records = await pb.collection("budgetable").getFullList(); - return new Response(JSON.stringify(records), { + return Response.json(records, { + status: 200, headers: { "Content-Type": "application/json" }, }); } catch (error) { console.error("Error fetching data:", error); - return new Response( - JSON.stringify({ - error: { - message: "Failed to fetch data", - }, - }), - { - status: 500, - headers: { "Content-Type": "application/json" }, + return Response.json({ + error: { + message: "Failed to fetch data", }, - ); + }, { + status: 500, + headers: { "Content-Type": "application/json" }, + }); } } @@ -39,21 +37,19 @@ export async function POST(req: Request) { await authenticateSuperuser(); const data = await req.json(); const record = await pb.collection("budgetable").create(data); - return new Response(JSON.stringify(record), { + return Response.json(record, { + status: 201, headers: { "Content-Type": "application/json" }, }); } catch (error) { console.error("Error adding data:", error); - return new Response( - JSON.stringify({ - error: { - message: "Failed to add data", - }, - }), - { - status: 500, - headers: { "Content-Type": "application/json" }, + return Response.json({ + error: { + message: "Failed to add data", }, - ); + }, { + status: 500, + headers: { "Content-Type": "application/json" }, + }); } } diff --git a/src/components/delete-dialog.tsx b/src/components/delete-dialog.tsx index 4027c91..ea0c03c 100644 --- a/src/components/delete-dialog.tsx +++ b/src/components/delete-dialog.tsx @@ -12,7 +12,7 @@ interface DeleteDialogProps { onConfirm: () => void; } -const DeleteDialog: React.FC = ({ onConfirm }) => ( +const DeleteDialog = ({ onConfirm }: DeleteDialogProps) => ( diff --git a/src/components/header.tsx b/src/components/header.tsx index 5ce9236..2fe9066 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -1,13 +1,22 @@ import { Button } from "@/components/ui/button"; +import Link from "next/link"; interface AppHeaderProps { isEditing: boolean; setIsEditing: (value: boolean) => void; } -const Header: React.FC = ({ isEditing, setIsEditing }) => ( +const Header = ({ isEditing, setIsEditing }: AppHeaderProps) => (
-

Budgetable

+

+ setIsEditing(false)} + > + Budgetable + +

diff --git a/src/components/loading-skeleton.tsx b/src/components/loading-skeleton.tsx index 0dca01e..3abe400 100644 --- a/src/components/loading-skeleton.tsx +++ b/src/components/loading-skeleton.tsx @@ -1,5 +1,5 @@ import { Skeleton } from "@/components/ui/skeleton"; -const LoadingSkeleton: React.FC = () => ; +const LoadingSkeleton = () => ; export default LoadingSkeleton; diff --git a/src/components/status-badge.tsx b/src/components/status-badge.tsx index 0b28121..0ce75d9 100644 --- a/src/components/status-badge.tsx +++ b/src/components/status-badge.tsx @@ -5,9 +5,9 @@ interface StatusBadgeProps { toggleStatus: () => void; } -const StatusBadge: React.FC = ({ status, toggleStatus }) => ( +const StatusBadge = ({ status, toggleStatus }: StatusBadgeProps) => ( diff --git a/src/components/table-new-row.tsx b/src/components/table-new-row.tsx index d077a21..cce832c 100644 --- a/src/components/table-new-row.tsx +++ b/src/components/table-new-row.tsx @@ -1,15 +1,7 @@ import { TableRow, TableCell } from "@/components/ui/table"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; - -interface Budgetable { - id: string; - title: string; - price: number; - link: string; - note?: string; - status: "Paid" | "Unpaid"; -} +import { Budgetable } from '@/lib/utils'; interface TNewRowProps { newRow: Budgetable; diff --git a/src/components/table-row.tsx b/src/components/table-row.tsx index d3340bd..e2f6439 100644 --- a/src/components/table-row.tsx +++ b/src/components/table-row.tsx @@ -3,15 +3,8 @@ import { TableRow, TableCell } from "@/components/ui/table"; import Link from "next/link"; import StatusBadge from "@/components/status-badge"; import DeleteDialog from "@/components/delete-dialog"; - -interface Budgetable { - id: string; - title: string; - price: number; - link: string; - note?: string; - status: "Paid" | "Unpaid"; -} +import { useState } from 'react'; +import { Budgetable } from '@/lib/utils'; interface TRowProps { row: Budgetable; @@ -26,7 +19,7 @@ interface TRowProps { toggleStatus: (row: Budgetable) => Promise; } -const TRow: React.FC = ({ +const TRow = ({ row, isEditing, setData, @@ -34,101 +27,113 @@ const TRow: React.FC = ({ handleSave, handleDeleteRow, toggleStatus, -}) => ( - - - {isEditing ? ( - - setData((prev) => - prev.map((item) => - item.id === row.id ? { ...item, title: e.target.value } : item, - ), - ) - } - onBlur={() => handleSave(row, { ...row })} - /> - ) : ( - {row.title} - )} - - - {isEditing ? ( - - setData((prev) => - prev.map((item) => - item.id === row.id - ? { ...item, price: Number.parseFloat(e.target.value) || 0 } - : item, - ), - ) - } - onBlur={() => handleSave(row, { ...row })} - /> - ) : ( - {row.price.toLocaleString()} HUF - )} - - - {isEditing ? ( - - setData((prev) => - prev.map((item) => - item.id === row.id ? { ...item, link: e.target.value } : item, - ), - ) - } - onBlur={() => handleSave(row, { ...row })} - /> - ) : row.link ? ( - - Visit - - ) : ( - No link - )} - - - {isEditing ? ( - - setData((prev) => - prev.map((item) => - item.id === row.id ? { ...item, note: e.target.value } : item, - ), - ) - } - onBlur={() => handleSave(row, { ...row })} - /> - ) : ( - {row.note || "-"} - )} - - - toggleStatus(row)} /> - - {isEditing && ( +}: TRowProps) => { + const [originalRow, setOriginalRow] = useState(row); + + const handleInputFocus = () => { + setOriginalRow({...row}); + }; + + return ( + - handleDeleteRow(row.id)} /> + {isEditing ? ( + + setData((prev) => + prev.map((item) => + item.id === row.id ? { ...item, title: e.target.value } : item, + ), + ) + } + onBlur={() => handleSave(row, originalRow)} + /> + ) : ( + {row.title} + )} - )} - -); + + {isEditing ? ( + + setData((prev) => + prev.map((item) => + item.id === row.id + ? { ...item, price: Number.parseFloat(e.target.value) || 0 } + : item, + ), + ) + } + onBlur={() => handleSave(row, originalRow)} + /> + ) : ( + {row.price.toLocaleString()} HUF + )} + + + {isEditing ? ( + + setData((prev) => + prev.map((item) => + item.id === row.id ? { ...item, link: e.target.value } : item, + ), + ) + } + onBlur={() => handleSave(row, originalRow)} + /> + ) : row.link ? ( + + Visit + + ) : ( + No link + )} + + + {isEditing ? ( + + setData((prev) => + prev.map((item) => + item.id === row.id ? { ...item, note: e.target.value } : item, + ), + ) + } + onBlur={() => handleSave(row, originalRow)} + /> + ) : ( + {row.note || "-"} + )} + + + toggleStatus(row)} /> + + {isEditing && ( + + handleDeleteRow(row.id)} /> + + )} + + ); +}; export default TRow; diff --git a/src/components/table-wrapper.tsx b/src/components/table-wrapper.tsx index 3fec932..2ad9afc 100644 --- a/src/components/table-wrapper.tsx +++ b/src/components/table-wrapper.tsx @@ -7,15 +7,7 @@ import { } from "@/components/ui/table"; import TRow from "@/components/table-row"; import TNewRow from "@/components/table-new-row"; - -interface Budgetable { - id: string; - title: string; - price: number; - link: string; - note?: string; - status: "Paid" | "Unpaid"; -} +import { Budgetable } from '@/lib/utils'; interface TWrapperProps { data: Budgetable[]; @@ -33,7 +25,7 @@ interface TWrapperProps { toggleStatus: (row: Budgetable) => Promise; } -const TWrapper: React.FC = ({ +const TWrapper = ({ data, isEditing, setData, @@ -44,7 +36,7 @@ const TWrapper: React.FC = ({ handleAddRow, handleDeleteRow, toggleStatus, -}) => ( +}: TWrapperProps) => ( diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx index a345c4e..57757ed 100644 --- a/src/components/theme-provider.tsx +++ b/src/components/theme-provider.tsx @@ -1,11 +1,11 @@ "use client"; -import type * as React from "react"; +import { ComponentProps } from "react"; import { ThemeProvider as NextThemesProvider } from "next-themes"; export function ThemeProvider({ children, ...props -}: React.ComponentProps) { +}: ComponentProps) { return {children}; } diff --git a/src/components/total-display.tsx b/src/components/total-display.tsx index c50d1ba..2f29e60 100644 --- a/src/components/total-display.tsx +++ b/src/components/total-display.tsx @@ -2,7 +2,7 @@ interface TotalDisplayProps { total: number; } -const TotalDisplay: React.FC = ({ total }) => ( +const TotalDisplay = ({ total }: TotalDisplayProps) => (
Total: {total.toLocaleString()}

diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index f08ba49..5ebb662 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -15,6 +15,8 @@ const badgeVariants = cva( destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", outline: "text-foreground", + paid: "border-transparent bg-green-500 text-green-50", + unpaid: "border-transparent bg-red-500 text-red-50", }, }, defaultVariants: { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 3200be2..e51e994 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,26 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export interface Budgetable { + id: string; + title: string; + price: number; + link: string; + note?: string; + status: "Paid" | "Unpaid"; +} + +export const areRowsEqual = (row1: Budgetable, row2: Budgetable): boolean => { + const normalize = (value: string | number | undefined) => + String(value ?? "").trim(); + + const areEqual = (field: keyof Budgetable) => + field === "price" + ? Number(row1[field]) === Number(row2[field]) + : normalize(row1[field]) === normalize(row2[field]); + + return ["title", "price", "link", "note", "status"].every(field => + areEqual(field as keyof Budgetable) + ); + };