17 - Add settings panel with distribution mode toggle
This commit is contained in:
parent
a7f9799391
commit
b98b6f6dd9
4 changed files with 174 additions and 3 deletions
|
|
@ -1,18 +1,41 @@
|
|||
import { Breadcrumbs } from '@/components/Breadcrumbs';
|
||||
import SettingsPanel from '@/components/SettingsPanel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function AppSidebarHeader({
|
||||
breadcrumbs = [],
|
||||
}: {
|
||||
breadcrumbs?: BreadcrumbItemType[];
|
||||
}) {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/50 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<Settings className="h-5 w-5 opacity-80" />
|
||||
<span className="sr-only">Settings</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingsPanel
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
142
resources/js/components/SettingsPanel.tsx
Normal file
142
resources/js/components/SettingsPanel.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { csrfToken } from '@/lib/utils';
|
||||
import { type SharedData } from '@/types';
|
||||
import { router, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
type SaveStatus = 'idle' | 'saving' | 'success' | 'error';
|
||||
type DistributionMode = 'even' | 'priority';
|
||||
|
||||
interface DistributionOption {
|
||||
value: DistributionMode;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const distributionOptions: DistributionOption[] = [
|
||||
{
|
||||
value: 'even',
|
||||
label: 'Even Split',
|
||||
description:
|
||||
'Split evenly across buckets in each phase, respecting individual capacity',
|
||||
},
|
||||
{
|
||||
value: 'priority',
|
||||
label: 'Priority Order',
|
||||
description: 'Fill highest-priority bucket first, then next',
|
||||
},
|
||||
];
|
||||
|
||||
interface SettingsPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function SettingsPanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: SettingsPanelProps) {
|
||||
const { scenario } = usePage<SharedData>().props;
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
|
||||
|
||||
if (!scenario) return null;
|
||||
|
||||
const handleDistributionModeChange = async (value: DistributionMode) => {
|
||||
if (value === scenario.distribution_mode) return;
|
||||
|
||||
setSaveStatus('saving');
|
||||
try {
|
||||
const response = await fetch(`/scenarios/${scenario.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
},
|
||||
body: JSON.stringify({ distribution_mode: value }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update');
|
||||
|
||||
setSaveStatus('success');
|
||||
router.reload({ only: ['scenario', 'buckets'] });
|
||||
setTimeout(() => setSaveStatus('idle'), 1500);
|
||||
} catch {
|
||||
setSaveStatus('error');
|
||||
setTimeout(() => setSaveStatus('idle'), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="top" className="px-6 pb-6">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Settings</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<fieldset disabled={saveStatus === 'saving'}>
|
||||
<legend className="text-sm font-medium text-foreground">
|
||||
Distribution Mode
|
||||
{saveStatus === 'success' && (
|
||||
<span className="ml-2 text-green-600">
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<span className="ml-2 text-red-600">
|
||||
Failed to save
|
||||
</span>
|
||||
)}
|
||||
</legend>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
How income is divided across buckets within each
|
||||
phase
|
||||
</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{distributionOptions.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors ${
|
||||
scenario.distribution_mode ===
|
||||
option.value
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="distribution_mode"
|
||||
value={option.value}
|
||||
checked={
|
||||
scenario.distribution_mode ===
|
||||
option.value
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleDistributionModeChange(
|
||||
e.target.value as DistributionMode,
|
||||
)
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{option.label}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,3 +16,11 @@ export function isSameUrl(
|
|||
export function resolveUrl(url: NonNullable<InertiaLinkProps['href']>): string {
|
||||
return typeof url === 'string' ? url : url.url;
|
||||
}
|
||||
|
||||
export function csrfToken(): string {
|
||||
return (
|
||||
document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute('content') || ''
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||
import InlineEditInput from '@/components/InlineEditInput';
|
||||
import InlineEditSelect from '@/components/InlineEditSelect';
|
||||
import IncomeDistributionPreview from '@/components/IncomeDistributionPreview';
|
||||
import { csrfToken } from '@/lib/utils';
|
||||
|
||||
interface Scenario {
|
||||
id: string;
|
||||
|
|
@ -97,9 +98,6 @@ const formatAllocationValue = (bucket: Bucket): string => {
|
|||
return `$${centsToDollars(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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue