Updating layout

This commit is contained in:
2026-04-27 16:50:14 -03:00
parent 2b87a535c2
commit cfd7a4a027
8 changed files with 715 additions and 115 deletions

View File

@@ -18,15 +18,20 @@ export async function createOS(formData: FormData) {
nextNumero = parseInt(maxRes.rows[0].max_num, 10) + 1; nextNumero = parseInt(maxRes.rows[0].max_num, 10) + 1;
} }
// Tentar pegar o ID do status 'Em preparação' como padrão
const statusRes = await pool.query(`SELECT id FROM public.os_status WHERE status = 'Em preparação' LIMIT 1`);
const defaultStatusId = statusRes.rows[0]?.id || null;
await pool.query( await pool.query(
`INSERT INTO public.os `INSERT INTO public.os
(numero_os, data_abertura, observacao, previsao, fechada, created_at) (numero_os, data_abertura, observacao, previsao, id_status, fechada, created_at)
VALUES VALUES
($1, NOW(), $2, $3, false, NOW())`, ($1, NOW(), $2, $3, $4, false, NOW())`,
[ [
nextNumero, nextNumero,
observacao || null, observacao || null,
previsao ? new Date(previsao) : null previsao ? new Date(previsao) : null,
defaultStatusId
] ]
); );
@@ -43,33 +48,58 @@ export async function createOS(formData: FormData) {
export async function validateAndSaveLicense(license: string) { export async function validateAndSaveLicense(license: string) {
const hash = process.env.LICENSE_HASH || "c7d2fdc0bafc21b291e11c132c08a446"; const hash = process.env.LICENSE_HASH || "c7d2fdc0bafc21b291e11c132c08a446";
console.log(`[Auth] Validando licença: ${license}`);
try { try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 12000); // 12 segundos de timeout
const response = await fetch("https://licencas.css-sistemas.com/consultalicenca", { const response = await fetch("https://licencas.css-sistemas.com/consultalicenca", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ licenca: license, hash: hash }), body: JSON.stringify({ licenca: license, hash: hash }),
signal: controller.signal
}); });
clearTimeout(timeoutId);
const data = await response.json(); const data = await response.json();
console.log("[Auth] Resposta da API:", data);
if (data.ok && data.status === 1) { if (data.ok && data.status === 1) {
// Salvar no .env // Salvar no .env apenas se for diferente para evitar restart desnecessário do dev server
const envPath = path.join(process.cwd(), ".env"); const envPath = path.join(process.cwd(), ".env");
let envContent = fs.readFileSync(envPath, "utf-8"); let envContent = "";
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, "utf-8");
}
const licenseLine = `LICENSE_NUMBER=${license}`;
// Se a licença já estiver lá exatamente igual, não faz nada
if (envContent.includes(licenseLine)) {
console.log("[Auth] Licença já configurada no .env.");
return { success: true };
}
if (envContent.includes("LICENSE_NUMBER=")) { if (envContent.includes("LICENSE_NUMBER=")) {
envContent = envContent.replace(/LICENSE_NUMBER=.*/, `LICENSE_NUMBER=${license}`); envContent = envContent.replace(/LICENSE_NUMBER=.*/, licenseLine);
} else { } else {
envContent += `\nLICENSE_NUMBER=${license}`; envContent += `\n${licenseLine}`;
} }
fs.writeFileSync(envPath, envContent); fs.writeFileSync(envPath, envContent);
console.log("[Auth] .env atualizado com nova licença.");
return { success: true }; return { success: true };
} else { } else {
return { success: false, message: "Licença inválida ou inativa." }; return { success: false, message: data.msg || "Licença inválida ou inativa." };
} }
} catch (error) { } catch (error: any) {
console.error("Erro na validação da licença:", error); if (error.name === 'AbortError') {
console.error("[Auth] Timeout ao validar licença.");
return { success: false, message: "O servidor de licenças demorou muito para responder." };
}
console.error("[Auth] Erro na validação da licença:", error);
return { success: false, message: "Erro ao conectar com o servidor de licenças." }; return { success: false, message: "Erro ao conectar com o servidor de licenças." };
} }
} }
@@ -77,3 +107,69 @@ export async function validateAndSaveLicense(license: string) {
export async function getAppLicense() { export async function getAppLicense() {
return process.env.LICENSE_NUMBER || null; return process.env.LICENSE_NUMBER || null;
} }
export async function deleteOS(id: number) {
try {
await pool.query(
`UPDATE public.os SET deleted_at = NOW(), fechada = true WHERE id = $1`,
[id]
);
revalidatePath("/dashboard");
revalidatePath("/tv");
return { success: true };
} catch (error) {
console.error("Erro ao deletar OS:", error);
return { success: false, message: "Falha ao excluir ordem de serviço" };
}
}
export async function getOSById(id: number) {
try {
const res = await pool.query(`
SELECT o.*, s.status as status_nome
FROM public.os o
LEFT JOIN public.os_status s ON o.id_status = s.id
WHERE o.id = $1
`, [id]);
return res.rows[0] || null;
} catch (error) {
console.error("Erro ao buscar OS:", error);
return null;
}
}
export async function updateOS(id: number, formData: FormData) {
const observacao = formData.get("observacao") as string;
const previsao = formData.get("previsao") as string;
const id_status = formData.get("id_status") ? parseInt(formData.get("id_status") as string) : null;
try {
await pool.query(
`UPDATE public.os
SET observacao = $1, previsao = $2, id_status = $3, updated_at = NOW()
WHERE id = $4`,
[
observacao || null,
previsao ? new Date(previsao) : null,
id_status,
id
]
);
revalidatePath("/dashboard");
revalidatePath("/tv");
} catch (error) {
console.error("Erro ao atualizar OS:", error);
throw new Error("Falha ao atualizar ordem de serviço");
}
}
export async function getStatuses() {
try {
const res = await pool.query(`SELECT id, status FROM public.os_status ORDER BY id`);
return res.rows;
} catch (error) {
console.error("Erro ao buscar status:", error);
return [];
}
}

View File

@@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { updateOS } from "@/app/actions";
interface Status {
id: number;
status: string;
}
interface OS {
id: number;
numero_os: string;
observacao: string;
previsao: string;
id_status: number;
}
export default function EditOSForm({ os, statuses }: { os: OS, statuses: Status[] }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
// Format date for input datetime-local
const formattedPrevisao = os.previsao
? new Date(os.previsao).toISOString().slice(0, 16)
: "";
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
const formData = new FormData(e.currentTarget);
try {
await updateOS(os.id, formData);
router.push("/dashboard");
} catch (error) {
alert("Erro ao atualizar OS");
} finally {
setLoading(false);
}
};
return (
<div className="glass-panel" style={{ padding: '2rem', maxWidth: '600px' }}>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ color: 'var(--accent-primary)' }}>Ordem de serviço {os.numero_os}</h3>
<div className="status-badge status-open" style={{ padding: '0.5rem 1rem' }}>
{statuses.find(s => s.id === os.id_status)?.status || "Sem Status"}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<label htmlFor="id_status" style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--text-secondary)' }}>
Status da OS
</label>
<select id="id_status" name="id_status" defaultValue={os.id_status || ""}>
{statuses.map(s => (
<option key={s.id} value={s.id}>{s.status}</option>
))}
</select>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<label htmlFor="observacao" style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--text-secondary)' }}>
Observações / Problema Relatado
</label>
<textarea
id="observacao"
name="observacao"
rows={4}
defaultValue={os.observacao || ""}
placeholder="Descreva o problema do veículo ou os serviços solicitados..."
></textarea>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<label htmlFor="previsao" style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--text-secondary)' }}>
Previsão de Entrega
</label>
<input
type="datetime-local"
id="previsao"
name="previsao"
defaultValue={formattedPrevisao}
/>
</div>
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'flex-end', marginTop: '1rem', borderTop: '1px solid var(--border-color)', paddingTop: '1.5rem' }}>
<Link href="/dashboard" className="btn-primary" style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-primary)' }}>
Cancelar
</Link>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? "Salvando..." : "Atualizar OS"}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import Link from "next/link";
import { getOSById, getStatuses } from "@/app/actions";
import EditOSForm from "./edit-form";
export default async function EditarOSPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = await params;
const osId = parseInt(resolvedParams.id);
const os = await getOSById(osId);
const statuses = await getStatuses();
if (!os) {
return (
<div className="animate-fade-in">
<h2>Ordem de Serviço não encontrada</h2>
<Link href="/dashboard">Voltar para Dashboard</Link>
</div>
);
}
return (
<div className="animate-fade-in">
<div className="page-header" style={{ marginBottom: '1.5rem' }}>
<div>
<Link href="/dashboard" style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem', color: 'var(--text-muted)', marginBottom: '0.5rem', fontSize: '0.875rem' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
Voltar
</Link>
<h2>Editar Ordem de Serviço</h2>
</div>
</div>
<EditOSForm os={os} statuses={statuses} />
</div>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { deleteOS } from "@/app/actions";
interface OS {
id: number;
numero_os: string;
data_abertura: string;
fechada: boolean;
status: string;
totalproduto: string;
totalservico: string;
}
export default function OSList({ initialOrders }: { initialOrders: OS[] }) {
const [orders, setOrders] = useState(initialOrders);
const [isDeleting, setIsDeleting] = useState<number | null>(null);
const handleDelete = async (id: number) => {
const res = await deleteOS(id);
if (res.success) {
setOrders(orders.filter(o => o.id !== id));
setIsDeleting(null);
} else {
alert(res.message);
}
};
return (
<>
<div className="os-grid">
{orders.map((order, i) => (
<div key={order.id} className="glass-panel os-card animate-slide-up" style={{ animationDelay: `${i * 0.05}s` }}>
<div className="os-card-header">
<span className="os-number">Ordem de serviço {order.numero_os}</span>
<span className="status-badge status-open">
{order.status || 'Aberta'}
</span>
</div>
<div className="os-card-body">
<div className="os-info-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
{order.data_abertura ? new Date(order.data_abertura).toLocaleDateString('pt-BR') : '-'}
</div>
</div>
<div className="os-card-footer">
<div className="os-price">
{((parseFloat(order.totalproduto || '0') + parseFloat(order.totalservico || '0'))).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
</div>
<div className="os-actions">
<Link href={`/dashboard/editar-os/${order.id}`} className="action-btn btn-edit" title="Editar">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</Link>
<button
onClick={() => setIsDeleting(order.id)}
className="action-btn btn-delete"
title="Excluir"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
</div>
</div>
))}
</div>
{isDeleting && (
<div className="modal-overlay">
<div className="glass-panel modal-content">
<h3 style={{ marginBottom: '1rem', color: 'var(--text-primary)' }}>Confirmar Exclusão</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem' }}>
Tem certeza que deseja excluir a Ordem de Serviço <strong>#{orders.find(o => o.id === isDeleting)?.numero_os}</strong>? Esta ação não pode ser desfeita.
</p>
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'flex-end' }}>
<button
onClick={() => setIsDeleting(null)}
className="btn-primary"
style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-primary)' }}
>
Cancelar
</button>
<button
onClick={() => handleDelete(isDeleting)}
className="btn-primary"
style={{ backgroundColor: 'var(--accent-danger)' }}
>
Excluir
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import pool from "@/lib/db"; import pool from "@/lib/db";
import OSList from "./os-list";
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -16,7 +17,7 @@ async function getOpenServiceOrders() {
o.totalservico o.totalservico
FROM public.os o FROM public.os o
LEFT JOIN public.os_status s ON o.id_status = s.id LEFT JOIN public.os_status s ON o.id_status = s.id
WHERE o.fechada = false WHERE o.fechada = false AND o.deleted_at IS NULL
ORDER BY o.data_abertura DESC ORDER BY o.data_abertura DESC
LIMIT 100 LIMIT 100
`); `);
@@ -31,7 +32,7 @@ export default async function DashboardPage() {
const orders = await getOpenServiceOrders(); const orders = await getOpenServiceOrders();
return ( return (
<div className="animate-fade-in"> <>
<div className="page-header"> <div className="page-header">
<div> <div>
<h2>Ordens de Serviço</h2> <h2>Ordens de Serviço</h2>
@@ -46,54 +47,20 @@ export default async function DashboardPage() {
</Link> </Link>
</div> </div>
<div className="glass-panel" style={{ padding: '1rem', overflowX: 'auto' }}> {orders.length === 0 ? (
{orders.length === 0 ? ( <div className="glass-panel animate-fade-in" style={{ padding: '3rem 1rem', textAlign: 'center', color: 'var(--text-muted)' }}>
<div style={{ textAlign: 'center', padding: '3rem 1rem', color: 'var(--text-muted)' }}> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" style={{ margin: '0 auto 1rem', opacity: 0.5 }}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" style={{ margin: '0 auto 1rem', opacity: 0.5 }}> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path> <polyline points="14 2 14 8 20 8"></polyline>
<polyline points="14 2 14 8 20 8"></polyline> <line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="13" x2="8" y2="13"></line> <line x1="16" y1="17" x2="8" y2="17"></line>
<line x1="16" y1="17" x2="8" y2="17"></line> </svg>
<polyline points="10 9 9 9 8 9"></polyline> <p>Nenhuma ordem de serviço aberta encontrada.</p>
</svg> <p style={{ fontSize: '0.875rem', marginTop: '0.5rem' }}>Clique em "Nova OS" para criar uma.</p>
<p>Nenhuma ordem de serviço aberta encontrada.</p> </div>
<p style={{ fontSize: '0.875rem', marginTop: '0.5rem' }}>Clique em "Nova OS" para criar uma.</p> ) : (
</div> <OSList initialOrders={orders} />
) : ( )}
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}> </>
<thead>
<tr style={{ borderBottom: '1px solid var(--border-color)', color: 'var(--text-secondary)' }}>
<th style={{ padding: '1rem', fontWeight: 500 }}> OS</th>
<th style={{ padding: '1rem', fontWeight: 500 }}>Data Abertura</th>
<th style={{ padding: '1rem', fontWeight: 500 }}>Status</th>
<th style={{ padding: '1rem', fontWeight: 500 }}>Total (R$)</th>
<th style={{ padding: '1rem', fontWeight: 500 }}>Ação</th>
</tr>
</thead>
<tbody>
{orders.map((order) => (
<tr key={order.id} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '1rem', fontWeight: 600, color: 'var(--text-primary)' }}>#{order.numero_os}</td>
<td style={{ padding: '1rem', color: 'var(--text-secondary)' }}>
{order.data_abertura ? new Date(order.data_abertura).toLocaleDateString('pt-BR') : '-'}
</td>
<td style={{ padding: '1rem' }}>
<span className="status-badge status-open">
{order.status || 'Aberta'}
</span>
</td>
<td style={{ padding: '1rem', color: 'var(--text-secondary)' }}>
{((parseFloat(order.totalproduto || '0') + parseFloat(order.totalservico || '0'))).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
</td>
<td style={{ padding: '1rem' }}>
<button style={{ color: 'var(--accent-primary)', fontSize: '0.875rem', fontWeight: 500 }}>Ver Detalhes</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
); );
} }

View File

@@ -131,10 +131,13 @@ input:focus, textarea:focus, select:focus {
} }
.status-badge { .status-badge {
padding: 0.25rem 0.75rem; padding: 0.35rem 0.85rem;
border-radius: var(--radius-full); border-radius: var(--radius-full);
font-size: 0.875rem; font-size: 0.7rem;
font-weight: 500; font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
white-space: nowrap;
} }
.status-open { .status-open {
@@ -204,14 +207,210 @@ input:focus, textarea:focus, select:focus {
/* Animations */ /* Animations */
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); } from { opacity: 0; }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; }
} }
.animate-fade-in { .animate-fade-in {
animation: fadeIn 0.4s ease forwards; animation: fadeIn 0.4s ease forwards;
} }
/* OS Cards */
.os-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-top: 1rem;
}
.os-card {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
position: relative;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease;
overflow: hidden;
}
.os-card:hover {
transform: translateY(-5px) scale(1.02);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2), 0 10px 10px -5px rgba(0, 0, 0, 0.1);
border-color: var(--accent-primary);
}
.os-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.os-number {
font-size: 1.25rem;
font-weight: 800;
color: var(--text-primary);
}
.os-card-body {
flex: 1;
}
.os-info-item {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.os-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.os-price {
font-weight: 700;
font-size: 1.125rem;
color: var(--accent-success);
}
.os-actions {
display: flex;
gap: 0.75rem;
}
.action-btn {
padding: 0.5rem;
border-radius: var(--radius-md);
transition: background-color var(--transition-fast), color var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
}
.btn-edit {
color: var(--accent-primary);
background-color: rgba(59, 130, 246, 0.1);
}
.btn-edit:hover {
background-color: var(--accent-primary);
color: white;
}
.btn-delete {
color: var(--accent-danger);
background-color: rgba(239, 68, 68, 0.1);
}
.btn-delete:hover {
background-color: var(--accent-danger);
color: white;
}
/* Kanban Board */
.kanban-board {
display: flex;
gap: 1.5rem;
overflow-x: auto;
padding: 1rem 0;
min-height: calc(100vh - 200px);
}
.kanban-column {
flex: 1;
min-width: 300px;
background-color: rgba(30, 41, 59, 0.4);
border-radius: var(--radius-xl);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.kanban-column-header {
padding: 0.5rem;
margin-bottom: 1rem;
border-bottom: 2px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.kanban-column-title {
font-size: 1.125rem;
font-weight: 700;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.kanban-count {
background-color: var(--bg-tertiary);
color: var(--text-secondary);
padding: 0.25rem 0.6rem;
border-radius: var(--radius-full);
font-size: 0.75rem;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
animation: fadeIn 0.3s ease;
}
.modal-content {
width: 90%;
max-width: 450px;
padding: 2.5rem;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-glass);
animation: scaleUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
}
@keyframes scaleUp {
from { transform: scale(0.9); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
/* More Animations */
.animate-slide-up {
animation: slideUp 0.5s ease-out forwards;
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4); }
70% { box-shadow: 0 0 0 10px rgba(59, 130, 246, 0); }
100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
}
/* Responsividade (Mobile) */ /* Responsividade (Mobile) */
@media (max-width: 768px) { @media (max-width: 768px) {
.dashboard-layout { .dashboard-layout {
@@ -273,5 +472,13 @@ input:focus, textarea:focus, select:focus {
.tv-header div { .tv-header div {
font-size: 1rem !important; font-size: 1rem !important;
} }
.os-grid {
grid-template-columns: 1fr;
}
.kanban-board {
flex-direction: column;
}
} }

120
src/app/tv/kanban-board.tsx Normal file
View File

@@ -0,0 +1,120 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
interface OS {
numero_os: string;
data_abertura: string;
status: string;
id_status: number;
}
const COLUMNS = [
"Em preparação",
"Trabalhando",
"Aguardando peças",
"Finalizado"
];
export default function KanbanBoard({ initialOrders }: { initialOrders: OS[] }) {
const router = useRouter();
const boardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, 30000);
return () => clearInterval(interval);
}, [router]);
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
boardRef.current?.requestFullscreen().catch(err => {
alert(`Erro ao tentar entrar em modo tela cheia: ${err.message}`);
});
} else {
document.exitFullscreen();
}
};
const getOrdersByStatus = (status: string) => {
return initialOrders.filter(o => o.status === status);
};
return (
<div ref={boardRef} style={{ padding: '2rem', minHeight: '100vh', backgroundColor: 'var(--bg-primary)', overflowY: 'auto' }}>
<header className="tv-header" style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', paddingBottom: '1rem', borderBottom: '2px solid var(--border-color)' }}>
<div>
<h1 style={{ fontSize: '2.5rem', color: 'var(--accent-primary)', fontFamily: 'Outfit, sans-serif' }}>AutoAPP</h1>
<h2 style={{ fontSize: '1.5rem', color: 'var(--text-secondary)' }}>Painel de Atendimento</h2>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '1.25rem', color: 'var(--text-primary)', fontWeight: 600 }}>
{new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
</div>
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
{new Date().toLocaleDateString('pt-BR')}
</div>
</div>
<button
onClick={toggleFullscreen}
className="btn-primary pulse"
style={{ padding: '0.5rem 1rem', fontSize: '0.875rem' }}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '0.5rem' }}>
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path>
</svg>
Maximizar (F11)
</button>
</div>
</header>
<div className="kanban-board">
{COLUMNS.map((colName) => {
const colOrders = getOrdersByStatus(colName);
return (
<div key={colName} className="kanban-column animate-fade-in">
<div className="kanban-column-header">
<h3 className="kanban-column-title">{colName}</h3>
<span className="kanban-count">{colOrders.length}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{colOrders.length === 0 ? (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.875rem', fontStyle: 'italic' }}>
Nenhuma OS
</div>
) : (
colOrders.map((order, i) => (
<div
key={i}
className="glass-panel animate-slide-up"
style={{
padding: '1.5rem',
borderLeft: '4px solid var(--accent-primary)',
animationDelay: `${i * 0.1}s`
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '1.5rem', fontWeight: 800, color: 'var(--text-primary)' }}>
OS {order.numero_os}
</span>
</div>
<div style={{ fontSize: '1rem', color: 'var(--text-secondary)' }}>
{order.data_abertura ? new Date(order.data_abertura).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '-'}
</div>
</div>
))
)}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import pool from "@/lib/db"; import pool from "@/lib/db";
import TVClientWrapper from "./tv-client-wrapper"; import KanbanBoard from "./kanban-board";
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const revalidate = 0; export const revalidate = 0;
@@ -10,12 +10,13 @@ async function getTVOrders() {
SELECT SELECT
o.numero_os, o.numero_os,
o.data_abertura, o.data_abertura,
s.status s.status,
o.id_status
FROM public.os o FROM public.os o
LEFT JOIN public.os_status s ON o.id_status = s.id LEFT JOIN public.os_status s ON o.id_status = s.id
WHERE o.fechada = false WHERE o.fechada = false AND o.deleted_at IS NULL
ORDER BY o.data_abertura DESC ORDER BY o.data_abertura DESC
LIMIT 20 LIMIT 100
`); `);
return res.rows; return res.rows;
} catch (error) { } catch (error) {
@@ -27,49 +28,5 @@ async function getTVOrders() {
export default async function TVPage() { export default async function TVPage() {
const orders = await getTVOrders(); const orders = await getTVOrders();
return ( return <KanbanBoard initialOrders={orders} />;
<TVClientWrapper>
<div style={{ padding: '2rem', minHeight: '100vh', backgroundColor: 'var(--bg-primary)' }}>
<header className="tv-header" style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem', justifyContent: 'space-between', alignItems: 'center', marginBottom: '3rem', paddingBottom: '1rem', borderBottom: '2px solid var(--border-color)' }}>
<h1 style={{ fontSize: '3rem', color: 'var(--accent-primary)', fontFamily: 'Outfit, sans-serif' }}>AutoAPP</h1>
<h2 style={{ fontSize: '2rem', color: 'var(--text-secondary)' }}>Painel de Atendimento</h2>
<div style={{ fontSize: '1.5rem', color: 'var(--text-muted)' }}>
{new Date().toLocaleDateString('pt-BR')}
</div>
</header>
{orders.length === 0 ? (
<div style={{ textAlign: 'center', marginTop: '10rem' }}>
<h2 style={{ fontSize: '3rem', color: 'var(--text-muted)' }}>Nenhuma Ordem de Serviço Aberta</h2>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(100%, 320px), 1fr))', gap: '2rem' }}>
{orders.map((order, i) => (
<div
key={i}
className="glass-panel animate-fade-in"
style={{
padding: '2rem',
borderLeft: '8px solid var(--accent-primary)',
animationDelay: `${i * 0.1}s`
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<span style={{ fontSize: '3.5rem', fontWeight: 800, color: 'var(--text-primary)', lineHeight: 1 }}>
#{order.numero_os}
</span>
</div>
<div style={{ fontSize: '1.5rem', color: 'var(--text-secondary)', marginBottom: '1.5rem' }}>
{order.data_abertura ? new Date(order.data_abertura).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '-'}
</div>
<div style={{ display: 'inline-block', padding: '0.5rem 1.5rem', borderRadius: '9999px', backgroundColor: 'rgba(59, 130, 246, 0.2)', color: 'var(--accent-primary)', fontSize: '1.5rem', fontWeight: 600 }}>
{order.status || 'Em Atendimento'}
</div>
</div>
))}
</div>
)}
</div>
</TVClientWrapper>
);
} }