Expenses CRUD operations

This commit is contained in:
Manuel Bustillo 2024-12-09 19:18:57 +01:00
parent b15b90b494
commit a3ddde45c0
4 changed files with 136 additions and 26 deletions

View File

@ -1,15 +1,44 @@
/* Copyright (C) 2024 Manuel Bustillo*/ /* Copyright (C) 2024 Manuel Bustillo*/
import { lusitana } from '@/app/ui/fonts'; 'use client'
import { AbstractApi } from '@/app/api/abstract-api';
import { Expense, ExpenseSerializer } from '@/app/lib/expense';
import { classNames } from '@/app/ui/components/button';
import ExpenseFormDialog from '@/app/ui/components/expense-form-dialog';
import ExpensesTable from '@/app/ui/expenses/table'; import ExpensesTable from '@/app/ui/expenses/table';
import SkeletonTable from '@/app/ui/guests/skeleton-row';
export default function Page () { import { Suspense, useEffect, useState } from 'react';
export default function Page() {
const refreshExpenses = () => {
new AbstractApi<Expense>().getAll(new ExpenseSerializer(), (expenses: Expense[]) => {
setExpenses(expenses);
});
}
const [expenses, setExpenses] = useState<Expense[]>([]);
const [expenseBeingEdited, setExpenseBeingEdited] = useState<Expense | undefined>(undefined);
useEffect(() => { refreshExpenses() }, []);
return ( return (
<div className="w-full"> <div className="w-full">
<div className="w-full items-center justify-between"> <div className="flex flex-col w-full items-center justify-between">
<h1 className={`${lusitana.className} text-2xl`}>Expenses</h1> <button onClick={() => setExpenseBeingEdited({})} className={classNames('primary')}>Add new</button>
<h2 className={`${lusitana.className} text-xl`}>Summary</h2> <ExpenseFormDialog
<ExpensesTable /> key={expenseBeingEdited?.id}
onCreate={() => { refreshExpenses(); setExpenseBeingEdited(undefined) }}
expense={expenseBeingEdited}
visible={expenseBeingEdited !== undefined}
onHide={() => { setExpenseBeingEdited(undefined) }}
/>
<Suspense fallback={<SkeletonTable />}>
<ExpensesTable
expenses={expenses}
onUpdate={refreshExpenses}
onEdit={(expense) => setExpenseBeingEdited(expense)}
/>
</Suspense>
</div> </div>
</div> </div>
); );

View File

@ -1,7 +1,7 @@
import { Serializable } from "../api/abstract-api"; import { Serializable } from "../api/abstract-api";
import { Entity } from "./definitions"; import { Entity } from "./definitions";
export const pricingTypes = ['fixed', 'per person'] as const; export const pricingTypes = ['fixed', 'per_person'] as const;
export type PricingType = typeof pricingTypes[number]; export type PricingType = typeof pricingTypes[number];
export class Expense implements Entity { export class Expense implements Entity {

View File

@ -0,0 +1,83 @@
/* Copyright (C) 2024 Manuel Bustillo*/
'use client';
import { AbstractApi } from '@/app/api/abstract-api';
import { Expense, ExpenseSerializer, PricingType, pricingTypes } from '@/app/lib/expense';
import { capitalize } from '@/app/lib/utils';
import { classNames } from '@/app/ui/components/button';
import { Dialog } from 'primereact/dialog';
import { Dropdown } from 'primereact/dropdown';
import { FloatLabel } from 'primereact/floatlabel';
import { InputText } from 'primereact/inputtext';
import { useState } from 'react';
export default function ExpenseFormDialog({ onCreate, onHide, expense, visible }: {
onCreate?: () => void,
onHide: () => void,
expense?: Expense,
visible: boolean,
}) {
const [name, setName] = useState<string>(expense?.name || '');
const [amount, setAmount] = useState<number>(expense?.amount || 0);
const [pricingType, setPricingType] = useState<PricingType>(expense?.pricingType || 'fixed');
const api = new AbstractApi<Expense>();
const serializer = new ExpenseSerializer();
function resetForm() {
setName('');
setAmount(0);
setPricingType('fixed');
}
function submitGroup() {
if (expense?.id !== undefined) {
expense.name = name;
expense.amount = amount;
expense.pricingType = pricingType;
api.update(serializer, expense, () => {
resetForm();
onCreate && onCreate();
});
} else {
api.create(serializer, new Expense(undefined, name, amount, pricingType), () => {
resetForm();
onCreate && onCreate();
});
}
}
return (
<>
<Dialog header="Add/edit expense" visible={visible} style={{ width: '60vw' }} onHide={onHide}>
<div className="card flex justify-evenly py-5">
<FloatLabel>
<InputText id="name" className='rounded-sm' value={name} onChange={(e) => setName(e.target.value)} />
<label htmlFor="name">Name</label>
</FloatLabel>
<FloatLabel>
<InputText id="amount" className='rounded-sm' value={amount.toString()} onChange={(e) => setAmount(parseFloat(e.target.value))} />
<label htmlFor="amount">Amount</label>
</FloatLabel>
<FloatLabel>
<Dropdown id="pricingType" className='rounded-sm min-w-32' value={pricingType} onChange={(e) => setPricingType(e.target.value)} options={
pricingTypes.map((type) => {
return { label: capitalize(type), value: type };
})
} />
<label htmlFor="pricingType">Pricing type</label>
</FloatLabel>
<button className={classNames('primary')} onClick={submitGroup} disabled={!(name.length > 0)}>
{expense?.id !== undefined ? 'Update' : 'Create'}
</button>
</div>
</Dialog>
</>
);
}

View File

@ -2,45 +2,43 @@
'use client' 'use client'
import { useState } from "react";
import InlineTextField from "../components/form/inlineTextField";
import TableOfContents from "../components/table-of-contents";
import { AbstractApi } from '@/app/api/abstract-api'; import { AbstractApi } from '@/app/api/abstract-api';
import { Expense, ExpenseSerializer } from '@/app/lib/expense'; import { Expense, ExpenseSerializer } from '@/app/lib/expense';
import { PencilIcon, TrashIcon } from '@heroicons/react/24/outline';
export default function ExpensesTable() { import TableOfContents from "../components/table-of-contents";
const [expenses, setExpenses] = useState<Array<Expense>>([]);
const [expensesLoaded, setExpensesLoaded] = useState(false);
function refreshExpenses() { export default function ExpensesTable({ expenses, onUpdate, onEdit }: {
new AbstractApi<Expense>().getAll(new ExpenseSerializer(), (expenses: Expense[]) => { expenses: Expense[],
setExpenses(expenses); onUpdate: () => void,
setExpensesLoaded(true); onEdit: (expense: Expense) => void,
}); }) {
}
!expensesLoaded && refreshExpenses();
const api = new AbstractApi<Expense>(); const api = new AbstractApi<Expense>();
const serializer = new ExpenseSerializer(); const serializer = new ExpenseSerializer();
return ( return (
<TableOfContents <TableOfContents
headers={['Name', 'Amount (€)', 'Pricing Type']} headers={['Name', 'Amount (€)', 'Pricing Type', 'Actions']}
caption='Expenses' caption='Expenses'
elements={expenses} elements={expenses}
rowRender={(expense) => ( rowRender={(expense) => (
<tr key={expense.id} className="bg-white border-b odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark:bg-gray-800"> <tr key={expense.id} className="bg-white border-b odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark:bg-gray-800">
<th scope="row" className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"> <th scope="row" className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
<InlineTextField initialValue={expense.name} onChange={(value) => { expense.name = value; updateExpense(expense) }} /> {expense.name}
</th> </th>
<td className="px-6 py-4"> <td className="px-6 py-4">
<InlineTextField initialValue={expense.amount.toString()} onChange={(value) => { expense.amount = parseFloat(value); updateExpense(expense) }} /> {expense.amount}
</td> </td>
<td> <td>
{expense.pricingType} {expense.pricingType}
</td> </td>
<td>
<div className="flex flex-row items-center">
<TrashIcon className='size-6 cursor-pointer' onClick={() => { api.destroy(serializer, expense, onUpdate) }} />
<PencilIcon className='size-6 cursor-pointer' onClick={() => onEdit(expense)} />
</div>
</td>
</tr> </tr>
)} )}
/> />