13 - Integrate inline editing into bucket cards

This commit is contained in:
myrmidex 2026-03-21 10:47:36 +01:00
parent 073efc4bda
commit a045ee6c23

View file

@ -2,6 +2,8 @@ import { Head, router } from '@inertiajs/react';
import { useState } from 'react'; import { useState } from 'react';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'; import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import InlineEditInput from '@/components/InlineEditInput';
import InlineEditSelect from '@/components/InlineEditSelect';
interface Scenario { interface Scenario {
id: string; id: string;
@ -22,6 +24,7 @@ interface Bucket {
allocation_type_label: string; allocation_type_label: string;
buffer_multiplier: number; buffer_multiplier: number;
effective_capacity: number; effective_capacity: number;
starting_amount: number;
current_balance: number; current_balance: number;
has_available_space: boolean; has_available_space: boolean;
available_space: number; available_space: number;
@ -67,7 +70,10 @@ const bucketTypeBorderColor = {
overflow: 'border-amber-500', overflow: 'border-amber-500',
} as const; } as const;
const BUFFER_PRESETS = ['0', '0.5', '1', '1.5', '2'] as const; const bucketTypeOptions = [
{ value: 'need', label: 'Need' },
{ value: 'want', label: 'Want' },
];
const formatAllocationValue = (bucket: Bucket): string => { const formatAllocationValue = (bucket: Bucket): string => {
if (bucket.allocation_type === 'unlimited') return 'All remaining'; if (bucket.allocation_type === 'unlimited') return 'All remaining';
@ -76,6 +82,26 @@ const formatAllocationValue = (bucket: Bucket): string => {
return `$${Number(bucket.allocation_value).toFixed(2)}`; return `$${Number(bucket.allocation_value).toFixed(2)}`;
}; };
const csrfToken = () =>
document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
const patchBucket = async (bucketId: string, data: Record<string, unknown>): Promise<void> => {
const response = await fetch(`/buckets/${bucketId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken(),
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to update bucket');
}
router.reload({ only: ['buckets'] });
};
const defaultFormData = { const defaultFormData = {
name: '', name: '',
type: 'need' as Bucket['type'], type: 'need' as Bucket['type'],
@ -88,30 +114,13 @@ const defaultFormData = {
export default function Show({ scenario, buckets, streams = { data: [] }, streamStats }: Props) { export default function Show({ scenario, buckets, streams = { data: [] }, streamStats }: Props) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [editingBucket, setEditingBucket] = useState<Bucket | null>(null);
const [formData, setFormData] = useState({ ...defaultFormData }); const [formData, setFormData] = useState({ ...defaultFormData });
const openCreateModal = () => { const openCreateModal = () => {
setEditingBucket(null);
setFormData({ ...defaultFormData }); setFormData({ ...defaultFormData });
setIsModalOpen(true); setIsModalOpen(true);
}; };
const handleEdit = (bucket: Bucket) => {
setEditingBucket(bucket);
const bufferStr = bucket.buffer_multiplier.toString();
const isPreset = (BUFFER_PRESETS as readonly string[]).includes(bufferStr);
setFormData({
name: bucket.name,
type: bucket.type,
allocation_type: bucket.allocation_type,
allocation_value: bucket.allocation_value ? bucket.allocation_value.toString() : '',
buffer_multiplier: bufferStr,
buffer_mode: isPreset ? 'preset' : 'custom',
});
setIsModalOpen(true);
};
const handleDelete = async (bucket: Bucket) => { const handleDelete = async (bucket: Bucket) => {
if (!confirm(`Are you sure you want to delete "${bucket.name}"?`)) { if (!confirm(`Are you sure you want to delete "${bucket.name}"?`)) {
return; return;
@ -120,9 +129,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
try { try {
const response = await fetch(`/buckets/${bucket.id}`, { const response = await fetch(`/buckets/${bucket.id}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: { 'X-CSRF-TOKEN': csrfToken() },
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
},
}); });
if (response.ok) { if (response.ok) {
@ -139,18 +146,12 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
e.preventDefault(); e.preventDefault();
setIsSubmitting(true); setIsSubmitting(true);
const url = editingBucket
? `/buckets/${editingBucket.id}`
: `/scenarios/${scenario.id}/buckets`;
const method = editingBucket ? 'PATCH' : 'POST';
try { try {
const response = await fetch(url, { const response = await fetch(`/scenarios/${scenario.id}/buckets`, {
method: method, method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '', 'X-CSRF-TOKEN': csrfToken(),
}, },
body: JSON.stringify({ body: JSON.stringify({
name: formData.name, name: formData.name,
@ -158,21 +159,19 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
allocation_type: formData.allocation_type, allocation_type: formData.allocation_type,
allocation_value: formData.allocation_value ? parseFloat(formData.allocation_value) : null, allocation_value: formData.allocation_value ? parseFloat(formData.allocation_value) : null,
buffer_multiplier: formData.allocation_type === 'fixed_limit' ? parseFloat(formData.buffer_multiplier) || 0 : 0, buffer_multiplier: formData.allocation_type === 'fixed_limit' ? parseFloat(formData.buffer_multiplier) || 0 : 0,
priority: editingBucket ? editingBucket.priority : undefined
}), }),
}); });
if (response.ok) { if (response.ok) {
setIsModalOpen(false); setIsModalOpen(false);
setFormData({ ...defaultFormData }); setFormData({ ...defaultFormData });
setEditingBucket(null);
router.reload({ only: ['buckets'] }); router.reload({ only: ['buckets'] });
} else { } else {
const errorData = await response.json(); const errorData = await response.json();
console.error(`Failed to ${editingBucket ? 'update' : 'create'} bucket:`, errorData); console.error('Failed to create bucket:', errorData);
} }
} catch (error) { } catch (error) {
console.error(`Error ${editingBucket ? 'updating' : 'creating'} bucket:`, error); console.error('Error creating bucket:', error);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -183,32 +182,10 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
if (!bucket) return; if (!bucket) return;
const newPriority = direction === 'up' ? bucket.priority - 1 : bucket.priority + 1; const newPriority = direction === 'up' ? bucket.priority - 1 : bucket.priority + 1;
// Don't allow moving beyond bounds
if (newPriority < 1 || newPriority > buckets.data.length) return; if (newPriority < 1 || newPriority > buckets.data.length) return;
try { try {
const response = await fetch(`/buckets/${bucketId}`, { await patchBucket(bucketId, { priority: newPriority });
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
},
body: JSON.stringify({
name: bucket.name,
type: bucket.type,
allocation_type: bucket.allocation_type,
allocation_value: bucket.allocation_value,
buffer_multiplier: bucket.buffer_multiplier,
priority: newPriority
}),
});
if (response.ok) {
router.reload({ only: ['buckets'] });
} else {
console.error('Failed to update bucket priority');
}
} catch (error) { } catch (error) {
console.error('Error updating bucket priority:', error); console.error('Error updating bucket priority:', error);
} }
@ -217,7 +194,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
return ( return (
<> <>
<Head title={scenario.name} /> <Head title={scenario.name} />
<div className="min-h-screen bg-gray-50 py-8"> <div className="min-h-screen bg-gray-50 py-8">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{/* Header */} {/* Header */}
@ -241,7 +218,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
+ Add Bucket + Add Bucket
</button> </button>
</div> </div>
{buckets.data.length === 0 ? ( {buckets.data.length === 0 ? (
<Card className="mx-auto max-w-lg text-center"> <Card className="mx-auto max-w-lg text-center">
<CardHeader> <CardHeader>
@ -286,7 +263,15 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
{bucket.name} {bucket.name}
</h3> </h3>
<p className="text-sm text-gray-600 mt-1"> <p className="text-sm text-gray-600 mt-1">
Priority {bucket.priority} {bucket.type_label} {bucket.allocation_type_label} Priority {bucket.priority} {' '}
<InlineEditSelect
value={bucket.type}
options={bucketTypeOptions}
onSave={(val) => patchBucket(bucket.id, { type: val })}
displayLabel={bucket.type_label}
disabled={bucket.type === 'overflow'}
/>
{' '} {bucket.allocation_type_label}
</p> </p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -318,19 +303,35 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
<div className="mt-4"> <div className="mt-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Current Balance</span> <span className="text-sm text-gray-600">Current Filling</span>
<span className="text-lg font-semibold text-gray-900"> <InlineEditInput
${bucket.current_balance.toFixed(2)} value={bucket.starting_amount}
</span> onSave={(val) => patchBucket(bucket.id, { starting_amount: val })}
formatDisplay={(v) => `$${v.toFixed(2)}`}
min={0}
step="1"
className="text-lg font-semibold text-gray-900"
/>
</div> </div>
<div className="mt-2"> <div className="mt-2">
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600">
Allocation: {formatAllocationValue(bucket)} Allocation: {formatAllocationValue(bucket)}
</span> </span>
{bucket.allocation_type === 'fixed_limit' && bucket.buffer_multiplier > 0 && ( {bucket.allocation_type === 'fixed_limit' && (
<span className="text-sm text-gray-500 ml-2"> <span className="text-sm text-gray-500 ml-2">
({bucket.buffer_multiplier}x buffer = ${bucket.effective_capacity.toFixed(2)} effective) (
<InlineEditInput
value={bucket.buffer_multiplier}
onSave={(val) => patchBucket(bucket.id, { buffer_multiplier: val })}
formatDisplay={(v) => v > 0 ? `${v}x buffer` : 'no buffer'}
min={0}
step="0.01"
/>
{bucket.buffer_multiplier > 0 && (
<> = ${bucket.effective_capacity.toFixed(2)} effective</>
)}
)
</span> </span>
)} )}
</div> </div>
@ -355,38 +356,32 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
)} )}
</div> </div>
<div className="mt-4 flex gap-2"> {bucket.type !== 'overflow' && (
<button <div className="mt-4 flex gap-2">
onClick={() => handleEdit(bucket)}
className="flex-1 text-sm text-blue-600 hover:text-blue-500"
>
Edit
</button>
{bucket.type !== 'overflow' && (
<button <button
onClick={() => handleDelete(bucket)} onClick={() => handleDelete(bucket)}
className="flex-1 text-sm text-red-600 hover:text-red-500" className="flex-1 text-sm text-red-600 hover:text-red-500"
> >
Delete Delete
</button> </button>
)} </div>
</div> )}
</div> </div>
))} ))}
</div> </div>
)} )}
{/* Streams Section */} {/* Streams Section */}
<div className="mt-8"> <div className="mt-8">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900">Income & Expense Streams</h2> <h2 className="text-xl font-semibold text-gray-900">Income & Expense Streams</h2>
<button <button
className="rounded-md bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-500" className="rounded-md bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-500"
> >
+ Add Stream + Add Stream
</button> </button>
</div> </div>
{/* Stream Statistics */} {/* Stream Statistics */}
{streamStats && ( {streamStats && (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-6"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-6">
@ -432,7 +427,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
</div> </div>
</div> </div>
)} )}
{streams.data.length === 0 ? ( {streams.data.length === 0 ? (
<div className="rounded-lg bg-white p-8 text-center shadow"> <div className="rounded-lg bg-white p-8 text-center shadow">
<p className="text-gray-600">No streams yet. Add income or expense streams to start tracking cash flow.</p> <p className="text-gray-600">No streams yet. Add income or expense streams to start tracking cash flow.</p>
@ -476,8 +471,8 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
</td> </td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500"> <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<span className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${ <span className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
stream.type === 'income' stream.type === 'income'
? 'bg-green-100 text-green-800' ? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800' : 'bg-red-100 text-red-800'
}`}> }`}>
{stream.type_label} {stream.type_label}
@ -498,8 +493,8 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500"> <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<button <button
className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${ className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
stream.is_active stream.is_active
? 'bg-green-100 text-green-800' ? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800' : 'bg-gray-100 text-gray-800'
}`} }`}
> >
@ -521,7 +516,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
</div> </div>
)} )}
</div> </div>
{/* Placeholder for future features */} {/* Placeholder for future features */}
<div className="rounded-lg bg-blue-50 p-6 text-center"> <div className="rounded-lg bg-blue-50 p-6 text-center">
<h3 className="text-lg font-medium text-blue-900"> <h3 className="text-lg font-medium text-blue-900">
@ -535,12 +530,12 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
</div> </div>
</div> </div>
{/* Add Bucket Modal */} {/* Create Bucket Modal */}
{isModalOpen && ( {isModalOpen && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"> <div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white"> <div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="mt-3"> <div className="mt-3">
<h3 className="text-lg font-medium text-gray-900 mb-4">{editingBucket ? 'Edit Bucket' : 'Add New Bucket'}</h3> <h3 className="text-lg font-medium text-gray-900 mb-4">Add New Bucket</h3>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700"> <label htmlFor="name" className="block text-sm font-medium text-gray-700">
@ -557,41 +552,35 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
/> />
</div> </div>
{/* Hide type select for overflow buckets (type is fixed) */} <div>
{!(editingBucket && editingBucket.type === 'overflow') && ( <label htmlFor="type" className="block text-sm font-medium text-gray-700">
<div> Bucket Type
<label htmlFor="type" className="block text-sm font-medium text-gray-700"> </label>
Bucket Type <select
</label> id="type"
<select value={formData.type}
id="type" onChange={(e) => setFormData({ ...formData, type: e.target.value as Bucket['type'] })}
value={formData.type} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-gray-900"
onChange={(e) => setFormData({ ...formData, type: e.target.value as Bucket['type'] })} >
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-gray-900" <option value="need">Need</option>
> <option value="want">Want</option>
<option value="need">Need</option> </select>
<option value="want">Want</option> </div>
</select>
</div>
)}
{/* Hide allocation type select for overflow (always unlimited) */} <div>
{formData.type !== 'overflow' && ( <label htmlFor="allocation_type" className="block text-sm font-medium text-gray-700">
<div> Allocation Type
<label htmlFor="allocation_type" className="block text-sm font-medium text-gray-700"> </label>
Allocation Type <select
</label> id="allocation_type"
<select value={formData.allocation_type}
id="allocation_type" onChange={(e) => setFormData({ ...formData, allocation_type: e.target.value })}
value={formData.allocation_type} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-gray-900"
onChange={(e) => setFormData({ ...formData, allocation_type: e.target.value })} >
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-gray-900" <option value="fixed_limit">Fixed Limit</option>
> <option value="percentage">Percentage</option>
<option value="fixed_limit">Fixed Limit</option> </select>
<option value="percentage">Percentage</option> </div>
</select>
</div>
)}
{formData.allocation_type !== 'unlimited' && ( {formData.allocation_type !== 'unlimited' && (
<div> <div>
@ -657,7 +646,6 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
type="button" type="button"
onClick={() => { onClick={() => {
setIsModalOpen(false); setIsModalOpen(false);
setEditingBucket(null);
setFormData({ ...defaultFormData }); setFormData({ ...defaultFormData });
}} }}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300" className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"
@ -670,7 +658,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50" className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
disabled={isSubmitting} disabled={isSubmitting}
> >
{isSubmitting ? (editingBucket ? 'Updating...' : 'Creating...') : (editingBucket ? 'Update Bucket' : 'Create Bucket')} {isSubmitting ? 'Creating...' : 'Create Bucket'}
</button> </button>
</div> </div>
</form> </form>
@ -680,4 +668,4 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
)} )}
</> </>
); );
} }