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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 {
|
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') || ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue