diff --git a/public/app.js b/public/app.js index baec428..d302868 100644 --- a/public/app.js +++ b/public/app.js @@ -1990,6 +1990,9 @@ const initApp = () => { let recordingStartTime = null; let timerInterval = null; let generatedReport = null; + let generatedCriancas = null; + let generatedTurma = 'não informada'; + let generatedTags = []; // Abrir modal e iniciar gravação if (fabRecord) { @@ -2059,7 +2062,10 @@ const initApp = () => { body: formData }); const data = await res.json(); - generatedReport = data.report; + generatedReport = data.relatorio || data.report; + generatedCriancas = data.criancas; + generatedTurma = data.turma || 'não informada'; + generatedTags = data.tags || []; showReportPreview(data); } catch (err) { console.error('Erro ao processar observação:', err); @@ -2080,7 +2086,22 @@ const initApp = () => { hour: '2-digit', minute: '2-digit' }); - reportContent.innerHTML = marked.parse(generatedReport || 'Relatório não disponível.'); + // Monta HTML com metadados + const criancasHtml = data.criancas && data.criancas.length + ? `
👧 Crianças: ${data.criancas.join(', ')}
` + : `
👧 Crianças: não identificadas
`; + const tagsHtml = data.tags && data.tags.length + ? `
🏷️ Tags: ${data.tags.map(t => `${t}`).join(' ')}
` + : ''; + const turmaHtml = data.turma && data.turma !== 'não informada' + ? `
🏫 Turma: ${data.turma}
` + : ''; + + reportContent.innerHTML = ` +
${criancasHtml}${turmaHtml}${tagsHtml}
+
+ ${marked.parse(generatedReport || 'Relatório não disponível.')} + `; reportContent.querySelectorAll('pre code').forEach(b => hljs.highlightElement(b)); }; @@ -2103,7 +2124,12 @@ const initApp = () => { const res = await fetch('/api/observacao/salvar', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ report: generatedReport }) + body: JSON.stringify({ + report: generatedReport, + criancas: generatedCriancas, + turma: generatedTurma, + tags: generatedTags + }) }); const data = await res.json(); if (data.success) { @@ -2145,6 +2171,151 @@ const initApp = () => { if (e.target === recordModal) closeRecordModal(); }); } + + // ============================================================ + // MODAL: MINHAS OBSERVAÇÕES + // ============================================================ + const obsModal = document.getElementById('obsModal'); + const btnObservacoes = document.getElementById('btnObservacoes'); + const btnCloseObsModal = document.getElementById('btnCloseObsModal'); + const obsList = document.getElementById('obsList'); + const obsFilterMes = document.getElementById('obsFilterMes'); + const obsFilterTag = document.getElementById('obsFilterTag'); + const obsSearchCrianca = document.getElementById('obsSearchCrianca'); + + let allObservations = []; + + // Abrir modal de observações + if (btnObservacoes) { + btnObservacoes.addEventListener('click', async () => { + obsModal.style.display = 'flex'; + obsList.innerHTML = '
Carregando...
'; + + try { + const res = await fetch('/api/observacoes'); + const data = await res.json(); + allObservations = data.observations || []; + populateMonthFilter(); + renderObsList(); + } catch (err) { + console.error('Erro ao carregar observações:', err); + obsList.innerHTML = '
Erro ao carregar observações.
'; + } + }); + } + + // Fechar modal + if (btnCloseObsModal) { + btnCloseObsModal.addEventListener('click', () => { + obsModal.style.display = 'none'; + }); + } + if (obsModal) { + obsModal.addEventListener('click', (e) => { + if (e.target === obsModal) obsModal.style.display = 'none'; + }); + } + + // Filtros + if (obsFilterMes) obsFilterMes.addEventListener('change', renderObsList); + if (obsFilterTag) obsFilterTag.addEventListener('change', renderObsList); + if (obsSearchCrianca) obsSearchCrianca.addEventListener('input', renderObsList); + + // Preenche select de meses + const populateMonthFilter = () => { + if (!obsFilterMes) return; + const months = [...new Set(allObservations.map(o => o.date?.slice(0, 7)))] + .filter(Boolean).sort().reverse(); + obsFilterMes.innerHTML = '' + + months.map(m => ``).join(''); + }; + + // Renderiza lista filtrada + const renderObsList = () => { + if (!obsList) return; + const mes = obsFilterMes?.value || ''; + const tag = obsFilterTag?.value || ''; + const search = obsSearchCrianca?.value.toLowerCase() || ''; + + const filtered = allObservations.filter(o => { + if (mes && !o.date?.startsWith(mes)) return false; + if (tag && !o.tags?.includes(tag)) return false; + if (search) { + const nomes = (o.criancas || []).join(' ').toLowerCase(); + if (!nomes.includes(search) && !(o.preview || '').toLowerCase().includes(search)) return false; + } + return true; + }); + + if (filtered.length === 0) { + obsList.innerHTML = '
Nenhuma observação encontrada.
'; + return; + } + + obsList.innerHTML = filtered.map(o => { + const tagsHtml = (o.tags || []).map(t => `${t}`).join(''); + const criancasHtml = (o.criancas || []).map(c => `${c}`).join(''); + const dateFormatted = o.date ? o.date.split('-').reverse().join('/') : ''; + return ` +
+
+ 📋 ${dateFormatted}${o.time ? ' às ' + o.time : ''} + ${o.turma && o.turma !== 'não informada' ? `🏫 ${o.turma}` : ''} +
+
${o.preview || 'Sem prévia disponível.'}
+ +
+ `; + }).join(''); + + // Click para abrir observação + obsList.querySelectorAll('.obs-item').forEach(item => { + item.addEventListener('click', async () => { + const ym = item.dataset.yearmonth; + const fn = item.dataset.filename; + if (!ym || !fn) return; + try { + const res = await fetch(`/api/observacoes/${ym}/${fn}`); + const text = await res.text(); + showObsDetail(text); + } catch (err) { + console.error('Erro ao carregar observação:', err); + } + }); + }); + }; + + // Mostra detalhe da observação em modal + const showObsDetail = (mdText) => { + const modal = document.createElement('div'); + modal.className = 'settings-modal'; + modal.style.display = 'flex'; + modal.innerHTML = ` +
+
+

📋 Observação

+ +
+
+
+ ${marked.parse(mdText.replace(/^---[\s\S]*?---\n/, ''))} +
+
+ + +
+
+
+ `; + document.body.appendChild(modal); + modal.addEventListener('click', (e) => { + if (e.target === modal) modal.remove(); + }); + }; }; // Player de áudio personalizado global para as mídias geradas diff --git a/public/index.html b/public/index.html index 58669f9..e093e6d 100644 --- a/public/index.html +++ b/public/index.html @@ -65,6 +65,14 @@ + + +
@@ -386,6 +394,43 @@
+ + + diff --git a/public/style.css b/public/style.css index 151c8be..ba7d822 100644 --- a/public/style.css +++ b/public/style.css @@ -2804,7 +2804,177 @@ code { opacity: 0.9; } -/* Animação fadeIn global */ +/* Meta info do relatório na prévia */ +.report-meta { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 8px; +} + +.report-meta-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; +} + +.meta-label { + color: var(--text-muted, #666); + font-weight: 500; +} + +.tag-chip { + display: inline-block; + background: var(--brand-green, #10a37f); + color: white; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; +} + +/* Botão Minhas Observações na sidebar */ +.btn-observacoes { + display: flex; + align-items: center; + gap: 10px; + width: calc(100% - 40px); + margin: 0 auto 8px; + padding: 12px 16px; + background: var(--bg-secondary, #1a1a1a); + border: 1px solid var(--border-light, #262626); + border-radius: 12px; + color: var(--text-secondary, #a3a3a3); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.btn-observacoes:hover { + background: var(--brand-green, #10a37f); + color: white; + border-color: var(--brand-green, #10a37f); +} + +.btn-observacoes svg { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +/* Modal de Observações */ +.obs-modal-content { + max-width: 640px !important; + max-height: 85vh; +} + +.obs-modal-body { + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.obs-filters { + display: flex; + gap: 8px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-light, #262626); + flex-wrap: wrap; +} + +.obs-select, .obs-input { + flex: 1; + min-width: 120px; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--border-light, #262626); + background: var(--bg-input, #121212); + color: var(--text-primary, #ffffff); + font-size: 0.85rem; +} + +.obs-input { + flex: 1; + min-width: 160px; +} + +.obs-list { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.obs-empty { + text-align: center; + color: var(--text-muted, #666); + padding: 40px 20px; + font-size: 0.9rem; +} + +.obs-item { + background: var(--bg-secondary, #1a1a1a); + border: 1px solid var(--border-light, #262626); + border-radius: 12px; + padding: 14px; + margin-bottom: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.obs-item:hover { + border-color: var(--brand-green, #10a37f); + background: var(--bg-input, #121212); +} + +.obs-item-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 8px; +} + +.obs-item-date { + font-size: 0.75rem; + color: var(--text-muted, #666); +} + +.obs-item-preview { + font-size: 0.82rem; + color: var(--text-secondary, #a3a3a3); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.obs-item-footer { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.obs-tag { + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 10px; + background: rgba(16, 163, 127, 0.2); + color: var(--brand-green, #10a37f); +} + +.obs-crianca { + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.1); + color: var(--text-secondary, #a3a3a3); +} + +/* FAB: Floating Action Button para gravação */ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } diff --git a/server.js b/server.js index baaa12f..e93169d 100644 --- a/server.js +++ b/server.js @@ -252,24 +252,32 @@ app.post('/api/observacao/transcrever', requireAuth, multerUpload.single('audio' } // Gera relatório estruturado via GPT + // Envia transcrição + pedido de extrair: crianças, turma, tags, relatório const reportPrompt = `Você é a Kemily, assistente pedagógica da professora Camila Martella Gasparini Reifonas. -Ela acabou de gravar uma observação em voz sobre seus alunos da educação infantil. Sua tarefa é analisar a transcrição e gerar um RELATÓRIO ESTRUTURADO de observação pedagógica. +Ela acabou de gravar uma observação em voz sobre seus alunos da educação infantil. Sua tarefa é ANALISAR a transcrição e retornar UM OBJETO JSON (sem markdown, sem código, apenas o JSON puro) com: -REGRAS DO RELATÓRIO: -- Use linguagem formal-pedagógica com calor humano -- Estruture em seções claras com_MARKDOWN -- Identifique crianças mencionadas, comportamentos, interações, conquistas, dificuldades -- Inclua sugestões de seguimento para o planejamento -- Foque em evidências observáveis, não interpretções subjetivas -- Seja objetiva mas detalhista +{ + "criancas": ["lista de nomes de crianças mencionadas ou 'null' se não identificar nenhuma"], + "turma": "turma mencionada ou 'não informada'", + "tags": ["tag1", "tag2"] (social, cognitivo, motor, linguagem, emocional,轮的哪个), + "relatorio": "...markdown estruturado do relatório pedagógico..." +} -TRANSCRIÇÃO DA OBSERVAÇÃO: +REGRAS: +- Identifique crianças pelo nome próprio mencionado na fala +- Se não conseguir identificar nomes, use "criancas": null +- Tags: escolha entre social, cognitivo, motor, linguagem, emocional, colaborativo, autónomo +- Relatório em português, linguagem formal-pedagógica, seções claras em **markdown** +- Foque em evidências observáveis +- Máximo 120 palavras no relatório + +TRANSCRIÇÃO: """ ${transcribedText} """ -Gerando o relatório...`; +Retorne APENAS o JSON válido:`; const reportRes = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', @@ -290,11 +298,29 @@ Gerando o relatório...`; } const reportData = await reportRes.json(); - const reportText = reportData.choices?.[0]?.message?.content || 'Relatório não pôde ser gerado.'; + const rawContent = reportData.choices?.[0]?.message?.content || ''; + + // Parse JSON da resposta + let parsedData = { criancas: null, turma: 'não informada', tags: [], relatorio: rawContent }; + try { + // Limpa markdown code blocks se houver + const cleanJson = rawContent.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); + const parsed = JSON.parse(cleanJson); + parsedData = { + criancas: parsed.criancas || null, + turma: parsed.turma || 'não informada', + tags: parsed.tags || [], + relatorio: parsed.relatorio || rawContent + }; + } catch (parseErr) { + // Se não conseguir parsear JSON, retorna o texto como relatório + console.warn('Não foi possível parsear JSON do GPT, usando texto bruto'); + parsedData.relatorio = rawContent; + } res.json({ transcribedText, - report: reportText + ...parsedData }); } catch (err) { @@ -306,7 +332,7 @@ Gerando o relatório...`; // Rota: POST /api/observacao/salvar — salva relatório em .md app.post('/api/observacao/salvar', requireAuth, (req, res) => { - const { report } = req.body; + const { report, criancas, turma, tags } = req.body; if (!report) return res.status(400).json({ error: 'Relatório vazio' }); const now = new Date(); @@ -316,13 +342,7 @@ app.post('/api/observacao/salvar', requireAuth, (req, res) => { const timestamp = now.toISOString(); // Monta markdown com frontmatter - const mdContent = `--- -data: "${dateStr}" -hora: "${timeStr}" -timestamp: "${timestamp}" ---- - -${report} + const mdContent = `---\ndata: "${dateStr}"\nhora: "${timeStr}"\ntimestamp: "${timestamp}"\ncriancas: ${JSON.stringify(criancas)}\nturma: "${turma || 'não informada'}"\ntags: ${JSON.stringify(tags)}\n---\n\n${report} `; const filename = `${dateStr}_${timeStr}.md`; @@ -332,7 +352,7 @@ ${report} const filePath = path.join(monthDir, filename); fs.writeFileSync(filePath, mdContent, 'utf8'); - // Atualiza índice + // Atualiza índice com metadados estruturados const idx = readObsIndex(); idx.observations.unshift({ id: Date.now(), @@ -340,6 +360,9 @@ ${report} date: dateStr, time: timeStr, timestamp, + criancas: criancas || null, + turma: turma || 'não informada', + tags: tags || [], preview: report.slice(0, 120) + '...' }); writeObsIndex(idx);