13 - Integrate inline editing into bucket cards
This commit is contained in:
parent
073efc4bda
commit
a045ee6c23
1 changed files with 112 additions and 124 deletions
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -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,22 +356,16 @@ 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue