Files
tgex-frontend/components/format-selector.tsx
2026-03-04 08:29:11 +03:00

236 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { Pencil, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface FormatSelectorProps {
value: string | null | undefined;
isEditing: boolean;
canEdit: boolean;
onEdit: () => void;
onChange: (value: string | null) => void;
onCloseEdit: () => void;
}
interface FormatOption {
label: string;
topHours: number;
feedHours: number | null;
}
const FORMAT_OPTIONS: FormatOption[] = [
{ label: "1/24", topHours: 1, feedHours: 24 },
{ label: "1/48", topHours: 1, feedHours: 48 },
{ label: "1/72", topHours: 1, feedHours: 72 },
{ label: "1/7 дней", topHours: 1, feedHours: 168 },
{ label: "1/без удаления", topHours: 1, feedHours: null },
];
function parseFormat(format: string | null | undefined): { topHours: number; feedHours: number | null } | null {
if (!format) return null;
const match = format.match(/^(\d+)\/(\d+(?:\s*дней)?|без\s*удаления)$/i);
if (!match) return null;
const topHours = parseInt(match[1], 10);
let feedHours: number | null = null;
if (match[2].toLowerCase().includes('без')) {
feedHours = null;
} else if (match[2].includes('дней')) {
feedHours = parseInt(match[2], 10) * 24;
} else {
feedHours = parseInt(match[2], 10);
}
return { topHours, feedHours };
}
function formatToString(topHours: number, feedHours: number | null): string {
if (feedHours === null) {
return "1/без удаления";
}
if (feedHours >= 168) {
const days = feedHours / 24;
return `1/${days} дней`;
}
return `1/${feedHours}`;
}
function findMatchingOption(topHours: number, feedHours: number | null): string | null {
const option = FORMAT_OPTIONS.find(
o => o.topHours === topHours && o.feedHours === feedHours
);
return option?.label || null;
}
function FormatOptionItem({
option,
isSelected,
onClick,
}: {
option: FormatOption;
isSelected: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
className={cn(
"flex items-center justify-between px-3 py-2 text-sm rounded-md transition-colors w-full",
isSelected ? "bg-muted font-medium" : "hover:bg-muted/50"
)}
onClick={onClick}
>
<span>{option.label}</span>
{isSelected && <Check className="h-4 w-4" />}
</button>
);
}
export function FormatSelector({
value,
isEditing,
canEdit,
onEdit,
onChange,
onCloseEdit,
}: FormatSelectorProps) {
const [showCustom, setShowCustom] = useState(false);
const [customTop, setCustomTop] = useState("");
const [customFeedText, setCustomFeedText] = useState("");
const parsed = parseFormat(value);
const matchedOption = parsed ? findMatchingOption(parsed.topHours, parsed.feedHours) : null;
const handleSelectPreset = (option: FormatOption) => {
const formatStr = formatToString(option.topHours, option.feedHours);
onChange(formatStr);
onCloseEdit();
};
const handleSelectCustom = () => {
const top = parseInt(customTop, 10);
let feed: number | null = null;
if (customFeedText.toLowerCase().includes('без')) {
feed = null;
} else if (customFeedText.includes('дней')) {
const daysMatch = customFeedText.match(/(\d+)/);
if (daysMatch) {
feed = parseInt(daysMatch[1], 10) * 24;
}
} else if (customFeedText) {
feed = parseInt(customFeedText, 10);
}
if (!isNaN(top) && feed !== null && !isNaN(feed)) {
const formatStr = formatToString(top, feed);
onChange(formatStr);
onCloseEdit();
}
};
if (isEditing) {
return (
<Popover open={isEditing} onOpenChange={(open) => !open && onCloseEdit()}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-7 w-full justify-between px-2 text-xs font-medium border shadow-sm"
>
<span className="truncate">{value || "Выберите..."}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start">
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground px-2 pb-1">
Стандартные форматы
</div>
{FORMAT_OPTIONS.map((option) => (
<FormatOptionItem
key={option.label}
option={option}
isSelected={matchedOption === option.label}
onClick={() => handleSelectPreset(option)}
/>
))}
<div className="border-t my-2" />
{!showCustom ? (
<button
type="button"
className="flex items-center gap-2 px-3 py-2 text-sm rounded-md hover:bg-muted/50 w-full text-left text-muted-foreground"
onClick={() => setShowCustom(true)}
>
<Pencil className="h-3 w-3" />
Свой формат...
</button>
) : (
<div className="px-2 py-2 space-y-2">
<div className="text-xs font-medium text-muted-foreground">
Свой формат
</div>
<div className="flex items-center gap-1">
<Input
type="number"
placeholder="Часы"
className="h-8 text-xs w-16 shrink-0"
value={customTop}
onChange={(e) => setCustomTop(e.target.value)}
/>
<span className="text-xs shrink-0">/</span>
<Input
placeholder="Часы"
className="h-8 text-xs"
value={customFeedText}
onChange={(e) => setCustomFeedText(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1 text-xs h-7"
onClick={() => setShowCustom(false)}
>
Отмена
</Button>
<Button
size="sm"
className="flex-1 text-xs h-7"
onClick={handleSelectCustom}
>
Применить
</Button>
</div>
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}
return (
<button
type="button"
disabled={!canEdit}
className="group h-7 w-full rounded-md border px-2 text-xs font-medium shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default flex items-center justify-between gap-2"
onClick={onEdit}
>
<span className="truncate">{value || "—"}</span>
{canEdit && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-70 transition-opacity shrink-0" />}
</button>
);
}