@viliha/vui-ui 1.1.7 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT.md +79 -9
- package/README.md +60 -0
- package/bin/vui.mjs +211 -0
- package/package.json +10 -2
- package/src/record-view.tsx +10 -5
- package/template/.env.example +20 -0
- package/template/app/(app)/branches/branches-table.tsx +69 -0
- package/template/app/(app)/branches/page.tsx +13 -0
- package/template/app/(app)/businesses/businesses-table.tsx +39 -0
- package/template/app/(app)/businesses/page.tsx +13 -0
- package/template/app/(app)/calendar/page.tsx +725 -0
- package/template/app/(app)/charts/charts-content.tsx +190 -0
- package/template/app/(app)/charts/layout.tsx +8 -0
- package/template/app/(app)/charts/page.tsx +44 -0
- package/template/app/(app)/chat/layout.tsx +14 -0
- package/template/app/(app)/chat/page.tsx +311 -0
- package/template/app/(app)/components/layout.tsx +8 -0
- package/template/app/(app)/components/page.tsx +464 -0
- package/template/app/(app)/crm/companies/companies-table.tsx +56 -0
- package/template/app/(app)/crm/companies/page.tsx +13 -0
- package/template/app/(app)/crm/opportunities/opportunities-board.tsx +484 -0
- package/template/app/(app)/crm/opportunities/page.tsx +13 -0
- package/template/app/(app)/crm/people/page.tsx +13 -0
- package/template/app/(app)/crm/people/people-table.tsx +50 -0
- package/template/app/(app)/dashboard/layout.tsx +8 -0
- package/template/app/(app)/dashboard/page.tsx +239 -0
- package/template/app/(app)/departments/departments-table.tsx +55 -0
- package/template/app/(app)/departments/page.tsx +13 -0
- package/template/app/(app)/employees/employees-table.tsx +64 -0
- package/template/app/(app)/employees/page.tsx +13 -0
- package/template/app/(app)/forms/layout.tsx +8 -0
- package/template/app/(app)/forms/page.tsx +388 -0
- package/template/app/(app)/layout.tsx +74 -0
- package/template/app/(app)/markets/markets-table.tsx +76 -0
- package/template/app/(app)/markets/page.tsx +13 -0
- package/template/app/(app)/organizations/edit/page.tsx +72 -0
- package/template/app/(app)/organizations/new/page.tsx +46 -0
- package/template/app/(app)/organizations/organizations-config.tsx +97 -0
- package/template/app/(app)/organizations/organizations-table.tsx +42 -0
- package/template/app/(app)/organizations/page.tsx +13 -0
- package/template/app/(app)/settings/layout.tsx +8 -0
- package/template/app/(app)/settings/page.tsx +255 -0
- package/template/app/(app)/steps/page.tsx +263 -0
- package/template/app/(app)/support/layout.tsx +14 -0
- package/template/app/(app)/support/page.tsx +345 -0
- package/template/app/(app)/system/cities/cities-table.tsx +34 -0
- package/template/app/(app)/system/cities/page.tsx +13 -0
- package/template/app/(app)/system/countries/countries-table.tsx +34 -0
- package/template/app/(app)/system/countries/page.tsx +13 -0
- package/template/app/(app)/system/currencies/currencies-table.tsx +34 -0
- package/template/app/(app)/system/currencies/page.tsx +13 -0
- package/template/app/(app)/system/languages/languages-table.tsx +32 -0
- package/template/app/(app)/system/languages/page.tsx +13 -0
- package/template/app/(app)/system/regions/page.tsx +13 -0
- package/template/app/(app)/system/regions/regions-table.tsx +32 -0
- package/template/app/(app)/users/page.tsx +13 -0
- package/template/app/(app)/users/users-table.tsx +97 -0
- package/template/app/_components/app-sidebar.tsx +565 -0
- package/template/app/_components/auth.tsx +184 -0
- package/template/app/_components/breadcrumbs.tsx +26 -0
- package/template/app/_components/global-search.tsx +209 -0
- package/template/app/_components/logo.tsx +49 -0
- package/template/app/_components/nav-config.ts +138 -0
- package/template/app/_components/open-tabs.tsx +530 -0
- package/template/app/_components/quick-actions.tsx +140 -0
- package/template/app/_components/route-meta.ts +137 -0
- package/template/app/_components/set-page-title.tsx +15 -0
- package/template/app/_components/showcase.tsx +29 -0
- package/template/app/_components/stat-card.tsx +56 -0
- package/template/app/_components/theme-toggle.tsx +46 -0
- package/template/app/_components/top-bar.tsx +109 -0
- package/template/app/_components/user-menu.tsx +109 -0
- package/template/app/_components/wordmark.tsx +45 -0
- package/template/app/apple-icon.png +0 -0
- package/template/app/auth/forgot-password/page.tsx +102 -0
- package/template/app/auth/layout.tsx +43 -0
- package/template/app/auth/page.tsx +13 -0
- package/template/app/auth/reset-password/page.tsx +108 -0
- package/template/app/auth/signin/page.tsx +238 -0
- package/template/app/auth/signup/page.tsx +159 -0
- package/template/app/auth/verify/page.tsx +86 -0
- package/template/app/error.tsx +56 -0
- package/template/app/globals.css +6 -0
- package/template/app/icon.png +0 -0
- package/template/app/icon.svg +4 -0
- package/template/app/layout.tsx +103 -0
- package/template/app/not-found.tsx +29 -0
- package/template/app/onboarding/layout.tsx +31 -0
- package/template/app/onboarding/page.tsx +399 -0
- package/template/app/page.tsx +13 -0
- package/template/app/register-business/layout.tsx +30 -0
- package/template/app/register-business/page.tsx +234 -0
- package/template/components/ui/accordion.tsx +66 -0
- package/template/components/ui/alert-dialog.tsx +196 -0
- package/template/components/ui/alert.tsx +66 -0
- package/template/components/ui/aspect-ratio.tsx +11 -0
- package/template/components/ui/avatar.tsx +109 -0
- package/template/components/ui/badge.tsx +48 -0
- package/template/components/ui/breadcrumb.tsx +109 -0
- package/template/components/ui/button.tsx +64 -0
- package/template/components/ui/calendar.tsx +220 -0
- package/template/components/ui/card.tsx +92 -0
- package/template/components/ui/checkbox.tsx +32 -0
- package/template/components/ui/collapsible.tsx +33 -0
- package/template/components/ui/command.tsx +184 -0
- package/template/components/ui/dialog.tsx +158 -0
- package/template/components/ui/dropdown-menu.tsx +257 -0
- package/template/components/ui/form.tsx +167 -0
- package/template/components/ui/hover-card.tsx +44 -0
- package/template/components/ui/input-otp.tsx +77 -0
- package/template/components/ui/input.tsx +21 -0
- package/template/components/ui/label.tsx +24 -0
- package/template/components/ui/popover.tsx +89 -0
- package/template/components/ui/progress.tsx +31 -0
- package/template/components/ui/radio-group.tsx +45 -0
- package/template/components/ui/scroll-area.tsx +58 -0
- package/template/components/ui/select.tsx +190 -0
- package/template/components/ui/separator.tsx +28 -0
- package/template/components/ui/sheet.tsx +143 -0
- package/template/components/ui/skeleton.tsx +13 -0
- package/template/components/ui/slider.tsx +63 -0
- package/template/components/ui/sonner.tsx +40 -0
- package/template/components/ui/switch.tsx +35 -0
- package/template/components/ui/table.tsx +116 -0
- package/template/components/ui/tabs.tsx +91 -0
- package/template/components/ui/textarea.tsx +18 -0
- package/template/components/ui/toggle-group.tsx +83 -0
- package/template/components/ui/toggle.tsx +47 -0
- package/template/components/ui/tooltip.tsx +57 -0
- package/template/lib/auth-demo.ts +94 -0
- package/template/lib/crm-data.ts +80 -0
- package/template/lib/demo-data.ts +267 -0
- package/template/lib/mock-data.ts +169 -0
- package/template/lib/org-store.ts +50 -0
- package/template/lib/seo.ts +188 -0
- package/template/lib/utils.ts +3 -0
- package/template/next.config.mjs +13 -0
- package/template/postcss.config.mjs +7 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import {
|
|
5
|
+
addDays,
|
|
6
|
+
addMonths,
|
|
7
|
+
endOfWeek,
|
|
8
|
+
format,
|
|
9
|
+
isSameDay,
|
|
10
|
+
isSameMonth,
|
|
11
|
+
isToday,
|
|
12
|
+
setHours,
|
|
13
|
+
startOfMonth,
|
|
14
|
+
startOfWeek,
|
|
15
|
+
subMonths,
|
|
16
|
+
} from "date-fns";
|
|
17
|
+
import {
|
|
18
|
+
CalendarIcon,
|
|
19
|
+
ChevronLeftIcon,
|
|
20
|
+
ChevronRightIcon,
|
|
21
|
+
ClockIcon,
|
|
22
|
+
Cross2Icon,
|
|
23
|
+
HomeIcon,
|
|
24
|
+
PersonIcon,
|
|
25
|
+
PlusIcon,
|
|
26
|
+
TextAlignLeftIcon,
|
|
27
|
+
VideoIcon,
|
|
28
|
+
} from "@radix-ui/react-icons";
|
|
29
|
+
|
|
30
|
+
import { cn } from "@viliha/vui-ui/utils";
|
|
31
|
+
import { Button } from "@viliha/vui-ui/button";
|
|
32
|
+
import { Input } from "@viliha/vui-ui/input";
|
|
33
|
+
import { Checkbox } from "@viliha/vui-ui/checkbox";
|
|
34
|
+
import { Select } from "@viliha/vui-ui/select";
|
|
35
|
+
import {
|
|
36
|
+
Dialog,
|
|
37
|
+
DialogBody,
|
|
38
|
+
DialogFooter,
|
|
39
|
+
DialogHeader,
|
|
40
|
+
DialogTitle,
|
|
41
|
+
} from "@viliha/vui-ui/dialog";
|
|
42
|
+
import { Breadcrumbs } from "@/app/_components/breadcrumbs";
|
|
43
|
+
import { SetPageTitle } from "@/app/_components/set-page-title";
|
|
44
|
+
|
|
45
|
+
type EvType = "event" | "task" | "appointment";
|
|
46
|
+
type Ev = {
|
|
47
|
+
id: number;
|
|
48
|
+
date: string; // yyyy-MM-dd
|
|
49
|
+
start: string; // HH:mm
|
|
50
|
+
end: string; // HH:mm
|
|
51
|
+
title: string;
|
|
52
|
+
color: string; // EVENT_COLORS key
|
|
53
|
+
type: EvType;
|
|
54
|
+
guests?: string;
|
|
55
|
+
meet?: boolean;
|
|
56
|
+
location?: string;
|
|
57
|
+
description?: string;
|
|
58
|
+
notify: string; // minutes before, as string
|
|
59
|
+
};
|
|
60
|
+
type Draft = Omit<Ev, "id">;
|
|
61
|
+
type Mode = "month" | "week" | "day";
|
|
62
|
+
|
|
63
|
+
const keyOf = (d: Date) => format(d, "yyyy-MM-dd");
|
|
64
|
+
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
65
|
+
const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
|
66
|
+
const HOUR_REM = 3.5; // matches h-14 hour rows
|
|
67
|
+
|
|
68
|
+
// time helpers ("HH:mm")
|
|
69
|
+
const toMin = (t: string) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3, 5));
|
|
70
|
+
const minToTime = (m: number) => {
|
|
71
|
+
const c = Math.max(0, Math.min(24 * 60 - 1, m));
|
|
72
|
+
return `${String(Math.floor(c / 60)).padStart(2, "0")}:${String(c % 60).padStart(2, "0")}`;
|
|
73
|
+
};
|
|
74
|
+
const hourLabel = (h: number) => format(setHours(new Date(2000, 0, 1), h), "h a");
|
|
75
|
+
const timeLabel = (t: string) => format(new Date(`2000-01-01T${t}:00`), "h:mm a");
|
|
76
|
+
|
|
77
|
+
// Google-style color labels — static Tailwind classes (same convention as TAB_COLORS).
|
|
78
|
+
const EVENT_COLORS: { key: string; label: string; chip: string; dot: string }[] =
|
|
79
|
+
[
|
|
80
|
+
{ key: "blueberry", label: "Blueberry", chip: "bg-blue-600 text-white", dot: "bg-blue-600" },
|
|
81
|
+
{ key: "tomato", label: "Tomato", chip: "bg-red-600 text-white", dot: "bg-red-600" },
|
|
82
|
+
{ key: "tangerine", label: "Tangerine", chip: "bg-orange-500 text-white", dot: "bg-orange-500" },
|
|
83
|
+
{ key: "banana", label: "Banana", chip: "bg-yellow-400 text-black", dot: "bg-yellow-400" },
|
|
84
|
+
{ key: "sage", label: "Sage", chip: "bg-green-500 text-white", dot: "bg-green-500" },
|
|
85
|
+
{ key: "peacock", label: "Peacock", chip: "bg-cyan-600 text-white", dot: "bg-cyan-600" },
|
|
86
|
+
{ key: "lavender", label: "Lavender", chip: "bg-violet-400 text-black", dot: "bg-violet-400" },
|
|
87
|
+
{ key: "grape", label: "Grape", chip: "bg-purple-600 text-white", dot: "bg-purple-600" },
|
|
88
|
+
{ key: "graphite", label: "Graphite", chip: "bg-gray-500 text-white", dot: "bg-gray-500" },
|
|
89
|
+
];
|
|
90
|
+
const DEFAULT_COLOR = "blueberry";
|
|
91
|
+
const CHIP_FOR = new Map(EVENT_COLORS.map((c) => [c.key, c.chip]));
|
|
92
|
+
const chipFor = (key: string) =>
|
|
93
|
+
CHIP_FOR.get(key) ?? "bg-blue-600 text-white";
|
|
94
|
+
|
|
95
|
+
// 15-minute slots, labelled in AM/PM — replaces the native time picker.
|
|
96
|
+
const TIME_OPTIONS = Array.from({ length: 96 }, (_, i) => {
|
|
97
|
+
const t = minToTime(i * 15);
|
|
98
|
+
return { value: t, label: timeLabel(t) };
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const NOTIFY_OPTIONS = [
|
|
102
|
+
{ value: "0", label: "At time of event" },
|
|
103
|
+
{ value: "5", label: "5 minutes before" },
|
|
104
|
+
{ value: "10", label: "10 minutes before" },
|
|
105
|
+
{ value: "15", label: "15 minutes before" },
|
|
106
|
+
{ value: "30", label: "30 minutes before" },
|
|
107
|
+
{ value: "60", label: "1 hour before" },
|
|
108
|
+
{ value: "1440", label: "1 day before" },
|
|
109
|
+
];
|
|
110
|
+
const TYPE_TABS: { key: EvType; label: string }[] = [
|
|
111
|
+
{ key: "event", label: "Event" },
|
|
112
|
+
{ key: "task", label: "Task" },
|
|
113
|
+
{ key: "appointment", label: "Appointment schedule" },
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const today = new Date();
|
|
117
|
+
const rel = (days: number) => keyOf(addDays(today, days));
|
|
118
|
+
const SEED: Ev[] = [
|
|
119
|
+
{ id: 1, date: rel(0), start: "09:30", end: "10:00", title: "Standup", color: "peacock", type: "event", notify: "10" },
|
|
120
|
+
{ id: 6, date: rel(0), start: "09:45", end: "10:30", title: "Client sync", color: "tangerine", type: "event", notify: "10" },
|
|
121
|
+
{ id: 2, date: rel(0), start: "14:00", end: "15:00", title: "Design review", color: "grape", type: "event", notify: "10", meet: true },
|
|
122
|
+
{ id: 3, date: rel(2), start: "11:00", end: "12:00", title: "Onboarding call", color: "tangerine", type: "event", notify: "15" },
|
|
123
|
+
{ id: 4, date: rel(5), start: "16:30", end: "17:00", title: "1:1 with Ava", color: "sage", type: "event", notify: "10" },
|
|
124
|
+
{ id: 5, date: rel(-3), start: "10:00", end: "11:30", title: "Sprint planning", color: "tomato", type: "event", notify: "30" },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
export default function CalendarPage() {
|
|
128
|
+
const [mode, setMode] = React.useState<Mode>("month");
|
|
129
|
+
const [cursor, setCursor] = React.useState(today);
|
|
130
|
+
const [events, setEvents] = React.useState<Ev[]>(SEED);
|
|
131
|
+
const [draft, setDraft] = React.useState<Draft | null>(null);
|
|
132
|
+
const [more, setMore] = React.useState(false);
|
|
133
|
+
|
|
134
|
+
const byDate = React.useMemo(() => {
|
|
135
|
+
const map = new Map<string, Ev[]>();
|
|
136
|
+
for (const e of events) {
|
|
137
|
+
const list = map.get(e.date);
|
|
138
|
+
if (list) list.push(e);
|
|
139
|
+
else map.set(e.date, [e]);
|
|
140
|
+
}
|
|
141
|
+
for (const list of map.values())
|
|
142
|
+
list.sort((a, b) => a.start.localeCompare(b.start));
|
|
143
|
+
return map;
|
|
144
|
+
}, [events]);
|
|
145
|
+
|
|
146
|
+
const openAdd = (dateKey: string, hour?: number) => {
|
|
147
|
+
const start = hour === undefined ? "09:00" : `${String(hour).padStart(2, "0")}:00`;
|
|
148
|
+
setMore(false);
|
|
149
|
+
setDraft({
|
|
150
|
+
date: dateKey,
|
|
151
|
+
start,
|
|
152
|
+
end: minToTime(toMin(start) + 60),
|
|
153
|
+
title: "",
|
|
154
|
+
color: DEFAULT_COLOR,
|
|
155
|
+
type: "event",
|
|
156
|
+
guests: "",
|
|
157
|
+
meet: false,
|
|
158
|
+
location: "",
|
|
159
|
+
description: "",
|
|
160
|
+
notify: "10",
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
const setD = (patch: Partial<Draft>) =>
|
|
164
|
+
setDraft((d) => (d ? { ...d, ...patch } : d));
|
|
165
|
+
const save = () => {
|
|
166
|
+
if (!draft || !draft.title.trim()) return;
|
|
167
|
+
const end =
|
|
168
|
+
toMin(draft.end) > toMin(draft.start)
|
|
169
|
+
? draft.end
|
|
170
|
+
: minToTime(toMin(draft.start) + 60);
|
|
171
|
+
setEvents((prev) => [
|
|
172
|
+
...prev,
|
|
173
|
+
{ ...draft, id: Date.now(), title: draft.title.trim(), end },
|
|
174
|
+
]);
|
|
175
|
+
setDraft(null);
|
|
176
|
+
};
|
|
177
|
+
const remove = (id: number) =>
|
|
178
|
+
setEvents((prev) => prev.filter((e) => e.id !== id));
|
|
179
|
+
|
|
180
|
+
const step = (dir: 1 | -1) =>
|
|
181
|
+
setCursor((c) =>
|
|
182
|
+
mode === "month"
|
|
183
|
+
? dir === 1
|
|
184
|
+
? addMonths(c, 1)
|
|
185
|
+
: subMonths(c, 1)
|
|
186
|
+
: mode === "week"
|
|
187
|
+
? addDays(c, dir * 7)
|
|
188
|
+
: addDays(c, dir),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const heading =
|
|
192
|
+
mode === "month"
|
|
193
|
+
? format(cursor, "MMMM yyyy")
|
|
194
|
+
: mode === "week"
|
|
195
|
+
? `${format(startOfWeek(cursor), "MMM d")} – ${format(endOfWeek(cursor), "MMM d, yyyy")}`
|
|
196
|
+
: format(cursor, "EEEE, MMM d, yyyy");
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<div className="flex h-full flex-col">
|
|
200
|
+
<SetPageTitle title="Calendar" icon={CalendarIcon} />
|
|
201
|
+
<div className="flex h-12 shrink-0 items-center border-b border-border px-4">
|
|
202
|
+
<Breadcrumbs />
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
|
|
206
|
+
{/* Toolbar */}
|
|
207
|
+
<div className="flex shrink-0 items-center justify-between gap-3">
|
|
208
|
+
<div className="flex items-center gap-2">
|
|
209
|
+
<Button onClick={() => setCursor(today)}>Today</Button>
|
|
210
|
+
<div className="flex items-center">
|
|
211
|
+
<Button size="icon" aria-label="Previous" onClick={() => step(-1)}>
|
|
212
|
+
<ChevronLeftIcon className="size-4" />
|
|
213
|
+
</Button>
|
|
214
|
+
<Button size="icon" aria-label="Next" onClick={() => step(1)}>
|
|
215
|
+
<ChevronRightIcon className="size-4" />
|
|
216
|
+
</Button>
|
|
217
|
+
</div>
|
|
218
|
+
<h2 className="text-lg font-semibold tracking-tight">{heading}</h2>
|
|
219
|
+
</div>
|
|
220
|
+
<div className="flex items-center gap-2">
|
|
221
|
+
<div
|
|
222
|
+
role="tablist"
|
|
223
|
+
aria-label="Calendar view"
|
|
224
|
+
className="flex items-center rounded-md border border-border p-0.5"
|
|
225
|
+
>
|
|
226
|
+
{(["month", "week", "day"] as Mode[]).map((m) => (
|
|
227
|
+
<button
|
|
228
|
+
key={m}
|
|
229
|
+
type="button"
|
|
230
|
+
role="tab"
|
|
231
|
+
aria-selected={mode === m}
|
|
232
|
+
onClick={() => setMode(m)}
|
|
233
|
+
className={cnTab(mode === m)}
|
|
234
|
+
>
|
|
235
|
+
{m.charAt(0).toUpperCase() + m.slice(1)}
|
|
236
|
+
</button>
|
|
237
|
+
))}
|
|
238
|
+
</div>
|
|
239
|
+
<Button variant="primary" onClick={() => openAdd(keyOf(today))}>
|
|
240
|
+
<PlusIcon className="size-4" />
|
|
241
|
+
Add appointment
|
|
242
|
+
</Button>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{mode === "month" ? (
|
|
247
|
+
<MonthView cursor={cursor} byDate={byDate} openAdd={openAdd} remove={remove} />
|
|
248
|
+
) : (
|
|
249
|
+
<TimeGridView
|
|
250
|
+
days={
|
|
251
|
+
mode === "day"
|
|
252
|
+
? [cursor]
|
|
253
|
+
: Array.from({ length: 7 }, (_, i) => addDays(startOfWeek(cursor), i))
|
|
254
|
+
}
|
|
255
|
+
byDate={byDate}
|
|
256
|
+
openAdd={openAdd}
|
|
257
|
+
remove={remove}
|
|
258
|
+
/>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{/* Add appointment dialog — Google-style */}
|
|
263
|
+
<Dialog
|
|
264
|
+
open={draft !== null}
|
|
265
|
+
onClose={() => setDraft(null)}
|
|
266
|
+
label="Add appointment"
|
|
267
|
+
className="max-w-lg"
|
|
268
|
+
>
|
|
269
|
+
<DialogHeader>
|
|
270
|
+
<DialogTitle>New appointment</DialogTitle>
|
|
271
|
+
</DialogHeader>
|
|
272
|
+
{draft && (
|
|
273
|
+
<DialogBody>
|
|
274
|
+
<div className="flex flex-col gap-4">
|
|
275
|
+
{/* Title with live color accent */}
|
|
276
|
+
<div className="flex items-center gap-3">
|
|
277
|
+
<span
|
|
278
|
+
aria-hidden
|
|
279
|
+
className={cn(
|
|
280
|
+
"size-3 shrink-0 rounded-full",
|
|
281
|
+
EVENT_COLORS.find((c) => c.key === draft.color)?.dot,
|
|
282
|
+
)}
|
|
283
|
+
/>
|
|
284
|
+
<input
|
|
285
|
+
value={draft.title}
|
|
286
|
+
onChange={(e) => setD({ title: e.target.value })}
|
|
287
|
+
onKeyDown={(e) => e.key === "Enter" && save()}
|
|
288
|
+
placeholder="Add title"
|
|
289
|
+
aria-label="Title"
|
|
290
|
+
className="w-full border-0 border-b border-input bg-transparent pb-1 text-lg font-medium placeholder:font-normal placeholder:text-muted-foreground focus-visible:border-[var(--button-primary)] focus-visible:outline-none"
|
|
291
|
+
/>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{/* Type tabs */}
|
|
295
|
+
<div
|
|
296
|
+
role="tablist"
|
|
297
|
+
aria-label="Entry type"
|
|
298
|
+
className="flex items-center gap-1 rounded-md bg-muted/50 p-0.5"
|
|
299
|
+
>
|
|
300
|
+
{TYPE_TABS.map((t) => (
|
|
301
|
+
<button
|
|
302
|
+
key={t.key}
|
|
303
|
+
type="button"
|
|
304
|
+
role="tab"
|
|
305
|
+
aria-selected={draft.type === t.key}
|
|
306
|
+
onClick={() => setD({ type: t.key })}
|
|
307
|
+
className={cnTab(draft.type === t.key)}
|
|
308
|
+
>
|
|
309
|
+
{t.label}
|
|
310
|
+
</button>
|
|
311
|
+
))}
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
{/* Date + start–end range */}
|
|
315
|
+
<Row icon={<ClockIcon className="size-4" />}>
|
|
316
|
+
<span className="text-sm font-medium">
|
|
317
|
+
{format(new Date(`${draft.date}T00:00:00`), "EEEE, MMM d")}
|
|
318
|
+
</span>
|
|
319
|
+
<Select
|
|
320
|
+
value={draft.start}
|
|
321
|
+
onValueChange={(v) =>
|
|
322
|
+
setD({
|
|
323
|
+
start: v,
|
|
324
|
+
end:
|
|
325
|
+
toMin(draft.end) <= toMin(v)
|
|
326
|
+
? minToTime(toMin(v) + 60)
|
|
327
|
+
: draft.end,
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
options={TIME_OPTIONS}
|
|
331
|
+
ariaLabel="Start time"
|
|
332
|
+
className="w-28 shrink-0"
|
|
333
|
+
/>
|
|
334
|
+
<span className="text-muted-foreground">–</span>
|
|
335
|
+
<Select
|
|
336
|
+
value={draft.end}
|
|
337
|
+
onValueChange={(v) => setD({ end: v })}
|
|
338
|
+
options={TIME_OPTIONS}
|
|
339
|
+
ariaLabel="End time"
|
|
340
|
+
className="w-28 shrink-0"
|
|
341
|
+
/>
|
|
342
|
+
</Row>
|
|
343
|
+
|
|
344
|
+
{/* Color label — essential, always visible */}
|
|
345
|
+
<Row icon={<CalendarIcon className="size-4" />}>
|
|
346
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
347
|
+
{EVENT_COLORS.map((c) => (
|
|
348
|
+
<button
|
|
349
|
+
key={c.key}
|
|
350
|
+
type="button"
|
|
351
|
+
aria-label={c.label}
|
|
352
|
+
aria-pressed={draft.color === c.key}
|
|
353
|
+
title={c.label}
|
|
354
|
+
onClick={() => setD({ color: c.key })}
|
|
355
|
+
className={cn(
|
|
356
|
+
"size-5 rounded-full ring-offset-2 ring-offset-background transition-transform hover:scale-110",
|
|
357
|
+
c.dot,
|
|
358
|
+
draft.color === c.key && "ring-2 ring-foreground",
|
|
359
|
+
)}
|
|
360
|
+
/>
|
|
361
|
+
))}
|
|
362
|
+
</div>
|
|
363
|
+
</Row>
|
|
364
|
+
|
|
365
|
+
{/* Progressive disclosure — keep the default add simple */}
|
|
366
|
+
{!more && (
|
|
367
|
+
<button
|
|
368
|
+
type="button"
|
|
369
|
+
onClick={() => setMore(true)}
|
|
370
|
+
className="self-start text-sm font-medium text-[var(--button-primary)] hover:underline"
|
|
371
|
+
>
|
|
372
|
+
More options
|
|
373
|
+
</button>
|
|
374
|
+
)}
|
|
375
|
+
|
|
376
|
+
{more && (
|
|
377
|
+
<div className="flex flex-col gap-4 border-t border-border pt-4">
|
|
378
|
+
{/* Guests */}
|
|
379
|
+
<Row icon={<PersonIcon className="size-4" />}>
|
|
380
|
+
<Input
|
|
381
|
+
value={draft.guests}
|
|
382
|
+
onChange={(e) => setD({ guests: e.target.value })}
|
|
383
|
+
placeholder="Add guests (comma-separated)"
|
|
384
|
+
aria-label="Guests"
|
|
385
|
+
/>
|
|
386
|
+
</Row>
|
|
387
|
+
|
|
388
|
+
{/* Google Meet */}
|
|
389
|
+
<Row icon={<VideoIcon className="size-4" />}>
|
|
390
|
+
<label
|
|
391
|
+
htmlFor="cal-meet"
|
|
392
|
+
className="flex cursor-pointer items-center gap-2 text-sm"
|
|
393
|
+
>
|
|
394
|
+
<Checkbox
|
|
395
|
+
id="cal-meet"
|
|
396
|
+
checked={draft.meet}
|
|
397
|
+
onChange={(e) => setD({ meet: e.target.checked })}
|
|
398
|
+
/>
|
|
399
|
+
Add Google Meet video conferencing
|
|
400
|
+
</label>
|
|
401
|
+
</Row>
|
|
402
|
+
|
|
403
|
+
{/* Location */}
|
|
404
|
+
<Row icon={<HomeIcon className="size-4" />}>
|
|
405
|
+
<Input
|
|
406
|
+
value={draft.location}
|
|
407
|
+
onChange={(e) => setD({ location: e.target.value })}
|
|
408
|
+
placeholder="Add location"
|
|
409
|
+
aria-label="Location"
|
|
410
|
+
/>
|
|
411
|
+
</Row>
|
|
412
|
+
|
|
413
|
+
{/* Description */}
|
|
414
|
+
<Row icon={<TextAlignLeftIcon className="size-4" />}>
|
|
415
|
+
<textarea
|
|
416
|
+
value={draft.description}
|
|
417
|
+
onChange={(e) => setD({ description: e.target.value })}
|
|
418
|
+
placeholder="Add description"
|
|
419
|
+
aria-label="Description"
|
|
420
|
+
rows={2}
|
|
421
|
+
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
422
|
+
/>
|
|
423
|
+
</Row>
|
|
424
|
+
|
|
425
|
+
{/* Notify */}
|
|
426
|
+
<Row icon={<PersonIcon className="size-4" />}>
|
|
427
|
+
<div className="flex items-center gap-2 text-sm">
|
|
428
|
+
<span>Suman Bonakurthi · Busy ·</span>
|
|
429
|
+
<Select
|
|
430
|
+
value={draft.notify}
|
|
431
|
+
onValueChange={(v) => setD({ notify: v })}
|
|
432
|
+
options={NOTIFY_OPTIONS}
|
|
433
|
+
ariaLabel="Notification"
|
|
434
|
+
className="w-44"
|
|
435
|
+
/>
|
|
436
|
+
</div>
|
|
437
|
+
</Row>
|
|
438
|
+
</div>
|
|
439
|
+
)}
|
|
440
|
+
</div>
|
|
441
|
+
</DialogBody>
|
|
442
|
+
)}
|
|
443
|
+
<DialogFooter>
|
|
444
|
+
<Button onClick={() => setDraft(null)}>
|
|
445
|
+
<Cross2Icon className="size-4" />
|
|
446
|
+
Cancel
|
|
447
|
+
</Button>
|
|
448
|
+
<Button
|
|
449
|
+
variant="primary"
|
|
450
|
+
onClick={save}
|
|
451
|
+
disabled={!draft?.title.trim()}
|
|
452
|
+
>
|
|
453
|
+
<PlusIcon className="size-4" />
|
|
454
|
+
Save
|
|
455
|
+
</Button>
|
|
456
|
+
</DialogFooter>
|
|
457
|
+
</Dialog>
|
|
458
|
+
</div>
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function Row({
|
|
463
|
+
icon,
|
|
464
|
+
children,
|
|
465
|
+
}: {
|
|
466
|
+
icon: React.ReactNode;
|
|
467
|
+
children: React.ReactNode;
|
|
468
|
+
}) {
|
|
469
|
+
return (
|
|
470
|
+
<div className="flex items-center gap-3">
|
|
471
|
+
<span className="shrink-0 text-muted-foreground">{icon}</span>
|
|
472
|
+
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
|
473
|
+
{children}
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
type ViewProps = {
|
|
480
|
+
byDate: Map<string, Ev[]>;
|
|
481
|
+
openAdd: (dateKey: string, hour?: number) => void;
|
|
482
|
+
remove: (id: number) => void;
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
function MonthView({
|
|
486
|
+
cursor,
|
|
487
|
+
byDate,
|
|
488
|
+
openAdd,
|
|
489
|
+
remove,
|
|
490
|
+
}: ViewProps & { cursor: Date }) {
|
|
491
|
+
const days = React.useMemo(() => {
|
|
492
|
+
const start = startOfWeek(startOfMonth(cursor), { weekStartsOn: 0 });
|
|
493
|
+
return Array.from({ length: 42 }, (_, i) => addDays(start, i));
|
|
494
|
+
}, [cursor]);
|
|
495
|
+
|
|
496
|
+
return (
|
|
497
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-card">
|
|
498
|
+
<div className="grid shrink-0 grid-cols-7 border-b border-border bg-muted/40">
|
|
499
|
+
{WEEKDAYS.map((d) => (
|
|
500
|
+
<div
|
|
501
|
+
key={d}
|
|
502
|
+
className="px-2 py-1.5 text-center text-xs font-medium text-muted-foreground"
|
|
503
|
+
>
|
|
504
|
+
{d}
|
|
505
|
+
</div>
|
|
506
|
+
))}
|
|
507
|
+
</div>
|
|
508
|
+
<div className="grid min-h-0 flex-1 grid-cols-7 grid-rows-6">
|
|
509
|
+
{days.map((day) => {
|
|
510
|
+
const dk = keyOf(day);
|
|
511
|
+
const list = byDate.get(dk) ?? [];
|
|
512
|
+
const inMonth = isSameMonth(day, cursor);
|
|
513
|
+
const shown = list.slice(0, 3);
|
|
514
|
+
const extra = list.length - shown.length;
|
|
515
|
+
return (
|
|
516
|
+
<div key={dk} className={cnCell(inMonth)}>
|
|
517
|
+
<div className="flex items-center justify-between">
|
|
518
|
+
<span
|
|
519
|
+
className={cnDate(inMonth, isToday(day))}
|
|
520
|
+
aria-current={isToday(day) ? "date" : undefined}
|
|
521
|
+
>
|
|
522
|
+
{format(day, "d")}
|
|
523
|
+
</span>
|
|
524
|
+
<button
|
|
525
|
+
type="button"
|
|
526
|
+
onClick={() => openAdd(dk)}
|
|
527
|
+
aria-label={`Add appointment on ${format(day, "d MMM")}`}
|
|
528
|
+
className="grid size-5 shrink-0 place-items-center rounded text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
|
|
529
|
+
>
|
|
530
|
+
<PlusIcon className="size-3.5" />
|
|
531
|
+
</button>
|
|
532
|
+
</div>
|
|
533
|
+
<div className="mt-0.5 flex flex-col gap-0.5 overflow-hidden">
|
|
534
|
+
{shown.map((e) => (
|
|
535
|
+
<button
|
|
536
|
+
key={e.id}
|
|
537
|
+
type="button"
|
|
538
|
+
onClick={() => remove(e.id)}
|
|
539
|
+
title={`${timeLabel(e.start)} ${e.title} — click to remove`}
|
|
540
|
+
className={cn(
|
|
541
|
+
"flex items-center gap-1 truncate rounded px-1 py-0.5 text-left text-[11px] transition-opacity hover:opacity-90",
|
|
542
|
+
chipFor(e.color),
|
|
543
|
+
)}
|
|
544
|
+
>
|
|
545
|
+
<span className="tabular-nums opacity-80">{e.start}</span>
|
|
546
|
+
<span className="truncate">{e.title}</span>
|
|
547
|
+
</button>
|
|
548
|
+
))}
|
|
549
|
+
{extra > 0 && (
|
|
550
|
+
<span className="px-1 text-[11px] text-muted-foreground">
|
|
551
|
+
+{extra} more
|
|
552
|
+
</span>
|
|
553
|
+
)}
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
);
|
|
557
|
+
})}
|
|
558
|
+
</div>
|
|
559
|
+
</div>
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function TimeGridView({
|
|
564
|
+
days,
|
|
565
|
+
byDate,
|
|
566
|
+
openAdd,
|
|
567
|
+
remove,
|
|
568
|
+
}: ViewProps & { days: Date[] }) {
|
|
569
|
+
const nowTopRem = ((today.getHours() * 60 + today.getMinutes()) / 60) * HOUR_REM;
|
|
570
|
+
const scrollRef = React.useRef<HTMLDivElement>(null);
|
|
571
|
+
const hasToday = days.some((d) => isSameDay(d, today));
|
|
572
|
+
// Scroll the current time into view (centered) whenever this grid is shown.
|
|
573
|
+
React.useEffect(() => {
|
|
574
|
+
const el = scrollRef.current;
|
|
575
|
+
if (!el || !hasToday) return;
|
|
576
|
+
el.scrollTop = Math.max(0, nowTopRem * 16 - el.clientHeight / 2);
|
|
577
|
+
}, [hasToday, nowTopRem]);
|
|
578
|
+
return (
|
|
579
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-card">
|
|
580
|
+
{/* Day header */}
|
|
581
|
+
<div className="flex shrink-0 border-b border-border bg-muted/40">
|
|
582
|
+
<div className="w-16 shrink-0" />
|
|
583
|
+
{days.map((day) => (
|
|
584
|
+
<div key={keyOf(day)} className="flex-1 px-2 py-1.5 text-center">
|
|
585
|
+
<div className="text-xs font-medium text-muted-foreground">
|
|
586
|
+
{format(day, "EEE")}
|
|
587
|
+
</div>
|
|
588
|
+
<div className={cnDate(true, isToday(day))}>{format(day, "d")}</div>
|
|
589
|
+
</div>
|
|
590
|
+
))}
|
|
591
|
+
</div>
|
|
592
|
+
{/* Scrollable hour grid */}
|
|
593
|
+
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
|
594
|
+
<div className="flex">
|
|
595
|
+
{/* Time gutter */}
|
|
596
|
+
<div className="w-16 shrink-0">
|
|
597
|
+
{HOURS.map((h) => (
|
|
598
|
+
<div
|
|
599
|
+
key={h}
|
|
600
|
+
className="h-14 border-b border-r border-border pr-1 pt-0.5 text-right text-[11px] tabular-nums text-muted-foreground"
|
|
601
|
+
>
|
|
602
|
+
{hourLabel(h)}
|
|
603
|
+
</div>
|
|
604
|
+
))}
|
|
605
|
+
</div>
|
|
606
|
+
{/* Day columns */}
|
|
607
|
+
<div className="relative flex flex-1">
|
|
608
|
+
{days.some((d) => isSameDay(d, today)) && (
|
|
609
|
+
<div
|
|
610
|
+
className="pointer-events-none absolute inset-x-0 z-20 border-t-2 border-red-500"
|
|
611
|
+
style={{ top: `${nowTopRem}rem` }}
|
|
612
|
+
>
|
|
613
|
+
<span className="absolute -left-1 -top-1 size-2 rounded-full bg-red-500" />
|
|
614
|
+
</div>
|
|
615
|
+
)}
|
|
616
|
+
{days.map((day) => {
|
|
617
|
+
const dk = keyOf(day);
|
|
618
|
+
const list = byDate.get(dk) ?? [];
|
|
619
|
+
return (
|
|
620
|
+
<div key={dk} className="relative flex-1 border-l border-border">
|
|
621
|
+
{/* Background hour cells (click to add) */}
|
|
622
|
+
{HOURS.map((h) => (
|
|
623
|
+
<button
|
|
624
|
+
key={h}
|
|
625
|
+
type="button"
|
|
626
|
+
onClick={() => openAdd(dk, h)}
|
|
627
|
+
aria-label={`Add appointment on ${format(day, "d MMM")} at ${hourLabel(h)}`}
|
|
628
|
+
className="block h-14 w-full border-b border-border transition-colors hover:bg-accent/30"
|
|
629
|
+
/>
|
|
630
|
+
))}
|
|
631
|
+
{/* Event blocks, positioned by start–end; overlapping events
|
|
632
|
+
split into side-by-side columns (lane / colsInCluster). */}
|
|
633
|
+
{layoutDay(list).map(({ ev: e, lane, cols }) => {
|
|
634
|
+
const top = (toMin(e.start) / 60) * HOUR_REM;
|
|
635
|
+
const height = (Math.max(toMin(e.end) - toMin(e.start), 20) / 60) * HOUR_REM;
|
|
636
|
+
return (
|
|
637
|
+
<button
|
|
638
|
+
key={e.id}
|
|
639
|
+
type="button"
|
|
640
|
+
onClick={() => remove(e.id)}
|
|
641
|
+
title={`${timeLabel(e.start)} – ${timeLabel(e.end)} · ${e.title} — click to remove`}
|
|
642
|
+
style={{
|
|
643
|
+
top: `${top}rem`,
|
|
644
|
+
height: `${height}rem`,
|
|
645
|
+
left: `calc(${(lane / cols) * 100}% + 1px)`,
|
|
646
|
+
width: `calc(${(1 / cols) * 100}% - 2px)`,
|
|
647
|
+
}}
|
|
648
|
+
className={cn(
|
|
649
|
+
"absolute z-10 flex flex-col overflow-hidden rounded px-1 py-0.5 text-left text-[11px] leading-tight shadow-sm transition-opacity hover:opacity-90",
|
|
650
|
+
chipFor(e.color),
|
|
651
|
+
)}
|
|
652
|
+
>
|
|
653
|
+
<span className="truncate font-medium">{e.title}</span>
|
|
654
|
+
<span className="truncate tabular-nums opacity-80">
|
|
655
|
+
{timeLabel(e.start)} – {timeLabel(e.end)}
|
|
656
|
+
</span>
|
|
657
|
+
</button>
|
|
658
|
+
);
|
|
659
|
+
})}
|
|
660
|
+
</div>
|
|
661
|
+
);
|
|
662
|
+
})}
|
|
663
|
+
</div>
|
|
664
|
+
</div>
|
|
665
|
+
</div>
|
|
666
|
+
</div>
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Assign overlapping events to side-by-side lanes. Events that overlap in time
|
|
671
|
+
* share a "cluster"; within it each gets a lane, and all report the cluster's
|
|
672
|
+
* column count so widths divide evenly. */
|
|
673
|
+
function layoutDay(events: Ev[]): { ev: Ev; lane: number; cols: number }[] {
|
|
674
|
+
const sorted = [...events].sort(
|
|
675
|
+
(a, b) => toMin(a.start) - toMin(b.start) || toMin(a.end) - toMin(b.end),
|
|
676
|
+
);
|
|
677
|
+
const out: { ev: Ev; lane: number; cols: number }[] = [];
|
|
678
|
+
let cluster: { ev: Ev; lane: number; end: number }[] = [];
|
|
679
|
+
let clusterEnd = -1;
|
|
680
|
+
const flush = () => {
|
|
681
|
+
const cols = Math.max(1, ...cluster.map((c) => c.lane + 1));
|
|
682
|
+
for (const c of cluster) out.push({ ev: c.ev, lane: c.lane, cols });
|
|
683
|
+
cluster = [];
|
|
684
|
+
clusterEnd = -1;
|
|
685
|
+
};
|
|
686
|
+
for (const ev of sorted) {
|
|
687
|
+
const s = toMin(ev.start);
|
|
688
|
+
const e = Math.max(toMin(ev.end), s + 20); // min height match
|
|
689
|
+
if (cluster.length && s >= clusterEnd) flush();
|
|
690
|
+
const used = new Set(cluster.filter((c) => c.end > s).map((c) => c.lane));
|
|
691
|
+
let lane = 0;
|
|
692
|
+
while (used.has(lane)) lane++;
|
|
693
|
+
cluster.push({ ev, lane, end: e });
|
|
694
|
+
clusterEnd = Math.max(clusterEnd, e);
|
|
695
|
+
}
|
|
696
|
+
flush();
|
|
697
|
+
return out;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function cnTab(active: boolean): string {
|
|
701
|
+
return cn(
|
|
702
|
+
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
|
|
703
|
+
active
|
|
704
|
+
? "bg-[var(--button-primary)] text-[var(--button-primary-foreground)]"
|
|
705
|
+
: "text-muted-foreground hover:text-foreground",
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function cnCell(inMonth: boolean): string {
|
|
710
|
+
return cn(
|
|
711
|
+
"group flex min-h-0 flex-col items-stretch overflow-hidden border-b border-r border-border p-1 transition-colors hover:bg-accent/30",
|
|
712
|
+
inMonth ? "bg-card" : "bg-muted/30",
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function cnDate(inMonth: boolean, today_: boolean): string {
|
|
717
|
+
return cn(
|
|
718
|
+
"inline-flex size-6 items-center justify-center self-start rounded-full text-xs",
|
|
719
|
+
today_
|
|
720
|
+
? "bg-[var(--button-primary)] font-semibold text-[var(--button-primary-foreground)]"
|
|
721
|
+
: inMonth
|
|
722
|
+
? "text-foreground"
|
|
723
|
+
: "text-muted-foreground",
|
|
724
|
+
);
|
|
725
|
+
}
|