feat: restore jira-style gantt board with responsive timeline UI
This commit is contained in:
parent
db188bec80
commit
ae11914a39
1578
package-lock.json
generated
1578
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@ -9,9 +9,21 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@types/frappe-gantt": "^0.9.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"firebase": "^12.9.0",
|
||||||
|
"framer-motion": "^12.34.1",
|
||||||
|
"frappe-gantt": "^1.2.1",
|
||||||
|
"lucide-react": "^0.574.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
|
"tailwind-merge": "^3.4.1",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
1
src/app/frappe-gantt.css
Normal file
1
src/app/frappe-gantt.css
Normal file
File diff suppressed because one or more lines are too long
@ -1,26 +1,122 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "./frappe-gantt.css";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--app-bg: #f3f6fb;
|
||||||
--foreground: #171717;
|
--app-surface: #ffffff;
|
||||||
|
--app-surface-soft: #eef3fa;
|
||||||
|
--app-border: #b9cbe0;
|
||||||
|
--app-text: #0f172a;
|
||||||
|
--app-muted: #334155;
|
||||||
|
--app-accent: #0369a1;
|
||||||
|
--app-accent-strong: #075985;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--app-bg);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--app-text);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-body);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-code);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
--app-bg: #0f172a;
|
||||||
--foreground: #ededed;
|
--app-surface: #111827;
|
||||||
|
--app-surface-soft: #172034;
|
||||||
|
--app-border: #334155;
|
||||||
|
--app-text: #e5e7eb;
|
||||||
|
--app-muted: #cbd5e1;
|
||||||
|
--app-accent: #38bdf8;
|
||||||
|
--app-accent-strong: #0ea5e9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
min-height: 100vh;
|
||||||
color: var(--foreground);
|
background:
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
radial-gradient(circle at 15% -20%, rgba(56, 189, 248, 0.18), transparent 35%),
|
||||||
|
radial-gradient(circle at 90% 20%, rgba(14, 165, 233, 0.12), transparent 30%),
|
||||||
|
var(--app-bg);
|
||||||
|
color: var(--app-text);
|
||||||
|
font-family: var(--font-body), sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
font-family: var(--font-heading), sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:not([type='range']):not([type='checkbox']),
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
background-color: var(--app-surface);
|
||||||
|
color: var(--app-text);
|
||||||
|
border-color: var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: color-mix(in srgb, var(--app-muted) 75%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='date'] {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--app-accent) 65%, white);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
animation-duration: 1ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 1ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-wrapper.task-backlog .bar {
|
||||||
|
fill: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-wrapper.task-in-progress .bar {
|
||||||
|
fill: #0ea5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-wrapper.task-blocked .bar {
|
||||||
|
fill: #fb7185;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-wrapper.task-done .bar {
|
||||||
|
fill: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-shell,
|
||||||
|
.gantt-shell .gantt-container {
|
||||||
|
background-color: var(--app-surface) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-shell .gantt .grid-background {
|
||||||
|
fill: var(--app-surface) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-shell .gantt-container .grid-header {
|
||||||
|
background-color: var(--app-surface) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +1,37 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from 'next'
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { JetBrains_Mono, Lexend, Source_Sans_3 } from 'next/font/google'
|
||||||
import "./globals.css";
|
import './globals.css'
|
||||||
|
|
||||||
const geistSans = Geist({
|
const headingFont = Lexend({
|
||||||
variable: "--font-geist-sans",
|
variable: '--font-heading',
|
||||||
subsets: ["latin"],
|
subsets: ['latin'],
|
||||||
});
|
})
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const bodyFont = Source_Sans_3({
|
||||||
variable: "--font-geist-mono",
|
variable: '--font-body',
|
||||||
subsets: ["latin"],
|
subsets: ['latin'],
|
||||||
});
|
})
|
||||||
|
|
||||||
|
const monoFont = JetBrains_Mono({
|
||||||
|
variable: '--font-code',
|
||||||
|
subsets: ['latin'],
|
||||||
|
})
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: 'OpenClaw Gantt Board',
|
||||||
description: "Generated by create next app",
|
description: 'Project timeline and execution workspace',
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body className={`${headingFont.variable} ${bodyFont.variable} ${monoFont.variable} antialiased`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
785
src/app/page.tsx
785
src/app/page.tsx
@ -1,65 +1,730 @@
|
|||||||
import Image from "next/image";
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
AlertTriangle,
|
||||||
|
CalendarDays,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock3,
|
||||||
|
LayoutList,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { GanttChart } from '@/components/ui/GanttChart'
|
||||||
|
import { type TaskPriority, type TaskStatus, useGanttStore } from '@/stores/useGanttStore'
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: Array<{ value: TaskStatus; label: string }> = [
|
||||||
|
{ value: 'backlog', label: 'Backlog' },
|
||||||
|
{ value: 'in-progress', label: 'In Progress' },
|
||||||
|
{ value: 'blocked', label: 'Blocked' },
|
||||||
|
{ value: 'done', label: 'Done' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PRIORITY_OPTIONS: Array<{ value: TaskPriority; label: string }> = [
|
||||||
|
{ value: 'low', label: 'Low' },
|
||||||
|
{ value: 'medium', label: 'Medium' },
|
||||||
|
{ value: 'high', label: 'High' },
|
||||||
|
{ value: 'critical', label: 'Critical' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const VIEW_MODE_OPTIONS = ['Day', 'Week', 'Month', 'Year'] as const
|
||||||
|
|
||||||
|
function addDays(dateISO: string, days: number): string {
|
||||||
|
const date = new Date(dateISO)
|
||||||
|
date.setDate(date.getDate() + days)
|
||||||
|
return date.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(progress: number, status?: string): TaskStatus {
|
||||||
|
if (status === 'backlog' || status === 'in-progress' || status === 'blocked' || status === 'done') {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
if (progress >= 100) return 'done'
|
||||||
|
if (progress > 0) return 'in-progress'
|
||||||
|
return 'backlog'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePriority(priority?: string): TaskPriority {
|
||||||
|
if (priority === 'low' || priority === 'medium' || priority === 'high' || priority === 'critical') {
|
||||||
|
return priority
|
||||||
|
}
|
||||||
|
return 'medium'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status: TaskStatus): string {
|
||||||
|
if (status === 'done') return 'bg-emerald-100 text-emerald-700'
|
||||||
|
if (status === 'blocked') return 'bg-rose-100 text-rose-700'
|
||||||
|
if (status === 'in-progress') return 'bg-sky-100 text-sky-700'
|
||||||
|
return 'bg-slate-100 text-slate-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityTone(priority: TaskPriority): string {
|
||||||
|
if (priority === 'critical') return 'bg-rose-100 text-rose-700'
|
||||||
|
if (priority === 'high') return 'bg-amber-100 text-amber-700'
|
||||||
|
if (priority === 'medium') return 'bg-sky-100 text-sky-700'
|
||||||
|
return 'bg-slate-100 text-slate-700'
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const { projects, tasks, addProject, addTask, updateTask, deleteTask } = useGanttStore()
|
||||||
|
|
||||||
|
const [selectedProject, setSelectedProject] = useState(projects[0] ?? '')
|
||||||
|
const [newProjectName, setNewProjectName] = useState('')
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'all' | TaskStatus>('all')
|
||||||
|
const [viewMode, setViewMode] = useState<(typeof VIEW_MODE_OPTIONS)[number]>('Week')
|
||||||
|
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
const [taskName, setTaskName] = useState('')
|
||||||
|
const [taskAssignee, setTaskAssignee] = useState('')
|
||||||
|
const [taskStatus, setTaskStatus] = useState<TaskStatus>('backlog')
|
||||||
|
const [taskPriority, setTaskPriority] = useState<TaskPriority>('medium')
|
||||||
|
|
||||||
|
const todayISO = new Date().toISOString().slice(0, 10)
|
||||||
|
const [taskStart, setTaskStart] = useState(todayISO)
|
||||||
|
const [taskEnd, setTaskEnd] = useState(addDays(todayISO, 2))
|
||||||
|
|
||||||
|
const activeProject = projects.includes(selectedProject) ? selectedProject : (projects[0] ?? '')
|
||||||
|
|
||||||
|
const projectTasks = useMemo(
|
||||||
|
() =>
|
||||||
|
tasks
|
||||||
|
.filter((task) => task.project === activeProject)
|
||||||
|
.map((task) => ({
|
||||||
|
...task,
|
||||||
|
assignee: task.assignee?.trim() || 'Unassigned',
|
||||||
|
status: normalizeStatus(task.progress, task.status),
|
||||||
|
priority: normalizePriority(task.priority),
|
||||||
|
})),
|
||||||
|
[tasks, activeProject]
|
||||||
|
)
|
||||||
|
|
||||||
|
const taskIndexById = useMemo(
|
||||||
|
() => new Map(projectTasks.map((task, index) => [task.id, index + 1])),
|
||||||
|
[projectTasks]
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredTasks = useMemo(
|
||||||
|
() =>
|
||||||
|
projectTasks.filter((task) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!searchQuery ||
|
||||||
|
task.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
task.assignee.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
|
||||||
|
const matchesStatus = statusFilter === 'all' || task.status === statusFilter
|
||||||
|
return matchesSearch && matchesStatus
|
||||||
|
}),
|
||||||
|
[projectTasks, searchQuery, statusFilter]
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedVisibleTaskIds = useMemo(
|
||||||
|
() => selectedTaskIds.filter((id) => filteredTasks.some((task) => task.id === id)),
|
||||||
|
[selectedTaskIds, filteredTasks]
|
||||||
|
)
|
||||||
|
|
||||||
|
const metrics = useMemo(() => {
|
||||||
|
const total = projectTasks.length
|
||||||
|
const done = projectTasks.filter((task) => task.status === 'done').length
|
||||||
|
const blocked = projectTasks.filter((task) => task.status === 'blocked').length
|
||||||
|
const overdue = projectTasks.filter((task) => task.end < todayISO && task.status !== 'done').length
|
||||||
|
const avgProgress =
|
||||||
|
total === 0 ? 0 : Math.round(projectTasks.reduce((sum, task) => sum + task.progress, 0) / total)
|
||||||
|
|
||||||
|
return { total, done, blocked, overdue, avgProgress }
|
||||||
|
}, [projectTasks, todayISO])
|
||||||
|
|
||||||
|
const isAllVisibleSelected =
|
||||||
|
filteredTasks.length > 0 && filteredTasks.every((task) => selectedVisibleTaskIds.includes(task.id))
|
||||||
|
|
||||||
|
const projectCode =
|
||||||
|
activeProject
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join('')
|
||||||
|
.slice(0, 4)
|
||||||
|
.toUpperCase() || 'PRJ'
|
||||||
|
|
||||||
|
const handleCreateProject = () => {
|
||||||
|
const trimmed = newProjectName.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
addProject(trimmed)
|
||||||
|
setSelectedProject(trimmed)
|
||||||
|
setNewProjectName('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateTask = () => {
|
||||||
|
const trimmed = taskName.trim()
|
||||||
|
if (!trimmed || !activeProject) return
|
||||||
|
|
||||||
|
const normalizedEnd = taskEnd < taskStart ? taskStart : taskEnd
|
||||||
|
const initialProgress = taskStatus === 'done' ? 100 : taskStatus === 'in-progress' ? 35 : 0
|
||||||
|
|
||||||
|
addTask({
|
||||||
|
name: trimmed,
|
||||||
|
assignee: taskAssignee.trim() || 'Unassigned',
|
||||||
|
status: taskStatus,
|
||||||
|
priority: taskPriority,
|
||||||
|
start: taskStart,
|
||||||
|
end: normalizedEnd,
|
||||||
|
progress: initialProgress,
|
||||||
|
dependencies: [],
|
||||||
|
project: activeProject,
|
||||||
|
})
|
||||||
|
|
||||||
|
setTaskName('')
|
||||||
|
setTaskAssignee('')
|
||||||
|
setTaskStart(todayISO)
|
||||||
|
setTaskEnd(addDays(todayISO, 2))
|
||||||
|
setTaskStatus('backlog')
|
||||||
|
setTaskPriority('medium')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleSelection = (taskId: string, checked: boolean) => {
|
||||||
|
setSelectedTaskIds((current) => {
|
||||||
|
if (checked) return Array.from(new Set([...current, taskId]))
|
||||||
|
return current.filter((id) => id !== taskId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleAllVisible = (checked: boolean) => {
|
||||||
|
setSelectedTaskIds((current) => {
|
||||||
|
const visibleIds = filteredTasks.map((task) => task.id)
|
||||||
|
if (checked) return Array.from(new Set([...current, ...visibleIds]))
|
||||||
|
return current.filter((id) => !visibleIds.includes(id))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkStatus = (status: TaskStatus) => {
|
||||||
|
selectedVisibleTaskIds.forEach((taskId) => {
|
||||||
|
const updates: { status: TaskStatus; progress?: number } = { status }
|
||||||
|
if (status === 'done') updates.progress = 100
|
||||||
|
if (status === 'backlog') updates.progress = 0
|
||||||
|
updateTask(taskId, updates)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkDelete = () => {
|
||||||
|
selectedVisibleTaskIds.forEach((taskId) => deleteTask(taskId))
|
||||||
|
setSelectedTaskIds((current) => current.filter((id) => !selectedVisibleTaskIds.includes(id)))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<main className="relative min-h-screen overflow-x-hidden bg-[var(--app-bg)] text-[var(--app-text)]">
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<div className="pointer-events-none absolute -left-28 -top-28 h-80 w-80 rounded-full bg-sky-300/25 blur-3xl" />
|
||||||
<Image
|
<div className="pointer-events-none absolute -right-32 top-28 h-80 w-80 rounded-full bg-cyan-200/35 blur-3xl" />
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
<div className="relative mx-auto w-full max-w-[1500px] p-4 md:p-6">
|
||||||
alt="Next.js logo"
|
<div className="flex flex-col gap-4 xl:flex-row">
|
||||||
width={100}
|
<aside className="hidden w-80 shrink-0 rounded-2xl border border-[var(--app-border)] bg-[var(--app-surface)] p-4 shadow-[0_24px_48px_-32px_rgba(2,6,23,0.35)] xl:flex xl:flex-col xl:gap-4">
|
||||||
height={20}
|
<div>
|
||||||
priority
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--app-muted)]">OpenClaw</p>
|
||||||
/>
|
<h2 className="mt-1 font-[family-name:var(--font-heading)] text-2xl font-semibold">Delivery Command</h2>
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
</div>
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
<div className="rounded-xl border border-[var(--app-border)] bg-[var(--app-surface-soft)] p-3">
|
||||||
</h1>
|
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
Create Project
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
</label>
|
||||||
<a
|
<div className="flex gap-2">
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<input
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
value={newProjectName}
|
||||||
>
|
onChange={(event) => setNewProjectName(event.target.value)}
|
||||||
Templates
|
placeholder="e.g. Mobile Revamp"
|
||||||
</a>{" "}
|
className="h-11 w-full rounded-lg border border-[var(--app-border)] bg-white px-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
|
||||||
or the{" "}
|
/>
|
||||||
<a
|
<button
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
type="button"
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
onClick={handleCreateProject}
|
||||||
>
|
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg bg-[var(--app-accent)] text-white transition hover:bg-[var(--app-accent-strong)]"
|
||||||
Learning
|
aria-label="Add project"
|
||||||
</a>{" "}
|
>
|
||||||
center.
|
<Plus className="h-4 w-4" />
|
||||||
</p>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Projects</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{projects.map((project) => {
|
||||||
|
const taskCount = tasks.filter((task) => task.project === project).length
|
||||||
|
const active = project === activeProject
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={project}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedProject(project)}
|
||||||
|
className={`flex w-full cursor-pointer items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition ${
|
||||||
|
active
|
||||||
|
? 'border-sky-300 bg-sky-50 text-sky-900'
|
||||||
|
: 'border-transparent hover:border-[var(--app-border)] hover:bg-[var(--app-surface-soft)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{project}</span>
|
||||||
|
<span className="rounded bg-white px-2 py-0.5 text-xs text-[var(--app-muted)]">{taskCount}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Status Filter</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
className={`h-10 cursor-pointer rounded-lg border text-xs font-semibold transition ${
|
||||||
|
statusFilter === 'all'
|
||||||
|
? 'border-sky-300 bg-sky-50 text-sky-700'
|
||||||
|
: 'border-[var(--app-border)] bg-white text-[var(--app-muted)] hover:bg-[var(--app-surface-soft)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{STATUS_OPTIONS.map((status) => (
|
||||||
|
<button
|
||||||
|
key={status.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter(status.value)}
|
||||||
|
className={`h-10 cursor-pointer rounded-lg border text-xs font-semibold transition ${
|
||||||
|
statusFilter === status.value
|
||||||
|
? 'border-sky-300 bg-sky-50 text-sky-700'
|
||||||
|
: 'border-[var(--app-border)] bg-white text-[var(--app-muted)] hover:bg-[var(--app-surface-soft)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedVisibleTaskIds.length > 0 && (
|
||||||
|
<div className="space-y-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-surface-soft)] p-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">
|
||||||
|
Bulk Actions ({selectedVisibleTaskIds.length})
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleBulkStatus('in-progress')}
|
||||||
|
className="h-10 flex-1 cursor-pointer rounded-lg bg-sky-600 px-3 text-xs font-semibold text-white transition hover:bg-sky-700"
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleBulkStatus('done')}
|
||||||
|
className="h-10 flex-1 cursor-pointer rounded-lg bg-emerald-600 px-3 text-xs font-semibold text-white transition hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBulkDelete}
|
||||||
|
className="inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-lg bg-rose-100 text-rose-700 transition hover:bg-rose-200"
|
||||||
|
aria-label="Delete selected tasks"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="min-w-0 flex-1 space-y-4">
|
||||||
|
<header className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-surface)] p-4 shadow-[0_24px_48px_-32px_rgba(2,6,23,0.35)] md:p-5">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--app-muted)]">
|
||||||
|
Jira-style Execution Board
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 font-[family-name:var(--font-heading)] text-2xl font-semibold tracking-tight">
|
||||||
|
{activeProject || 'Create a project to begin'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:flex-wrap lg:ml-auto lg:max-w-3xl">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--app-muted)]" />
|
||||||
|
<input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
placeholder="Search issue summary or assignee"
|
||||||
|
className="h-11 w-full rounded-lg border border-[var(--app-border)] bg-white pl-10 pr-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={activeProject}
|
||||||
|
onChange={(event) => setSelectedProject(event.target.value)}
|
||||||
|
className="h-11 rounded-lg border border-[var(--app-border)] bg-white px-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 xl:hidden"
|
||||||
|
>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<option key={project} value={project}>
|
||||||
|
{project}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="grid h-11 grid-cols-4 rounded-lg border border-[var(--app-border)] bg-white p-1">
|
||||||
|
{VIEW_MODE_OPTIONS.map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode(mode)}
|
||||||
|
aria-pressed={viewMode === mode}
|
||||||
|
className={`h-full cursor-pointer rounded-md px-2 text-xs font-semibold transition ${
|
||||||
|
viewMode === mode
|
||||||
|
? 'bg-slate-900 text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.15)]'
|
||||||
|
: 'text-slate-700 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{mode}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<article className="rounded-xl border border-[var(--app-border)] bg-[var(--app-surface)] p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Completion</span>
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<p className="font-[family-name:var(--font-heading)] text-3xl font-semibold leading-none">{metrics.avgProgress}%</p>
|
||||||
|
<p className="mt-2 text-sm text-[var(--app-muted)]">
|
||||||
|
{metrics.done} / {metrics.total} issues done
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-xl border border-[var(--app-border)] bg-[var(--app-surface)] p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Blocked</span>
|
||||||
|
<AlertTriangle className="h-4 w-4 text-rose-600" />
|
||||||
|
</div>
|
||||||
|
<p className="font-[family-name:var(--font-heading)] text-3xl font-semibold leading-none">{metrics.blocked}</p>
|
||||||
|
<p className="mt-2 text-sm text-[var(--app-muted)]">Needs unblock action</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-xl border border-[var(--app-border)] bg-[var(--app-surface)] p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Overdue</span>
|
||||||
|
<Clock3 className="h-4 w-4 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<p className="font-[family-name:var(--font-heading)] text-3xl font-semibold leading-none">{metrics.overdue}</p>
|
||||||
|
<p className="mt-2 text-sm text-[var(--app-muted)]">Past target date</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="rounded-xl border border-[var(--app-border)] bg-[var(--app-surface)] p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">In Scope</span>
|
||||||
|
<Activity className="h-4 w-4 text-sky-600" />
|
||||||
|
</div>
|
||||||
|
<p className="font-[family-name:var(--font-heading)] text-3xl font-semibold leading-none">{filteredTasks.length}</p>
|
||||||
|
<p className="mt-2 text-sm text-[var(--app-muted)]">Matching active filters</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-surface)] p-4 md:p-5">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<Plus className="h-4 w-4 text-[var(--app-accent)]" />
|
||||||
|
<h2 className="font-[family-name:var(--font-heading)] text-lg font-semibold">Create Issue</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="min-w-0 block">
|
||||||
|
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Summary</span>
|
||||||
|
<input
|
||||||
|
value={taskName}
|
||||||
|
onChange={(event) => setTaskName(event.target.value)}
|
||||||
|
placeholder="Add issue title"
|
||||||
|
className="h-11 w-full rounded-lg border border-[var(--app-border)] bg-white px-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<label className="min-w-0">
|
||||||
|
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Assignee</span>
|
||||||
|
<input
|
||||||
|
value={taskAssignee}
|
||||||
|
onChange={(event) => setTaskAssignee(event.target.value)}
|
||||||
|
placeholder="Unassigned"
|
||||||
|
className="h-11 w-full rounded-lg border border-[var(--app-border)] bg-white px-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="min-w-0">
|
||||||
|
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Status</span>
|
||||||
|
<select
|
||||||
|
value={taskStatus}
|
||||||
|
onChange={(event) => setTaskStatus(event.target.value as TaskStatus)}
|
||||||
|
className="h-11 w-full rounded-lg border border-[var(--app-border)] bg-white px-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((status) => (
|
||||||
|
<option key={status.value} value={status.value}>
|
||||||
|
{status.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="min-w-0">
|
||||||
|
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Priority</span>
|
||||||
|
<select
|
||||||
|
value={taskPriority}
|
||||||
|
onChange={(event) => setTaskPriority(event.target.value as TaskPriority)}
|
||||||
|
className="h-11 w-full rounded-lg border border-[var(--app-border)] bg-white px-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
|
||||||
|
>
|
||||||
|
{PRIORITY_OPTIONS.map((priority) => (
|
||||||
|
<option key={priority.value} value={priority.value}>
|
||||||
|
{priority.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<label className="min-w-[220px] max-w-[260px] flex-1">
|
||||||
|
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">Start</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={taskStart}
|
||||||
|
onChange={(event) => setTaskStart(event.target.value)}
|
||||||
|
className="h-11 w-full rounded-lg border border-[var(--app-border)] bg-white px-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 [min-width:0]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="min-w-[220px] max-w-[260px] flex-1">
|
||||||
|
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.12em] text-[var(--app-muted)]">End</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={taskEnd}
|
||||||
|
onChange={(event) => setTaskEnd(event.target.value)}
|
||||||
|
className="h-11 w-full rounded-lg border border-[var(--app-border)] bg-white px-3 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 [min-width:0]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreateTask}
|
||||||
|
disabled={!taskName.trim() || !activeProject}
|
||||||
|
className="inline-flex h-11 cursor-pointer items-center gap-2 rounded-lg bg-[var(--app-accent)] px-4 text-sm font-semibold text-white transition hover:bg-[var(--app-accent-strong)] disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create Issue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid gap-4 2xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
|
||||||
|
<section className="min-w-0 rounded-2xl border border-[var(--app-border)] bg-[var(--app-surface)]">
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--app-border)] px-4 py-3">
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<LayoutList className="h-4 w-4 text-[var(--app-accent)]" />
|
||||||
|
<h2 className="font-[family-name:var(--font-heading)] text-lg font-semibold">Issue Matrix</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedVisibleTaskIds.length > 0 && (
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleBulkStatus('done')}
|
||||||
|
className="h-9 cursor-pointer rounded-lg bg-emerald-600 px-3 text-xs font-semibold text-white transition hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
Mark Done
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBulkDelete}
|
||||||
|
className="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg bg-rose-100 text-rose-700 transition hover:bg-rose-200"
|
||||||
|
aria-label="Delete selected tasks"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto overflow-y-hidden">
|
||||||
|
<table className="min-w-[860px] text-sm md:min-w-[960px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[var(--app-border)] bg-[var(--app-surface-soft)] text-left text-xs uppercase tracking-[0.12em] text-[var(--app-muted)]">
|
||||||
|
<th className="px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isAllVisibleSelected}
|
||||||
|
onChange={(event) => handleToggleAllVisible(event.target.checked)}
|
||||||
|
className="h-4 w-4 cursor-pointer rounded border-[var(--app-border)]"
|
||||||
|
aria-label="Select all visible issues"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2">Issue</th>
|
||||||
|
<th className="px-3 py-2">Status</th>
|
||||||
|
<th className="px-3 py-2">Priority</th>
|
||||||
|
<th className="px-3 py-2">Assignee</th>
|
||||||
|
<th className="px-3 py-2">Dates</th>
|
||||||
|
<th className="px-3 py-2">Progress</th>
|
||||||
|
<th className="px-3 py-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredTasks.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-3 py-12 text-center text-sm text-[var(--app-muted)]">
|
||||||
|
No issues match the current filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredTasks.map((task) => {
|
||||||
|
const issueNumber = taskIndexById.get(task.id) ?? 0
|
||||||
|
const issueKey = `${projectCode}-${issueNumber}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={task.id} className="border-b border-[var(--app-border)] hover:bg-[var(--app-surface-soft)]">
|
||||||
|
<td className="px-3 py-3 align-top">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedTaskIds.includes(task.id)}
|
||||||
|
onChange={(event) => handleToggleSelection(task.id, event.target.checked)}
|
||||||
|
className="h-4 w-4 cursor-pointer rounded border-[var(--app-border)]"
|
||||||
|
aria-label={`Select ${task.name}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<p className="font-mono text-xs text-[var(--app-muted)]">{issueKey}</p>
|
||||||
|
<p className="mt-1 font-medium text-[var(--app-text)]">{task.name}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<select
|
||||||
|
value={task.status}
|
||||||
|
onChange={(event) => updateTask(task.id, { status: event.target.value as TaskStatus })}
|
||||||
|
className={`h-10 w-full cursor-pointer rounded-lg border border-transparent px-2 text-sm font-semibold outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 ${statusTone(task.status)}`}
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((status) => (
|
||||||
|
<option key={status.value} value={status.value}>
|
||||||
|
{status.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<select
|
||||||
|
value={task.priority}
|
||||||
|
onChange={(event) => updateTask(task.id, { priority: event.target.value as TaskPriority })}
|
||||||
|
className={`h-10 w-full cursor-pointer rounded-lg border border-transparent px-2 text-sm font-semibold outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 ${priorityTone(task.priority)}`}
|
||||||
|
>
|
||||||
|
{PRIORITY_OPTIONS.map((priority) => (
|
||||||
|
<option key={priority.value} value={priority.value}>
|
||||||
|
{priority.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<input
|
||||||
|
value={task.assignee}
|
||||||
|
onChange={(event) => updateTask(task.id, { assignee: event.target.value })}
|
||||||
|
className="h-10 w-full rounded-lg border border-[var(--app-border)] bg-white px-2 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<div className="grid w-36 max-w-full gap-1">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={task.start}
|
||||||
|
onChange={(event) => updateTask(task.id, { start: event.target.value })}
|
||||||
|
className="h-9 w-full rounded-lg border border-[var(--app-border)] bg-white px-2 text-xs outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 [min-width:0]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={task.end}
|
||||||
|
onChange={(event) => updateTask(task.id, { end: event.target.value })}
|
||||||
|
className="h-9 w-full rounded-lg border border-[var(--app-border)] bg-white px-2 text-xs outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 [min-width:0]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<div className="min-w-36">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={task.progress}
|
||||||
|
onChange={(event) => updateTask(task.id, { progress: Number(event.target.value) })}
|
||||||
|
className="h-2 w-full cursor-pointer accent-sky-600"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs font-semibold text-[var(--app-muted)]">{task.progress}%</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 align-top">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
deleteTask(task.id)
|
||||||
|
setSelectedTaskIds((current) => current.filter((id) => id !== task.id))
|
||||||
|
}}
|
||||||
|
className="inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-lg text-rose-700 transition hover:bg-rose-100"
|
||||||
|
aria-label={`Delete ${task.name}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="min-w-0 rounded-2xl border border-[var(--app-border)] bg-[var(--app-surface)]">
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--app-border)] px-4 py-3">
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<CalendarDays className="h-4 w-4 text-[var(--app-accent)]" />
|
||||||
|
<h2 className="font-[family-name:var(--font-heading)] text-lg font-semibold">Timeline</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-slate-100 px-2 py-1 font-semibold text-slate-700">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-slate-500" />
|
||||||
|
Backlog
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-sky-100 px-2 py-1 font-semibold text-sky-700">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-sky-500" />
|
||||||
|
In Progress
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-rose-100 px-2 py-1 font-semibold text-rose-700">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-rose-500" />
|
||||||
|
Blocked
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-emerald-100 px-2 py-1 font-semibold text-emerald-700">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
Done
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{activeProject ? (
|
||||||
|
<GanttChart project={activeProject} viewMode={viewMode} />
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-10 text-center text-sm text-[var(--app-muted)]">Create a project to see timeline data.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
</div>
|
||||||
<a
|
</main>
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
)
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
85
src/components/ui/GanttChart.tsx
Normal file
85
src/components/ui/GanttChart.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { type TaskStatus, useGanttStore } from '@/stores/useGanttStore'
|
||||||
|
|
||||||
|
interface GanttChartProps {
|
||||||
|
project: string
|
||||||
|
viewMode?: 'Day' | 'Week' | 'Month' | 'Year'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateString(value: string | Date): string {
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
return value.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskStatus(progress: number, status?: string): TaskStatus {
|
||||||
|
if (status === 'backlog' || status === 'in-progress' || status === 'blocked' || status === 'done') {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
if (progress >= 100) return 'done'
|
||||||
|
if (progress > 0) return 'in-progress'
|
||||||
|
return 'backlog'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GanttChart({ project, viewMode = 'Week' }: GanttChartProps) {
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { tasks, updateTask } = useGanttStore()
|
||||||
|
|
||||||
|
const projectTasks = tasks.filter(task => task.project === project)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chartRef.current || projectTasks.length === 0) {
|
||||||
|
if (chartRef.current) chartRef.current.innerHTML = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false
|
||||||
|
const container = chartRef.current
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
const { default: FrappeGantt } = await import('frappe-gantt')
|
||||||
|
if (disposed) return
|
||||||
|
|
||||||
|
container.innerHTML = ''
|
||||||
|
|
||||||
|
new FrappeGantt(
|
||||||
|
container,
|
||||||
|
projectTasks.map(task => ({
|
||||||
|
id: task.id,
|
||||||
|
name: task.name,
|
||||||
|
start: task.start,
|
||||||
|
end: task.end,
|
||||||
|
progress: task.progress,
|
||||||
|
dependencies: task.dependencies,
|
||||||
|
custom_class: `task-${getTaskStatus(task.progress, task.status)}`
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
on_date_change: (task, start, end) => {
|
||||||
|
if (!task.id) return
|
||||||
|
updateTask(task.id, { start: toDateString(start), end: toDateString(end) })
|
||||||
|
},
|
||||||
|
on_progress_change: (task, progress) => {
|
||||||
|
if (!task.id) return
|
||||||
|
updateTask(task.id, { progress })
|
||||||
|
},
|
||||||
|
view_mode: viewMode,
|
||||||
|
bar_height: 30,
|
||||||
|
bar_corner_radius: 3,
|
||||||
|
container_height: 560,
|
||||||
|
padding: 18,
|
||||||
|
date_format: 'YYYY-MM-DD'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
container.innerHTML = ''
|
||||||
|
}
|
||||||
|
}, [projectTasks, project, updateTask, viewMode])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={chartRef} className="gantt-shell w-full h-[600px] border rounded-lg overflow-auto" />
|
||||||
|
)
|
||||||
|
}
|
||||||
118
src/stores/useGanttStore.ts
Normal file
118
src/stores/useGanttStore.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
export type TaskStatus = 'backlog' | 'in-progress' | 'blocked' | 'done'
|
||||||
|
export type TaskPriority = 'low' | 'medium' | 'high' | 'critical'
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
progress: number
|
||||||
|
dependencies: string[]
|
||||||
|
project: string
|
||||||
|
assignee: string
|
||||||
|
status: TaskStatus
|
||||||
|
priority: TaskPriority
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GanttStore {
|
||||||
|
projects: string[]
|
||||||
|
tasks: Task[]
|
||||||
|
addTask: (task: Omit<Task, 'id'>) => void
|
||||||
|
updateTask: (id: string, updates: Partial<Task>) => void
|
||||||
|
deleteTask: (id: string) => void
|
||||||
|
addProject: (name: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGanttStore = create<GanttStore>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
projects: ['OpenClaw iOS', 'BedrockTestApp', 'Web Gantt', 'Skills Update'],
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'BedrockTestApp Icons',
|
||||||
|
start: '2026-02-17',
|
||||||
|
end: '2026-02-18',
|
||||||
|
progress: 50,
|
||||||
|
dependencies: [],
|
||||||
|
project: 'BedrockTestApp',
|
||||||
|
assignee: 'Matt',
|
||||||
|
status: 'in-progress',
|
||||||
|
priority: 'high'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Assets Complete',
|
||||||
|
start: '2026-02-18',
|
||||||
|
end: '2026-02-20',
|
||||||
|
progress: 20,
|
||||||
|
dependencies: ['1'],
|
||||||
|
project: 'BedrockTestApp',
|
||||||
|
assignee: 'Ari',
|
||||||
|
status: 'backlog',
|
||||||
|
priority: 'medium'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'MoodWeave Recreate',
|
||||||
|
start: '2026-02-18',
|
||||||
|
end: '2026-02-25',
|
||||||
|
progress: 0,
|
||||||
|
dependencies: [],
|
||||||
|
project: 'OpenClaw iOS',
|
||||||
|
assignee: 'Sam',
|
||||||
|
status: 'blocked',
|
||||||
|
priority: 'critical'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: 'Firebase Auth',
|
||||||
|
start: '2026-02-18',
|
||||||
|
end: '2026-02-21',
|
||||||
|
progress: 0,
|
||||||
|
dependencies: [],
|
||||||
|
project: 'Web Gantt',
|
||||||
|
assignee: 'Priya',
|
||||||
|
status: 'backlog',
|
||||||
|
priority: 'medium'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
addTask: (task) =>
|
||||||
|
set((state) => ({
|
||||||
|
tasks: [
|
||||||
|
...state.tasks,
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
...task,
|
||||||
|
assignee: task.assignee.trim() || 'Unassigned',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
updateTask: (id, updates) =>
|
||||||
|
set((state) => ({
|
||||||
|
tasks: state.tasks.map(t => t.id === id ? { ...t, ...updates } : t)
|
||||||
|
})),
|
||||||
|
deleteTask: (id) =>
|
||||||
|
set((state) => ({
|
||||||
|
tasks: state.tasks.filter(t => t.id !== id)
|
||||||
|
})),
|
||||||
|
addProject: (name) =>
|
||||||
|
set((state) => {
|
||||||
|
const trimmed = name.trim()
|
||||||
|
if (!trimmed || state.projects.includes(trimmed)) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects: [...state.projects, trimmed]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'gantt-storage'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user