Skip to content

Commit c300e88

Browse files
authored
Merge pull request #302 from ShaerWare/feat/gantt-roadmap-view
feat: add Gantt roadmap view to Tasks tab
2 parents 2f14b86 + 5e7dd64 commit c300e88

9 files changed

Lines changed: 404 additions & 17 deletions

File tree

admin/package-lock.json

Lines changed: 7 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

admin/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"class-variance-authority": "^0.7.1",
2020
"clsx": "^2.1.1",
2121
"dompurify": "^3.3.1",
22+
"frappe-gantt": "^1.2.1",
2223
"lucide-vue-next": "^0.468.0",
2324
"marked": "^17.0.2",
2425
"pinia": "^2.3.0",

admin/src/assets/main.css

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@import '../../node_modules/frappe-gantt/dist/frappe-gantt.css';
2+
13
@tailwind base;
24
@tailwind components;
35
@tailwind utilities;
@@ -354,6 +356,87 @@
354356
border-radius: 0.5rem;
355357
}
356358

359+
/* frappe-gantt dark mode overrides */
360+
.dark {
361+
--g-header-background: hsl(222.2 84% 4.9%);
362+
--g-text-dark: hsl(210 40% 98%);
363+
--g-text-muted: hsl(215 20.2% 65.1%);
364+
--g-text-light: hsl(210 40% 98%);
365+
--g-border-color: hsl(217.2 32.6% 17.5%);
366+
--g-row-border-color: hsl(217.2 32.6% 17.5%);
367+
--g-row-color: hsl(222.2 84% 6%);
368+
--g-tick-color: hsl(217.2 32.6% 17.5%);
369+
--g-tick-color-thick: hsl(217.2 32.6% 22%);
370+
--g-bar-color: hsl(217.2 32.6% 17.5%);
371+
--g-bar-border: hsl(217.2 32.6% 22%);
372+
--g-progress-color: hsl(217.2 91.2% 59.8%);
373+
--g-arrow-color: hsl(215 20.2% 65.1%);
374+
--g-today-highlight: hsl(210 40% 98%);
375+
--g-weekend-highlight-color: hsl(217.2 32.6% 12%);
376+
--g-handle-color: hsl(210 40% 98%);
377+
--g-actions-background: hsl(222.2 84% 4.9%);
378+
--g-popup-actions: hsl(217.2 32.6% 17.5%);
379+
--g-expected-progress: hsl(217.2 32.6% 25%);
380+
}
381+
382+
/* frappe-gantt night-eyes mode overrides */
383+
.night-eyes {
384+
--g-header-background: hsl(25 20% 8%);
385+
--g-text-dark: hsl(30 25% 85%);
386+
--g-text-muted: hsl(30 15% 55%);
387+
--g-text-light: hsl(30 25% 85%);
388+
--g-border-color: hsl(25 15% 20%);
389+
--g-row-border-color: hsl(25 15% 20%);
390+
--g-row-color: hsl(25 18% 10%);
391+
--g-tick-color: hsl(25 15% 20%);
392+
--g-tick-color-thick: hsl(25 15% 25%);
393+
--g-bar-color: hsl(25 15% 18%);
394+
--g-bar-border: hsl(25 15% 22%);
395+
--g-progress-color: hsl(35 80% 55%);
396+
--g-arrow-color: hsl(30 15% 55%);
397+
--g-today-highlight: hsl(30 25% 85%);
398+
--g-weekend-highlight-color: hsl(25 15% 12%);
399+
--g-handle-color: hsl(30 25% 85%);
400+
--g-actions-background: hsl(25 20% 8%);
401+
--g-popup-actions: hsl(25 15% 18%);
402+
--g-expected-progress: hsl(25 15% 25%);
403+
}
404+
405+
/* frappe-gantt status-colored bars */
406+
.gantt-bar-draft .bar { fill: #a1a1aa; }
407+
.gantt-bar-draft .bar-progress { fill: #71717a; }
408+
.gantt-bar-todo .bar { fill: #3b82f6; }
409+
.gantt-bar-todo .bar-progress { fill: #2563eb; }
410+
.gantt-bar-in-progress .bar { fill: #f59e0b; }
411+
.gantt-bar-in-progress .bar-progress { fill: #d97706; }
412+
.gantt-bar-review .bar { fill: #a855f7; }
413+
.gantt-bar-review .bar-progress { fill: #9333ea; }
414+
.gantt-bar-done .bar { fill: #22c55e; }
415+
.gantt-bar-done .bar-progress { fill: #16a34a; }
416+
417+
/* frappe-gantt popup styling */
418+
.gantt-popup {
419+
padding: 0.5rem;
420+
font-size: 0.875rem;
421+
line-height: 1.4;
422+
}
423+
.gantt-popup-title {
424+
font-weight: 600;
425+
margin-bottom: 0.25rem;
426+
}
427+
.gantt-popup-dates {
428+
color: hsl(var(--muted-foreground));
429+
}
430+
.gantt-popup-progress {
431+
color: hsl(var(--muted-foreground));
432+
font-size: 0.75rem;
433+
}
434+
435+
/* Hide drag handles in readonly mode */
436+
.gantt-container[data-readonly] .handle-group {
437+
display: none;
438+
}
439+
357440
/* Reduced motion preference */
358441
@media (prefers-reduced-motion: reduce) {
359442
*,
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<script setup lang="ts">
2+
import { ref, watch, onMounted, nextTick, onBeforeUnmount } from 'vue'
3+
import { useI18n } from 'vue-i18n'
4+
import Gantt from 'frappe-gantt'
5+
// CSS imported in main.css to avoid exports resolution issues
6+
import type { GanttTask } from 'frappe-gantt'
7+
8+
const props = defineProps<{
9+
tasks: GanttTask[]
10+
loading: boolean
11+
disabled: boolean
12+
}>()
13+
14+
const emit = defineEmits<{
15+
clickTask: [taskId: number]
16+
dateChange: [taskId: number, startDate: string, endDate: string]
17+
}>()
18+
19+
const { t } = useI18n()
20+
const containerRef = ref<HTMLElement | null>(null)
21+
const viewMode = ref<'Day' | 'Week' | 'Month'>('Week')
22+
23+
let ganttInstance: Gantt | null = null
24+
25+
function formatDate(d: Date): string {
26+
const y = d.getFullYear()
27+
const m = String(d.getMonth() + 1).padStart(2, '0')
28+
const day = String(d.getDate()).padStart(2, '0')
29+
return `${y}-${m}-${day}`
30+
}
31+
32+
function initGantt() {
33+
if (!containerRef.value || props.tasks.length === 0) return
34+
35+
containerRef.value.innerHTML = ''
36+
37+
ganttInstance = new Gantt(containerRef.value, props.tasks, {
38+
view_mode: viewMode.value,
39+
date_format: 'YYYY-MM-DD',
40+
readonly: props.disabled,
41+
on_click: (task: GanttTask) => {
42+
emit('clickTask', Number(task.id))
43+
},
44+
on_date_change: (task: GanttTask, start: Date, end: Date) => {
45+
if (props.disabled) return
46+
emit('dateChange', Number(task.id), formatDate(start), formatDate(end))
47+
},
48+
custom_popup_html: (task: GanttTask) => {
49+
return `
50+
<div class="gantt-popup">
51+
<div class="gantt-popup-title">${task.name}</div>
52+
<div class="gantt-popup-dates">${task.start} &mdash; ${task.end}</div>
53+
<div class="gantt-popup-progress">${t('kanban.roadmap.progress')}: ${task.progress}%</div>
54+
</div>
55+
`
56+
},
57+
})
58+
}
59+
60+
onMounted(() => {
61+
nextTick(() => {
62+
if (props.tasks.length > 0) initGantt()
63+
})
64+
})
65+
66+
onBeforeUnmount(() => {
67+
ganttInstance = null
68+
})
69+
70+
watch(
71+
() => props.tasks,
72+
(newTasks) => {
73+
if (newTasks.length === 0) {
74+
if (containerRef.value) containerRef.value.innerHTML = ''
75+
ganttInstance = null
76+
return
77+
}
78+
if (ganttInstance) {
79+
ganttInstance.refresh(newTasks)
80+
} else {
81+
nextTick(() => initGantt())
82+
}
83+
},
84+
{ deep: true },
85+
)
86+
87+
watch(viewMode, (mode) => {
88+
if (ganttInstance) {
89+
ganttInstance.change_view_mode(mode)
90+
}
91+
})
92+
93+
watch(
94+
() => props.disabled,
95+
() => {
96+
nextTick(() => initGantt())
97+
},
98+
)
99+
</script>
100+
101+
<template>
102+
<div class="space-y-3">
103+
<!-- View mode toggle -->
104+
<div class="flex items-center justify-between">
105+
<div class="flex rounded-lg border border-border overflow-hidden">
106+
<button
107+
v-for="mode in (['Day', 'Week', 'Month'] as const)"
108+
:key="mode"
109+
class="px-3 py-1.5 text-sm font-medium transition-colors"
110+
:class="
111+
viewMode === mode
112+
? 'bg-primary text-primary-foreground'
113+
: 'bg-card text-muted-foreground hover:bg-muted'
114+
"
115+
@click="viewMode = mode"
116+
>
117+
{{ t(`kanban.roadmap.${mode.toLowerCase()}`) }}
118+
</button>
119+
</div>
120+
</div>
121+
122+
<!-- Gantt container -->
123+
<div
124+
v-if="tasks.length > 0"
125+
ref="containerRef"
126+
class="gantt-container rounded-lg border border-border overflow-auto"
127+
/>
128+
129+
<!-- Empty state -->
130+
<div
131+
v-else-if="!loading"
132+
class="flex flex-col items-center justify-center py-16 text-muted-foreground"
133+
>
134+
<p class="text-lg">{{ t('kanban.roadmap.noTasks') }}</p>
135+
</div>
136+
137+
<!-- Loading state -->
138+
<div v-if="loading" class="flex items-center justify-center py-16">
139+
<div class="animate-spin w-6 h-6 border-2 border-primary border-t-transparent rounded-full" />
140+
</div>
141+
</div>
142+
</template>
143+
144+
<style scoped>
145+
.gantt-container {
146+
height: calc(100vh - 14rem);
147+
min-height: 400px;
148+
}
149+
</style>

0 commit comments

Comments
 (0)