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>

Установка

npx shadcn-vue@latest add https://revueui.rootapi.dev/r/c-gantt-1.json

Зависимости реестра

npm-зависимости

  • date-fns

Источник: порт из ReUI (Keenthemes, MIT)