compassmock/src/components/schedule/schedule-calendar-view.tsx
Nicholai d30decf723
feat(ui): add mobile support and dashboard improvements (#30)
* feat(schema): add auth, people, and financial tables

Add users, organizations, teams, groups, and project
members tables. Extend customers/vendors with netsuite
fields. Add netsuite schema for invoices, bills,
payments, and credit memos. Include all migrations,
seeds, new UI primitives, and config updates.

* feat(auth): add WorkOS authentication system

Add login, signup, password reset, email verification,
and invitation flows via WorkOS AuthKit. Includes auth
middleware, permission helpers, dev mode fallbacks,
and auth page components.

* feat(people): add people management system

Add user, team, group, and organization management
with CRUD actions, dashboard pages, invite dialog,
user drawer, and role-based filtering. Includes
WorkOS invitation integration.

* feat(netsuite): add NetSuite integration and financials

Add bidirectional NetSuite REST API integration with
OAuth 2.0, rate limiting, sync engine, and conflict
resolution. Includes invoices, vendor bills, payments,
credit memos CRUD, customer/vendor management pages,
and financial dashboard with tabbed views.

* feat(ui): add mobile support and dashboard improvements

Add mobile bottom nav, FAB, filter bar, search, project
switcher, pull-to-refresh, and schedule mobile view.
Update sidebar with new nav items, settings modal with
integrations tab, responsive dialogs, improved schedule
and file components, PWA manifest, and service worker.

* ci: retrigger build

* ci: retrigger build

---------

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
2026-02-04 16:39:39 -07:00

236 lines
7.2 KiB
TypeScript
Executable File

"use client"
import { useState, useMemo } from "react"
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
format,
addMonths,
subMonths,
isToday,
isSameMonth,
isWeekend,
isSameDay,
parseISO,
isWithinInterval,
} from "date-fns"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
IconChevronLeft,
IconChevronRight,
} from "@tabler/icons-react"
import type {
ScheduleTaskData,
WorkdayExceptionData,
} from "@/lib/schedule/types"
interface ScheduleCalendarViewProps {
projectId: string
tasks: ScheduleTaskData[]
exceptions: WorkdayExceptionData[]
}
function isExceptionDay(
date: Date,
exceptions: WorkdayExceptionData[]
): boolean {
return exceptions.some((ex) => {
const start = parseISO(ex.startDate)
const end = parseISO(ex.endDate)
return isWithinInterval(date, { start, end })
})
}
function getTaskColor(task: ScheduleTaskData): string {
if (task.status === "COMPLETE") return "bg-green-500"
if (task.status === "IN_PROGRESS") return "bg-blue-500"
if (task.status === "BLOCKED") return "bg-red-500"
if (task.isCriticalPath) return "bg-orange-500"
return "bg-gray-400"
}
const MAX_VISIBLE_TASKS = 3
export function ScheduleCalendarView({
tasks,
exceptions,
}: ScheduleCalendarViewProps) {
const [currentDate, setCurrentDate] = useState(new Date())
const [expandedCells, setExpandedCells] = useState<Set<string>>(
new Set()
)
const monthStart = startOfMonth(currentDate)
const monthEnd = endOfMonth(currentDate)
const calendarStart = startOfWeek(monthStart)
const calendarEnd = endOfWeek(monthEnd)
const days = useMemo(
() => eachDayOfInterval({ start: calendarStart, end: calendarEnd }),
[calendarStart.getTime(), calendarEnd.getTime()]
)
const tasksByDate = useMemo(() => {
const map = new Map<string, ScheduleTaskData[]>()
for (const task of tasks) {
const key = task.startDate
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(task)
}
return map
}, [tasks])
const toggleExpand = (dateKey: string) => {
setExpandedCells((prev) => {
const next = new Set(prev)
if (next.has(dateKey)) {
next.delete(dateKey)
} else {
next.add(dateKey)
}
return next
})
}
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentDate(new Date())}
className="h-9"
>
Today
</Button>
<Button
variant="ghost"
size="icon"
className="size-9"
onClick={() => setCurrentDate(subMonths(currentDate, 1))}
>
<IconChevronLeft className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-9"
onClick={() => setCurrentDate(addMonths(currentDate, 1))}
>
<IconChevronRight className="size-4" />
</Button>
<h2 className="text-base sm:text-lg font-medium whitespace-nowrap">
{format(currentDate, "MMMM yyyy")}
</h2>
</div>
<Select defaultValue="month">
<SelectTrigger className="h-9 w-28 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="month">Month</SelectItem>
<SelectItem value="day">Day</SelectItem>
</SelectContent>
</Select>
</div>
<div className="border rounded-md overflow-hidden flex flex-col flex-1 min-h-0">
<div className="grid grid-cols-7 border-b">
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(
(day) => (
<div
key={day}
className="text-center text-xs font-medium text-muted-foreground py-2 border-r last:border-r-0"
>
{day}
</div>
)
)}
</div>
<div className="grid grid-cols-7 flex-1">
{days.map((day) => {
const dateKey = format(day, "yyyy-MM-dd")
const dayTasks = tasksByDate.get(dateKey) || []
const isNonWork =
isWeekend(day) || isExceptionDay(day, exceptions)
const inMonth = isSameMonth(day, currentDate)
const expanded = expandedCells.has(dateKey)
const visibleTasks = expanded
? dayTasks
: dayTasks.slice(0, MAX_VISIBLE_TASKS)
const overflow = dayTasks.length - MAX_VISIBLE_TASKS
return (
<div
key={dateKey}
className={`min-h-[60px] sm:min-h-[80px] border-r border-b last:border-r-0 p-1 sm:p-1.5 ${
!inMonth ? "bg-muted/30" : ""
} ${isNonWork ? "bg-muted/50" : ""}`}
>
<div className="flex items-start justify-between mb-0.5 min-w-0">
<span
className={`text-xs shrink-0 ${
isToday(day)
? "bg-primary text-primary-foreground rounded-full size-5 sm:size-6 flex items-center justify-center font-bold"
: inMonth
? "text-foreground"
: "text-muted-foreground"
}`}
>
{format(day, "d")}
</span>
{isNonWork && (
<span className="text-[8px] sm:text-[9px] text-muted-foreground truncate ml-1">
<span className="hidden sm:inline">Non-workday</span>
<span className="sm:hidden">Off</span>
</span>
)}
</div>
<div className="space-y-0.5">
{visibleTasks.map((task) => (
<div
key={task.id}
className={`${getTaskColor(task)} text-white text-[9px] sm:text-[10px] px-1 py-0.5 rounded truncate`}
title={task.title}
>
{task.title.length > 15 ? `${task.title.slice(0, 12)}...` : task.title}
</div>
))}
{!expanded && overflow > 0 && (
<button
className="text-[9px] sm:text-[10px] text-primary hover:underline"
onClick={() => toggleExpand(dateKey)}
>
+{overflow}
</button>
)}
{expanded && dayTasks.length > MAX_VISIBLE_TASKS && (
<button
className="text-[9px] sm:text-[10px] text-primary hover:underline"
onClick={() => toggleExpand(dateKey)}
>
Less
</button>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
)
}