diff --git a/pages/TrailDetail.tsx b/pages/TrailDetail.tsx index 79c2e56..be719f5 100644 --- a/pages/TrailDetail.tsx +++ b/pages/TrailDetail.tsx @@ -1,12 +1,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { ArrowDown, ArrowLeft, ArrowUp, BookOpen, ExternalLink, Loader2, Pencil, Plus, Save, Trash2, Upload, X } from 'lucide-react'; import { useNavigate, useParams } from 'react-router-dom'; -import { adminApi, instructorApi, LearningPath, LearningPathInput } from '../services/api'; +import { adminApi, instructorApi, LearningPath, LearningPathInput, TrailCourseOption } from '../services/api'; import { deleteCourse } from '../services/db'; import { CourseCoverImage } from '../components/CourseCoverImage'; import { useToast } from '../context/ToastContext'; -type CourseOption = { id: string; title: string; status: string; category: string; coverImageUrl: string | null }; +type CourseOption = TrailCourseOption; const emptyInput = (): LearningPathInput => ({ title: '', description: '', coverImageUrl: null, coverImageZoom: 1, coverImagePositionX: 50, coverImagePositionY: 50, status: 'draft', courseIds: [] }); const toInput = (trail: LearningPath): LearningPathInput => ({ title: trail.title, description: trail.description, coverImageUrl: trail.coverImageUrl, coverImageZoom: trail.coverImageZoom, coverImagePositionX: trail.coverImagePositionX, coverImagePositionY: trail.coverImagePositionY, status: trail.status === 'published' ? 'published' : 'draft', courseIds: trail.courses.map((course) => course.id) }); @@ -26,8 +26,8 @@ export const TrailDetail: React.FC = () => { const load = async () => { setIsLoading(true); try { - const [pathsResponse, homeResponse] = await Promise.all([adminApi.listPaths(), adminApi.homeConfiguration()]); - setCourses(homeResponse.data.courses); + const [pathsResponse, coursesResponse] = await Promise.all([adminApi.listPaths(), adminApi.listPathCourses()]); + setCourses(coursesResponse.data); if (!isNew) { const found = pathsResponse.data.find((item) => item.id === trailId); if (!found) { showToast('Trilha não encontrada.', 'error'); navigate('/admin'); return; } @@ -66,7 +66,7 @@ export const TrailDetail: React.FC = () => { const save = async (event: React.FormEvent) => { event.preventDefault(); - if (input.courseIds.length === 0) { showToast('Adicione pelo menos um curso à trilha.', 'error'); return; } + if (input.status === 'published' && input.courseIds.length === 0) { showToast('Adicione pelo menos um curso antes de publicar a trilha.', 'error'); return; } setIsSaving(true); try { const response = isNew ? await adminApi.createPath(input) : await adminApi.updatePath(trail!.id, input); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index ad5e380..4aba83e 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -58,7 +58,15 @@ const learningPathSchema = z.object({ coverImagePositionX: z.number().min(0).max(100).default(50), coverImagePositionY: z.number().min(0).max(100).default(50), status: z.enum(['draft', 'published']).default('draft'), - courseIds: z.array(z.string().uuid()).min(1).max(100), + courseIds: z.array(z.string().uuid()).max(100), +}).superRefine((input, context) => { + if (input.status === 'published' && input.courseIds.length === 0) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['courseIds'], + message: 'Adicione pelo menos um curso antes de publicar a trilha.', + }); + } }); type LearningPathInput = z.infer; @@ -143,6 +151,17 @@ export const adminRoutes: FastifyPluginAsync = async (app) => { app.get('/paths', adminAccess, async () => ({ data: await getLearningPaths() })); + app.get('/path-courses', adminAccess, async () => { + const result = await pool.query<{ id: string; title: string; status: string; category: string; coverImageUrl: string | null }>( + `select id, title, status, category, + coalesce(cover_image_url, (select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl')) as "coverImageUrl" + from courses + where status <> 'archived' + order by category, title`, + ); + return { data: result.rows }; + }); + app.get('/home-banners', adminAccess, async () => ({ data: await selectHomeBanners() })); app.put('/home-banners', adminAccess, async (request) => { @@ -189,7 +208,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => { return reply.code(201).send({ data: paths.find((item) => item.id === path.rows[0].id) }); } catch (error) { await client.query('rollback'); - if (error instanceof Error && /course|Publish/.test(error.message)) return reply.code(400).send({ error: error.message }); + if (error instanceof Error && /curso|publique/i.test(error.message)) return reply.code(400).send({ error: error.message }); throw error; } finally { client.release(); @@ -223,7 +242,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => { return { data: paths.find((item) => item.id === pathId) }; } catch (error) { await client.query('rollback'); - if (error instanceof Error && /course|Publish/.test(error.message)) return reply.code(400).send({ error: error.message }); + if (error instanceof Error && /curso|publique/i.test(error.message)) return reply.code(400).send({ error: error.message }); throw error; } finally { client.release(); diff --git a/services/api.ts b/services/api.ts index d47fb6e..d9fc37a 100644 --- a/services/api.ts +++ b/services/api.ts @@ -74,6 +74,13 @@ export interface LearningPathInput { status: 'draft' | 'published'; courseIds: string[]; } +export interface TrailCourseOption { + id: string; + title: string; + status: 'draft' | 'published'; + category: string; + coverImageUrl: string | null; +} export interface HomeBannerInput { kind: 'course' | 'custom'; courseId: string | null; @@ -197,6 +204,7 @@ export const adminApi = { async createCategory(name: string) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>('/admin/categories', { method: 'POST', body: JSON.stringify({ name }) }); }, async updateCategory(categoryId: string, update: { name: string; isActive?: boolean; position?: number }) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>(`/admin/categories/${categoryId}`, { method: 'PATCH', body: JSON.stringify(update) }); }, async listPaths() { return apiRequest<{ data: LearningPath[] }>('/admin/paths'); }, + async listPathCourses() { return apiRequest<{ data: TrailCourseOption[] }>('/admin/path-courses'); }, async createPath(input: LearningPathInput) { return apiRequest<{ data: LearningPath }>('/admin/paths', { method: 'POST', body: JSON.stringify(input) }); }, async updatePath(pathId: string, input: LearningPathInput) { return apiRequest<{ data: LearningPath }>(`/admin/paths/${pathId}`, { method: 'PATCH', body: JSON.stringify(input) }); }, async deletePath(pathId: string) { return apiRequest(`/admin/paths/${pathId}`, { method: 'DELETE' }); },