@hed-hog/lms 0.0.312 → 0.0.315
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/class-group/class-group.controller.d.ts +2 -2
- package/dist/class-group/class-group.service.d.ts +2 -2
- package/dist/enterprise/dto/enterprise-profile.dto.d.ts +13 -0
- package/dist/enterprise/dto/enterprise-profile.dto.d.ts.map +1 -0
- package/dist/enterprise/dto/enterprise-profile.dto.js +3 -0
- package/dist/enterprise/dto/enterprise-profile.dto.js.map +1 -0
- package/dist/enterprise/enterprise.controller.d.ts +3 -0
- package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
- package/dist/enterprise/enterprise.controller.js +14 -0
- package/dist/enterprise/enterprise.controller.js.map +1 -1
- package/dist/enterprise/enterprise.service.d.ts +3 -0
- package/dist/enterprise/enterprise.service.d.ts.map +1 -1
- package/dist/enterprise/enterprise.service.js +128 -1
- package/dist/enterprise/enterprise.service.js.map +1 -1
- package/dist/instructor/instructor.controller.d.ts +23 -0
- package/dist/instructor/instructor.controller.d.ts.map +1 -1
- package/dist/instructor/instructor.controller.js +41 -0
- package/dist/instructor/instructor.controller.js.map +1 -1
- package/dist/instructor/instructor.service.d.ts +25 -0
- package/dist/instructor/instructor.service.d.ts.map +1 -1
- package/dist/instructor/instructor.service.js +126 -8
- package/dist/instructor/instructor.service.js.map +1 -1
- package/hedhog/data/menu.yaml +23 -7
- package/hedhog/data/role.yaml +9 -1
- package/hedhog/data/route.yaml +54 -0
- package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +2 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +44 -44
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +362 -362
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +111 -111
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -134
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -113
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -314
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +62 -62
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +173 -173
- package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +58 -58
- package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +51 -51
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +276 -276
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +1216 -1216
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1824 -1824
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +443 -443
- package/hedhog/frontend/app/courses/[id]/structure/_components/highlighted-text.tsx.ejs +40 -40
- package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +185 -185
- package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +264 -264
- package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +95 -95
- package/hedhog/frontend/app/courses/[id]/structure/_components/session-picker-dialog.tsx.ejs +73 -73
- package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +136 -136
- package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +80 -80
- package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +949 -949
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +525 -525
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +181 -181
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +51 -51
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +271 -271
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +167 -167
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +108 -108
- package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +318 -318
- package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +10 -10
- package/hedhog/frontend/app/enterprise/_components/enterprise-course-create-sheet.tsx.ejs +2 -1
- package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +438 -0
- package/hedhog/frontend/app/instructors/_components/instructor-types.ts.ejs +40 -0
- package/hedhog/frontend/app/instructors/page.tsx.ejs +696 -0
- package/hedhog/frontend/app/training/page.tsx.ejs +2339 -0
- package/hedhog/table/enterprise_user.yaml +1 -1
- package/package.json +8 -8
- package/src/enterprise/dto/enterprise-profile.dto.ts +17 -0
- package/src/enterprise/enterprise.controller.ts +9 -1
- package/src/enterprise/enterprise.service.ts +147 -4
- package/src/instructor/instructor.controller.ts +36 -9
- package/src/instructor/instructor.service.ts +140 -10
|
@@ -1,443 +1,443 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { zodResolver } from '@hookform/resolvers/zod';
|
|
4
|
-
import {
|
|
5
|
-
CircleDot,
|
|
6
|
-
Clock,
|
|
7
|
-
Eye,
|
|
8
|
-
EyeOff,
|
|
9
|
-
Layers,
|
|
10
|
-
Loader2,
|
|
11
|
-
Lock,
|
|
12
|
-
Plus,
|
|
13
|
-
Save,
|
|
14
|
-
Trash2,
|
|
15
|
-
Undo2,
|
|
16
|
-
Video,
|
|
17
|
-
} from 'lucide-react';
|
|
18
|
-
import { useEffect, useMemo } from 'react';
|
|
19
|
-
import { useForm } from 'react-hook-form';
|
|
20
|
-
import { z } from 'zod';
|
|
21
|
-
|
|
22
|
-
import { Badge } from '@/components/ui/badge';
|
|
23
|
-
import { Button } from '@/components/ui/button';
|
|
24
|
-
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
25
|
-
import {
|
|
26
|
-
Form,
|
|
27
|
-
FormControl,
|
|
28
|
-
FormField,
|
|
29
|
-
FormItem,
|
|
30
|
-
FormLabel,
|
|
31
|
-
FormMessage,
|
|
32
|
-
} from '@/components/ui/form';
|
|
33
|
-
import { Input } from '@/components/ui/input';
|
|
34
|
-
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
35
|
-
import {
|
|
36
|
-
Select,
|
|
37
|
-
SelectContent,
|
|
38
|
-
SelectItem,
|
|
39
|
-
SelectTrigger,
|
|
40
|
-
SelectValue,
|
|
41
|
-
} from '@/components/ui/select';
|
|
42
|
-
import { Separator } from '@/components/ui/separator';
|
|
43
|
-
import { Switch } from '@/components/ui/switch';
|
|
44
|
-
import { Textarea } from '@/components/ui/textarea';
|
|
45
|
-
|
|
46
|
-
import {
|
|
47
|
-
useCreateLessonMutation,
|
|
48
|
-
useDeleteSessionMutation,
|
|
49
|
-
useUpdateSessionMutation,
|
|
50
|
-
} from '../_data/use-course-structure-mutations';
|
|
51
|
-
import { useStructureStore } from './store';
|
|
52
|
-
import type { Visibility } from './types';
|
|
53
|
-
|
|
54
|
-
// ── Schema ────────────────────────────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
const schema = z.object({
|
|
57
|
-
code: z.string().min(1, 'Código obrigatório'),
|
|
58
|
-
title: z.string().min(1, 'Título obrigatório'),
|
|
59
|
-
description: z.string(),
|
|
60
|
-
duration: z.coerce.number().min(0),
|
|
61
|
-
visibility: z.enum(['publico', 'privado', 'restrito'] as const),
|
|
62
|
-
published: z.boolean(),
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
type FormValues = z.infer<typeof schema>;
|
|
66
|
-
|
|
67
|
-
// ── Component ─────────────────────────────────────────────────────────────────
|
|
68
|
-
|
|
69
|
-
interface EditorSessionProps {
|
|
70
|
-
sessionId: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function EditorSession({ sessionId }: EditorSessionProps) {
|
|
74
|
-
const session = useStructureStore((s) =>
|
|
75
|
-
s.sessions.find((ss) => ss.id === sessionId)
|
|
76
|
-
);
|
|
77
|
-
const allLessons = useStructureStore((s) => s.lessons);
|
|
78
|
-
const lessons = useMemo(
|
|
79
|
-
() => allLessons.filter((l) => l.sessionId === sessionId),
|
|
80
|
-
[allLessons, sessionId]
|
|
81
|
-
);
|
|
82
|
-
const showConfirm = useStructureStore((s) => s.showConfirm);
|
|
83
|
-
|
|
84
|
-
const updateSession = useUpdateSessionMutation();
|
|
85
|
-
const createLesson = useCreateLessonMutation();
|
|
86
|
-
const deleteSession = useDeleteSessionMutation();
|
|
87
|
-
|
|
88
|
-
const totalMinutes = lessons.reduce((sum, l) => sum + l.duration, 0);
|
|
89
|
-
const hours = Math.floor(totalMinutes / 60);
|
|
90
|
-
const minutes = totalMinutes % 60;
|
|
91
|
-
|
|
92
|
-
const defaultValues: FormValues = {
|
|
93
|
-
code: session?.code ?? '',
|
|
94
|
-
title: session?.title ?? '',
|
|
95
|
-
description: session?.description ?? '',
|
|
96
|
-
duration: session?.duration ?? 0,
|
|
97
|
-
visibility: (session?.visibility ?? 'publico') as Visibility,
|
|
98
|
-
published: session?.published ?? true,
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
const form = useForm<FormValues>({
|
|
102
|
-
resolver: zodResolver(schema),
|
|
103
|
-
defaultValues,
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
const { isDirty } = form.formState;
|
|
107
|
-
|
|
108
|
-
useEffect(() => {
|
|
109
|
-
if (!session) return;
|
|
110
|
-
form.reset({
|
|
111
|
-
code: session.code,
|
|
112
|
-
title: session.title,
|
|
113
|
-
description: session.description ?? '',
|
|
114
|
-
duration: session.duration,
|
|
115
|
-
visibility: session.visibility ?? 'publico',
|
|
116
|
-
published: session.published ?? true,
|
|
117
|
-
});
|
|
118
|
-
}, [session?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
119
|
-
|
|
120
|
-
if (!session) return null;
|
|
121
|
-
|
|
122
|
-
function onSubmit(values: FormValues) {
|
|
123
|
-
updateSession.mutate({ sessionId, formValues: values });
|
|
124
|
-
form.reset(values);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function handleDelete() {
|
|
128
|
-
showConfirm({
|
|
129
|
-
title: `Excluir sessão "${session!.title}"?`,
|
|
130
|
-
description:
|
|
131
|
-
'A sessão e todas as suas aulas serão excluídas permanentemente.',
|
|
132
|
-
onConfirm: () => deleteSession.mutate({ sessionId }),
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return (
|
|
137
|
-
<Form {...form}>
|
|
138
|
-
<form
|
|
139
|
-
onSubmit={form.handleSubmit(onSubmit)}
|
|
140
|
-
className="flex flex-col h-full min-h-0"
|
|
141
|
-
>
|
|
142
|
-
{/* ── Header ───────────────────────────────────────────────────────── */}
|
|
143
|
-
<div className="flex items-center gap-3 px-4 py-3 border-b bg-muted/30 shrink-0">
|
|
144
|
-
<div className="flex size-9 items-center justify-center rounded-lg bg-muted shrink-0">
|
|
145
|
-
<Layers className="size-4 text-muted-foreground" />
|
|
146
|
-
</div>
|
|
147
|
-
<div className="flex-1 min-w-0">
|
|
148
|
-
<div className="flex items-center gap-1.5">
|
|
149
|
-
<span className="text-sm font-semibold truncate">Sessão</span>
|
|
150
|
-
{isDirty && (
|
|
151
|
-
<CircleDot className="size-3 text-amber-500 shrink-0" />
|
|
152
|
-
)}
|
|
153
|
-
</div>
|
|
154
|
-
<p className="text-[0.65rem] text-muted-foreground truncate">
|
|
155
|
-
{session.code}
|
|
156
|
-
</p>
|
|
157
|
-
</div>
|
|
158
|
-
<Badge
|
|
159
|
-
variant={session.published ? 'default' : 'secondary'}
|
|
160
|
-
className="shrink-0 text-xs"
|
|
161
|
-
>
|
|
162
|
-
{session.published ? 'Publicada' : 'Oculta'}
|
|
163
|
-
</Badge>
|
|
164
|
-
<Button
|
|
165
|
-
type="button"
|
|
166
|
-
variant="ghost"
|
|
167
|
-
size="icon"
|
|
168
|
-
className="size-7 text-destructive/60 hover:text-destructive shrink-0"
|
|
169
|
-
title="Excluir sessão"
|
|
170
|
-
aria-label="Excluir sessão"
|
|
171
|
-
disabled={deleteSession.isPending}
|
|
172
|
-
onClick={handleDelete}
|
|
173
|
-
>
|
|
174
|
-
{deleteSession.isPending ? (
|
|
175
|
-
<Loader2 className="size-3.5 animate-spin" />
|
|
176
|
-
) : (
|
|
177
|
-
<Trash2 className="size-3.5" />
|
|
178
|
-
)}
|
|
179
|
-
</Button>
|
|
180
|
-
</div>
|
|
181
|
-
|
|
182
|
-
{/* ── Scrollable body ───────────────────────────────────────────────── */}
|
|
183
|
-
<ScrollArea className="flex-1 min-h-0">
|
|
184
|
-
<div className="flex flex-col gap-3 p-3">
|
|
185
|
-
{/* ── Stats ────────────────────────────────────────────────────── */}
|
|
186
|
-
<div className="grid grid-cols-2 gap-2">
|
|
187
|
-
<StatChip
|
|
188
|
-
icon={<Video className="size-3" />}
|
|
189
|
-
label="Aulas"
|
|
190
|
-
value={lessons.length}
|
|
191
|
-
/>
|
|
192
|
-
<StatChip
|
|
193
|
-
icon={<Clock className="size-3" />}
|
|
194
|
-
label="Duração"
|
|
195
|
-
value={hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`}
|
|
196
|
-
/>
|
|
197
|
-
</div>
|
|
198
|
-
|
|
199
|
-
{/* ── Dados ────────────────────────────────────────────────────── */}
|
|
200
|
-
<Card className="bg-muted/20 py-2 gap-2">
|
|
201
|
-
<CardHeader className="px-3 pt-2 pb-1">
|
|
202
|
-
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
203
|
-
Dados
|
|
204
|
-
</CardTitle>
|
|
205
|
-
</CardHeader>
|
|
206
|
-
<CardContent className="px-3 pb-2 flex flex-col gap-3">
|
|
207
|
-
<div className="grid grid-cols-2 gap-2">
|
|
208
|
-
<FormField
|
|
209
|
-
control={form.control}
|
|
210
|
-
name="code"
|
|
211
|
-
render={({ field }) => (
|
|
212
|
-
<FormItem>
|
|
213
|
-
<FormLabel className="text-xs">Código</FormLabel>
|
|
214
|
-
<FormControl>
|
|
215
|
-
<Input {...field} className="h-8 text-xs font-mono" />
|
|
216
|
-
</FormControl>
|
|
217
|
-
<FormMessage className="text-xs" />
|
|
218
|
-
</FormItem>
|
|
219
|
-
)}
|
|
220
|
-
/>
|
|
221
|
-
<FormField
|
|
222
|
-
control={form.control}
|
|
223
|
-
name="duration"
|
|
224
|
-
render={({ field }) => (
|
|
225
|
-
<FormItem>
|
|
226
|
-
<FormLabel className="text-xs">Duração (min)</FormLabel>
|
|
227
|
-
<FormControl>
|
|
228
|
-
<Input
|
|
229
|
-
{...field}
|
|
230
|
-
type="number"
|
|
231
|
-
className="h-8 text-xs"
|
|
232
|
-
/>
|
|
233
|
-
</FormControl>
|
|
234
|
-
<FormMessage className="text-xs" />
|
|
235
|
-
</FormItem>
|
|
236
|
-
)}
|
|
237
|
-
/>
|
|
238
|
-
</div>
|
|
239
|
-
|
|
240
|
-
<FormField
|
|
241
|
-
control={form.control}
|
|
242
|
-
name="title"
|
|
243
|
-
render={({ field }) => (
|
|
244
|
-
<FormItem>
|
|
245
|
-
<FormLabel className="text-xs">Título</FormLabel>
|
|
246
|
-
<FormControl>
|
|
247
|
-
<Input {...field} className="h-8 text-sm" />
|
|
248
|
-
</FormControl>
|
|
249
|
-
<FormMessage className="text-xs" />
|
|
250
|
-
</FormItem>
|
|
251
|
-
)}
|
|
252
|
-
/>
|
|
253
|
-
|
|
254
|
-
<FormField
|
|
255
|
-
control={form.control}
|
|
256
|
-
name="description"
|
|
257
|
-
render={({ field }) => (
|
|
258
|
-
<FormItem>
|
|
259
|
-
<FormLabel className="text-xs">Descrição</FormLabel>
|
|
260
|
-
<FormControl>
|
|
261
|
-
<Textarea
|
|
262
|
-
{...field}
|
|
263
|
-
rows={3}
|
|
264
|
-
className="text-sm resize-none"
|
|
265
|
-
placeholder="Descrição opcional da sessão…"
|
|
266
|
-
/>
|
|
267
|
-
</FormControl>
|
|
268
|
-
<FormMessage className="text-xs" />
|
|
269
|
-
</FormItem>
|
|
270
|
-
)}
|
|
271
|
-
/>
|
|
272
|
-
</CardContent>
|
|
273
|
-
</Card>
|
|
274
|
-
|
|
275
|
-
{/* ── Publicação ───────────────────────────────────────────────── */}
|
|
276
|
-
<Card className="bg-muted/20 py-2 gap-2">
|
|
277
|
-
<CardHeader className="px-3 pt-2 pb-1">
|
|
278
|
-
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
279
|
-
Publicação
|
|
280
|
-
</CardTitle>
|
|
281
|
-
</CardHeader>
|
|
282
|
-
<CardContent className="px-3 pb-2">
|
|
283
|
-
<div className="grid grid-cols-2 gap-2 items-end">
|
|
284
|
-
<FormField
|
|
285
|
-
control={form.control}
|
|
286
|
-
name="visibility"
|
|
287
|
-
render={({ field }) => (
|
|
288
|
-
<FormItem>
|
|
289
|
-
<FormLabel className="text-xs">Visibilidade</FormLabel>
|
|
290
|
-
<Select
|
|
291
|
-
value={field.value}
|
|
292
|
-
onValueChange={field.onChange}
|
|
293
|
-
>
|
|
294
|
-
<FormControl>
|
|
295
|
-
<SelectTrigger className="h-8 text-xs w-full">
|
|
296
|
-
<SelectValue />
|
|
297
|
-
</SelectTrigger>
|
|
298
|
-
</FormControl>
|
|
299
|
-
<SelectContent>
|
|
300
|
-
<SelectItem value="publico">
|
|
301
|
-
<span className="flex items-center gap-1.5">
|
|
302
|
-
<Eye className="size-3" /> Público
|
|
303
|
-
</span>
|
|
304
|
-
</SelectItem>
|
|
305
|
-
<SelectItem value="privado">
|
|
306
|
-
<span className="flex items-center gap-1.5">
|
|
307
|
-
<EyeOff className="size-3" /> Privado
|
|
308
|
-
</span>
|
|
309
|
-
</SelectItem>
|
|
310
|
-
<SelectItem value="restrito">
|
|
311
|
-
<span className="flex items-center gap-1.5">
|
|
312
|
-
<Lock className="size-3" /> Restrito
|
|
313
|
-
</span>
|
|
314
|
-
</SelectItem>
|
|
315
|
-
</SelectContent>
|
|
316
|
-
</Select>
|
|
317
|
-
<FormMessage className="text-xs" />
|
|
318
|
-
</FormItem>
|
|
319
|
-
)}
|
|
320
|
-
/>
|
|
321
|
-
|
|
322
|
-
<FormField
|
|
323
|
-
control={form.control}
|
|
324
|
-
name="published"
|
|
325
|
-
render={({ field }) => (
|
|
326
|
-
<FormItem className="flex items-center justify-between gap-3 rounded-md border px-3 h-8">
|
|
327
|
-
<FormLabel className="text-xs cursor-pointer">
|
|
328
|
-
Publicada
|
|
329
|
-
</FormLabel>
|
|
330
|
-
<FormControl>
|
|
331
|
-
<Switch
|
|
332
|
-
checked={field.value}
|
|
333
|
-
onCheckedChange={field.onChange}
|
|
334
|
-
/>
|
|
335
|
-
</FormControl>
|
|
336
|
-
</FormItem>
|
|
337
|
-
)}
|
|
338
|
-
/>
|
|
339
|
-
</div>
|
|
340
|
-
</CardContent>
|
|
341
|
-
</Card>
|
|
342
|
-
|
|
343
|
-
{/* ── Aulas da sessão ───────────────────────────────────────────── */}
|
|
344
|
-
{lessons.length === 0 && (
|
|
345
|
-
<div className="flex flex-col items-center gap-2 py-6 px-2 rounded-lg border border-dashed text-center">
|
|
346
|
-
<Video className="size-5 text-muted-foreground/40" />
|
|
347
|
-
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
348
|
-
Esta sessão ainda não tem aulas.
|
|
349
|
-
</p>
|
|
350
|
-
<Button
|
|
351
|
-
type="button"
|
|
352
|
-
variant="outline"
|
|
353
|
-
size="sm"
|
|
354
|
-
className="h-7 text-xs gap-1.5"
|
|
355
|
-
disabled={createLesson.isPending}
|
|
356
|
-
onClick={() => createLesson.mutate({ sessionId })}
|
|
357
|
-
>
|
|
358
|
-
{createLesson.isPending ? (
|
|
359
|
-
<Loader2 className="size-3 animate-spin" />
|
|
360
|
-
) : (
|
|
361
|
-
<Plus className="size-3" />
|
|
362
|
-
)}
|
|
363
|
-
Adicionar aula
|
|
364
|
-
</Button>
|
|
365
|
-
</div>
|
|
366
|
-
)}
|
|
367
|
-
</div>
|
|
368
|
-
</ScrollArea>
|
|
369
|
-
|
|
370
|
-
{/* ── Footer ───────────────────────────────────────────────────────── */}
|
|
371
|
-
<div className="shrink-0 border-t bg-background">
|
|
372
|
-
<Separator />
|
|
373
|
-
<div className="flex items-center gap-2 px-3 py-2">
|
|
374
|
-
<Button
|
|
375
|
-
type="button"
|
|
376
|
-
variant="ghost"
|
|
377
|
-
size="sm"
|
|
378
|
-
className="h-7 text-xs"
|
|
379
|
-
disabled={!isDirty || updateSession.isPending}
|
|
380
|
-
onClick={() => form.reset()}
|
|
381
|
-
>
|
|
382
|
-
<Undo2 className="size-3 mr-1" />
|
|
383
|
-
Cancelar
|
|
384
|
-
</Button>
|
|
385
|
-
<div className="flex-1" />
|
|
386
|
-
<Button
|
|
387
|
-
type="button"
|
|
388
|
-
variant="outline"
|
|
389
|
-
size="sm"
|
|
390
|
-
className="h-7 text-xs"
|
|
391
|
-
disabled={createLesson.isPending}
|
|
392
|
-
onClick={() => createLesson.mutate({ sessionId })}
|
|
393
|
-
>
|
|
394
|
-
{createLesson.isPending ? (
|
|
395
|
-
<Loader2 className="size-3 mr-1 animate-spin" />
|
|
396
|
-
) : (
|
|
397
|
-
<Plus className="size-3 mr-1" />
|
|
398
|
-
)}
|
|
399
|
-
Nova aula
|
|
400
|
-
</Button>
|
|
401
|
-
<Button
|
|
402
|
-
type="submit"
|
|
403
|
-
size="sm"
|
|
404
|
-
className="h-7 text-xs"
|
|
405
|
-
disabled={!isDirty || updateSession.isPending}
|
|
406
|
-
>
|
|
407
|
-
{updateSession.isPending ? (
|
|
408
|
-
<Loader2 className="size-3 mr-1 animate-spin" />
|
|
409
|
-
) : (
|
|
410
|
-
<Save className="size-3 mr-1" />
|
|
411
|
-
)}
|
|
412
|
-
Salvar
|
|
413
|
-
</Button>
|
|
414
|
-
</div>
|
|
415
|
-
</div>
|
|
416
|
-
</form>
|
|
417
|
-
</Form>
|
|
418
|
-
);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
422
|
-
|
|
423
|
-
function StatChip({
|
|
424
|
-
icon,
|
|
425
|
-
label,
|
|
426
|
-
value,
|
|
427
|
-
}: {
|
|
428
|
-
icon: React.ReactNode;
|
|
429
|
-
label: string;
|
|
430
|
-
value: string | number;
|
|
431
|
-
}) {
|
|
432
|
-
return (
|
|
433
|
-
<div className="flex items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-2">
|
|
434
|
-
<span className="text-muted-foreground shrink-0">{icon}</span>
|
|
435
|
-
<div className="min-w-0">
|
|
436
|
-
<p className="text-[0.65rem] text-muted-foreground truncate">{label}</p>
|
|
437
|
-
<p className="text-sm font-semibold tabular-nums leading-none">
|
|
438
|
-
{value}
|
|
439
|
-
</p>
|
|
440
|
-
</div>
|
|
441
|
-
</div>
|
|
442
|
-
);
|
|
443
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
4
|
+
import {
|
|
5
|
+
CircleDot,
|
|
6
|
+
Clock,
|
|
7
|
+
Eye,
|
|
8
|
+
EyeOff,
|
|
9
|
+
Layers,
|
|
10
|
+
Loader2,
|
|
11
|
+
Lock,
|
|
12
|
+
Plus,
|
|
13
|
+
Save,
|
|
14
|
+
Trash2,
|
|
15
|
+
Undo2,
|
|
16
|
+
Video,
|
|
17
|
+
} from 'lucide-react';
|
|
18
|
+
import { useEffect, useMemo } from 'react';
|
|
19
|
+
import { useForm } from 'react-hook-form';
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
|
|
22
|
+
import { Badge } from '@/components/ui/badge';
|
|
23
|
+
import { Button } from '@/components/ui/button';
|
|
24
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
25
|
+
import {
|
|
26
|
+
Form,
|
|
27
|
+
FormControl,
|
|
28
|
+
FormField,
|
|
29
|
+
FormItem,
|
|
30
|
+
FormLabel,
|
|
31
|
+
FormMessage,
|
|
32
|
+
} from '@/components/ui/form';
|
|
33
|
+
import { Input } from '@/components/ui/input';
|
|
34
|
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
35
|
+
import {
|
|
36
|
+
Select,
|
|
37
|
+
SelectContent,
|
|
38
|
+
SelectItem,
|
|
39
|
+
SelectTrigger,
|
|
40
|
+
SelectValue,
|
|
41
|
+
} from '@/components/ui/select';
|
|
42
|
+
import { Separator } from '@/components/ui/separator';
|
|
43
|
+
import { Switch } from '@/components/ui/switch';
|
|
44
|
+
import { Textarea } from '@/components/ui/textarea';
|
|
45
|
+
|
|
46
|
+
import {
|
|
47
|
+
useCreateLessonMutation,
|
|
48
|
+
useDeleteSessionMutation,
|
|
49
|
+
useUpdateSessionMutation,
|
|
50
|
+
} from '../_data/use-course-structure-mutations';
|
|
51
|
+
import { useStructureStore } from './store';
|
|
52
|
+
import type { Visibility } from './types';
|
|
53
|
+
|
|
54
|
+
// ── Schema ────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
const schema = z.object({
|
|
57
|
+
code: z.string().min(1, 'Código obrigatório'),
|
|
58
|
+
title: z.string().min(1, 'Título obrigatório'),
|
|
59
|
+
description: z.string(),
|
|
60
|
+
duration: z.coerce.number().min(0),
|
|
61
|
+
visibility: z.enum(['publico', 'privado', 'restrito'] as const),
|
|
62
|
+
published: z.boolean(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
type FormValues = z.infer<typeof schema>;
|
|
66
|
+
|
|
67
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
interface EditorSessionProps {
|
|
70
|
+
sessionId: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function EditorSession({ sessionId }: EditorSessionProps) {
|
|
74
|
+
const session = useStructureStore((s) =>
|
|
75
|
+
s.sessions.find((ss) => ss.id === sessionId)
|
|
76
|
+
);
|
|
77
|
+
const allLessons = useStructureStore((s) => s.lessons);
|
|
78
|
+
const lessons = useMemo(
|
|
79
|
+
() => allLessons.filter((l) => l.sessionId === sessionId),
|
|
80
|
+
[allLessons, sessionId]
|
|
81
|
+
);
|
|
82
|
+
const showConfirm = useStructureStore((s) => s.showConfirm);
|
|
83
|
+
|
|
84
|
+
const updateSession = useUpdateSessionMutation();
|
|
85
|
+
const createLesson = useCreateLessonMutation();
|
|
86
|
+
const deleteSession = useDeleteSessionMutation();
|
|
87
|
+
|
|
88
|
+
const totalMinutes = lessons.reduce((sum, l) => sum + l.duration, 0);
|
|
89
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
90
|
+
const minutes = totalMinutes % 60;
|
|
91
|
+
|
|
92
|
+
const defaultValues: FormValues = {
|
|
93
|
+
code: session?.code ?? '',
|
|
94
|
+
title: session?.title ?? '',
|
|
95
|
+
description: session?.description ?? '',
|
|
96
|
+
duration: session?.duration ?? 0,
|
|
97
|
+
visibility: (session?.visibility ?? 'publico') as Visibility,
|
|
98
|
+
published: session?.published ?? true,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const form = useForm<FormValues>({
|
|
102
|
+
resolver: zodResolver(schema),
|
|
103
|
+
defaultValues,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const { isDirty } = form.formState;
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!session) return;
|
|
110
|
+
form.reset({
|
|
111
|
+
code: session.code,
|
|
112
|
+
title: session.title,
|
|
113
|
+
description: session.description ?? '',
|
|
114
|
+
duration: session.duration,
|
|
115
|
+
visibility: session.visibility ?? 'publico',
|
|
116
|
+
published: session.published ?? true,
|
|
117
|
+
});
|
|
118
|
+
}, [session?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
119
|
+
|
|
120
|
+
if (!session) return null;
|
|
121
|
+
|
|
122
|
+
function onSubmit(values: FormValues) {
|
|
123
|
+
updateSession.mutate({ sessionId, formValues: values });
|
|
124
|
+
form.reset(values);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function handleDelete() {
|
|
128
|
+
showConfirm({
|
|
129
|
+
title: `Excluir sessão "${session!.title}"?`,
|
|
130
|
+
description:
|
|
131
|
+
'A sessão e todas as suas aulas serão excluídas permanentemente.',
|
|
132
|
+
onConfirm: () => deleteSession.mutate({ sessionId }),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<Form {...form}>
|
|
138
|
+
<form
|
|
139
|
+
onSubmit={form.handleSubmit(onSubmit)}
|
|
140
|
+
className="flex flex-col h-full min-h-0"
|
|
141
|
+
>
|
|
142
|
+
{/* ── Header ───────────────────────────────────────────────────────── */}
|
|
143
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b bg-muted/30 shrink-0">
|
|
144
|
+
<div className="flex size-9 items-center justify-center rounded-lg bg-muted shrink-0">
|
|
145
|
+
<Layers className="size-4 text-muted-foreground" />
|
|
146
|
+
</div>
|
|
147
|
+
<div className="flex-1 min-w-0">
|
|
148
|
+
<div className="flex items-center gap-1.5">
|
|
149
|
+
<span className="text-sm font-semibold truncate">Sessão</span>
|
|
150
|
+
{isDirty && (
|
|
151
|
+
<CircleDot className="size-3 text-amber-500 shrink-0" />
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
<p className="text-[0.65rem] text-muted-foreground truncate">
|
|
155
|
+
{session.code}
|
|
156
|
+
</p>
|
|
157
|
+
</div>
|
|
158
|
+
<Badge
|
|
159
|
+
variant={session.published ? 'default' : 'secondary'}
|
|
160
|
+
className="shrink-0 text-xs"
|
|
161
|
+
>
|
|
162
|
+
{session.published ? 'Publicada' : 'Oculta'}
|
|
163
|
+
</Badge>
|
|
164
|
+
<Button
|
|
165
|
+
type="button"
|
|
166
|
+
variant="ghost"
|
|
167
|
+
size="icon"
|
|
168
|
+
className="size-7 text-destructive/60 hover:text-destructive shrink-0"
|
|
169
|
+
title="Excluir sessão"
|
|
170
|
+
aria-label="Excluir sessão"
|
|
171
|
+
disabled={deleteSession.isPending}
|
|
172
|
+
onClick={handleDelete}
|
|
173
|
+
>
|
|
174
|
+
{deleteSession.isPending ? (
|
|
175
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
176
|
+
) : (
|
|
177
|
+
<Trash2 className="size-3.5" />
|
|
178
|
+
)}
|
|
179
|
+
</Button>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
{/* ── Scrollable body ───────────────────────────────────────────────── */}
|
|
183
|
+
<ScrollArea className="flex-1 min-h-0">
|
|
184
|
+
<div className="flex flex-col gap-3 p-3">
|
|
185
|
+
{/* ── Stats ────────────────────────────────────────────────────── */}
|
|
186
|
+
<div className="grid grid-cols-2 gap-2">
|
|
187
|
+
<StatChip
|
|
188
|
+
icon={<Video className="size-3" />}
|
|
189
|
+
label="Aulas"
|
|
190
|
+
value={lessons.length}
|
|
191
|
+
/>
|
|
192
|
+
<StatChip
|
|
193
|
+
icon={<Clock className="size-3" />}
|
|
194
|
+
label="Duração"
|
|
195
|
+
value={hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`}
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{/* ── Dados ────────────────────────────────────────────────────── */}
|
|
200
|
+
<Card className="bg-muted/20 py-2 gap-2">
|
|
201
|
+
<CardHeader className="px-3 pt-2 pb-1">
|
|
202
|
+
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
203
|
+
Dados
|
|
204
|
+
</CardTitle>
|
|
205
|
+
</CardHeader>
|
|
206
|
+
<CardContent className="px-3 pb-2 flex flex-col gap-3">
|
|
207
|
+
<div className="grid grid-cols-2 gap-2">
|
|
208
|
+
<FormField
|
|
209
|
+
control={form.control}
|
|
210
|
+
name="code"
|
|
211
|
+
render={({ field }) => (
|
|
212
|
+
<FormItem>
|
|
213
|
+
<FormLabel className="text-xs">Código</FormLabel>
|
|
214
|
+
<FormControl>
|
|
215
|
+
<Input {...field} className="h-8 text-xs font-mono" />
|
|
216
|
+
</FormControl>
|
|
217
|
+
<FormMessage className="text-xs" />
|
|
218
|
+
</FormItem>
|
|
219
|
+
)}
|
|
220
|
+
/>
|
|
221
|
+
<FormField
|
|
222
|
+
control={form.control}
|
|
223
|
+
name="duration"
|
|
224
|
+
render={({ field }) => (
|
|
225
|
+
<FormItem>
|
|
226
|
+
<FormLabel className="text-xs">Duração (min)</FormLabel>
|
|
227
|
+
<FormControl>
|
|
228
|
+
<Input
|
|
229
|
+
{...field}
|
|
230
|
+
type="number"
|
|
231
|
+
className="h-8 text-xs"
|
|
232
|
+
/>
|
|
233
|
+
</FormControl>
|
|
234
|
+
<FormMessage className="text-xs" />
|
|
235
|
+
</FormItem>
|
|
236
|
+
)}
|
|
237
|
+
/>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<FormField
|
|
241
|
+
control={form.control}
|
|
242
|
+
name="title"
|
|
243
|
+
render={({ field }) => (
|
|
244
|
+
<FormItem>
|
|
245
|
+
<FormLabel className="text-xs">Título</FormLabel>
|
|
246
|
+
<FormControl>
|
|
247
|
+
<Input {...field} className="h-8 text-sm" />
|
|
248
|
+
</FormControl>
|
|
249
|
+
<FormMessage className="text-xs" />
|
|
250
|
+
</FormItem>
|
|
251
|
+
)}
|
|
252
|
+
/>
|
|
253
|
+
|
|
254
|
+
<FormField
|
|
255
|
+
control={form.control}
|
|
256
|
+
name="description"
|
|
257
|
+
render={({ field }) => (
|
|
258
|
+
<FormItem>
|
|
259
|
+
<FormLabel className="text-xs">Descrição</FormLabel>
|
|
260
|
+
<FormControl>
|
|
261
|
+
<Textarea
|
|
262
|
+
{...field}
|
|
263
|
+
rows={3}
|
|
264
|
+
className="text-sm resize-none"
|
|
265
|
+
placeholder="Descrição opcional da sessão…"
|
|
266
|
+
/>
|
|
267
|
+
</FormControl>
|
|
268
|
+
<FormMessage className="text-xs" />
|
|
269
|
+
</FormItem>
|
|
270
|
+
)}
|
|
271
|
+
/>
|
|
272
|
+
</CardContent>
|
|
273
|
+
</Card>
|
|
274
|
+
|
|
275
|
+
{/* ── Publicação ───────────────────────────────────────────────── */}
|
|
276
|
+
<Card className="bg-muted/20 py-2 gap-2">
|
|
277
|
+
<CardHeader className="px-3 pt-2 pb-1">
|
|
278
|
+
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
279
|
+
Publicação
|
|
280
|
+
</CardTitle>
|
|
281
|
+
</CardHeader>
|
|
282
|
+
<CardContent className="px-3 pb-2">
|
|
283
|
+
<div className="grid grid-cols-2 gap-2 items-end">
|
|
284
|
+
<FormField
|
|
285
|
+
control={form.control}
|
|
286
|
+
name="visibility"
|
|
287
|
+
render={({ field }) => (
|
|
288
|
+
<FormItem>
|
|
289
|
+
<FormLabel className="text-xs">Visibilidade</FormLabel>
|
|
290
|
+
<Select
|
|
291
|
+
value={field.value}
|
|
292
|
+
onValueChange={field.onChange}
|
|
293
|
+
>
|
|
294
|
+
<FormControl>
|
|
295
|
+
<SelectTrigger className="h-8 text-xs w-full">
|
|
296
|
+
<SelectValue />
|
|
297
|
+
</SelectTrigger>
|
|
298
|
+
</FormControl>
|
|
299
|
+
<SelectContent>
|
|
300
|
+
<SelectItem value="publico">
|
|
301
|
+
<span className="flex items-center gap-1.5">
|
|
302
|
+
<Eye className="size-3" /> Público
|
|
303
|
+
</span>
|
|
304
|
+
</SelectItem>
|
|
305
|
+
<SelectItem value="privado">
|
|
306
|
+
<span className="flex items-center gap-1.5">
|
|
307
|
+
<EyeOff className="size-3" /> Privado
|
|
308
|
+
</span>
|
|
309
|
+
</SelectItem>
|
|
310
|
+
<SelectItem value="restrito">
|
|
311
|
+
<span className="flex items-center gap-1.5">
|
|
312
|
+
<Lock className="size-3" /> Restrito
|
|
313
|
+
</span>
|
|
314
|
+
</SelectItem>
|
|
315
|
+
</SelectContent>
|
|
316
|
+
</Select>
|
|
317
|
+
<FormMessage className="text-xs" />
|
|
318
|
+
</FormItem>
|
|
319
|
+
)}
|
|
320
|
+
/>
|
|
321
|
+
|
|
322
|
+
<FormField
|
|
323
|
+
control={form.control}
|
|
324
|
+
name="published"
|
|
325
|
+
render={({ field }) => (
|
|
326
|
+
<FormItem className="flex items-center justify-between gap-3 rounded-md border px-3 h-8">
|
|
327
|
+
<FormLabel className="text-xs cursor-pointer">
|
|
328
|
+
Publicada
|
|
329
|
+
</FormLabel>
|
|
330
|
+
<FormControl>
|
|
331
|
+
<Switch
|
|
332
|
+
checked={field.value}
|
|
333
|
+
onCheckedChange={field.onChange}
|
|
334
|
+
/>
|
|
335
|
+
</FormControl>
|
|
336
|
+
</FormItem>
|
|
337
|
+
)}
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
340
|
+
</CardContent>
|
|
341
|
+
</Card>
|
|
342
|
+
|
|
343
|
+
{/* ── Aulas da sessão ───────────────────────────────────────────── */}
|
|
344
|
+
{lessons.length === 0 && (
|
|
345
|
+
<div className="flex flex-col items-center gap-2 py-6 px-2 rounded-lg border border-dashed text-center">
|
|
346
|
+
<Video className="size-5 text-muted-foreground/40" />
|
|
347
|
+
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
348
|
+
Esta sessão ainda não tem aulas.
|
|
349
|
+
</p>
|
|
350
|
+
<Button
|
|
351
|
+
type="button"
|
|
352
|
+
variant="outline"
|
|
353
|
+
size="sm"
|
|
354
|
+
className="h-7 text-xs gap-1.5"
|
|
355
|
+
disabled={createLesson.isPending}
|
|
356
|
+
onClick={() => createLesson.mutate({ sessionId })}
|
|
357
|
+
>
|
|
358
|
+
{createLesson.isPending ? (
|
|
359
|
+
<Loader2 className="size-3 animate-spin" />
|
|
360
|
+
) : (
|
|
361
|
+
<Plus className="size-3" />
|
|
362
|
+
)}
|
|
363
|
+
Adicionar aula
|
|
364
|
+
</Button>
|
|
365
|
+
</div>
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
</ScrollArea>
|
|
369
|
+
|
|
370
|
+
{/* ── Footer ───────────────────────────────────────────────────────── */}
|
|
371
|
+
<div className="shrink-0 border-t bg-background">
|
|
372
|
+
<Separator />
|
|
373
|
+
<div className="flex items-center gap-2 px-3 py-2">
|
|
374
|
+
<Button
|
|
375
|
+
type="button"
|
|
376
|
+
variant="ghost"
|
|
377
|
+
size="sm"
|
|
378
|
+
className="h-7 text-xs"
|
|
379
|
+
disabled={!isDirty || updateSession.isPending}
|
|
380
|
+
onClick={() => form.reset()}
|
|
381
|
+
>
|
|
382
|
+
<Undo2 className="size-3 mr-1" />
|
|
383
|
+
Cancelar
|
|
384
|
+
</Button>
|
|
385
|
+
<div className="flex-1" />
|
|
386
|
+
<Button
|
|
387
|
+
type="button"
|
|
388
|
+
variant="outline"
|
|
389
|
+
size="sm"
|
|
390
|
+
className="h-7 text-xs"
|
|
391
|
+
disabled={createLesson.isPending}
|
|
392
|
+
onClick={() => createLesson.mutate({ sessionId })}
|
|
393
|
+
>
|
|
394
|
+
{createLesson.isPending ? (
|
|
395
|
+
<Loader2 className="size-3 mr-1 animate-spin" />
|
|
396
|
+
) : (
|
|
397
|
+
<Plus className="size-3 mr-1" />
|
|
398
|
+
)}
|
|
399
|
+
Nova aula
|
|
400
|
+
</Button>
|
|
401
|
+
<Button
|
|
402
|
+
type="submit"
|
|
403
|
+
size="sm"
|
|
404
|
+
className="h-7 text-xs"
|
|
405
|
+
disabled={!isDirty || updateSession.isPending}
|
|
406
|
+
>
|
|
407
|
+
{updateSession.isPending ? (
|
|
408
|
+
<Loader2 className="size-3 mr-1 animate-spin" />
|
|
409
|
+
) : (
|
|
410
|
+
<Save className="size-3 mr-1" />
|
|
411
|
+
)}
|
|
412
|
+
Salvar
|
|
413
|
+
</Button>
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
</form>
|
|
417
|
+
</Form>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
function StatChip({
|
|
424
|
+
icon,
|
|
425
|
+
label,
|
|
426
|
+
value,
|
|
427
|
+
}: {
|
|
428
|
+
icon: React.ReactNode;
|
|
429
|
+
label: string;
|
|
430
|
+
value: string | number;
|
|
431
|
+
}) {
|
|
432
|
+
return (
|
|
433
|
+
<div className="flex items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-2">
|
|
434
|
+
<span className="text-muted-foreground shrink-0">{icon}</span>
|
|
435
|
+
<div className="min-w-0">
|
|
436
|
+
<p className="text-[0.65rem] text-muted-foreground truncate">{label}</p>
|
|
437
|
+
<p className="text-sm font-semibold tabular-nums leading-none">
|
|
438
|
+
{value}
|
|
439
|
+
</p>
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
);
|
|
443
|
+
}
|