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 { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import InlineEditInput from '@/components/InlineEditInput';
import InlineEditSelect from '@/components/InlineEditSelect';
interface Scenario {
id: string;
@ -22,6 +24,7 @@ interface Bucket {
allocation_type_label: string;
buffer_multiplier: number;
effective_capacity: number;
starting_amount: number;
current_balance: number;
has_available_space: boolean;
available_space: number;
@ -67,7 +70,10 @@ const bucketTypeBorderColor = {
overflow: 'border-amber-500',
} 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 => {
if (bucket.allocation_type === 'unlimited') return 'All remaining';
@ -76,6 +82,26 @@ const formatAllocationValue = (bucket: Bucket): string => {
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 = {
name: '',
type: 'need' as Bucket['type'],
@ -88,30 +114,13 @@ const defaultFormData = {
export default function Show({ scenario, buckets, streams = { data: [] }, streamStats }: Props) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [editingBucket, setEditingBucket] = useState<Bucket | null>(null);
const [formData, setFormData] = useState({ ...defaultFormData });
const openCreateModal = () => {
setEditingBucket(null);
setFormData({ ...defaultFormData });
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) => {
if (!confirm(`Are you sure you want to delete "${bucket.name}"?`)) {
return;
@ -120,9 +129,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
try {
const response = await fetch(`/buckets/${bucket.id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
},
headers: { 'X-CSRF-TOKEN': csrfToken() },
});
if (response.ok) {
@ -139,18 +146,12 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
e.preventDefault();
setIsSubmitting(true);
const url = editingBucket
? `/buckets/${editingBucket.id}`
: `/scenarios/${scenario.id}/buckets`;
const method = editingBucket ? 'PATCH' : 'POST';
try {
const response = await fetch(url, {
method: method,
const response = await fetch(`/scenarios/${scenario.id}/buckets`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-CSRF-TOKEN': csrfToken(),
},
body: JSON.stringify({
name: formData.name,
@ -158,21 +159,19 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
allocation_type: formData.allocation_type,
allocation_value: formData.allocation_value ? parseFloat(formData.allocation_value) : null,
buffer_multiplier: formData.allocation_type === 'fixed_limit' ? parseFloat(formData.buffer_multiplier) || 0 : 0,
priority: editingBucket ? editingBucket.priority : undefined
}),
});
if (response.ok) {
setIsModalOpen(false);
setFormData({ ...defaultFormData });
setEditingBucket(null);
router.reload({ only: ['buckets'] });
} else {
const errorData = await response.json();
console.error(`Failed to ${editingBucket ? 'update' : 'create'} bucket:`, errorData);
console.error('Failed to create bucket:', errorData);
}
} catch (error) {
console.error(`Error ${editingBucket ? 'updating' : 'creating'} bucket:`, error);
console.error('Error creating bucket:', error);
} finally {
setIsSubmitting(false);
}
@ -183,32 +182,10 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
if (!bucket) return;
const newPriority = direction === 'up' ? bucket.priority - 1 : bucket.priority + 1;
// Don't allow moving beyond bounds
if (newPriority < 1 || newPriority > buckets.data.length) return;
try {
const response = await fetch(`/buckets/${bucketId}`, {
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');
}
await patchBucket(bucketId, { priority: newPriority });
} catch (error) {
console.error('Error updating bucket priority:', error);
}
@ -217,7 +194,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
return (
<>
<Head title={scenario.name} />
<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">
{/* Header */}
@ -241,7 +218,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
+ Add Bucket
</button>
</div>
{buckets.data.length === 0 ? (
<Card className="mx-auto max-w-lg text-center">
<CardHeader>
@ -286,7 +263,15 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
{bucket.name}
</h3>
<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>
</div>
<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="flex items-center justify-between">
<span className="text-sm text-gray-600">Current Balance</span>
<span className="text-lg font-semibold text-gray-900">
${bucket.current_balance.toFixed(2)}
</span>
<span className="text-sm text-gray-600">Current Filling</span>
<InlineEditInput
value={bucket.starting_amount}
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 className="mt-2">
<span className="text-sm text-gray-600">
Allocation: {formatAllocationValue(bucket)}
</span>
{bucket.allocation_type === 'fixed_limit' && bucket.buffer_multiplier > 0 && (
{bucket.allocation_type === 'fixed_limit' && (
<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>
)}
</div>
@ -355,38 +356,32 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
)}
</div>
<div className="mt-4 flex gap-2">
<button
onClick={() => handleEdit(bucket)}
className="flex-1 text-sm text-blue-600 hover:text-blue-500"
>
Edit
</button>
{bucket.type !== 'overflow' && (
{bucket.type !== 'overflow' && (
<div className="mt-4 flex gap-2">
<button
onClick={() => handleDelete(bucket)}
className="flex-1 text-sm text-red-600 hover:text-red-500"
>
Delete
</button>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Streams Section */}
<div className="mt-8">
<div className="flex items-center justify-between mb-4">
<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"
>
+ Add Stream
</button>
</div>
{/* Stream Statistics */}
{streamStats && (
<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>
)}
{streams.data.length === 0 ? (
<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>
@ -476,8 +471,8 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
</td>
<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 ${
stream.type === 'income'
? 'bg-green-100 text-green-800'
stream.type === 'income'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{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">
<button
className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
stream.is_active
? 'bg-green-100 text-green-800'
stream.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
@ -521,7 +516,7 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
</div>
)}
</div>
{/* Placeholder for future features */}
<div className="rounded-lg bg-blue-50 p-6 text-center">
<h3 className="text-lg font-medium text-blue-900">
@ -535,12 +530,12 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
</div>
</div>
{/* Add Bucket Modal */}
{/* Create Bucket Modal */}
{isModalOpen && (
<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="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">
<div>
<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>
{/* Hide type select for overflow buckets (type is fixed) */}
{!(editingBucket && editingBucket.type === 'overflow') && (
<div>
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
Bucket Type
</label>
<select
id="type"
value={formData.type}
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>
</select>
</div>
)}
<div>
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
Bucket Type
</label>
<select
id="type"
value={formData.type}
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>
</select>
</div>
{/* Hide allocation type select for overflow (always unlimited) */}
{formData.type !== 'overflow' && (
<div>
<label htmlFor="allocation_type" className="block text-sm font-medium text-gray-700">
Allocation Type
</label>
<select
id="allocation_type"
value={formData.allocation_type}
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>
</select>
</div>
)}
<div>
<label htmlFor="allocation_type" className="block text-sm font-medium text-gray-700">
Allocation Type
</label>
<select
id="allocation_type"
value={formData.allocation_type}
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>
</select>
</div>
{formData.allocation_type !== 'unlimited' && (
<div>
@ -657,7 +646,6 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
type="button"
onClick={() => {
setIsModalOpen(false);
setEditingBucket(null);
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"
@ -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"
disabled={isSubmitting}
>
{isSubmitting ? (editingBucket ? 'Updating...' : 'Creating...') : (editingBucket ? 'Update Bucket' : 'Create Bucket')}
{isSubmitting ? 'Creating...' : 'Create Bucket'}
</button>
</div>
</form>
@ -680,4 +668,4 @@ export default function Show({ scenario, buckets, streams = { data: [] }, stream
)}
</>
);
}
}