This commit is contained in:
skidoodle 2024-12-30 17:32:55 +01:00
parent bcfa0f92a6
commit dedb1c0628
Signed by: albert
GPG key ID: A06E3070D7D55BF2
16 changed files with 316 additions and 239 deletions

View file

@ -12,6 +12,7 @@
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-toast": "^1.2.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

26
pnpm-lock.yaml generated
View file

@ -11,6 +11,9 @@ importers:
'@radix-ui/react-dialog': '@radix-ui/react-dialog':
specifier: ^1.1.4 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) 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': '@radix-ui/react-slot':
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1(@types/react@19.0.2)(react@19.0.0) version: 1.1.1(@types/react@19.0.2)(react@19.0.0)
@ -529,6 +532,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true 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': '@radix-ui/react-slot@1.1.1':
resolution: {integrity: sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==} resolution: {integrity: sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==}
peerDependencies: peerDependencies:
@ -2397,6 +2413,16 @@ snapshots:
'@types/react': 19.0.2 '@types/react': 19.0.2
'@types/react-dom': 19.0.2(@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)': '@radix-ui/react-slot@1.1.1(@types/react@19.0.2)(react@19.0.0)':
dependencies: dependencies:
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.2)(react@19.0.0) '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.2)(react@19.0.0)

View file

@ -5,18 +5,12 @@ import TableWrapper from "@/components/table-wrapper";
import Header from "@/components/header"; import Header from "@/components/header";
import TotalDisplay from "@/components/total-display"; import TotalDisplay from "@/components/total-display";
import { toast } from "sonner"; import { toast } from "sonner";
interface Budgetable { import { Budgetable, areRowsEqual } from "@/lib/utils";
id: string;
title: string;
price: number;
link: string;
note?: string;
status: "Paid" | "Unpaid";
}
export default function App() { export default function App() {
const [data, setData] = useState<Budgetable[]>([]); const [data, setData] = useState<Budgetable[]>(() => []);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(false);
const [newRow, setNewRow] = useState<Budgetable>({ const [newRow, setNewRow] = useState<Budgetable>({
id: "", id: "",
title: "", title: "",
@ -28,7 +22,6 @@ export default function App() {
const [recentlyUpdatedRowId, setRecentlyUpdatedRowId] = useState< const [recentlyUpdatedRowId, setRecentlyUpdatedRowId] = useState<
string | null string | null
>(null); >(null);
const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
@ -53,32 +46,46 @@ export default function App() {
updatedRow: Budgetable, updatedRow: Budgetable,
originalRow: Budgetable, originalRow: Budgetable,
) => { ) => {
setLoading(true); if (areRowsEqual(updatedRow, originalRow)) {
try { return;
await new Promise((resolve) => setTimeout(resolve, 250)); }
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) => setData((prev) =>
prev.map((item) => (item.id === originalRow.id ? updatedRow : item)), prev.map((row) => (row.id === updatedRow.id ? updatedData : row)),
); );
setRecentlyUpdatedRowId(updatedRow.id); setRecentlyUpdatedRowId(updatedRow.id);
setTimeout(() => setRecentlyUpdatedRowId(null), 250); setTimeout(() => setRecentlyUpdatedRowId(null), 500);
toast.success("Row saved successfully"); toast.success("Row updated successfully!");
} catch (err) { } catch (err) {
toast.error("Error saving row. Please try again."); toast.error("Error updating row. Please try again.");
console.error("Error saving row:", err); console.error("Error updating row:", err);
} finally {
setLoading(false);
} }
}; };
const handleAddRow = async () => { 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 { try {
await new Promise((resolve) => setTimeout(resolve, 250)); const res = await fetch("/pocketbase", {
method: "POST",
setData((prev) => [...prev, { ...newRow, id: `${Date.now()}` }]); 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({ setNewRow({
id: "", id: "",
title: "", title: "",
@ -87,48 +94,39 @@ export default function App() {
note: "", note: "",
status: "Unpaid", status: "Unpaid",
}); });
toast.success("Row added successfully"); toast.success("Row added successfully!");
} catch (err) { } catch (err) {
toast.error("Error adding row. Please try again."); toast.error("Error adding row. Please try again.");
console.error("Error adding row:", err); console.error("Error adding row:", err);
} finally {
setLoading(false);
} }
}; };
const handleDeleteRow = async (id: string) => { const handleDeleteRow = async (id: string) => {
setLoading(true);
try { try {
await new Promise((resolve) => setTimeout(resolve, 250)); const res = await fetch(`/pocketbase/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error("Failed to delete row");
setData((prev) => prev.filter((item) => item.id !== id)); setData((prev) => prev.filter((row) => row.id !== id));
toast.success("Row deleted successfully"); toast.success("Row deleted successfully!");
} catch (err) { } catch (err) {
toast.error("Error deleting row. Please try again."); toast.error("Error deleting row. Please try again.");
console.error("Error deleting row:", err); console.error("Error deleting row:", err);
} finally {
setLoading(false);
} }
}; };
const toggleStatus = async (row: Budgetable) => { const toggleStatus = async (row: Budgetable) => {
setLoading(true); const updatedStatus: "Paid" | "Unpaid" =
try { row.status === "Paid" ? "Unpaid" : "Paid";
await new Promise((resolve) => setTimeout(resolve, 250)); const updatedRow: Budgetable = { ...row, status: updatedStatus };
await handleSave(updatedRow, row);
setData((prev) => setData((prev) =>
prev.map((item) => prev.map((item) => (item.id === row.id ? updatedRow : item)),
item.id === row.id );
? { ...item, status: item.status === "Paid" ? "Unpaid" : "Paid" }
: item,
),
);
} finally {
setLoading(false);
}
}; };
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 ( return (
<main className="container mx-auto p-4 max-w-5xl"> <main className="container mx-auto p-4 max-w-5xl">

View file

@ -12,7 +12,7 @@ async function authenticateSuperuser() {
} }
export async function GET( export async function GET(
req: Request, _req: Request,
context: { params: Promise<{ id: string }> }, context: { params: Promise<{ id: string }> },
) { ) {
try { try {
@ -20,27 +20,36 @@ export async function GET(
const id = (await context.params)?.id; const id = (await context.params)?.id;
if (!id) { if (!id) {
return new Response( return Response.json({
JSON.stringify({ error: { message: "Missing ID in request" } }), error: {
{ status: 400, headers: { "Content-Type": "application/json" } }, message: "Missing ID in request",
); },
}, {
status: 400,
headers: { "Content-Type": "application/json" },
});
} }
const record = await pb.collection("budgetable").getOne(id); 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" }, headers: { "Content-Type": "application/json" },
}); });
} catch (error) { } catch (error) {
console.error("Error fetching data:", error); console.error("Error fetching data:", error);
return new Response( return Response.json({
JSON.stringify({ error: { message: "Failed to fetch data" } }), error: {
{ status: 500, headers: { "Content-Type": "application/json" } }, message: "Failed to fetch data",
); },
}, {
status: 500,
headers: { "Content-Type": "application/json" },
})
} }
} }
export async function DELETE( export async function DELETE(
req: Request, _req: Request,
context: { params: Promise<{ id: string }> }, context: { params: Promise<{ id: string }> },
) { ) {
try { try {
@ -48,22 +57,33 @@ export async function DELETE(
const id = (await context.params)?.id; const id = (await context.params)?.id;
if (!id) { if (!id) {
return new Response( return Response.json({
JSON.stringify({ error: { message: "Missing ID in request" } }), error: {
{ status: 400, headers: { "Content-Type": "application/json" } }, message: "Missing ID in request",
); },
}, {
status: 400,
headers: { "Content-Type": "application/json" },
});
} }
await pb.collection("budgetable").delete(id); 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" }, headers: { "Content-Type": "application/json" },
}); });
} catch (error) { } catch (error) {
console.error("Error deleting data:", error); console.error("Error deleting data:", error);
return new Response( return Response.json({
JSON.stringify({ error: { message: "Failed to delete data" } }), error: {
{ status: 500, headers: { "Content-Type": "application/json" } }, message: "Failed to delete data",
); },
}, {
status: 500,
headers: { "Content-Type": "application/json" },
});
} }
} }
@ -74,31 +94,44 @@ export async function PUT(
try { try {
await authenticateSuperuser(); await authenticateSuperuser();
const id = (await context.params)?.id; // Use `context.params?.id` const id = (await context.params)?.id;
if (!id) { if (!id) {
return new Response( return Response.json({
JSON.stringify({ error: { message: "Missing ID in request" } }), error: {
{ status: 400, headers: { "Content-Type": "application/json" } }, message: "Missing ID in request",
); },
}, {
status: 400,
headers: { "Content-Type": "application/json" },
});
} }
const body = await req.json(); const body = await req.json();
if (!body.title || typeof body.price !== "number") { if (!body.title || typeof body.price !== "number") {
return new Response( return Response.json({
JSON.stringify({ error: { message: "Invalid data provided" } }), error: {
{ status: 400, headers: { "Content-Type": "application/json" } }, message: "Invalid data provided",
); },
}, {
status: 400,
headers: { "Content-Type": "application/json" },
});
} }
const updatedRecord = await pb.collection("budgetable").update(id, body); 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" }, headers: { "Content-Type": "application/json" },
}); });
} catch (error) { } catch (error) {
console.error("Error updating data:", error); console.error("Error updating data:", error);
return new Response( return Response.json({
JSON.stringify({ error: { message: "Failed to update data" } }), error: {
{ status: 500, headers: { "Content-Type": "application/json" } }, message: "Failed to update data",
); },
}, {
status: 500,
headers: { "Content-Type": "application/json" },
});
} }
} }

View file

@ -15,22 +15,20 @@ export async function GET() {
try { try {
await authenticateSuperuser(); await authenticateSuperuser();
const records = await pb.collection("budgetable").getFullList(); const records = await pb.collection("budgetable").getFullList();
return new Response(JSON.stringify(records), { return Response.json(records, {
status: 200,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} catch (error) { } catch (error) {
console.error("Error fetching data:", error); console.error("Error fetching data:", error);
return new Response( return Response.json({
JSON.stringify({ error: {
error: { message: "Failed to fetch data",
message: "Failed to fetch data",
},
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}, },
); }, {
status: 500,
headers: { "Content-Type": "application/json" },
});
} }
} }
@ -39,21 +37,19 @@ export async function POST(req: Request) {
await authenticateSuperuser(); await authenticateSuperuser();
const data = await req.json(); const data = await req.json();
const record = await pb.collection("budgetable").create(data); 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" }, headers: { "Content-Type": "application/json" },
}); });
} catch (error) { } catch (error) {
console.error("Error adding data:", error); console.error("Error adding data:", error);
return new Response( return Response.json({
JSON.stringify({ error: {
error: { message: "Failed to add data",
message: "Failed to add data",
},
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}, },
); }, {
status: 500,
headers: { "Content-Type": "application/json" },
});
} }
} }

View file

@ -12,7 +12,7 @@ interface DeleteDialogProps {
onConfirm: () => void; onConfirm: () => void;
} }
const DeleteDialog: React.FC<DeleteDialogProps> = ({ onConfirm }) => ( const DeleteDialog = ({ onConfirm }: DeleteDialogProps) => (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="destructive">Delete</Button> <Button variant="destructive">Delete</Button>

View file

@ -1,13 +1,22 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import Link from "next/link";
interface AppHeaderProps { interface AppHeaderProps {
isEditing: boolean; isEditing: boolean;
setIsEditing: (value: boolean) => void; setIsEditing: (value: boolean) => void;
} }
const Header: React.FC<AppHeaderProps> = ({ isEditing, setIsEditing }) => ( const Header = ({ isEditing, setIsEditing }: AppHeaderProps) => (
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Budgetable</h1> <h1 className="text-3xl font-bold">
<Link
href="/"
className="text-blue-500 hover:text-blue-600 transition-colors"
onClick={() => setIsEditing(false)}
>
Budgetable
</Link>
</h1>
<Button variant="ghost" onClick={() => setIsEditing(!isEditing)}> <Button variant="ghost" onClick={() => setIsEditing(!isEditing)}>
{isEditing ? "Lock" : "Unlock"} Editing {isEditing ? "Lock" : "Unlock"} Editing
</Button> </Button>

View file

@ -1,5 +1,5 @@
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
const LoadingSkeleton: React.FC = () => <Skeleton className="h-10 w-full" />; const LoadingSkeleton = () => <Skeleton className="h-10 w-full" />;
export default LoadingSkeleton; export default LoadingSkeleton;

View file

@ -5,9 +5,9 @@ interface StatusBadgeProps {
toggleStatus: () => void; toggleStatus: () => void;
} }
const StatusBadge: React.FC<StatusBadgeProps> = ({ status, toggleStatus }) => ( const StatusBadge = ({ status, toggleStatus }: StatusBadgeProps) => (
<Badge <Badge
variant={status === "Paid" ? "destructive" : "secondary"} variant={status === "Paid" ? "paid" : "unpaid"}
className="cursor-pointer" className="cursor-pointer"
onClick={toggleStatus} onClick={toggleStatus}
> >

View file

@ -1,15 +1,7 @@
import { TableRow, TableCell } from "@/components/ui/table"; import { TableRow, TableCell } from "@/components/ui/table";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Budgetable } from '@/lib/utils';
interface Budgetable {
id: string;
title: string;
price: number;
link: string;
note?: string;
status: "Paid" | "Unpaid";
}
interface TNewRowProps { interface TNewRowProps {
newRow: Budgetable; newRow: Budgetable;

View file

@ -3,15 +3,8 @@ import { TableRow, TableCell } from "@/components/ui/table";
import Link from "next/link"; import Link from "next/link";
import StatusBadge from "@/components/status-badge"; import StatusBadge from "@/components/status-badge";
import DeleteDialog from "@/components/delete-dialog"; import DeleteDialog from "@/components/delete-dialog";
import { useState } from 'react';
interface Budgetable { import { Budgetable } from '@/lib/utils';
id: string;
title: string;
price: number;
link: string;
note?: string;
status: "Paid" | "Unpaid";
}
interface TRowProps { interface TRowProps {
row: Budgetable; row: Budgetable;
@ -26,7 +19,7 @@ interface TRowProps {
toggleStatus: (row: Budgetable) => Promise<void>; toggleStatus: (row: Budgetable) => Promise<void>;
} }
const TRow: React.FC<TRowProps> = ({ const TRow = ({
row, row,
isEditing, isEditing,
setData, setData,
@ -34,101 +27,113 @@ const TRow: React.FC<TRowProps> = ({
handleSave, handleSave,
handleDeleteRow, handleDeleteRow,
toggleStatus, toggleStatus,
}) => ( }: TRowProps) => {
<TableRow const [originalRow, setOriginalRow] = useState<Budgetable>(row);
className={`transition-opacity ${
recentlyUpdatedRowId === row.id ? "blur-sm opacity-50" : "" const handleInputFocus = () => {
}`} setOriginalRow({...row});
> };
<TableCell>
{isEditing ? ( return (
<Input <TableRow
value={row.title} className={`transition-opacity ${
onChange={(e) => recentlyUpdatedRowId === row.id ? "blur-sm opacity-50" : ""
setData((prev) => }`}
prev.map((item) => >
item.id === row.id ? { ...item, title: e.target.value } : item,
),
)
}
onBlur={() => handleSave(row, { ...row })}
/>
) : (
<span>{row.title}</span>
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
type="number"
value={row.price}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
item.id === row.id
? { ...item, price: Number.parseFloat(e.target.value) || 0 }
: item,
),
)
}
onBlur={() => handleSave(row, { ...row })}
/>
) : (
<span>{row.price.toLocaleString()} HUF</span>
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
value={row.link}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
item.id === row.id ? { ...item, link: e.target.value } : item,
),
)
}
onBlur={() => handleSave(row, { ...row })}
/>
) : row.link ? (
<Link
href={row.link}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline"
>
Visit
</Link>
) : (
<span className="text-gray-400 italic">No link</span>
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
value={row.note || ""}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
item.id === row.id ? { ...item, note: e.target.value } : item,
),
)
}
onBlur={() => handleSave(row, { ...row })}
/>
) : (
<span>{row.note || "-"}</span>
)}
</TableCell>
<TableCell>
<StatusBadge status={row.status} toggleStatus={() => toggleStatus(row)} />
</TableCell>
{isEditing && (
<TableCell> <TableCell>
<DeleteDialog onConfirm={() => handleDeleteRow(row.id)} /> {isEditing ? (
<Input
value={row.title}
onFocus={handleInputFocus}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
item.id === row.id ? { ...item, title: e.target.value } : item,
),
)
}
onBlur={() => handleSave(row, originalRow)}
/>
) : (
<span>{row.title}</span>
)}
</TableCell> </TableCell>
)} <TableCell>
</TableRow> {isEditing ? (
); <Input
type="number"
value={row.price}
onFocus={handleInputFocus}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
item.id === row.id
? { ...item, price: Number.parseFloat(e.target.value) || 0 }
: item,
),
)
}
onBlur={() => handleSave(row, originalRow)}
/>
) : (
<span>{row.price.toLocaleString()} HUF</span>
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
value={row.link}
onFocus={handleInputFocus}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
item.id === row.id ? { ...item, link: e.target.value } : item,
),
)
}
onBlur={() => handleSave(row, originalRow)}
/>
) : row.link ? (
<Link
href={row.link}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline"
>
Visit
</Link>
) : (
<span className="text-gray-400 italic">No link</span>
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
value={row.note || ""}
onFocus={handleInputFocus}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
item.id === row.id ? { ...item, note: e.target.value } : item,
),
)
}
onBlur={() => handleSave(row, originalRow)}
/>
) : (
<span>{row.note || "-"}</span>
)}
</TableCell>
<TableCell>
<StatusBadge status={row.status} toggleStatus={() => toggleStatus(row)} />
</TableCell>
{isEditing && (
<TableCell>
<DeleteDialog onConfirm={() => handleDeleteRow(row.id)} />
</TableCell>
)}
</TableRow>
);
};
export default TRow; export default TRow;

View file

@ -7,15 +7,7 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import TRow from "@/components/table-row"; import TRow from "@/components/table-row";
import TNewRow from "@/components/table-new-row"; import TNewRow from "@/components/table-new-row";
import { Budgetable } from '@/lib/utils';
interface Budgetable {
id: string;
title: string;
price: number;
link: string;
note?: string;
status: "Paid" | "Unpaid";
}
interface TWrapperProps { interface TWrapperProps {
data: Budgetable[]; data: Budgetable[];
@ -33,7 +25,7 @@ interface TWrapperProps {
toggleStatus: (row: Budgetable) => Promise<void>; toggleStatus: (row: Budgetable) => Promise<void>;
} }
const TWrapper: React.FC<TWrapperProps> = ({ const TWrapper = ({
data, data,
isEditing, isEditing,
setData, setData,
@ -44,7 +36,7 @@ const TWrapper: React.FC<TWrapperProps> = ({
handleAddRow, handleAddRow,
handleDeleteRow, handleDeleteRow,
toggleStatus, toggleStatus,
}) => ( }: TWrapperProps) => (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>

View file

@ -1,11 +1,11 @@
"use client"; "use client";
import type * as React from "react"; import { ComponentProps } from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes"; import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ export function ThemeProvider({
children, children,
...props ...props
}: React.ComponentProps<typeof NextThemesProvider>) { }: ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>; return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
} }

View file

@ -2,7 +2,7 @@ interface TotalDisplayProps {
total: number; total: number;
} }
const TotalDisplay: React.FC<TotalDisplayProps> = ({ total }) => ( const TotalDisplay = ({ total }: TotalDisplayProps) => (
<div className="mt-4 text-right font-bold text-lg"> <div className="mt-4 text-right font-bold text-lg">
Total: {total.toLocaleString()} Total: {total.toLocaleString()}
<p className="text-xs ml-1"> <p className="text-xs ml-1">

View file

@ -15,6 +15,8 @@ const badgeVariants = cva(
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground", outline: "text-foreground",
paid: "border-transparent bg-green-500 text-green-50",
unpaid: "border-transparent bg-red-500 text-red-50",
}, },
}, },
defaultVariants: { defaultVariants: {

View file

@ -4,3 +4,26 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); 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)
);
};