17 - Add settings panel with distribution mode toggle

This commit is contained in:
myrmidex 2026-03-22 01:08:44 +01:00
parent a7f9799391
commit b98b6f6dd9
4 changed files with 174 additions and 3 deletions

View file

@ -1,18 +1,41 @@
import { Breadcrumbs } from '@/components/Breadcrumbs'; import { Breadcrumbs } from '@/components/Breadcrumbs';
import SettingsPanel from '@/components/SettingsPanel';
import { Button } from '@/components/ui/button';
import { SidebarTrigger } from '@/components/ui/sidebar'; import { SidebarTrigger } from '@/components/ui/sidebar';
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types'; import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
import { Settings } from 'lucide-react';
import { useState } from 'react';
export function AppSidebarHeader({ export function AppSidebarHeader({
breadcrumbs = [], breadcrumbs = [],
}: { }: {
breadcrumbs?: BreadcrumbItemType[]; breadcrumbs?: BreadcrumbItemType[];
}) { }) {
const [settingsOpen, setSettingsOpen] = useState(false);
return ( 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"> <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"> <div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Breadcrumbs breadcrumbs={breadcrumbs} /> <Breadcrumbs breadcrumbs={breadcrumbs} />
</div> </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> </header>
); );
} }

View 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>
);
}

View file

@ -16,3 +16,11 @@ export function isSameUrl(
export function resolveUrl(url: NonNullable<InertiaLinkProps['href']>): string { export function resolveUrl(url: NonNullable<InertiaLinkProps['href']>): string {
return typeof url === 'string' ? url : url.url; return typeof url === 'string' ? url : url.url;
} }
export function csrfToken(): string {
return (
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') || ''
);
}

View file

@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import InlineEditInput from '@/components/InlineEditInput'; import InlineEditInput from '@/components/InlineEditInput';
import InlineEditSelect from '@/components/InlineEditSelect'; import InlineEditSelect from '@/components/InlineEditSelect';
import IncomeDistributionPreview from '@/components/IncomeDistributionPreview'; import IncomeDistributionPreview from '@/components/IncomeDistributionPreview';
import { csrfToken } from '@/lib/utils';
interface Scenario { interface Scenario {
id: string; id: string;
@ -97,9 +98,6 @@ const formatAllocationValue = (bucket: Bucket): string => {
return `$${centsToDollars(bucket.allocation_value).toFixed(2)}`; 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 patchBucket = async (bucketId: string, data: Record<string, unknown>): Promise<void> => {
const response = await fetch(`/buckets/${bucketId}`, { const response = await fetch(`/buckets/${bucketId}`, {
method: 'PATCH', method: 'PATCH',