This commit is contained in:
@@ -1 +1 @@
|
|||||||
2.1.29
|
2.1.46
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seção colapsável com chevron-down.
|
||||||
|
* Quando fechada, o conteúdo NÃO é renderizado → zero espaço em tela.
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* - title: string — título da seção
|
||||||
|
* - defaultOpen: boolean (default false)
|
||||||
|
* - children: ReactNode
|
||||||
|
* - className: string (opcional) — classe extra no container
|
||||||
|
* - compact: boolean (default false) — estilo menor para uso inline
|
||||||
|
*/
|
||||||
|
export default function CollapsibleSection({
|
||||||
|
title,
|
||||||
|
defaultOpen = false,
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
compact = false,
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`collapsible-section${compact ? ' collapsible-compact' : ''} ${className}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="collapsible-header"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span className="collapsible-title">{title}</span>
|
||||||
|
<i
|
||||||
|
className="fa-solid fa-chevron-down collapsible-chevron"
|
||||||
|
style={{ transform: open ? 'rotate(180deg)' : 'none' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open && <div className="collapsible-body">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,23 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Modal from './Modal';
|
import Modal from './Modal';
|
||||||
|
import CollapsibleSection from './CollapsibleSection';
|
||||||
import api from '../api/client';
|
import api from '../api/client';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
|
||||||
|
/** Detecta viewport mobile (<= 1023px) */
|
||||||
|
function useIsMobile() {
|
||||||
|
const [mobile, setMobile] = useState(() =>
|
||||||
|
typeof window !== 'undefined' ? window.innerWidth <= 1023 : false
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia('(max-width: 1023px)');
|
||||||
|
const handler = (e) => setMobile(e.matches);
|
||||||
|
mql.addEventListener('change', handler);
|
||||||
|
return () => mql.removeEventListener('change', handler);
|
||||||
|
}, []);
|
||||||
|
return mobile;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateString) {
|
function formatDate(dateString) {
|
||||||
if (!dateString) return '—';
|
if (!dateString) return '—';
|
||||||
return new Date(dateString).toLocaleString('pt-BR', {
|
return new Date(dateString).toLocaleString('pt-BR', {
|
||||||
@@ -13,6 +28,7 @@ function formatDate(dateString) {
|
|||||||
|
|
||||||
export default function TransformModal({ isOpen, onClose, image, onSaved }) {
|
export default function TransformModal({ isOpen, onClose, image, onSaved }) {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [activeView, setActiveView] = useState('transform'); // 'transform' | 'gto'
|
const [activeView, setActiveView] = useState('transform'); // 'transform' | 'gto'
|
||||||
const [step, setStep] = useState('choose'); // 'choose' (4 posições) | 'finetune'
|
const [step, setStep] = useState('choose'); // 'choose' (4 posições) | 'finetune'
|
||||||
const [choosePage, setChoosePage] = useState(0); // 0=rotações, 1=flip H, 2=flip V
|
const [choosePage, setChoosePage] = useState(0); // 0=rotações, 1=flip H, 2=flip V
|
||||||
@@ -338,13 +354,15 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
|
|||||||
{activeView === 'transform' && step === 'finetune' && (
|
{activeView === 'transform' && step === 'finetune' && (
|
||||||
<div className="transform-view-wrapper">
|
<div className="transform-view-wrapper">
|
||||||
<div className="new-transform-layout">
|
<div className="new-transform-layout">
|
||||||
{/* Original */}
|
{/* Original — escondido no mobile, mostrado via CollapsibeSection */}
|
||||||
<div className="transform-panel panel-original">
|
{!isMobile && (
|
||||||
<h3 className="panel-title">Original</h3>
|
<div className="transform-panel panel-original">
|
||||||
<div className="img-container">
|
<h3 className="panel-title">Original</h3>
|
||||||
<img src={originalUrl || imageUrl} alt="Original" />
|
<div className="img-container">
|
||||||
|
<img src={originalUrl || imageUrl} alt="Original" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Preview (orientação escolhida + ajuste fino) */}
|
{/* Preview (orientação escolhida + ajuste fino) */}
|
||||||
<div className="transform-panel panel-preview">
|
<div className="transform-panel panel-preview">
|
||||||
@@ -362,8 +380,10 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
|
|||||||
|
|
||||||
{/* Controles de rotação */}
|
{/* Controles de rotação */}
|
||||||
<div className="transform-sidebar">
|
<div className="transform-sidebar">
|
||||||
<h3 className="sidebar-title" style={{ fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: 1, color: 'var(--text-muted)', marginBottom: 12 }}>Ajuste</h3>
|
{!isMobile && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center', width: '100%' }}>
|
<h3 className="sidebar-title" style={{ fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: 1, color: 'var(--text-muted)', marginBottom: 12 }}>Ajuste</h3>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', flexDirection: isMobile ? 'row' : 'column', gap: 8, alignItems: 'center', width: isMobile ? 'auto' : '100%' }}>
|
||||||
{[
|
{[
|
||||||
{ label: '-5°', delta: -5, icon: '↺' },
|
{ label: '-5°', delta: -5, icon: '↺' },
|
||||||
{ label: '-1°', delta: -1, icon: '↺' },
|
{ label: '-1°', delta: -1, icon: '↺' },
|
||||||
@@ -374,7 +394,7 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="fine-tune-display" style={{ margin: '8px 0' }}>
|
<div className="fine-tune-display" style={{ margin: isMobile ? '0 4px' : '8px 0' }}>
|
||||||
{fineTuneAngle > 0 ? '+' : ''}{fineTuneAngle}°
|
{fineTuneAngle > 0 ? '+' : ''}{fineTuneAngle}°
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -389,7 +409,7 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{fineTuneAngle !== 0 && (
|
{fineTuneAngle !== 0 && (
|
||||||
<button type="button" className="btn btn-danger btn-small" onClick={resetAngle} style={{ marginTop: 12, width: '100%' }}>
|
<button type="button" className="btn btn-danger btn-small" onClick={resetAngle} style={{ marginTop: isMobile ? 0 : 12, width: isMobile ? 'auto' : '100%' }}>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -397,40 +417,92 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Original colapsável — apenas no mobile */}
|
||||||
|
{isMobile && (
|
||||||
|
<CollapsibleSection title="Ver Original" compact>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
|
||||||
|
<img
|
||||||
|
src={originalUrl || imageUrl}
|
||||||
|
alt="Original"
|
||||||
|
style={{ maxWidth: '80%', maxHeight: 200, objectFit: 'contain', borderRadius: 'var(--radius-sm)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Área de ação */}
|
{/* Área de ação */}
|
||||||
<div className="transform-action-area">
|
{isMobile ? (
|
||||||
<div className="form-group">
|
<CollapsibleSection title="Ações e Observação">
|
||||||
<label htmlFor="transform-remark">Nova Observação / Detalhes (Opcional):</label>
|
<div className="form-group" style={{ marginBottom: 12 }}>
|
||||||
<textarea
|
<label htmlFor="transform-remark">Observação (Opcional):</label>
|
||||||
id="transform-remark"
|
<textarea
|
||||||
name="remark"
|
id="transform-remark"
|
||||||
className="form-control"
|
name="remark"
|
||||||
rows={2}
|
className="form-control"
|
||||||
value={remark}
|
rows={2}
|
||||||
onChange={(e) => setRemark(e.target.value)}
|
value={remark}
|
||||||
placeholder="Ex: Raio-X Dente 45..."
|
onChange={(e) => setRemark(e.target.value)}
|
||||||
/>
|
placeholder="Ex: Raio-X Dente 45..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={saveTransform}
|
||||||
|
disabled={saving}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{saving ? 'Salvando...' : '💾 Salvar imagem e orientação'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setActiveView('gto')} style={{ width: '100%' }}>
|
||||||
|
📋 Vincular à GTO
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => downloadImage(true)} style={{ width: '100%' }}>
|
||||||
|
⬇️ Baixar orientada
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => downloadImage(false)} style={{ width: '100%' }}>
|
||||||
|
⬇️ Baixar original
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={onClose} style={{ width: '100%' }}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
) : (
|
||||||
|
<div className="transform-action-area">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="transform-remark">Nova Observação / Detalhes (Opcional):</label>
|
||||||
|
<textarea
|
||||||
|
id="transform-remark"
|
||||||
|
name="remark"
|
||||||
|
className="form-control"
|
||||||
|
rows={2}
|
||||||
|
value={remark}
|
||||||
|
onChange={(e) => setRemark(e.target.value)}
|
||||||
|
placeholder="Ex: Raio-X Dente 45..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions-inline" style={{ flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<button className="btn btn-secondary" onClick={onClose}>Cancelar</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => downloadImage(true)} title="Baixar a imagem com a orientação aplicada">
|
||||||
|
⬇️ Baixar orientada
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => downloadImage(false)} title="Baixar a imagem original sem alterações">
|
||||||
|
⬇️ Baixar original
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setActiveView('gto')} title="Vincular esta imagem a uma GTO">
|
||||||
|
📋 Vincular à GTO
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={saveTransform}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Salvando...' : '💾 Salvar imagem e orientação'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-actions-inline" style={{ flexWrap: 'wrap', gap: 8 }}>
|
)}
|
||||||
<button className="btn btn-secondary" onClick={onClose}>Cancelar</button>
|
|
||||||
<button className="btn btn-secondary" onClick={() => downloadImage(true)} title="Baixar a imagem com a orientação aplicada">
|
|
||||||
⬇️ Baixar orientada
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-secondary" onClick={() => downloadImage(false)} title="Baixar a imagem original sem alterações">
|
|
||||||
⬇️ Baixar original
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-secondary" onClick={() => setActiveView('gto')} title="Vincular esta imagem a uma GTO">
|
|
||||||
📋 Vincular à GTO
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={saveTransform}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
{saving ? 'Salvando...' : '💾 Salvar imagem e orientação'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -438,7 +510,7 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
|
|||||||
{activeView === 'gto' && (
|
{activeView === 'gto' && (
|
||||||
<div>
|
<div>
|
||||||
{/* Baixar imagem atual */}
|
{/* Baixar imagem atual */}
|
||||||
<div className="form-actions-inline" style={{ marginBottom: 12, gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<button className="btn btn-secondary btn-small" onClick={() => downloadImage(false)} title="Baixar a imagem original">
|
<button className="btn btn-secondary btn-small" onClick={() => downloadImage(false)} title="Baixar a imagem original">
|
||||||
⬇️ Baixar imagem
|
⬇️ Baixar imagem
|
||||||
</button>
|
</button>
|
||||||
@@ -449,32 +521,54 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Criar GTO */}
|
{/* Criar GTO — colapsável no mobile */}
|
||||||
<div className="gto-create-section">
|
{isMobile ? (
|
||||||
<h3 className="section-title">Cadastrar Nova GTO</h3>
|
<CollapsibleSection title="Cadastrar Nova GTO">
|
||||||
<div className="form-actions-inline" style={{ marginTop: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
<input
|
<input
|
||||||
id="gto-number" name="gtoNumber"
|
id="gto-number" name="gtoNumber"
|
||||||
type="text" className="form-control"
|
type="text" className="form-control"
|
||||||
placeholder="Número/ID da GTO"
|
placeholder="Número/ID da GTO"
|
||||||
value={gtoNumber}
|
value={gtoNumber}
|
||||||
onChange={(e) => setGtoNumber(e.target.value)}
|
onChange={(e) => setGtoNumber(e.target.value)}
|
||||||
style={{ flex: 1 }}
|
/>
|
||||||
/>
|
<input
|
||||||
<input
|
id="gto-desc" name="gtoDesc"
|
||||||
id="gto-desc" name="gtoDesc"
|
type="text" className="form-control"
|
||||||
type="text" className="form-control"
|
placeholder="Descrição (Opcional)"
|
||||||
placeholder="Descrição (Opcional)"
|
value={gtoDesc}
|
||||||
value={gtoDesc}
|
onChange={(e) => setGtoDesc(e.target.value)}
|
||||||
onChange={(e) => setGtoDesc(e.target.value)}
|
/>
|
||||||
style={{ flex: 2 }}
|
<button className="btn btn-primary" onClick={createGto} style={{ width: '100%' }}>Cadastrar</button>
|
||||||
/>
|
</div>
|
||||||
<button className="btn btn-primary" onClick={createGto}>Cadastrar</button>
|
</CollapsibleSection>
|
||||||
|
) : (
|
||||||
|
<div className="gto-create-section">
|
||||||
|
<h3 className="section-title">Cadastrar Nova GTO</h3>
|
||||||
|
<div className="form-actions-inline" style={{ marginTop: 12 }}>
|
||||||
|
<input
|
||||||
|
id="gto-number" name="gtoNumber"
|
||||||
|
type="text" className="form-control"
|
||||||
|
placeholder="Número/ID da GTO"
|
||||||
|
value={gtoNumber}
|
||||||
|
onChange={(e) => setGtoNumber(e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id="gto-desc" name="gtoDesc"
|
||||||
|
type="text" className="form-control"
|
||||||
|
placeholder="Descrição (Opcional)"
|
||||||
|
value={gtoDesc}
|
||||||
|
onChange={(e) => setGtoDesc(e.target.value)}
|
||||||
|
style={{ flex: 2 }}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={createGto}>Cadastrar</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Lista de GTOs */}
|
{/* Lista de GTOs */}
|
||||||
<div className="gto-list-container">
|
<div className="gto-list-container" style={{ marginTop: 12 }}>
|
||||||
{loadingGtos ? (
|
{loadingGtos ? (
|
||||||
<div style={{ padding: 20, textAlign: 'center', color: '#888' }}>Carregando GTOs...</div>
|
<div style={{ padding: 20, textAlign: 'center', color: '#888' }}>Carregando GTOs...</div>
|
||||||
) : gtos.length === 0 ? (
|
) : gtos.length === 0 ? (
|
||||||
@@ -482,9 +576,21 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
|
|||||||
) : (
|
) : (
|
||||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||||
{gtos.map((gto, idx) => (
|
{gtos.map((gto, idx) => (
|
||||||
<li key={gto.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 15, background: idx % 2 === 0 ? '#fff' : '#f9f9f9', borderBottom: '1px solid #eee' }}>
|
<li
|
||||||
|
key={gto.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: isMobile ? 'column' : 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: isMobile ? 'stretch' : 'center',
|
||||||
|
padding: isMobile ? 12 : 15,
|
||||||
|
background: idx % 2 === 0 ? '#fff' : '#f9f9f9',
|
||||||
|
borderBottom: '1px solid #eee',
|
||||||
|
gap: isMobile ? 10 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600, fontSize: '1.05rem', display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ fontWeight: 600, fontSize: isMobile ? '0.95rem' : '1.05rem', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
{gto.gto_number}
|
{gto.gto_number}
|
||||||
{gto.sent && (
|
{gto.sent && (
|
||||||
<span style={{ fontSize: '0.7rem', fontWeight: 700, color: '#fff', background: '#10b981', padding: '2px 8px', borderRadius: 999 }}>
|
<span style={{ fontSize: '0.7rem', fontWeight: 700, color: '#fff', background: '#10b981', padding: '2px 8px', borderRadius: 999 }}>
|
||||||
@@ -492,19 +598,22 @@ export default function TransformModal({ isOpen, onClose, image, onSaved }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#666', fontSize: '0.9rem' }}>{gto.description || 'Sem descrição'}</div>
|
<div style={{ color: '#666', fontSize: '0.85rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: isMobile ? '70vw' : 'none' }}>
|
||||||
<div style={{ color: '#999', fontSize: '0.8rem', marginTop: 4 }}>📅 {formatDate(gto.created_at)}</div>
|
{gto.description || 'Sem descrição'}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#999', fontSize: '0.75rem', marginTop: 3 }}>📅 {formatDate(gto.created_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<button className="btn btn-primary btn-small" onClick={() => linkToGto(gto.id)}>
|
<button className="btn btn-primary btn-small" onClick={() => linkToGto(gto.id)} style={{ flex: isMobile ? 1 : 'none' }}>
|
||||||
✅ Vincular
|
✅ Vincular
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`btn btn-small ${gto.sent ? 'btn-secondary' : 'btn-success'}`}
|
className={`btn btn-small ${gto.sent ? 'btn-secondary' : 'btn-success'}`}
|
||||||
onClick={() => markGtoSent(gto.id, !gto.sent)}
|
onClick={() => markGtoSent(gto.id, !gto.sent)}
|
||||||
title={gto.sent ? 'Desmarcar envio' : 'Marcar GTO como enviada'}
|
title={gto.sent ? 'Desmarcar envio' : 'Marcar GTO como enviada'}
|
||||||
|
style={{ flex: isMobile ? 1 : 'none' }}
|
||||||
>
|
>
|
||||||
{gto.sent ? '↩️ Desmarcar' : '📤 Imagem enviada'}
|
{gto.sent ? '↩️ Desmarcar' : '📤 Enviada'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -621,6 +621,60 @@ body {
|
|||||||
.dropdown-custom { padding: 6px 8px; }
|
.dropdown-custom { padding: 6px 8px; }
|
||||||
.dropdown-custom select, .dropdown-custom input { width: 100%; }
|
.dropdown-custom select, .dropdown-custom input { width: 100%; }
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
COLLAPSIBLE SECTION — Seção colapsável com chevron-down
|
||||||
|
================================================================ */
|
||||||
|
.collapsible-section {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-color);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
width: 100%; padding: 12px 14px;
|
||||||
|
background: none; border: none;
|
||||||
|
cursor: pointer; font-family: inherit;
|
||||||
|
font-size: 0.85rem; font-weight: 700;
|
||||||
|
color: var(--text-secondary); text-align: left;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-header:hover { background: rgba(0,0,0,0.03); }
|
||||||
|
.collapsible-header:active { background: rgba(0,0,0,0.06); }
|
||||||
|
|
||||||
|
.collapsible-chevron {
|
||||||
|
font-size: 0.65rem; color: var(--text-muted);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-body {
|
||||||
|
padding: 0 14px 14px;
|
||||||
|
animation: collapsibleIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-compact {
|
||||||
|
border: none; background: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-compact .collapsible-header {
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-compact .collapsible-body {
|
||||||
|
padding: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes collapsibleIn {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
/* Helpers de visibilidade por viewport (desktop = padrão) */
|
/* Helpers de visibilidade por viewport (desktop = padrão) */
|
||||||
.show-mobile { display: none !important; }
|
.show-mobile { display: none !important; }
|
||||||
.hide-mobile { display: contents; }
|
.hide-mobile { display: contents; }
|
||||||
@@ -779,12 +833,97 @@ body {
|
|||||||
width: 100% !important; height: 100dvh !important;
|
width: 100% !important; height: 100dvh !important;
|
||||||
max-width: 100% !important; border-radius: 0 !important;
|
max-width: 100% !important; border-radius: 0 !important;
|
||||||
}
|
}
|
||||||
.new-transform-layout { flex-direction: column; align-items: center; }
|
|
||||||
.transform-panel { max-width: 100%; width: 100%; }
|
/* ---- Modal header compacto ---- */
|
||||||
.transform-sidebar { width: 100%; flex-direction: row; border-radius: var(--radius-lg); justify-content: space-around; padding: 12px; }
|
.modal-header { padding: 12px 16px; }
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
max-width: 55vw;
|
||||||
|
}
|
||||||
|
.modal-title-group {
|
||||||
|
flex-wrap: wrap; gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.modal-actions-group { gap: 4px; }
|
||||||
|
.modal-actions-group .btn { width: 36px; height: 36px; font-size: 1rem; }
|
||||||
|
.modal-body { padding: 12px; }
|
||||||
|
|
||||||
|
/* ---- TransformModal: layout mobile otimizado ---- */
|
||||||
|
.new-transform-layout {
|
||||||
|
flex-direction: column; align-items: center;
|
||||||
|
gap: 12px; padding: 8px 0; min-height: auto;
|
||||||
|
}
|
||||||
|
.transform-panel {
|
||||||
|
max-width: 100%; width: 100%;
|
||||||
|
padding: 8px; border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.transform-panel .img-container {
|
||||||
|
max-height: none; aspect-ratio: auto;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
.transform-panel img {
|
||||||
|
max-width: 90%; max-height: 90%;
|
||||||
|
}
|
||||||
|
/* Painel original oculto por padrão no mobile (mostrado via CollapsibleSection) */
|
||||||
|
.transform-panel.panel-original.mobile-collapsible {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.panel-title { font-size: 0.85rem; margin-bottom: 8px; }
|
||||||
|
|
||||||
|
/* Barra de controles de rotação — horizontal fixa no bottom */
|
||||||
|
.transform-sidebar {
|
||||||
|
width: 100%; flex-direction: row;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 10px; gap: 6px;
|
||||||
|
position: sticky; bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 -4px 12px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.sidebar-btn { width: 48px; height: 48px; }
|
||||||
|
.sidebar-btn .icon { font-size: 1rem; }
|
||||||
|
.sidebar-btn .label { font-size: 0.6rem; }
|
||||||
|
.fine-tune-display { margin: 0 !important; min-width: 50px; }
|
||||||
|
|
||||||
|
/* Área de ação do transform — compacta */
|
||||||
|
.transform-action-area {
|
||||||
|
margin-top: 12px; padding: 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Escolha de orientação — thumbnails */
|
||||||
|
.transform-choose-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr) !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- GTO dentro do TransformModal: cards compactos ---- */
|
||||||
|
.gto-create-section { padding: 14px; }
|
||||||
|
.gto-create-section .form-actions-inline {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.gto-create-section .form-control { width: 100% !important; flex: none !important; }
|
||||||
|
.gto-link-section { flex-direction: column; gap: 10px; align-items: stretch; }
|
||||||
|
|
||||||
|
/* ---- Cards de paciente: ocultar remark no mobile ---- */
|
||||||
|
.image-doctor-remark { display: none; }
|
||||||
|
|
||||||
|
/* ---- Extra info panel compacto ---- */
|
||||||
|
.extra-info-panel { padding: 10px 14px; font-size: 0.85rem; margin-bottom: 12px; }
|
||||||
|
|
||||||
/* ---- Botões maiores para toque ---- */
|
/* ---- Botões maiores para toque ---- */
|
||||||
.btn { min-height: 40px; }
|
.btn { min-height: 40px; }
|
||||||
|
|
||||||
|
/* ---- Form actions empilham no mobile ---- */
|
||||||
|
.form-actions-inline { flex-direction: column; gap: 8px; }
|
||||||
|
.form-actions-inline .btn { width: 100%; }
|
||||||
|
|
||||||
|
/* ---- Toast no mobile ---- */
|
||||||
|
.toast-container { bottom: 16px; right: 16px; left: 16px; }
|
||||||
|
.toast { max-width: 100%; font-size: 0.9rem; padding: 12px 16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
@@ -795,10 +934,23 @@ body {
|
|||||||
.images-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
.images-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||||
.image-preview { height: 130px; }
|
.image-preview { height: 130px; }
|
||||||
.image-info { padding: 10px; }
|
.image-info { padding: 10px; }
|
||||||
.image-doctor-remark { display: none; } /* economiza espaço no card compacto */
|
.image-patient-name { font-size: 0.85rem; }
|
||||||
|
.image-meta { font-size: 0.75rem; }
|
||||||
.stat-card-row > * { flex: 1 1 100% !important; }
|
.stat-card-row > * { flex: 1 1 100% !important; }
|
||||||
.header h1 { font-size: 1.15rem; }
|
.header h1 { font-size: 1.15rem; }
|
||||||
.modal-header h2 { font-size: 1.05rem; }
|
.modal-header h2 { font-size: 0.9rem; max-width: 45vw; }
|
||||||
|
.modal-actions-group .btn { width: 32px; height: 32px; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
/* TransformModal — preview ainda maior */
|
||||||
|
.transform-panel img { max-width: 95%; max-height: 95%; }
|
||||||
|
.sidebar-btn { width: 44px; height: 44px; }
|
||||||
|
|
||||||
|
/* Paginação: só anterior/próximo */
|
||||||
|
.pagination-controls .btn:not(:first-child):not(:last-child) { display: none; }
|
||||||
|
|
||||||
|
/* Collapsible mais compacto */
|
||||||
|
.collapsible-header { padding: 10px 12px; font-size: 0.78rem; }
|
||||||
|
.collapsible-body { padding: 0 12px 12px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
@@ -811,4 +963,6 @@ body {
|
|||||||
.mobile-topbar-brand span:not(.mobile-topbar-logo) { font-size: 0.95rem; }
|
.mobile-topbar-brand span:not(.mobile-topbar-logo) { font-size: 0.95rem; }
|
||||||
.responsive-table td { font-size: 0.85rem; }
|
.responsive-table td { font-size: 0.85rem; }
|
||||||
.btn { font-size: 0.88rem; padding: 9px 14px; }
|
.btn { font-size: 0.88rem; padding: 9px 14px; }
|
||||||
|
.sidebar-btn { width: 40px; height: 40px; }
|
||||||
|
.sidebar-btn .label { display: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export default function AdminClinicsPage() {
|
|||||||
</header>
|
</header>
|
||||||
<div className="content-scroll">
|
<div className="content-scroll">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div style={{ display: 'flex', gap: 16, marginBottom: 24, flexWrap: 'wrap' }}>
|
<div className="stat-card-row" style={{ display: 'flex', gap: 16, marginBottom: 24, flexWrap: 'wrap' }}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Total de Dispositivos', value: total, color: '#4f46e5' },
|
{ label: 'Total de Dispositivos', value: total, color: '#4f46e5' },
|
||||||
{ label: 'Dispositivos Ativos', value: active, color: '#10b981' },
|
{ label: 'Dispositivos Ativos', value: active, color: '#10b981' },
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import UserManagementModal from '../components/UserManagementModal';
|
|||||||
import PluginsModal from '../components/PluginsModal';
|
import PluginsModal from '../components/PluginsModal';
|
||||||
import SyncModal from '../components/SyncModal';
|
import SyncModal from '../components/SyncModal';
|
||||||
import Sidebar from '../components/Sidebar';
|
import Sidebar from '../components/Sidebar';
|
||||||
|
import CollapsibleSection from '../components/CollapsibleSection';
|
||||||
|
import DropdownMenu from '../components/DropdownMenu';
|
||||||
|
|
||||||
function formatDate(d) {
|
function formatDate(d) {
|
||||||
if (!d) return '—';
|
if (!d) return '—';
|
||||||
@@ -273,26 +275,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
{view === 'patients' && (
|
{view === 'patients' && (
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<select
|
{/* Busca sempre visível */}
|
||||||
className="client-filter"
|
|
||||||
value={selectedClient}
|
|
||||||
onChange={(e) => setSelectedClient(e.target.value)}
|
|
||||||
style={{ height: 42 }}
|
|
||||||
>
|
|
||||||
<option value="">📋 Todos os Clientes</option>
|
|
||||||
{clientsList.map((c) => (
|
|
||||||
<option key={c.name} value={c.name}>
|
|
||||||
{c.name === 'Upload Web' ? 'Upload Web 💻 (Imagens do Painel)' : `${c.name}${c.status === 'identified' ? ' ✅' : c.status === 'historic' ? ' 📚' : ''}`}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={() => setShowDisabled(!showDisabled)}
|
|
||||||
style={{ height: 42, padding: '10px 16px', display: 'flex', alignItems: 'center', gap: 6 }}
|
|
||||||
>
|
|
||||||
👁️ {showDisabled ? 'Ver Ativas' : 'Ver Ocultas'}
|
|
||||||
</button>
|
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className="search-input"
|
className="search-input"
|
||||||
@@ -301,9 +284,60 @@ export default function DashboardPage() {
|
|||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
style={{ height: 42, minWidth: 200 }}
|
style={{ height: 42, minWidth: 200 }}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-secondary" onClick={() => loadPatients()} style={{ height: 42, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '10px 16px' }}>
|
{/* Desktop: filtros inline */}
|
||||||
🔄
|
<span className="hide-mobile">
|
||||||
</button>
|
<select
|
||||||
|
className="client-filter"
|
||||||
|
value={selectedClient}
|
||||||
|
onChange={(e) => setSelectedClient(e.target.value)}
|
||||||
|
style={{ height: 42 }}
|
||||||
|
>
|
||||||
|
<option value="">📋 Todos os Clientes</option>
|
||||||
|
{clientsList.map((c) => (
|
||||||
|
<option key={c.name} value={c.name}>
|
||||||
|
{c.name === 'Upload Web' ? 'Upload Web 💻 (Imagens do Painel)' : `${c.name}${c.status === 'identified' ? ' ✅' : c.status === 'historic' ? ' 📚' : ''}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setShowDisabled(!showDisabled)}
|
||||||
|
style={{ height: 42, padding: '10px 16px', display: 'flex', alignItems: 'center', gap: 6 }}
|
||||||
|
>
|
||||||
|
👁️ {showDisabled ? 'Ver Ativas' : 'Ver Ocultas'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => loadPatients()} style={{ height: 42, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '10px 16px' }}>
|
||||||
|
🔄
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/* Mobile: ações em dropdown */}
|
||||||
|
<span className="show-mobile">
|
||||||
|
<DropdownMenu
|
||||||
|
label="Ações"
|
||||||
|
buttonClassName="btn btn-primary"
|
||||||
|
items={[
|
||||||
|
{ label: showDisabled ? 'Ver Ativas' : 'Ver Ocultas', icon: '👁️', onClick: () => setShowDisabled(!showDisabled) },
|
||||||
|
{ label: 'Atualizar', icon: '🔄', onClick: () => loadPatients() },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Filtro de clientes dentro do dropdown */}
|
||||||
|
<div className="dropdown-custom">
|
||||||
|
<select
|
||||||
|
className="client-filter"
|
||||||
|
value={selectedClient}
|
||||||
|
onChange={(e) => setSelectedClient(e.target.value)}
|
||||||
|
style={{ height: 38, fontSize: '0.85rem', width: '100%' }}
|
||||||
|
>
|
||||||
|
<option value="">📋 Todos os Clientes</option>
|
||||||
|
{clientsList.map((c) => (
|
||||||
|
<option key={c.name} value={c.name}>
|
||||||
|
{c.name === 'Upload Web' ? 'Upload Web 💻' : `${c.name}${c.status === 'identified' ? ' ✅' : c.status === 'historic' ? ' 📚' : ''}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</DropdownMenu>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{view === 'patient-images' && (
|
{view === 'patient-images' && (
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{!loadingCron && cronJobs.length > 0 && (
|
{!loadingCron && cronJobs.length > 0 && (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
<table className="responsive-table" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{['Nome', 'Descrição', 'Intervalo', 'Status', 'Última execução', 'Próxima execução', 'Resultado', ''].map(col => (
|
{['Nome', 'Descrição', 'Intervalo', 'Status', 'Última execução', 'Próxima execução', 'Resultado', ''].map(col => (
|
||||||
@@ -145,28 +145,28 @@ export default function SettingsPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{cronJobs.map(job => (
|
{cronJobs.map(job => (
|
||||||
<tr key={job.key} style={{ borderBottom: '1px solid var(--border)' }}>
|
<tr key={job.key} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
<td style={{ padding: '14px 16px', fontWeight: 600, whiteSpace: 'nowrap' }}>{job.name}</td>
|
<td data-label="Nome" style={{ padding: '14px 16px', fontWeight: 600, whiteSpace: 'nowrap' }}>{job.name}</td>
|
||||||
<td style={{ padding: '14px 16px', color: 'var(--text-secondary)', maxWidth: 240, fontSize: '0.82rem' }}>{job.description}</td>
|
<td data-label="Descrição" style={{ padding: '14px 16px', color: 'var(--text-secondary)', maxWidth: 240, fontSize: '0.82rem' }}>{job.description}</td>
|
||||||
<td style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)' }}>
|
<td data-label="Intervalo" style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)' }}>
|
||||||
A cada {job.intervalMinutes} min
|
A cada {job.intervalMinutes} min
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '14px 16px' }}>
|
<td data-label="Status" style={{ padding: '14px 16px' }}>
|
||||||
<StatusBadge running={job.running} lastResult={job.lastResult} />
|
<StatusBadge running={job.running} lastResult={job.lastResult} />
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)', fontSize: '0.82rem' }}>
|
<td data-label="Última execução" style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)', fontSize: '0.82rem' }}>
|
||||||
{fmtDate(job.lastRun)}
|
{fmtDate(job.lastRun)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)', fontSize: '0.82rem' }}>
|
<td data-label="Próxima" style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)', fontSize: '0.82rem' }}>
|
||||||
{fmtDate(job.nextRun)}
|
{fmtDate(job.nextRun)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '14px 16px', fontSize: '0.82rem', color: 'var(--text-secondary)' }}>
|
<td data-label="Resultado" style={{ padding: '14px 16px', fontSize: '0.82rem', color: 'var(--text-secondary)' }}>
|
||||||
{job.lastResult ? (
|
{job.lastResult ? (
|
||||||
job.lastResult.status === 'error'
|
job.lastResult.status === 'error'
|
||||||
? <span style={{ color: '#ef4444' }}>{job.lastResult.error}</span>
|
? <span style={{ color: '#ef4444' }}>{job.lastResult.error}</span>
|
||||||
: `${job.lastResult.total} imagem(ns) processada(s)`
|
: `${job.lastResult.total} imagem(ns) processada(s)`
|
||||||
) : '—'}
|
) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '14px 16px' }}>
|
<td data-label="Ações" style={{ padding: '14px 16px' }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-small btn-secondary"
|
className="btn btn-small btn-secondary"
|
||||||
style={{ whiteSpace: 'nowrap', padding: '6px 12px', fontSize: '0.8rem' }}
|
style={{ whiteSpace: 'nowrap', padding: '6px 12px', fontSize: '0.8rem' }}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2.1.45
|
2.1.46
|
||||||
|
|||||||
Reference in New Issue
Block a user