@wakastellar/ui 2.1.2 → 2.3.2
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/dist/blocks/apm-overview/index.d.ts +58 -0
- package/dist/blocks/cicd-builder/index.d.ts +47 -0
- package/dist/blocks/cloud-cost-dashboard/index.d.ts +49 -0
- package/dist/blocks/container-orchestrator/index.d.ts +63 -0
- package/dist/blocks/database-admin/index.d.ts +84 -0
- package/dist/blocks/gitops-sync-status/index.d.ts +45 -0
- package/dist/blocks/incident-manager/index.d.ts +44 -0
- package/dist/blocks/index.d.ts +10 -0
- package/dist/blocks/infrastructure-map/index.d.ts +32 -0
- package/dist/blocks/on-call-schedule/index.d.ts +43 -0
- package/dist/blocks/release-notes/index.d.ts +49 -0
- package/dist/components/index.d.ts +34 -0
- package/dist/components/waka-ad-banner/index.d.ts +36 -0
- package/dist/components/waka-ad-fallback/index.d.ts +33 -0
- package/dist/components/waka-ad-inline/index.d.ts +15 -0
- package/dist/components/waka-ad-interstitial/index.d.ts +26 -0
- package/dist/components/waka-ad-placeholder/index.d.ts +17 -0
- package/dist/components/waka-ad-provider/index.d.ts +103 -0
- package/dist/components/waka-ad-sidebar/index.d.ts +18 -0
- package/dist/components/waka-ad-sticky-footer/index.d.ts +17 -0
- package/dist/components/waka-alert-panel/index.d.ts +45 -0
- package/dist/components/waka-artifact-list/index.d.ts +32 -0
- package/dist/components/waka-build-matrix/index.d.ts +36 -0
- package/dist/components/waka-config-comparator/index.d.ts +37 -0
- package/dist/components/waka-container-list/index.d.ts +51 -0
- package/dist/components/waka-content-recommendation/index.d.ts +23 -0
- package/dist/components/waka-database-card/index.d.ts +46 -0
- package/dist/components/waka-dependency-tree/index.d.ts +38 -0
- package/dist/components/waka-env-var-editor/index.d.ts +30 -0
- package/dist/components/waka-feature-flag-row/index.d.ts +45 -0
- package/dist/components/waka-kubernetes-overview/index.d.ts +98 -0
- package/dist/components/waka-log-viewer/index.d.ts +38 -0
- package/dist/components/waka-migration-list/index.d.ts +36 -0
- package/dist/components/waka-outstream-video/index.d.ts +24 -0
- package/dist/components/waka-pod-card/index.d.ts +73 -0
- package/dist/components/waka-query-explain/index.d.ts +48 -0
- package/dist/components/waka-secret-card/index.d.ts +43 -0
- package/dist/components/waka-security-scan-result/index.d.ts +45 -0
- package/dist/components/waka-service-graph/index.d.ts +44 -0
- package/dist/components/waka-sponsored-badge/index.d.ts +20 -0
- package/dist/components/waka-sponsored-card/index.d.ts +25 -0
- package/dist/components/waka-sponsored-feed/index.d.ts +31 -0
- package/dist/components/waka-test-report/index.d.ts +60 -0
- package/dist/components/waka-trace-viewer/index.d.ts +36 -0
- package/dist/components/waka-video-ad/index.d.ts +32 -0
- package/dist/components/waka-video-overlay/index.d.ts +26 -0
- package/dist/index.cjs.js +251 -200
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +47315 -35823
- package/dist/utils/security.d.ts +96 -0
- package/package.json +4 -4
- package/src/blocks/apm-overview/index.tsx +672 -0
- package/src/blocks/cicd-builder/index.tsx +738 -0
- package/src/blocks/cloud-cost-dashboard/index.tsx +597 -0
- package/src/blocks/container-orchestrator/index.tsx +729 -0
- package/src/blocks/database-admin/index.tsx +679 -0
- package/src/blocks/gitops-sync-status/index.tsx +557 -0
- package/src/blocks/incident-manager/index.tsx +586 -0
- package/src/blocks/index.ts +119 -0
- package/src/blocks/infrastructure-map/index.tsx +638 -0
- package/src/blocks/on-call-schedule/index.tsx +615 -0
- package/src/blocks/release-notes/index.tsx +643 -0
- package/src/blocks/sidebar/index.tsx +6 -6
- package/src/components/DataTable/templates/index.tsx +3 -2
- package/src/components/index.ts +283 -0
- package/src/components/waka-3d-pie-chart/index.tsx +11 -11
- package/src/components/waka-achievement-unlock/index.tsx +16 -16
- package/src/components/waka-ad-banner/index.tsx +275 -0
- package/src/components/waka-ad-fallback/index.tsx +181 -0
- package/src/components/waka-ad-inline/index.tsx +103 -0
- package/src/components/waka-ad-interstitial/index.tsx +278 -0
- package/src/components/waka-ad-placeholder/index.tsx +84 -0
- package/src/components/waka-ad-provider/index.tsx +329 -0
- package/src/components/waka-ad-sidebar/index.tsx +113 -0
- package/src/components/waka-ad-sticky-footer/index.tsx +125 -0
- package/src/components/waka-alert-panel/index.tsx +493 -0
- package/src/components/waka-artifact-list/index.tsx +416 -0
- package/src/components/waka-badge-showcase/index.tsx +12 -11
- package/src/components/waka-build-matrix/index.tsx +396 -0
- package/src/components/waka-command-bar/index.tsx +2 -1
- package/src/components/waka-config-comparator/index.tsx +416 -0
- package/src/components/waka-container-list/index.tsx +475 -0
- package/src/components/waka-content-recommendation/index.tsx +294 -0
- package/src/components/waka-cost-breakdown/index.tsx +10 -10
- package/src/components/waka-database-card/index.tsx +473 -0
- package/src/components/waka-dependency-tree/index.tsx +542 -0
- package/src/components/waka-env-var-editor/index.tsx +417 -0
- package/src/components/waka-feature-flag-row/index.tsx +386 -0
- package/src/components/waka-funnel-chart/index.tsx +8 -8
- package/src/components/waka-health-pulse/index.tsx +6 -6
- package/src/components/waka-kubernetes-overview/index.tsx +536 -0
- package/src/components/waka-leaderboard/index.tsx +9 -9
- package/src/components/waka-log-viewer/index.tsx +386 -0
- package/src/components/waka-loot-box/index.tsx +20 -20
- package/src/components/waka-migration-list/index.tsx +487 -0
- package/src/components/waka-outstream-video/index.tsx +240 -0
- package/src/components/waka-player-card/index.tsx +5 -5
- package/src/components/waka-pod-card/index.tsx +528 -0
- package/src/components/waka-query-explain/index.tsx +657 -0
- package/src/components/waka-quota-bar/index.tsx +4 -4
- package/src/components/waka-radar-score/index.tsx +10 -10
- package/src/components/waka-scratch-card/index.tsx +5 -4
- package/src/components/waka-secret-card/index.tsx +371 -0
- package/src/components/waka-security-scan-result/index.tsx +473 -0
- package/src/components/waka-server-rack/index.tsx +28 -27
- package/src/components/waka-service-graph/index.tsx +445 -0
- package/src/components/waka-sponsored-badge/index.tsx +97 -0
- package/src/components/waka-sponsored-card/index.tsx +275 -0
- package/src/components/waka-sponsored-feed/index.tsx +127 -0
- package/src/components/waka-spotlight/index.tsx +2 -1
- package/src/components/waka-success-explosion/index.tsx +4 -4
- package/src/components/waka-test-report/index.tsx +469 -0
- package/src/components/waka-trace-viewer/index.tsx +490 -0
- package/src/components/waka-video-ad/index.tsx +406 -0
- package/src/components/waka-video-overlay/index.tsx +257 -0
- package/src/components/waka-xp-bar/index.tsx +13 -13
- package/src/styles/base.css +16 -0
- package/src/styles/tailwind.preset.js +12 -0
- package/src/styles/themes/forest.css +16 -0
- package/src/styles/themes/monochrome.css +16 -0
- package/src/styles/themes/perpetuity.css +16 -0
- package/src/styles/themes/sunset.css +16 -0
- package/src/styles/themes/twilight.css +16 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cn } from "../../utils/cn"
|
|
5
|
+
import { Badge } from "../../components/badge"
|
|
6
|
+
import { Button } from "../../components/button"
|
|
7
|
+
import { Card, CardContent, CardHeader, CardTitle } from "../../components/card"
|
|
8
|
+
import { Avatar, AvatarFallback, AvatarImage } from "../../components/avatar"
|
|
9
|
+
import { ScrollArea } from "../../components/scroll-area"
|
|
10
|
+
import {
|
|
11
|
+
Select,
|
|
12
|
+
SelectContent,
|
|
13
|
+
SelectItem,
|
|
14
|
+
SelectTrigger,
|
|
15
|
+
SelectValue,
|
|
16
|
+
} from "../../components/select"
|
|
17
|
+
import {
|
|
18
|
+
Phone,
|
|
19
|
+
Calendar,
|
|
20
|
+
Clock,
|
|
21
|
+
Users,
|
|
22
|
+
ChevronLeft,
|
|
23
|
+
ChevronRight,
|
|
24
|
+
AlertCircle,
|
|
25
|
+
Bell,
|
|
26
|
+
User,
|
|
27
|
+
Shield,
|
|
28
|
+
Repeat,
|
|
29
|
+
Plus,
|
|
30
|
+
Settings,
|
|
31
|
+
Mail,
|
|
32
|
+
MessageSquare,
|
|
33
|
+
ArrowRight,
|
|
34
|
+
CheckCircle2,
|
|
35
|
+
XCircle,
|
|
36
|
+
} from "lucide-react"
|
|
37
|
+
|
|
38
|
+
export type RotationType = "daily" | "weekly" | "biweekly" | "monthly" | "custom"
|
|
39
|
+
export type EscalationLevel = 1 | 2 | 3
|
|
40
|
+
|
|
41
|
+
export interface OnCallUser {
|
|
42
|
+
id: string
|
|
43
|
+
name: string
|
|
44
|
+
email: string
|
|
45
|
+
phone?: string
|
|
46
|
+
avatar?: string
|
|
47
|
+
timezone?: string
|
|
48
|
+
status?: "available" | "busy" | "dnd" | "offline"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface OnCallShift {
|
|
52
|
+
id: string
|
|
53
|
+
user: OnCallUser
|
|
54
|
+
startTime: Date
|
|
55
|
+
endTime: Date
|
|
56
|
+
level: EscalationLevel
|
|
57
|
+
overrideReason?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface OnCallSchedule {
|
|
61
|
+
id: string
|
|
62
|
+
name: string
|
|
63
|
+
description?: string
|
|
64
|
+
team: string
|
|
65
|
+
rotation: RotationType
|
|
66
|
+
escalationPolicy: {
|
|
67
|
+
level1Timeout: number // minutes
|
|
68
|
+
level2Timeout: number // minutes
|
|
69
|
+
level3Timeout: number // minutes
|
|
70
|
+
}
|
|
71
|
+
shifts: OnCallShift[]
|
|
72
|
+
users: OnCallUser[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface OnCallScheduleBlockProps {
|
|
76
|
+
schedule: OnCallSchedule
|
|
77
|
+
onEditSchedule?: () => void
|
|
78
|
+
onAddOverride?: (date: Date) => void
|
|
79
|
+
onSwapShift?: (shift: OnCallShift) => void
|
|
80
|
+
onContactOnCall?: (user: OnCallUser) => void
|
|
81
|
+
className?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const levelConfig: Record<EscalationLevel, { label: string; color: string; bgColor: string }> = {
|
|
85
|
+
1: { label: "Primary", color: "text-green-500", bgColor: "bg-green-500" },
|
|
86
|
+
2: { label: "Secondary", color: "text-yellow-500", bgColor: "bg-yellow-500" },
|
|
87
|
+
3: { label: "Tertiary", color: "text-red-500", bgColor: "bg-red-500" },
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const statusConfig = {
|
|
91
|
+
available: { color: "bg-green-500", label: "Available" },
|
|
92
|
+
busy: { color: "bg-yellow-500", label: "Busy" },
|
|
93
|
+
dnd: { color: "bg-red-500", label: "Do Not Disturb" },
|
|
94
|
+
offline: { color: "bg-gray-500", label: "Offline" },
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatTimeRange(start: Date, end: Date): string {
|
|
98
|
+
const formatTime = (d: Date) => d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })
|
|
99
|
+
const formatDate = (d: Date) => d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })
|
|
100
|
+
|
|
101
|
+
const sameDay = start.toDateString() === end.toDateString()
|
|
102
|
+
if (sameDay) {
|
|
103
|
+
return `${formatDate(start)} ${formatTime(start)} - ${formatTime(end)}`
|
|
104
|
+
}
|
|
105
|
+
return `${formatDate(start)} ${formatTime(start)} - ${formatDate(end)} ${formatTime(end)}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isCurrentShift(shift: OnCallShift): boolean {
|
|
109
|
+
const now = new Date()
|
|
110
|
+
return shift.startTime <= now && shift.endTime > now
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function CurrentOnCallCard({
|
|
114
|
+
shift,
|
|
115
|
+
nextShift,
|
|
116
|
+
onContact,
|
|
117
|
+
}: {
|
|
118
|
+
shift?: OnCallShift
|
|
119
|
+
nextShift?: OnCallShift
|
|
120
|
+
onContact?: (user: OnCallUser) => void
|
|
121
|
+
}) {
|
|
122
|
+
if (!shift) {
|
|
123
|
+
return (
|
|
124
|
+
<Card className="border-dashed">
|
|
125
|
+
<CardContent className="p-6 text-center text-muted-foreground">
|
|
126
|
+
<AlertCircle className="h-8 w-8 mx-auto mb-2" />
|
|
127
|
+
<p>No one is currently on call</p>
|
|
128
|
+
</CardContent>
|
|
129
|
+
</Card>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const levelConf = levelConfig[shift.level]
|
|
134
|
+
const statusConf = shift.user.status ? statusConfig[shift.user.status] : null
|
|
135
|
+
const timeRemaining = shift.endTime.getTime() - Date.now()
|
|
136
|
+
const hoursRemaining = Math.floor(timeRemaining / 3600000)
|
|
137
|
+
const minutesRemaining = Math.floor((timeRemaining % 3600000) / 60000)
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<Card className="border-green-500/30 bg-green-500/5">
|
|
141
|
+
<CardHeader className="pb-2">
|
|
142
|
+
<div className="flex items-center justify-between">
|
|
143
|
+
<Badge className={cn("text-white", levelConf.bgColor)}>
|
|
144
|
+
{levelConf.label} On-Call
|
|
145
|
+
</Badge>
|
|
146
|
+
<span className="text-sm text-muted-foreground flex items-center gap-1">
|
|
147
|
+
<Clock className="h-4 w-4" />
|
|
148
|
+
{hoursRemaining}h {minutesRemaining}m remaining
|
|
149
|
+
</span>
|
|
150
|
+
</div>
|
|
151
|
+
</CardHeader>
|
|
152
|
+
<CardContent className="space-y-4">
|
|
153
|
+
<div className="flex items-center gap-4">
|
|
154
|
+
<div className="relative">
|
|
155
|
+
<Avatar className="h-16 w-16">
|
|
156
|
+
<AvatarImage src={shift.user.avatar} />
|
|
157
|
+
<AvatarFallback className="text-xl">
|
|
158
|
+
{shift.user.name.slice(0, 2).toUpperCase()}
|
|
159
|
+
</AvatarFallback>
|
|
160
|
+
</Avatar>
|
|
161
|
+
{statusConf && (
|
|
162
|
+
<div className={cn(
|
|
163
|
+
"absolute bottom-0 right-0 w-4 h-4 rounded-full border-2 border-background",
|
|
164
|
+
statusConf.color
|
|
165
|
+
)} />
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div className="flex-1">
|
|
170
|
+
<div className="text-xl font-semibold">{shift.user.name}</div>
|
|
171
|
+
<div className="text-sm text-muted-foreground">{shift.user.email}</div>
|
|
172
|
+
{shift.user.timezone && (
|
|
173
|
+
<div className="text-xs text-muted-foreground mt-1">
|
|
174
|
+
Timezone: {shift.user.timezone}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<div className="flex flex-col gap-2">
|
|
180
|
+
{shift.user.phone && (
|
|
181
|
+
<Button size="sm" variant="outline" onClick={() => onContact?.(shift.user)}>
|
|
182
|
+
<Phone className="h-4 w-4 mr-1" />
|
|
183
|
+
Call
|
|
184
|
+
</Button>
|
|
185
|
+
)}
|
|
186
|
+
<Button size="sm" variant="outline" onClick={() => onContact?.(shift.user)}>
|
|
187
|
+
<MessageSquare className="h-4 w-4 mr-1" />
|
|
188
|
+
Message
|
|
189
|
+
</Button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{shift.overrideReason && (
|
|
194
|
+
<div className="text-sm text-muted-foreground bg-muted/50 p-2 rounded">
|
|
195
|
+
<span className="font-medium">Override:</span> {shift.overrideReason}
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{nextShift && (
|
|
200
|
+
<div className="flex items-center gap-2 pt-2 border-t text-sm">
|
|
201
|
+
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
|
202
|
+
<span className="text-muted-foreground">Next:</span>
|
|
203
|
+
<Avatar className="h-5 w-5">
|
|
204
|
+
<AvatarImage src={nextShift.user.avatar} />
|
|
205
|
+
<AvatarFallback className="text-xs">
|
|
206
|
+
{nextShift.user.name.slice(0, 2).toUpperCase()}
|
|
207
|
+
</AvatarFallback>
|
|
208
|
+
</Avatar>
|
|
209
|
+
<span>{nextShift.user.name}</span>
|
|
210
|
+
<span className="text-muted-foreground">
|
|
211
|
+
at {nextShift.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
|
|
212
|
+
</span>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
</CardContent>
|
|
216
|
+
</Card>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function EscalationPolicyCard({
|
|
221
|
+
policy,
|
|
222
|
+
currentLevel,
|
|
223
|
+
}: {
|
|
224
|
+
policy: OnCallSchedule["escalationPolicy"]
|
|
225
|
+
currentLevel?: EscalationLevel
|
|
226
|
+
}) {
|
|
227
|
+
return (
|
|
228
|
+
<Card>
|
|
229
|
+
<CardHeader className="pb-2">
|
|
230
|
+
<CardTitle className="text-base flex items-center gap-2">
|
|
231
|
+
<Shield className="h-4 w-4" />
|
|
232
|
+
Escalation Policy
|
|
233
|
+
</CardTitle>
|
|
234
|
+
</CardHeader>
|
|
235
|
+
<CardContent>
|
|
236
|
+
<div className="space-y-3">
|
|
237
|
+
{([1, 2, 3] as EscalationLevel[]).map((level) => {
|
|
238
|
+
const conf = levelConfig[level]
|
|
239
|
+
const timeout = level === 1 ? policy.level1Timeout : level === 2 ? policy.level2Timeout : policy.level3Timeout
|
|
240
|
+
const isActive = currentLevel === level
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<div
|
|
244
|
+
key={level}
|
|
245
|
+
className={cn(
|
|
246
|
+
"flex items-center gap-3 p-2 rounded",
|
|
247
|
+
isActive && `${conf.bgColor}/10 border ${conf.bgColor}/30`
|
|
248
|
+
)}
|
|
249
|
+
>
|
|
250
|
+
<div className={cn("w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold", conf.bgColor)}>
|
|
251
|
+
{level}
|
|
252
|
+
</div>
|
|
253
|
+
<div className="flex-1">
|
|
254
|
+
<div className="font-medium">{conf.label}</div>
|
|
255
|
+
<div className="text-xs text-muted-foreground">
|
|
256
|
+
Escalates after {timeout} minutes
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
{isActive && (
|
|
260
|
+
<Badge variant="outline" className={conf.color}>
|
|
261
|
+
Active
|
|
262
|
+
</Badge>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
)
|
|
266
|
+
})}
|
|
267
|
+
</div>
|
|
268
|
+
</CardContent>
|
|
269
|
+
</Card>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function WeekView({
|
|
274
|
+
shifts,
|
|
275
|
+
weekStart,
|
|
276
|
+
onShiftClick,
|
|
277
|
+
}: {
|
|
278
|
+
shifts: OnCallShift[]
|
|
279
|
+
weekStart: Date
|
|
280
|
+
onShiftClick?: (shift: OnCallShift) => void
|
|
281
|
+
}) {
|
|
282
|
+
const days = React.useMemo(() => {
|
|
283
|
+
const result = []
|
|
284
|
+
for (let i = 0; i < 7; i++) {
|
|
285
|
+
const date = new Date(weekStart)
|
|
286
|
+
date.setDate(date.getDate() + i)
|
|
287
|
+
result.push(date)
|
|
288
|
+
}
|
|
289
|
+
return result
|
|
290
|
+
}, [weekStart])
|
|
291
|
+
|
|
292
|
+
const getShiftsForDay = (date: Date) => {
|
|
293
|
+
return shifts.filter((shift) => {
|
|
294
|
+
const shiftDate = new Date(shift.startTime)
|
|
295
|
+
return shiftDate.toDateString() === date.toDateString()
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const isToday = (date: Date) => date.toDateString() === new Date().toDateString()
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<div className="grid grid-cols-7 gap-2">
|
|
303
|
+
{days.map((day) => {
|
|
304
|
+
const dayShifts = getShiftsForDay(day)
|
|
305
|
+
const today = isToday(day)
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<div
|
|
309
|
+
key={day.toISOString()}
|
|
310
|
+
className={cn(
|
|
311
|
+
"border rounded-lg p-2 min-h-[120px]",
|
|
312
|
+
today && "border-primary bg-primary/5"
|
|
313
|
+
)}
|
|
314
|
+
>
|
|
315
|
+
<div className={cn(
|
|
316
|
+
"text-center mb-2",
|
|
317
|
+
today && "font-bold text-primary"
|
|
318
|
+
)}>
|
|
319
|
+
<div className="text-xs text-muted-foreground">
|
|
320
|
+
{day.toLocaleDateString("en-US", { weekday: "short" })}
|
|
321
|
+
</div>
|
|
322
|
+
<div className={cn("text-lg", today && "text-primary")}>
|
|
323
|
+
{day.getDate()}
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
<div className="space-y-1">
|
|
328
|
+
{dayShifts.map((shift) => {
|
|
329
|
+
const conf = levelConfig[shift.level]
|
|
330
|
+
const isCurrent = isCurrentShift(shift)
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<button
|
|
334
|
+
key={shift.id}
|
|
335
|
+
className={cn(
|
|
336
|
+
"w-full text-left p-1.5 rounded text-xs transition-colors",
|
|
337
|
+
`${conf.bgColor}/10 hover:${conf.bgColor}/20`,
|
|
338
|
+
isCurrent && `ring-2 ring-${conf.bgColor}`
|
|
339
|
+
)}
|
|
340
|
+
onClick={() => onShiftClick?.(shift)}
|
|
341
|
+
>
|
|
342
|
+
<div className="flex items-center gap-1">
|
|
343
|
+
<Avatar className="h-4 w-4">
|
|
344
|
+
<AvatarImage src={shift.user.avatar} />
|
|
345
|
+
<AvatarFallback className="text-[8px]">
|
|
346
|
+
{shift.user.name.slice(0, 2).toUpperCase()}
|
|
347
|
+
</AvatarFallback>
|
|
348
|
+
</Avatar>
|
|
349
|
+
<span className="truncate">{shift.user.name.split(" ")[0]}</span>
|
|
350
|
+
</div>
|
|
351
|
+
<div className="text-muted-foreground mt-0.5">
|
|
352
|
+
{shift.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
|
|
353
|
+
</div>
|
|
354
|
+
</button>
|
|
355
|
+
)
|
|
356
|
+
})}
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
)
|
|
360
|
+
})}
|
|
361
|
+
</div>
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function TeamMembers({
|
|
366
|
+
users,
|
|
367
|
+
shifts,
|
|
368
|
+
}: {
|
|
369
|
+
users: OnCallUser[]
|
|
370
|
+
shifts: OnCallShift[]
|
|
371
|
+
}) {
|
|
372
|
+
// Calculate on-call hours for each user
|
|
373
|
+
const userStats = React.useMemo(() => {
|
|
374
|
+
return users.map((user) => {
|
|
375
|
+
const userShifts = shifts.filter((s) => s.user.id === user.id)
|
|
376
|
+
const totalHours = userShifts.reduce((acc, s) => {
|
|
377
|
+
return acc + (s.endTime.getTime() - s.startTime.getTime()) / 3600000
|
|
378
|
+
}, 0)
|
|
379
|
+
const isOnCall = userShifts.some(isCurrentShift)
|
|
380
|
+
return { user, totalHours, shiftCount: userShifts.length, isOnCall }
|
|
381
|
+
})
|
|
382
|
+
}, [users, shifts])
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<Card>
|
|
386
|
+
<CardHeader className="pb-2">
|
|
387
|
+
<CardTitle className="text-base flex items-center gap-2">
|
|
388
|
+
<Users className="h-4 w-4" />
|
|
389
|
+
Team Members
|
|
390
|
+
</CardTitle>
|
|
391
|
+
</CardHeader>
|
|
392
|
+
<CardContent>
|
|
393
|
+
<div className="space-y-2">
|
|
394
|
+
{userStats.map(({ user, totalHours, shiftCount, isOnCall }) => {
|
|
395
|
+
const statusConf = user.status ? statusConfig[user.status] : null
|
|
396
|
+
|
|
397
|
+
return (
|
|
398
|
+
<div
|
|
399
|
+
key={user.id}
|
|
400
|
+
className={cn(
|
|
401
|
+
"flex items-center gap-3 p-2 rounded",
|
|
402
|
+
isOnCall && "bg-green-500/10 border border-green-500/30"
|
|
403
|
+
)}
|
|
404
|
+
>
|
|
405
|
+
<div className="relative">
|
|
406
|
+
<Avatar className="h-8 w-8">
|
|
407
|
+
<AvatarImage src={user.avatar} />
|
|
408
|
+
<AvatarFallback className="text-xs">
|
|
409
|
+
{user.name.slice(0, 2).toUpperCase()}
|
|
410
|
+
</AvatarFallback>
|
|
411
|
+
</Avatar>
|
|
412
|
+
{statusConf && (
|
|
413
|
+
<div className={cn(
|
|
414
|
+
"absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background",
|
|
415
|
+
statusConf.color
|
|
416
|
+
)} />
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
|
|
420
|
+
<div className="flex-1 min-w-0">
|
|
421
|
+
<div className="font-medium truncate flex items-center gap-2">
|
|
422
|
+
{user.name}
|
|
423
|
+
{isOnCall && (
|
|
424
|
+
<Badge className="bg-green-500 text-xs">On-Call</Badge>
|
|
425
|
+
)}
|
|
426
|
+
</div>
|
|
427
|
+
<div className="text-xs text-muted-foreground">
|
|
428
|
+
{shiftCount} shifts • {Math.round(totalHours)}h total
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
)
|
|
433
|
+
})}
|
|
434
|
+
</div>
|
|
435
|
+
</CardContent>
|
|
436
|
+
</Card>
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function OnCallScheduleBlock({
|
|
441
|
+
schedule,
|
|
442
|
+
onEditSchedule,
|
|
443
|
+
onAddOverride,
|
|
444
|
+
onSwapShift,
|
|
445
|
+
onContactOnCall,
|
|
446
|
+
className,
|
|
447
|
+
}: OnCallScheduleBlockProps) {
|
|
448
|
+
const [weekOffset, setWeekOffset] = React.useState(0)
|
|
449
|
+
|
|
450
|
+
const weekStart = React.useMemo(() => {
|
|
451
|
+
const now = new Date()
|
|
452
|
+
const dayOfWeek = now.getDay()
|
|
453
|
+
const start = new Date(now)
|
|
454
|
+
start.setDate(now.getDate() - dayOfWeek + (weekOffset * 7))
|
|
455
|
+
start.setHours(0, 0, 0, 0)
|
|
456
|
+
return start
|
|
457
|
+
}, [weekOffset])
|
|
458
|
+
|
|
459
|
+
// Find current on-call
|
|
460
|
+
const currentShift = schedule.shifts.find(isCurrentShift)
|
|
461
|
+
const upcomingShifts = schedule.shifts
|
|
462
|
+
.filter((s) => s.startTime > new Date())
|
|
463
|
+
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
|
|
464
|
+
const nextShift = upcomingShifts[0]
|
|
465
|
+
|
|
466
|
+
return (
|
|
467
|
+
<div className={cn("space-y-6", className)}>
|
|
468
|
+
{/* Header */}
|
|
469
|
+
<Card>
|
|
470
|
+
<CardHeader>
|
|
471
|
+
<div className="flex items-center justify-between">
|
|
472
|
+
<div className="flex items-center gap-3">
|
|
473
|
+
<Phone className="h-6 w-6" />
|
|
474
|
+
<div>
|
|
475
|
+
<CardTitle>{schedule.name}</CardTitle>
|
|
476
|
+
<div className="flex items-center gap-2 mt-1">
|
|
477
|
+
<Badge variant="outline">{schedule.team}</Badge>
|
|
478
|
+
<Badge variant="secondary">
|
|
479
|
+
<Repeat className="h-3 w-3 mr-1" />
|
|
480
|
+
{schedule.rotation}
|
|
481
|
+
</Badge>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
|
|
486
|
+
<div className="flex items-center gap-2">
|
|
487
|
+
{onAddOverride && (
|
|
488
|
+
<Button variant="outline" onClick={() => onAddOverride(new Date())}>
|
|
489
|
+
<Plus className="h-4 w-4 mr-1" />
|
|
490
|
+
Add Override
|
|
491
|
+
</Button>
|
|
492
|
+
)}
|
|
493
|
+
{onEditSchedule && (
|
|
494
|
+
<Button variant="outline" onClick={onEditSchedule}>
|
|
495
|
+
<Settings className="h-4 w-4 mr-1" />
|
|
496
|
+
Settings
|
|
497
|
+
</Button>
|
|
498
|
+
)}
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
</CardHeader>
|
|
502
|
+
</Card>
|
|
503
|
+
|
|
504
|
+
<div className="grid grid-cols-3 gap-6">
|
|
505
|
+
{/* Current on-call */}
|
|
506
|
+
<div className="col-span-2 space-y-4">
|
|
507
|
+
<CurrentOnCallCard
|
|
508
|
+
shift={currentShift}
|
|
509
|
+
nextShift={nextShift}
|
|
510
|
+
onContact={onContactOnCall}
|
|
511
|
+
/>
|
|
512
|
+
|
|
513
|
+
{/* Week view */}
|
|
514
|
+
<Card>
|
|
515
|
+
<CardHeader className="pb-2">
|
|
516
|
+
<div className="flex items-center justify-between">
|
|
517
|
+
<CardTitle className="text-base flex items-center gap-2">
|
|
518
|
+
<Calendar className="h-4 w-4" />
|
|
519
|
+
Schedule
|
|
520
|
+
</CardTitle>
|
|
521
|
+
<div className="flex items-center gap-2">
|
|
522
|
+
<Button variant="ghost" size="sm" onClick={() => setWeekOffset((o) => o - 1)}>
|
|
523
|
+
<ChevronLeft className="h-4 w-4" />
|
|
524
|
+
</Button>
|
|
525
|
+
<span className="text-sm min-w-[120px] text-center">
|
|
526
|
+
{weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" })} -
|
|
527
|
+
{new Date(weekStart.getTime() + 6 * 24 * 3600000).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
|
528
|
+
</span>
|
|
529
|
+
<Button variant="ghost" size="sm" onClick={() => setWeekOffset((o) => o + 1)}>
|
|
530
|
+
<ChevronRight className="h-4 w-4" />
|
|
531
|
+
</Button>
|
|
532
|
+
{weekOffset !== 0 && (
|
|
533
|
+
<Button variant="ghost" size="sm" onClick={() => setWeekOffset(0)}>
|
|
534
|
+
Today
|
|
535
|
+
</Button>
|
|
536
|
+
)}
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
</CardHeader>
|
|
540
|
+
<CardContent>
|
|
541
|
+
<WeekView
|
|
542
|
+
shifts={schedule.shifts}
|
|
543
|
+
weekStart={weekStart}
|
|
544
|
+
onShiftClick={onSwapShift}
|
|
545
|
+
/>
|
|
546
|
+
</CardContent>
|
|
547
|
+
</Card>
|
|
548
|
+
</div>
|
|
549
|
+
|
|
550
|
+
{/* Sidebar */}
|
|
551
|
+
<div className="space-y-4">
|
|
552
|
+
<EscalationPolicyCard
|
|
553
|
+
policy={schedule.escalationPolicy}
|
|
554
|
+
currentLevel={currentShift?.level}
|
|
555
|
+
/>
|
|
556
|
+
<TeamMembers users={schedule.users} shifts={schedule.shifts} />
|
|
557
|
+
</div>
|
|
558
|
+
</div>
|
|
559
|
+
</div>
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Default sample data
|
|
564
|
+
const sampleUsers: OnCallUser[] = [
|
|
565
|
+
{ id: "1", name: "John Doe", email: "john@example.com", phone: "+1 555-0101", status: "available", timezone: "America/New_York" },
|
|
566
|
+
{ id: "2", name: "Jane Smith", email: "jane@example.com", phone: "+1 555-0102", status: "available", timezone: "America/Los_Angeles" },
|
|
567
|
+
{ id: "3", name: "Bob Wilson", email: "bob@example.com", phone: "+1 555-0103", status: "busy", timezone: "Europe/London" },
|
|
568
|
+
{ id: "4", name: "Alice Brown", email: "alice@example.com", phone: "+1 555-0104", status: "available", timezone: "Europe/Paris" },
|
|
569
|
+
]
|
|
570
|
+
|
|
571
|
+
function generateShifts(): OnCallShift[] {
|
|
572
|
+
const shifts: OnCallShift[] = []
|
|
573
|
+
const now = new Date()
|
|
574
|
+
const startOfWeek = new Date(now)
|
|
575
|
+
startOfWeek.setDate(now.getDate() - now.getDay())
|
|
576
|
+
startOfWeek.setHours(0, 0, 0, 0)
|
|
577
|
+
|
|
578
|
+
for (let week = -1; week < 3; week++) {
|
|
579
|
+
for (let day = 0; day < 7; day++) {
|
|
580
|
+
const shiftStart = new Date(startOfWeek)
|
|
581
|
+
shiftStart.setDate(startOfWeek.getDate() + (week * 7) + day)
|
|
582
|
+
shiftStart.setHours(9, 0, 0, 0)
|
|
583
|
+
|
|
584
|
+
const shiftEnd = new Date(shiftStart)
|
|
585
|
+
shiftEnd.setDate(shiftEnd.getDate() + 1)
|
|
586
|
+
|
|
587
|
+
const userIndex = (week + 1 + day) % sampleUsers.length
|
|
588
|
+
|
|
589
|
+
shifts.push({
|
|
590
|
+
id: `shift-${week}-${day}`,
|
|
591
|
+
user: sampleUsers[userIndex],
|
|
592
|
+
startTime: shiftStart,
|
|
593
|
+
endTime: shiftEnd,
|
|
594
|
+
level: 1,
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return shifts
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export const defaultOnCallSchedule: OnCallSchedule = {
|
|
603
|
+
id: "1",
|
|
604
|
+
name: "Platform Team On-Call",
|
|
605
|
+
description: "Primary on-call rotation for the platform engineering team",
|
|
606
|
+
team: "Platform Engineering",
|
|
607
|
+
rotation: "weekly",
|
|
608
|
+
escalationPolicy: {
|
|
609
|
+
level1Timeout: 15,
|
|
610
|
+
level2Timeout: 30,
|
|
611
|
+
level3Timeout: 60,
|
|
612
|
+
},
|
|
613
|
+
shifts: generateShifts(),
|
|
614
|
+
users: sampleUsers,
|
|
615
|
+
}
|