320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
/**
|
|
* Profile Selector Component
|
|
*
|
|
* Allows users to manage medication schedule profiles with create, save,
|
|
* save-as, and delete functionality. Provides a combobox-style interface
|
|
* for profile selection and management.
|
|
*
|
|
* @author Andreas Weyer
|
|
* @license MIT
|
|
*/
|
|
|
|
import React, { useState } from 'react';
|
|
import { Card, CardContent } from './ui/card';
|
|
import { Label } from './ui/label';
|
|
import { Input } from './ui/input';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from './ui/select';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
|
import { Save, Trash2, Plus, Pencil } from 'lucide-react';
|
|
import { IconButtonWithTooltip } from './ui/icon-button-with-tooltip';
|
|
import { MAX_PROFILES, type ScheduleProfile } from '../constants/defaults';
|
|
|
|
interface ProfileSelectorProps {
|
|
profiles: ScheduleProfile[];
|
|
activeProfileId: string;
|
|
hasUnsavedChanges: boolean;
|
|
onSwitchProfile: (profileId: string) => void;
|
|
onSaveProfile: () => void;
|
|
onSaveProfileAs: (name: string) => string | null;
|
|
onRenameProfile: (profileId: string, newName: string) => void;
|
|
onDeleteProfile: (profileId: string) => boolean;
|
|
t: (key: string) => string;
|
|
}
|
|
|
|
export const ProfileSelector: React.FC<ProfileSelectorProps> = ({
|
|
profiles,
|
|
activeProfileId,
|
|
hasUnsavedChanges,
|
|
onSwitchProfile,
|
|
onSaveProfile,
|
|
onSaveProfileAs,
|
|
onRenameProfile,
|
|
onDeleteProfile,
|
|
t,
|
|
}) => {
|
|
const [newProfileName, setNewProfileName] = useState('');
|
|
const [isSaveAsMode, setIsSaveAsMode] = useState(false);
|
|
const [isRenameMode, setIsRenameMode] = useState(false);
|
|
const [renameName, setRenameName] = useState('');
|
|
|
|
const activeProfile = profiles.find(p => p.id === activeProfileId);
|
|
const canDelete = profiles.length > 1;
|
|
const canCreateNew = profiles.length < MAX_PROFILES;
|
|
|
|
// Sort profiles alphabetically (case-insensitive)
|
|
const sortedProfiles = [...profiles].sort((a, b) =>
|
|
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
|
);
|
|
|
|
const handleSelectChange = (value: string) => {
|
|
if (value === '__new__') {
|
|
// Enter "save as" mode
|
|
setIsSaveAsMode(true);
|
|
setIsRenameMode(false);
|
|
setNewProfileName('');
|
|
} else {
|
|
// Confirm before switching if there are unsaved changes
|
|
if (hasUnsavedChanges) {
|
|
if (!window.confirm(t('profileSwitchUnsavedConfirm'))) {
|
|
return;
|
|
}
|
|
}
|
|
onSwitchProfile(value);
|
|
setIsSaveAsMode(false);
|
|
setIsRenameMode(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveAs = () => {
|
|
if (!newProfileName.trim()) {
|
|
return;
|
|
}
|
|
|
|
// Check for duplicate names
|
|
const isDuplicate = profiles.some(
|
|
p => p.name.toLowerCase() === newProfileName.trim().toLowerCase()
|
|
);
|
|
|
|
let finalName = newProfileName.trim();
|
|
if (isDuplicate) {
|
|
// Find next available suffix
|
|
let suffix = 2;
|
|
while (profiles.some(p => p.name.toLowerCase() === `${newProfileName.trim()} (${suffix})`.toLowerCase())) {
|
|
suffix++;
|
|
}
|
|
finalName = `${newProfileName.trim()} (${suffix})`;
|
|
}
|
|
|
|
const newProfileId = onSaveProfileAs(finalName);
|
|
if (newProfileId) {
|
|
setIsSaveAsMode(false);
|
|
setNewProfileName('');
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') {
|
|
handleSaveAs();
|
|
} else if (e.key === 'Escape') {
|
|
setIsSaveAsMode(false);
|
|
setNewProfileName('');
|
|
}
|
|
};
|
|
|
|
const handleDelete = () => {
|
|
if (activeProfile && canDelete) {
|
|
if (window.confirm(t('profileDeleteConfirm')?.replace('{name}', activeProfile.name))) {
|
|
onDeleteProfile(activeProfile.id);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleStartRename = () => {
|
|
if (activeProfile) {
|
|
setIsRenameMode(true);
|
|
setIsSaveAsMode(false);
|
|
setRenameName(activeProfile.name);
|
|
}
|
|
};
|
|
|
|
const handleRename = () => {
|
|
if (!renameName.trim() || !activeProfile) {
|
|
return;
|
|
}
|
|
|
|
const trimmedName = renameName.trim();
|
|
|
|
// Check if name is unchanged
|
|
if (trimmedName === activeProfile.name) {
|
|
setIsRenameMode(false);
|
|
return;
|
|
}
|
|
|
|
// Check for duplicate names (excluding current profile)
|
|
const isDuplicate = profiles.some(
|
|
p => p.id !== activeProfile.id && p.name.toLowerCase() === trimmedName.toLowerCase()
|
|
);
|
|
|
|
if (isDuplicate) {
|
|
alert(t('profileNameAlreadyExists') || 'A schedule with this name already exists');
|
|
return;
|
|
}
|
|
|
|
onRenameProfile(activeProfile.id, trimmedName);
|
|
setIsRenameMode(false);
|
|
setRenameName('');
|
|
};
|
|
|
|
const handleRenameKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') {
|
|
handleRename();
|
|
} else if (e.key === 'Escape') {
|
|
setIsRenameMode(false);
|
|
setRenameName('');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card className="mb-4">
|
|
<CardContent className="pt-6">
|
|
<div className="space-y-2">
|
|
{/* Title label */}
|
|
<Label htmlFor="profile-selector" className="text-sm font-medium">
|
|
{t('savedPlans')}
|
|
</Label>
|
|
|
|
{/* Profile selector with integrated buttons */}
|
|
<div className="flex items-stretch">
|
|
{/* Profile selector / name input */}
|
|
{isSaveAsMode ? (
|
|
<Input
|
|
id="profile-selector"
|
|
type="text"
|
|
value={newProfileName}
|
|
onChange={(e) => setNewProfileName(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={t('profileSaveAsPlaceholder')}
|
|
autoFocus
|
|
className="h-9 rounded-r-none border-r-0 w-[288px] bg-background"
|
|
/>
|
|
) : isRenameMode ? (
|
|
<Input
|
|
id="profile-selector"
|
|
type="text"
|
|
value={renameName}
|
|
onChange={(e) => setRenameName(e.target.value)}
|
|
onKeyDown={handleRenameKeyDown}
|
|
placeholder={t('profileRenamePlaceholder')}
|
|
autoFocus
|
|
className="h-9 rounded-r-none border-r-0 w-[288px] bg-background"
|
|
/>
|
|
) : (
|
|
<Select
|
|
value={activeProfileId}
|
|
onValueChange={handleSelectChange}
|
|
>
|
|
<SelectTrigger id="profile-selector" className="h-9 rounded-r-none border-r-0 w-[288px] bg-background">
|
|
<SelectValue>
|
|
{activeProfile?.name}
|
|
{hasUnsavedChanges && ' *'}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{sortedProfiles.map(profile => (
|
|
<SelectItem key={profile.id} value={profile.id}>
|
|
{profile.name}
|
|
</SelectItem>
|
|
))}
|
|
{canCreateNew && (
|
|
<>
|
|
<div className="my-1 h-px bg-border" />
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<SelectItem value="__new__">
|
|
<div className="flex items-center gap-2">
|
|
<Plus className="h-4 w-4" />
|
|
<span>{t('profileSaveAsNewProfile')}</span>
|
|
</div>
|
|
</SelectItem>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right">
|
|
<p className="text-xs">{t('profileSaveAs')}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
|
|
{/* Save button - integrated */}
|
|
<IconButtonWithTooltip
|
|
onClick={isSaveAsMode ? handleSaveAs : isRenameMode ? handleRename : onSaveProfile}
|
|
icon={<Save className="h-4 w-4" />}
|
|
tooltip={isSaveAsMode ? t('profileSaveAs') : isRenameMode ? t('profileRename') : t('profileSave')}
|
|
disabled={(isSaveAsMode && !newProfileName.trim()) || (isRenameMode && !renameName.trim()) || (!isSaveAsMode && !isRenameMode && !hasUnsavedChanges)}
|
|
variant="outline"
|
|
size="icon"
|
|
className="rounded-none border-r-0"
|
|
/>
|
|
|
|
{/* Rename button - integrated */}
|
|
<IconButtonWithTooltip
|
|
onClick={handleStartRename}
|
|
icon={<Pencil className="h-4 w-4" />}
|
|
tooltip={t('profileRename')}
|
|
disabled={isSaveAsMode || isRenameMode}
|
|
variant="outline"
|
|
size="icon"
|
|
className="rounded-none border-r-0"
|
|
/>
|
|
|
|
{/* Delete button - integrated */}
|
|
<IconButtonWithTooltip
|
|
onClick={handleDelete}
|
|
icon={<Trash2 className="h-4 w-4" />}
|
|
tooltip={canDelete ? t('profileDelete') : t('profileDeleteDisabled')}
|
|
disabled={!canDelete || isSaveAsMode || isRenameMode}
|
|
variant="outline"
|
|
size="icon"
|
|
className="rounded-l-none text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
|
/>
|
|
</div>
|
|
|
|
{/* Helper text for save-as mode */}
|
|
{isSaveAsMode && (
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-xs text-muted-foreground flex-1">
|
|
{t('profileSaveAsHelp')}
|
|
</p>
|
|
<button
|
|
onClick={() => {
|
|
setIsSaveAsMode(false);
|
|
setNewProfileName('');
|
|
}}
|
|
className="text-xs text-muted-foreground hover:text-foreground underline"
|
|
>
|
|
{t('cancel')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Helper text for rename mode */}
|
|
{isRenameMode && (
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-xs text-muted-foreground flex-1">
|
|
{t('profileRenameHelp')}
|
|
</p>
|
|
<button
|
|
onClick={() => {
|
|
setIsRenameMode(false);
|
|
setRenameName('');
|
|
}}
|
|
className="text-xs text-muted-foreground hover:text-foreground underline"
|
|
>
|
|
{t('cancel')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|