@hed-hog/lms 0.0.357 → 0.0.358
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/course/course-operations-integration.service.d.ts +31 -0
- package/dist/course/course-operations-integration.service.d.ts.map +1 -1
- package/dist/course/course-operations-integration.service.js +286 -22
- package/dist/course/course-operations-integration.service.js.map +1 -1
- package/dist/course/course-operations.controller.d.ts +10 -0
- package/dist/course/course-operations.controller.d.ts.map +1 -0
- package/dist/course/course-operations.controller.js +67 -0
- package/dist/course/course-operations.controller.js.map +1 -0
- package/dist/course/course-structure.controller.d.ts +3 -1
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.service.d.ts +3 -1
- package/dist/course/course-structure.service.d.ts.map +1 -1
- package/dist/course/course-structure.service.js +13 -6
- package/dist/course/course-structure.service.js.map +1 -1
- package/dist/course/course.module.d.ts.map +1 -1
- package/dist/course/course.module.js +4 -1
- package/dist/course/course.module.js.map +1 -1
- package/dist/course/dto/update-course-operations-config.dto.d.ts +6 -0
- package/dist/course/dto/update-course-operations-config.dto.d.ts.map +1 -0
- package/dist/course/dto/update-course-operations-config.dto.js +33 -0
- package/dist/course/dto/update-course-operations-config.dto.js.map +1 -0
- package/dist/course/lms-operations-task.subscriber.d.ts +13 -0
- package/dist/course/lms-operations-task.subscriber.d.ts.map +1 -0
- package/dist/course/lms-operations-task.subscriber.js +57 -0
- package/dist/course/lms-operations-task.subscriber.js.map +1 -0
- package/dist/enterprise/enterprise.service.js +1 -1
- package/dist/enterprise/enterprise.service.js.map +1 -1
- package/dist/instructor/instructor.service.d.ts.map +1 -1
- package/dist/instructor/instructor.service.js +12 -3
- package/dist/instructor/instructor.service.js.map +1 -1
- package/hedhog/data/route.yaml +27 -0
- package/hedhog/frontend/app/_lib/editor/templateSerializer.ts.ejs +26 -4
- package/hedhog/frontend/app/_lib/editor/types.ts.ejs +6 -0
- package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +447 -31
- package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +59 -47
- package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +201 -35
- package/hedhog/frontend/app/certificates/models/TemplateEditorPage.tsx.ejs +36 -5
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-operations-tab.tsx.ejs +382 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +31 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +91 -20
- package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +1 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +11 -3
- package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -2
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-query.ts.ejs +2 -2
- package/hedhog/frontend/app/courses/page.tsx.ejs +21 -88
- package/hedhog/frontend/app/enterprise/page.tsx.ejs +18 -6
- package/hedhog/frontend/app/exams/[id]/page.tsx.ejs +5 -4
- package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +16 -10
- package/package.json +8 -8
- package/src/course/course-operations-integration.service.ts +460 -22
- package/src/course/course-operations.controller.ts +45 -0
- package/src/course/course-structure.service.ts +5 -1
- package/src/course/course.module.ts +4 -1
- package/src/course/dto/update-course-operations-config.dto.ts +16 -0
- package/src/course/lms-operations-task.subscriber.ts +44 -0
- package/src/enterprise/enterprise.service.ts +1 -1
- package/src/instructor/instructor.service.ts +12 -3
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { EntityPicker } from '@/components/ui/entity-picker';
|
|
6
|
+
import { Button } from '@/components/ui/button';
|
|
7
|
+
import { Label } from '@/components/ui/label';
|
|
8
|
+
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
|
9
|
+
import {
|
|
10
|
+
Select,
|
|
11
|
+
SelectContent,
|
|
12
|
+
SelectItem,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
SelectValue,
|
|
15
|
+
} from '@/components/ui/select';
|
|
16
|
+
import { Separator } from '@/components/ui/separator';
|
|
17
|
+
import { cn } from '@/lib/utils';
|
|
18
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
19
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
20
|
+
import {
|
|
21
|
+
AlertTriangle,
|
|
22
|
+
Briefcase,
|
|
23
|
+
Loader2,
|
|
24
|
+
RefreshCw,
|
|
25
|
+
} from 'lucide-react';
|
|
26
|
+
import { toast } from 'sonner';
|
|
27
|
+
|
|
28
|
+
type OperationsConfig = {
|
|
29
|
+
isAvailable: boolean;
|
|
30
|
+
projectId: number | null;
|
|
31
|
+
projectCode: string | null;
|
|
32
|
+
projectName: string | null;
|
|
33
|
+
taskMode: 'per_lesson' | 'per_production_status' | null;
|
|
34
|
+
completionStatus: string | null;
|
|
35
|
+
missingTasksCount: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type ProjectOption = {
|
|
39
|
+
id: number;
|
|
40
|
+
code?: string | null;
|
|
41
|
+
name: string;
|
|
42
|
+
clientName?: string | null;
|
|
43
|
+
status?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type PickerOption = {
|
|
47
|
+
value: string;
|
|
48
|
+
label: string;
|
|
49
|
+
description?: string | null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const PRODUCTION_STATUSES: { value: string; label: string }[] = [
|
|
53
|
+
{ value: 'preparada', label: 'Preparada' },
|
|
54
|
+
{ value: 'gravada', label: 'Gravada' },
|
|
55
|
+
{ value: 'editada', label: 'Editada' },
|
|
56
|
+
{ value: 'finalizada', label: 'Finalizada' },
|
|
57
|
+
{ value: 'publicada', label: 'Publicada' },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
61
|
+
preparada: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300',
|
|
62
|
+
gravada: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
|
63
|
+
editada: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
|
|
64
|
+
finalizada: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
|
65
|
+
publicada: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const PRODUCTION_STAGES = [
|
|
69
|
+
{ stage: 'preparacao', label: 'Preparação' },
|
|
70
|
+
{ stage: 'gravacao', label: 'Gravação' },
|
|
71
|
+
{ stage: 'edicao', label: 'Edição' },
|
|
72
|
+
{ stage: 'finalizacao', label: 'Finalização' },
|
|
73
|
+
{ stage: 'publicacao', label: 'Publicação' },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
type Props = {
|
|
77
|
+
courseId: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function CourseOperationsTab({ courseId }: Props) {
|
|
81
|
+
const { request } = useApp();
|
|
82
|
+
const queryClient = useQueryClient();
|
|
83
|
+
|
|
84
|
+
const configQueryKey = ['lms-course-operations-config', courseId];
|
|
85
|
+
|
|
86
|
+
const { data: config, isLoading: isLoadingConfig } = useQuery<OperationsConfig>({
|
|
87
|
+
queryKey: configQueryKey,
|
|
88
|
+
enabled: Boolean(courseId),
|
|
89
|
+
queryFn: async () => {
|
|
90
|
+
const res = await request<OperationsConfig>({
|
|
91
|
+
url: `/lms/courses/${courseId}/operations`,
|
|
92
|
+
method: 'GET',
|
|
93
|
+
});
|
|
94
|
+
return res.data;
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const { data: projectOptionsData } = useQuery<
|
|
99
|
+
{ data: ProjectOption[] } | ProjectOption[]
|
|
100
|
+
>({
|
|
101
|
+
queryKey: ['lms-operations-project-options-tab'],
|
|
102
|
+
queryFn: async () => {
|
|
103
|
+
try {
|
|
104
|
+
const res = await request<{ data: ProjectOption[] } | ProjectOption[]>({
|
|
105
|
+
url: '/operations/projects/options?pageSize=200&sortField=name&sortOrder=asc',
|
|
106
|
+
method: 'GET',
|
|
107
|
+
});
|
|
108
|
+
return res.data;
|
|
109
|
+
} catch {
|
|
110
|
+
return { data: [] };
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
initialData: { data: [] },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const projectOptions: PickerOption[] = (() => {
|
|
117
|
+
const raw = Array.isArray(projectOptionsData)
|
|
118
|
+
? projectOptionsData
|
|
119
|
+
: (projectOptionsData?.data ?? []);
|
|
120
|
+
|
|
121
|
+
const current =
|
|
122
|
+
config?.projectId && config?.projectName
|
|
123
|
+
? [
|
|
124
|
+
{
|
|
125
|
+
value: String(config.projectId),
|
|
126
|
+
label: config.projectName,
|
|
127
|
+
description: config.projectCode ?? undefined,
|
|
128
|
+
},
|
|
129
|
+
]
|
|
130
|
+
: [];
|
|
131
|
+
|
|
132
|
+
const remote = raw.map((p) => ({
|
|
133
|
+
value: String(p.id),
|
|
134
|
+
label: p.name,
|
|
135
|
+
description: p.code ?? p.clientName ?? undefined,
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
return [...current, ...remote].filter(
|
|
139
|
+
(item, idx, arr) =>
|
|
140
|
+
arr.findIndex((c) => c.value === item.value) === idx,
|
|
141
|
+
);
|
|
142
|
+
})();
|
|
143
|
+
|
|
144
|
+
const [projectId, setProjectId] = useState<string>('');
|
|
145
|
+
const [taskMode, setTaskMode] = useState<
|
|
146
|
+
'per_lesson' | 'per_production_status' | ''
|
|
147
|
+
>('');
|
|
148
|
+
const [completionStatus, setCompletionStatus] = useState<string>('');
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!config) return;
|
|
152
|
+
setProjectId(config.projectId ? String(config.projectId) : '');
|
|
153
|
+
setTaskMode(config.taskMode ?? '');
|
|
154
|
+
setCompletionStatus(config.completionStatus ?? '');
|
|
155
|
+
}, [config]);
|
|
156
|
+
|
|
157
|
+
const { mutate: saveConfig, isPending: isSaving } = useMutation({
|
|
158
|
+
mutationFn: async () => {
|
|
159
|
+
await request({
|
|
160
|
+
url: `/lms/courses/${courseId}/operations`,
|
|
161
|
+
method: 'PATCH',
|
|
162
|
+
data: {
|
|
163
|
+
projectId: projectId ? Number(projectId) : null,
|
|
164
|
+
taskMode: taskMode || null,
|
|
165
|
+
completionStatus: completionStatus || null,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
onSuccess: () => {
|
|
170
|
+
void queryClient.invalidateQueries({ queryKey: configQueryKey });
|
|
171
|
+
toast.success('Configuração de operações salva.');
|
|
172
|
+
},
|
|
173
|
+
onError: () => {
|
|
174
|
+
toast.error('Erro ao salvar configuração de operações.');
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const { mutate: recreateTasks, isPending: isRecreating } = useMutation({
|
|
179
|
+
mutationFn: async () => {
|
|
180
|
+
const res = await request<{ count?: number }>({
|
|
181
|
+
url: `/lms/courses/${courseId}/operations/recreate-tasks`,
|
|
182
|
+
method: 'POST',
|
|
183
|
+
});
|
|
184
|
+
return res.data;
|
|
185
|
+
},
|
|
186
|
+
onSuccess: (data) => {
|
|
187
|
+
void queryClient.invalidateQueries({ queryKey: configQueryKey });
|
|
188
|
+
const count = typeof data === 'number' ? data : (data?.count ?? 0);
|
|
189
|
+
toast.success(
|
|
190
|
+
count > 0
|
|
191
|
+
? `${count} tarefa(s) recriada(s) com sucesso.`
|
|
192
|
+
: 'Nenhuma tarefa faltante encontrada.',
|
|
193
|
+
);
|
|
194
|
+
},
|
|
195
|
+
onError: () => {
|
|
196
|
+
toast.error('Erro ao recriar tarefas.');
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (isLoadingConfig) {
|
|
201
|
+
return (
|
|
202
|
+
<div className="flex items-center justify-center py-10">
|
|
203
|
+
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div className="flex flex-col gap-5 p-1">
|
|
210
|
+
{/* ── Projeto vinculado ──────────────────────────────────────────── */}
|
|
211
|
+
<div className="flex flex-col gap-1.5">
|
|
212
|
+
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
213
|
+
Projeto Operations
|
|
214
|
+
</Label>
|
|
215
|
+
<EntityPicker<PickerOption>
|
|
216
|
+
value={projectId}
|
|
217
|
+
onChange={(val) => {
|
|
218
|
+
const next = String(val ?? '');
|
|
219
|
+
setProjectId(next);
|
|
220
|
+
if (!next) {
|
|
221
|
+
setTaskMode('');
|
|
222
|
+
setCompletionStatus('');
|
|
223
|
+
}
|
|
224
|
+
}}
|
|
225
|
+
options={projectOptions}
|
|
226
|
+
placeholder="Selecionar projeto..."
|
|
227
|
+
searchPlaceholder="Buscar projeto..."
|
|
228
|
+
entityLabel="projeto"
|
|
229
|
+
emptyStateDescription="Nenhum projeto encontrado."
|
|
230
|
+
getOptionValue={(o) => o.value}
|
|
231
|
+
getOptionLabel={(o) => o.label}
|
|
232
|
+
getOptionDescription={(o) => o.description ?? undefined}
|
|
233
|
+
/>
|
|
234
|
+
{config?.projectName && projectId === String(config.projectId) && (
|
|
235
|
+
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
|
236
|
+
<Briefcase className="size-3" />
|
|
237
|
+
{[config.projectCode, config.projectName].filter(Boolean).join(' — ')}
|
|
238
|
+
</p>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<Separator />
|
|
243
|
+
|
|
244
|
+
{/* ── Configurações (habilitadas só com projeto) ──────────────────── */}
|
|
245
|
+
<fieldset disabled={!projectId} className="flex flex-col gap-5 disabled:opacity-50">
|
|
246
|
+
{/* Modo de criação de tarefas */}
|
|
247
|
+
<div className="flex flex-col gap-2">
|
|
248
|
+
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
249
|
+
Modo de criação de tarefas
|
|
250
|
+
</Label>
|
|
251
|
+
<RadioGroup
|
|
252
|
+
value={taskMode}
|
|
253
|
+
onValueChange={(val) =>
|
|
254
|
+
setTaskMode(val as 'per_lesson' | 'per_production_status')
|
|
255
|
+
}
|
|
256
|
+
className="flex flex-col gap-2"
|
|
257
|
+
>
|
|
258
|
+
<div className="flex items-start gap-2.5 rounded-md border p-3 cursor-pointer hover:bg-muted/40 transition-colors">
|
|
259
|
+
<RadioGroupItem value="per_lesson" id="mode-per-lesson" className="mt-0.5" />
|
|
260
|
+
<div className="flex flex-col gap-0.5">
|
|
261
|
+
<Label htmlFor="mode-per-lesson" className="cursor-pointer text-sm font-medium">
|
|
262
|
+
Uma tarefa por aula
|
|
263
|
+
</Label>
|
|
264
|
+
<p className="text-xs text-muted-foreground">
|
|
265
|
+
Cria uma única tarefa no projeto para cada aula. Ao concluir a tarefa, o status de publicação da aula é atualizado automaticamente.
|
|
266
|
+
</p>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<div className="flex items-start gap-2.5 rounded-md border p-3 cursor-pointer hover:bg-muted/40 transition-colors">
|
|
271
|
+
<RadioGroupItem value="per_production_status" id="mode-per-status" className="mt-0.5" />
|
|
272
|
+
<div className="flex flex-col gap-0.5">
|
|
273
|
+
<Label htmlFor="mode-per-status" className="cursor-pointer text-sm font-medium">
|
|
274
|
+
Uma tarefa por status de produção
|
|
275
|
+
</Label>
|
|
276
|
+
<p className="text-xs text-muted-foreground">
|
|
277
|
+
Cria 5 tarefas por aula — uma para cada etapa de produção:
|
|
278
|
+
</p>
|
|
279
|
+
<div className="flex flex-wrap gap-1 mt-1">
|
|
280
|
+
{PRODUCTION_STAGES.map((s) => (
|
|
281
|
+
<span
|
|
282
|
+
key={s.stage}
|
|
283
|
+
className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium"
|
|
284
|
+
>
|
|
285
|
+
{s.label}
|
|
286
|
+
</span>
|
|
287
|
+
))}
|
|
288
|
+
</div>
|
|
289
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
290
|
+
Ao concluir cada tarefa, o status correspondente é aplicado à aula automaticamente via tag.
|
|
291
|
+
</p>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
</RadioGroup>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
{/* Status ao concluir (apenas modo per_lesson) */}
|
|
298
|
+
{taskMode === 'per_lesson' && (
|
|
299
|
+
<div className="flex flex-col gap-1.5">
|
|
300
|
+
<Label
|
|
301
|
+
htmlFor="completion-status"
|
|
302
|
+
className="text-xs font-medium text-muted-foreground uppercase tracking-wide"
|
|
303
|
+
>
|
|
304
|
+
Status ao concluir a tarefa
|
|
305
|
+
</Label>
|
|
306
|
+
<Select value={completionStatus} onValueChange={setCompletionStatus}>
|
|
307
|
+
<SelectTrigger id="completion-status" className="w-full">
|
|
308
|
+
<SelectValue placeholder="Selecionar status..." />
|
|
309
|
+
</SelectTrigger>
|
|
310
|
+
<SelectContent>
|
|
311
|
+
{PRODUCTION_STATUSES.map((s) => (
|
|
312
|
+
<SelectItem key={s.value} value={s.value}>
|
|
313
|
+
<span
|
|
314
|
+
className={cn(
|
|
315
|
+
'text-xs px-1.5 py-0.5 rounded',
|
|
316
|
+
STATUS_COLORS[s.value],
|
|
317
|
+
)}
|
|
318
|
+
>
|
|
319
|
+
{s.label}
|
|
320
|
+
</span>
|
|
321
|
+
</SelectItem>
|
|
322
|
+
))}
|
|
323
|
+
</SelectContent>
|
|
324
|
+
</Select>
|
|
325
|
+
<p className="text-xs text-muted-foreground">
|
|
326
|
+
O status de produção desta aula será atualizado para o valor selecionado quando a tarefa for movida para "Concluído" no Operations.
|
|
327
|
+
</p>
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
</fieldset>
|
|
331
|
+
|
|
332
|
+
{/* ── Tarefas faltantes ───────────────────────────────────────────── */}
|
|
333
|
+
{config?.missingTasksCount !== undefined && config.missingTasksCount > 0 && (
|
|
334
|
+
<>
|
|
335
|
+
<Separator />
|
|
336
|
+
<div className="flex flex-col gap-2 rounded-md border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 p-3">
|
|
337
|
+
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-400">
|
|
338
|
+
<AlertTriangle className="size-4 shrink-0" />
|
|
339
|
+
<p className="text-sm font-medium">
|
|
340
|
+
{config.missingTasksCount === 1
|
|
341
|
+
? '1 aula sem tarefa vinculada'
|
|
342
|
+
: `${config.missingTasksCount} aulas sem tarefas vinculadas`}
|
|
343
|
+
</p>
|
|
344
|
+
</div>
|
|
345
|
+
<p className="text-xs text-amber-600 dark:text-amber-500">
|
|
346
|
+
As tarefas podem ter sido excluídas manualmente no Operations. Clique abaixo para recriá-las.
|
|
347
|
+
</p>
|
|
348
|
+
<Button
|
|
349
|
+
type="button"
|
|
350
|
+
variant="outline"
|
|
351
|
+
size="sm"
|
|
352
|
+
className="self-start gap-2 border-amber-300 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-400 dark:hover:bg-amber-900/40"
|
|
353
|
+
onClick={() => recreateTasks()}
|
|
354
|
+
disabled={isRecreating || !projectId}
|
|
355
|
+
>
|
|
356
|
+
{isRecreating ? (
|
|
357
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
358
|
+
) : (
|
|
359
|
+
<RefreshCw className="size-3.5" />
|
|
360
|
+
)}
|
|
361
|
+
Recriar tarefas faltantes
|
|
362
|
+
</Button>
|
|
363
|
+
</div>
|
|
364
|
+
</>
|
|
365
|
+
)}
|
|
366
|
+
|
|
367
|
+
{/* ── Ações ───────────────────────────────────────────────────────── */}
|
|
368
|
+
<div className="flex justify-end">
|
|
369
|
+
<Button
|
|
370
|
+
type="button"
|
|
371
|
+
size="sm"
|
|
372
|
+
className="gap-2"
|
|
373
|
+
onClick={() => saveConfig()}
|
|
374
|
+
disabled={isSaving}
|
|
375
|
+
>
|
|
376
|
+
{isSaving && <Loader2 className="size-3.5 animate-spin" />}
|
|
377
|
+
Salvar configurações
|
|
378
|
+
</Button>
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
@@ -95,6 +95,7 @@ import {
|
|
|
95
95
|
import { useCreateSessionMutation } from '../_data/use-course-structure-mutations';
|
|
96
96
|
import { courseStructureQueryKey } from '../_data/use-course-structure-query';
|
|
97
97
|
import { IconActionTooltip } from './icon-action-tooltip';
|
|
98
|
+
import { CourseOperationsTab } from './course-operations-tab';
|
|
98
99
|
import { useStructureStore } from './store';
|
|
99
100
|
import { Resource } from './types';
|
|
100
101
|
|
|
@@ -551,6 +552,25 @@ export function EditorCourse() {
|
|
|
551
552
|
initialData: [],
|
|
552
553
|
});
|
|
553
554
|
|
|
555
|
+
const { data: operationsConfig } = useQuery<{ isAvailable: boolean }>({
|
|
556
|
+
queryKey: ['lms-course-operations-config', courseId],
|
|
557
|
+
enabled: Boolean(courseId),
|
|
558
|
+
queryFn: async () => {
|
|
559
|
+
try {
|
|
560
|
+
const res = await request<{ isAvailable: boolean }>({
|
|
561
|
+
url: `/lms/courses/${courseId}/operations`,
|
|
562
|
+
method: 'GET',
|
|
563
|
+
});
|
|
564
|
+
return res.data;
|
|
565
|
+
} catch {
|
|
566
|
+
return { isAvailable: false };
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
initialData: { isAvailable: false },
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const showOperationsTab = operationsConfig?.isAvailable === true;
|
|
573
|
+
|
|
554
574
|
// ── Save mutation ───────────────────────────────────────────────────────────
|
|
555
575
|
const { mutate: saveCourse, isPending: saving } = useMutation({
|
|
556
576
|
mutationFn: async (data: CourseEditFormValues) => {
|
|
@@ -1559,6 +1579,7 @@ export function EditorCourse() {
|
|
|
1559
1579
|
'recursos',
|
|
1560
1580
|
'extra',
|
|
1561
1581
|
...(showVideoTab ? ['videos'] : []),
|
|
1582
|
+
...(showOperationsTab ? ['operacoes'] : []),
|
|
1562
1583
|
'publicacao',
|
|
1563
1584
|
] as const
|
|
1564
1585
|
).map((tab) => (
|
|
@@ -1584,7 +1605,9 @@ export function EditorCourse() {
|
|
|
1584
1605
|
? t('structureEditor.tabs.extra')
|
|
1585
1606
|
: tab === 'videos'
|
|
1586
1607
|
? t('structureEditor.tabs.videoProfiles')
|
|
1587
|
-
:
|
|
1608
|
+
: tab === 'operacoes'
|
|
1609
|
+
? 'Operações'
|
|
1610
|
+
: t('structureEditor.tabs.publish')}
|
|
1588
1611
|
</TabsTrigger>
|
|
1589
1612
|
))}
|
|
1590
1613
|
</TabsList>
|
|
@@ -2271,6 +2294,13 @@ export function EditorCourse() {
|
|
|
2271
2294
|
</TabsContent>
|
|
2272
2295
|
)}
|
|
2273
2296
|
|
|
2297
|
+
{/* ── Tab: Operações ──────────────────────────────────────── */}
|
|
2298
|
+
{showOperationsTab && (
|
|
2299
|
+
<TabsContent value="operacoes" className="mt-0 min-w-0">
|
|
2300
|
+
<CourseOperationsTab courseId={courseId} />
|
|
2301
|
+
</TabsContent>
|
|
2302
|
+
)}
|
|
2303
|
+
|
|
2274
2304
|
{/* ── Tab: Publicação ─────────────────────────────────────── */}
|
|
2275
2305
|
<TabsContent
|
|
2276
2306
|
value="publicacao"
|
|
@@ -160,6 +160,28 @@ function formatDateTimeLabel(value?: string | null): string | null {
|
|
|
160
160
|
}).format(parsed);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
function getInitials(name?: string | null): string {
|
|
164
|
+
const normalizedName = String(name ?? '').trim();
|
|
165
|
+
if (!normalizedName) return '--';
|
|
166
|
+
|
|
167
|
+
return normalizedName
|
|
168
|
+
.split(' ')
|
|
169
|
+
.filter(Boolean)
|
|
170
|
+
.slice(0, 2)
|
|
171
|
+
.map((part) => part[0])
|
|
172
|
+
.join('')
|
|
173
|
+
.toUpperCase();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function getInstructorAvatarUrl(avatarId?: number | null): string | undefined {
|
|
177
|
+
if (typeof avatarId !== 'number' || avatarId <= 0) return undefined;
|
|
178
|
+
|
|
179
|
+
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, '');
|
|
180
|
+
return baseUrl
|
|
181
|
+
? `${baseUrl}/person/avatar/${avatarId}`
|
|
182
|
+
: `/person/avatar/${avatarId}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
163
185
|
function videoProfileResourceType(profileId: number): string {
|
|
164
186
|
return `video_profile:${profileId}`;
|
|
165
187
|
}
|
|
@@ -2915,13 +2937,18 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
|
|
|
2915
2937
|
control={form.control}
|
|
2916
2938
|
name="published"
|
|
2917
2939
|
render={({ field }) => (
|
|
2918
|
-
<FormItem
|
|
2940
|
+
<FormItem>
|
|
2919
2941
|
<FormLabel className="text-xs">Publicada</FormLabel>
|
|
2920
2942
|
<FormControl>
|
|
2921
|
-
<
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2943
|
+
<div className="flex h-8 items-center justify-between gap-3 rounded-md border px-3">
|
|
2944
|
+
<p className="cursor-default text-xs text-foreground">
|
|
2945
|
+
Publicada
|
|
2946
|
+
</p>
|
|
2947
|
+
<Switch
|
|
2948
|
+
checked={field.value}
|
|
2949
|
+
onCheckedChange={field.onChange}
|
|
2950
|
+
/>
|
|
2951
|
+
</div>
|
|
2925
2952
|
</FormControl>
|
|
2926
2953
|
<FormMessage className="text-xs" />
|
|
2927
2954
|
</FormItem>
|
|
@@ -2940,7 +2967,11 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
|
|
|
2940
2967
|
</CardHeader>
|
|
2941
2968
|
<CardContent className="px-3 pb-2 flex flex-col gap-2">
|
|
2942
2969
|
{/* Picker para adicionar */}
|
|
2943
|
-
<EntityPicker<{
|
|
2970
|
+
<EntityPicker<{
|
|
2971
|
+
id: number;
|
|
2972
|
+
name: string;
|
|
2973
|
+
avatarId?: number | null;
|
|
2974
|
+
}>
|
|
2944
2975
|
key={instructorPickerResetKey}
|
|
2945
2976
|
value={null}
|
|
2946
2977
|
onChange={(val) => {
|
|
@@ -2962,6 +2993,44 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
|
|
|
2962
2993
|
)}
|
|
2963
2994
|
getOptionValue={(o) => o.id}
|
|
2964
2995
|
getOptionLabel={(o) => o.name}
|
|
2996
|
+
renderOption={({ option }) => {
|
|
2997
|
+
const avatarUrl = getInstructorAvatarUrl(
|
|
2998
|
+
option.avatarId
|
|
2999
|
+
);
|
|
3000
|
+
const displayName = option.name ?? String(option.id);
|
|
3001
|
+
|
|
3002
|
+
return (
|
|
3003
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
3004
|
+
<Avatar className="size-6 shrink-0">
|
|
3005
|
+
<AvatarImage src={avatarUrl} alt={displayName} />
|
|
3006
|
+
<AvatarFallback className="text-[0.6rem] font-medium">
|
|
3007
|
+
{getInitials(displayName)}
|
|
3008
|
+
</AvatarFallback>
|
|
3009
|
+
</Avatar>
|
|
3010
|
+
<span className="truncate text-xs">
|
|
3011
|
+
{displayName}
|
|
3012
|
+
</span>
|
|
3013
|
+
</div>
|
|
3014
|
+
);
|
|
3015
|
+
}}
|
|
3016
|
+
renderSelectedValue={({ option, label }) => {
|
|
3017
|
+
const avatarUrl = getInstructorAvatarUrl(
|
|
3018
|
+
option?.avatarId
|
|
3019
|
+
);
|
|
3020
|
+
const displayName = option?.name ?? label;
|
|
3021
|
+
|
|
3022
|
+
return (
|
|
3023
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
3024
|
+
<Avatar className="size-5 shrink-0">
|
|
3025
|
+
<AvatarImage src={avatarUrl} alt={displayName} />
|
|
3026
|
+
<AvatarFallback className="text-[0.6rem] font-medium">
|
|
3027
|
+
{getInitials(displayName)}
|
|
3028
|
+
</AvatarFallback>
|
|
3029
|
+
</Avatar>
|
|
3030
|
+
<span className="truncate">{displayName}</span>
|
|
3031
|
+
</span>
|
|
3032
|
+
);
|
|
3033
|
+
}}
|
|
2965
3034
|
/>
|
|
2966
3035
|
{/* Lista de instrutores selecionados */}
|
|
2967
3036
|
{selectedInstructorIds.length > 0 && (
|
|
@@ -2976,20 +3045,22 @@ export function EditorLesson({ lessonId }: EditorLessonProps) {
|
|
|
2976
3045
|
key={sid}
|
|
2977
3046
|
className="flex items-center gap-2 rounded-md border bg-muted/20 px-2 py-1"
|
|
2978
3047
|
>
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
.
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
3048
|
+
{(() => {
|
|
3049
|
+
const avatarUrl = getInstructorAvatarUrl(
|
|
3050
|
+
inst?.avatarId
|
|
3051
|
+
);
|
|
3052
|
+
return (
|
|
3053
|
+
<Avatar className="size-6 shrink-0">
|
|
3054
|
+
<AvatarImage
|
|
3055
|
+
src={avatarUrl}
|
|
3056
|
+
alt={displayName}
|
|
3057
|
+
/>
|
|
3058
|
+
<AvatarFallback className="text-[0.6rem] font-medium">
|
|
3059
|
+
{getInitials(displayName)}
|
|
3060
|
+
</AvatarFallback>
|
|
3061
|
+
</Avatar>
|
|
3062
|
+
);
|
|
3063
|
+
})()}
|
|
2993
3064
|
<span className="text-xs flex-1 truncate">
|
|
2994
3065
|
{displayName}
|
|
2995
3066
|
</span>
|
package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs
CHANGED
|
@@ -164,7 +164,11 @@ export function normalizeSession(raw: ApiSession, index: number): Session {
|
|
|
164
164
|
*/
|
|
165
165
|
export function normalizeLesson(raw: ApiLesson, order = 0): Lesson {
|
|
166
166
|
const instructors: LessonInstructor[] = (raw.instrutores ?? []).map(
|
|
167
|
-
(inst) => ({
|
|
167
|
+
(inst) => ({
|
|
168
|
+
id: String(inst.id),
|
|
169
|
+
name: inst.name ?? inst.nome,
|
|
170
|
+
avatarId: inst.avatarId ?? null,
|
|
171
|
+
})
|
|
168
172
|
);
|
|
169
173
|
|
|
170
174
|
return {
|
|
@@ -210,7 +214,7 @@ export function normalizeStructureResponse(raw: ApiGetStructureResponse): {
|
|
|
210
214
|
course: Course | null;
|
|
211
215
|
sessions: Session[];
|
|
212
216
|
lessons: Lesson[];
|
|
213
|
-
instructors: { id: number; name: string }[];
|
|
217
|
+
instructors: { id: number; name: string; avatarId?: number | null }[];
|
|
214
218
|
} {
|
|
215
219
|
const sessions = raw.sessoes.map((s, i) => normalizeSession(s, i));
|
|
216
220
|
|
|
@@ -227,7 +231,11 @@ export function normalizeStructureResponse(raw: ApiGetStructureResponse): {
|
|
|
227
231
|
course: raw.curso ? normalizeCourse(raw.curso) : null,
|
|
228
232
|
sessions,
|
|
229
233
|
lessons,
|
|
230
|
-
instructors: raw.instructors ?? []
|
|
234
|
+
instructors: (raw.instructors ?? []).map((instructor) => ({
|
|
235
|
+
id: Number(instructor.id),
|
|
236
|
+
name: instructor.name ?? instructor.nome ?? '',
|
|
237
|
+
avatarId: instructor.avatarId ?? null,
|
|
238
|
+
})),
|
|
231
239
|
};
|
|
232
240
|
}
|
|
233
241
|
|
|
@@ -52,8 +52,10 @@ export interface ApiCourseResource {
|
|
|
52
52
|
|
|
53
53
|
/** Instructor linked to a lesson or available in the course pool. */
|
|
54
54
|
export interface ApiLessonInstructor {
|
|
55
|
-
id: number;
|
|
56
|
-
name
|
|
55
|
+
id: number | string;
|
|
56
|
+
name?: string;
|
|
57
|
+
nome?: string;
|
|
58
|
+
avatarId?: number | null;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
/**
|
|
@@ -34,7 +34,7 @@ export interface CourseStructureCacheData {
|
|
|
34
34
|
course: Course | null;
|
|
35
35
|
sessions: Session[];
|
|
36
36
|
lessons: Lesson[];
|
|
37
|
-
instructors: { id: number; name: string }[];
|
|
37
|
+
instructors: { id: number; name: string; avatarId?: number | null }[];
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -45,7 +45,7 @@ export interface CourseStructureQueryResult {
|
|
|
45
45
|
course: Course | null;
|
|
46
46
|
sessions: Session[];
|
|
47
47
|
lessons: Lesson[];
|
|
48
|
-
instructors: { id: number; name: string }[];
|
|
48
|
+
instructors: { id: number; name: string; avatarId?: number | null }[];
|
|
49
49
|
isLoading: boolean;
|
|
50
50
|
isFetching: boolean;
|
|
51
51
|
isError: boolean;
|