mirror of
https://github.com/skidoodle/budgetable.git
synced 2025-02-15 03:39:14 +01:00
i forgor
This commit is contained in:
parent
bcfa0f92a6
commit
dedb1c0628
16 changed files with 316 additions and 239 deletions
|
@ -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
26
pnpm-lock.yaml
generated
|
@ -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)
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue