gantt
Gantt chart with settings menu and schedulable rows
Gantt chart with settings menu and schedulable rows
Загрузка превью…
src/gantt/c-gantt-1.vue
<!--
ПОРТ c-gantt-1 ("Gantt chart with settings menu and schedulable rows").
Все переключатели тесно повторяют реальные пропы `<Gantt>`
(rowCheckboxes/summaryBars/zoomControl/offscreenIndicators/infiniteScroll/
nowIndicator/offDays/dragCreate/displayScheduleHint/barLabel/timelineLines/
interactions/locale/i18n/timeZone) — все они реально портированы (context.ts/
GanttView.vue/GanttTimelineRow.vue), поэтому эта панель настроек не
«изображает» рабочее поведение (правило 4a), оно реальное.
`events` держится управляемо (`ref` + `onEventsChange`), чтобы
`canSelectSlot`/`handleSelectSlot` (планирование НЕЗАПЛАНИРОВАННЫХ строк
Launch/Release prep) читали актуальный список без обхода через
отсутствующий `apiRef` (см. заголовок Gantt.vue). Дата фиксирована явно в
UTC (никаких `new Date()`), тот же якорь, что и в `ui-gantt`.
-->
<script setup lang="ts">
import { computed, reactive, ref } from "vue"
import { de, es, fr, ja, ar, type Locale } from "date-fns/locale"
import { addDays, startOfDay, startOfWeek } from "date-fns"
import { Gantt, GanttNav, GanttToolbar, GanttView } from "@/components/reui/gantt"
import type { GanttEvent, GanttI18nOverrides, GanttInteractions, GanttResource, GanttSlotDraft } from "@/components/reui/gantt"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const ANCHOR = new Date(Date.UTC(2024, 0, 8, 0, 0, 0))
const RESOURCES: GanttResource[] = [
{
id: "planning",
title: "Planning",
children: [
{ id: "brief", title: "Project brief" },
{ id: "scope", title: "Scope review" },
],
},
{
id: "design",
title: "Design",
children: [
{ id: "wireframes", title: "Wireframes" },
{ id: "visual-design", title: "Visual design" },
],
},
{
id: "build",
title: "Build",
children: [
{ id: "frontend", title: "Frontend" },
{ id: "backend", title: "Backend" },
{ id: "qa", title: "QA pass" },
{ id: "release-prep", title: "Release prep" },
],
},
{
id: "launch",
title: "Launch",
children: [
{ id: "docs", title: "Docs" },
{ id: "marketing-site", title: "Marketing site" },
{ id: "announcement", title: "Announcement" },
],
},
]
const RESOURCE_TITLES = new Map(RESOURCES.flatMap((group) => group.children ?? []).map((leaf) => [leaf.id, leaf.title]))
function buildBars(anchor: Date): GanttEvent[] {
const week = startOfWeek(startOfDay(anchor), { weekStartsOn: 0 })
const day = (dayOffset: number) => addDays(week, dayOffset)
const bar = (resourceId: string, title: string, startOffset: number, days: number, color: string, progress?: number): GanttEvent => ({
id: `bar-${resourceId}`,
title,
start: day(startOffset),
end: day(startOffset + days),
allDay: true,
color,
resourceId,
progress,
})
return [
bar("brief", "Project brief", -9, 3, "var(--color-blue-500)", 100),
bar("scope", "Scope review", -6, 2, "var(--color-sky-500)", 100),
bar("wireframes", "Wireframes", -4, 4, "var(--color-violet-500)", 80),
bar("visual-design", "Visual design", 0, 5, "var(--color-purple-500)", 35),
bar("frontend", "Frontend", 3, 7, "var(--color-emerald-500)", 10),
bar("backend", "Backend", 5, 6, "var(--color-teal-500)"),
bar("qa", "QA pass", 12, 4, "var(--color-amber-500)"),
]
}
interface DemoLocale {
id: string
label: string
locale: Locale | undefined
dir: "ltr" | "rtl"
i18n: GanttI18nOverrides | undefined
}
const LOCALES: DemoLocale[] = [
{ id: "en", label: "English", locale: undefined, dir: "ltr", i18n: undefined },
{
id: "de",
label: "Deutsch",
locale: de,
dir: "ltr",
i18n: {
labels: {
today: "Heute",
scheduleHint: "Zum Planen klicken",
reorder: "Neu anordnen",
scales: { day: "Tag", week: "Woche", month: "Monat", quarter: "Quartal", year: "Jahr" },
},
},
},
{
id: "fr",
label: "Français",
locale: fr,
dir: "ltr",
i18n: {
labels: {
today: "Aujourd'hui",
scheduleHint: "Cliquer pour planifier",
reorder: "Réorganiser",
scales: { day: "Jour", week: "Semaine", month: "Mois", quarter: "Trimestre", year: "Année" },
},
},
},
{
id: "es",
label: "Español",
locale: es,
dir: "ltr",
i18n: {
labels: {
today: "Hoy",
scheduleHint: "Clic para programar",
reorder: "Reordenar",
scales: { day: "Día", week: "Semana", month: "Mes", quarter: "Trimestre", year: "Año" },
},
},
},
{
id: "ja",
label: "日本語",
locale: ja,
dir: "ltr",
i18n: {
labels: {
today: "今日",
scheduleHint: "クリックして予定を追加",
reorder: "並べ替え",
scales: { day: "日", week: "週", month: "月", quarter: "四半期", year: "年" },
},
},
},
{
id: "ar",
label: "العربية",
locale: ar,
dir: "rtl",
i18n: {
labels: {
today: "اليوم",
scheduleHint: "انقر لإضافة جدول",
reorder: "إعادة ترتيب",
scales: { day: "يوم", week: "أسبوع", month: "شهر", quarter: "ربع سنوي", year: "سنة" },
},
},
},
]
const TIME_ZONES: Array<{ id: string; label: string; value?: string }> = [
{ id: "local", label: "Browser", value: "UTC" },
{ id: "ny", label: "New York", value: "America/New_York" },
{ id: "london", label: "London", value: "Europe/London" },
{ id: "tokyo", label: "Tokyo", value: "Asia/Tokyo" },
{ id: "kolkata", label: "Kolkata", value: "Asia/Kolkata" },
]
const SETTINGS_DEFAULTS = {
rowCheckboxes: true,
summaryBars: true,
zoomControl: true,
offscreenIndicators: true,
infiniteScroll: false,
nowIndicator: true,
offDays: false,
dragCreate: true,
displayScheduleHint: true,
barLabel: "inside" as "inside" | "outside" | "auto",
timelineLines: "vertical" as "vertical" | "both" | "none",
interactions: { drag: true, resize: true, selectSlot: true } as GanttInteractions,
localeId: "en",
timeZoneId: "local",
}
const bars = buildBars(ANCHOR)
const events = ref<GanttEvent[]>(bars)
const settings = reactive({ ...SETTINGS_DEFAULTS })
const activeLocale = computed(() => LOCALES.find((entry) => entry.id === settings.localeId) ?? LOCALES[0]!)
const activeTimeZone = computed(() => TIME_ZONES.find((entry) => entry.id === settings.timeZoneId) ?? TIME_ZONES[0]!)
function resetSettings() {
Object.assign(settings, SETTINGS_DEFAULTS)
}
function canSelectSlot(slot: GanttSlotDraft) {
return !!slot.resourceId && !events.value.some((event) => event.resourceId === slot.resourceId)
}
function handleSelectSlot(slot: GanttSlotDraft) {
if (!slot.resourceId) return
events.value = [
...events.value,
{
id: `scheduled-${slot.resourceId}`,
title: RESOURCE_TITLES.get(slot.resourceId) ?? "New schedule",
start: slot.start,
end: slot.end,
allDay: true,
color: "var(--color-indigo-500)",
resourceId: slot.resourceId,
},
]
}
</script>
<template>
<div class="w-full p-4" :dir="activeLocale.dir">
<Card class="w-full py-0">
<CardContent class="p-0">
<Gantt
:date="ANCHOR"
initial-center="anchor"
:events="events"
:on-events-change="(next: GanttEvent[]) => (events = next)"
:resources="RESOURCES"
default-scale="month"
:locale="activeLocale.locale"
:i18n="activeLocale.i18n"
:time-zone="activeTimeZone.value"
:tree-panel="{ width: 200 }"
:row-checkboxes="settings.rowCheckboxes"
:summary-bars="settings.summaryBars"
:zoom-control="settings.zoomControl"
:offscreen-indicators="settings.offscreenIndicators"
:infinite-scroll="settings.infiniteScroll"
:now-indicator="settings.nowIndicator"
:off-days="settings.offDays"
:drag-create="settings.dragCreate"
:display-schedule-hint="settings.displayScheduleHint"
:bar-label="settings.barLabel"
:timeline-lines="settings.timelineLines"
:interactions="settings.interactions"
:on-interactions-change="(next: GanttInteractions) => (settings.interactions = next)"
:can-select-slot="canSelectSlot"
:on-select-slot="handleSelectSlot"
class="h-[520px] w-full"
>
<div class="flex flex-wrap items-center gap-2 border-b pe-3">
<GanttNav class="min-w-0 flex-1 border-b-0" />
<GanttToolbar>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M14 17H5" /><path d="M19 7h-9" /><circle cx="17" cy="17" r="3" /><circle cx="7" cy="7" r="3" /></svg>
Settings
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-80 p-0">
<Tabs default-value="display">
<div class="border-b p-2">
<TabsList class="grid w-full grid-cols-4">
<TabsTrigger value="display">Display</TabsTrigger>
<TabsTrigger value="behavior">Behavior</TabsTrigger>
<TabsTrigger value="style">Style</TabsTrigger>
<TabsTrigger value="region">Region</TabsTrigger>
</TabsList>
</div>
<TabsContent value="display" class="space-y-0.5 p-3">
<div class="flex items-center justify-between gap-4 py-1">
<Label for="gantt1-row-checkboxes" class="font-normal">Row checkboxes</Label>
<Switch id="gantt1-row-checkboxes" :model-value="settings.rowCheckboxes" @update:model-value="(v: boolean) => (settings.rowCheckboxes = v)" />
</div>
<div class="flex items-center justify-between gap-4 py-1">
<Label for="gantt1-summary-bars" class="font-normal">Summary bars</Label>
<Switch id="gantt1-summary-bars" :model-value="settings.summaryBars" @update:model-value="(v: boolean) => (settings.summaryBars = v)" />
</div>
<div class="flex items-center justify-between gap-4 py-1">
<Label for="gantt1-zoom-control" class="font-normal">Zoom control</Label>
<Switch id="gantt1-zoom-control" :model-value="settings.zoomControl" @update:model-value="(v: boolean) => (settings.zoomControl = v)" />
</div>
<div class="flex items-center justify-between gap-4 py-1">
<Label for="gantt1-offscreen-chips" class="font-normal">Off-screen chips</Label>
<Switch id="gantt1-offscreen-chips" :model-value="settings.offscreenIndicators" @update:model-value="(v: boolean) => (settings.offscreenIndicators = v)" />
</div>
<div class="flex items-center justify-between gap-4 py-1">
<Label for="gantt1-infinite-scroll" class="font-normal">Infinite scroll</Label>
<Switch id="gantt1-infinite-scroll" :model-value="settings.infiniteScroll" @update:model-value="(v: boolean) => (settings.infiniteScroll = v)" />
</div>
<div class="flex items-center justify-between gap-4 py-1">
<Label for="gantt1-now-indicator" class="font-normal">Now indicator</Label>
<Switch id="gantt1-now-indicator" :model-value="settings.nowIndicator" @update:model-value="(v: boolean) => (settings.nowIndicator = v)" />
</div>
<div class="flex items-center justify-between gap-4 py-1">
<Label for="gantt1-off-days" class="font-normal">Mark off days</Label>
<Switch id="gantt1-off-days" :model-value="settings.offDays" @update:model-value="(v: boolean) => (settings.offDays = v)" />
</div>
</TabsContent>
<TabsContent value="behavior" class="space-y-0.5 p-3">
<div class="flex items-center justify-between gap-4 py-1">
<Label for="gantt1-drag" class="font-normal">Drag to move</Label>
<Switch id="gantt1-drag" :model-value="settings.interactions.drag" @update:model-value="(v: boolean) => (settings.interactions = { ...settings.interactions, drag: v })" />
</div>
<div class="flex items-center justify-between gap-4 py-1">
<Label for="gantt1-resize" class="font-normal">Resize</Label>
<Switch id="gantt1-resize" :model-value="settings.interactions.resize" @update:model-value="(v: boolean) => (settings.interactions = { ...settings.interactions, resize: v })" />
</div>
<div class="flex items-center justify-between gap-4 py-1">
<Label for="gantt1-select-slot" class="font-normal">Select slot</Label>
<Switch id="gantt1-select-slot" :model-value="settings.interactions.selectSlot" @update:model-value="(v: boolean) => (settings.interactions = { ...settings.interactions, selectSlot: v })" />
</div>
<div class="flex items-center justify-between gap-4 py-1">
<Label for="gantt1-drag-create" class="font-normal">Drag to create</Label>
<Switch id="gantt1-drag-create" :model-value="settings.dragCreate" @update:model-value="(v: boolean) => (settings.dragCreate = v)" />
</div>
<div class="flex items-center justify-between gap-4 py-1">
<Label for="gantt1-schedule-hint" class="font-normal">Schedule hint</Label>
<Switch id="gantt1-schedule-hint" :model-value="settings.displayScheduleHint" @update:model-value="(v: boolean) => (settings.displayScheduleHint = v)" />
</div>
</TabsContent>
<TabsContent value="style" class="space-y-4 p-3">
<div class="space-y-1.5">
<div class="text-muted-foreground text-xs font-medium">Bar label</div>
<RadioGroup :model-value="settings.barLabel" @update:model-value="(v) => (settings.barLabel = v as typeof settings.barLabel)">
<div class="flex items-center gap-2 py-0.5"><RadioGroupItem value="inside" id="gantt1-label-inside" /><Label for="gantt1-label-inside" class="font-normal">Inside</Label></div>
<div class="flex items-center gap-2 py-0.5"><RadioGroupItem value="outside" id="gantt1-label-outside" /><Label for="gantt1-label-outside" class="font-normal">Outside</Label></div>
<div class="flex items-center gap-2 py-0.5"><RadioGroupItem value="auto" id="gantt1-label-auto" /><Label for="gantt1-label-auto" class="font-normal">Auto</Label></div>
</RadioGroup>
</div>
<div class="space-y-1.5">
<div class="text-muted-foreground text-xs font-medium">Grid lines</div>
<RadioGroup :model-value="settings.timelineLines" @update:model-value="(v) => (settings.timelineLines = v as typeof settings.timelineLines)">
<div class="flex items-center gap-2 py-0.5"><RadioGroupItem value="vertical" id="gantt1-lines-vertical" /><Label for="gantt1-lines-vertical" class="font-normal">Vertical</Label></div>
<div class="flex items-center gap-2 py-0.5"><RadioGroupItem value="both" id="gantt1-lines-both" /><Label for="gantt1-lines-both" class="font-normal">Both</Label></div>
<div class="flex items-center gap-2 py-0.5"><RadioGroupItem value="none" id="gantt1-lines-none" /><Label for="gantt1-lines-none" class="font-normal">None</Label></div>
</RadioGroup>
</div>
</TabsContent>
<TabsContent value="region" class="space-y-2 p-3">
<div class="flex items-center justify-between gap-4 py-1">
<Label for="gantt1-language" class="font-normal">Language</Label>
<Select :model-value="settings.localeId" @update:model-value="(v: string | number) => (settings.localeId = String(v))">
<SelectTrigger id="gantt1-language" size="sm" class="w-36"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="option in LOCALES" :key="option.id" :value="option.id">{{ option.label }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between gap-4 py-1">
<Label for="gantt1-timezone" class="font-normal">Time zone</Label>
<Select :model-value="settings.timeZoneId" @update:model-value="(v: string | number) => (settings.timeZoneId = String(v))">
<SelectTrigger id="gantt1-timezone" size="sm" class="w-36"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="option in TIME_ZONES" :key="option.id" :value="option.id">{{ option.label }}</SelectItem>
</SelectContent>
</Select>
</div>
<p class="text-muted-foreground text-xs leading-relaxed">
Language switches the date-fns locale, the scale names, and the week start. Time zone re-anchors every bar. Arabic also flips the chart to right-to-left.
</p>
</TabsContent>
</Tabs>
<div class="border-t p-2">
<Button variant="outline" size="sm" class="w-full" @click="resetSettings">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /></svg>
Reset to defaults
</Button>
</div>
</PopoverContent>
</Popover>
</GanttToolbar>
</div>
<GanttView />
</Gantt>
</CardContent>
</Card>
</div>
</template>