From cfd7a4a027b6aa252a39473e21ddf6645e4e6e3f Mon Sep 17 00:00:00 2001 From: Caio Barcelos de Brito Date: Mon, 27 Apr 2026 16:50:14 -0300 Subject: [PATCH] Updating layout --- src/app/actions.ts | 116 +++++++++- .../dashboard/editar-os/[id]/edit-form.tsx | 103 +++++++++ src/app/dashboard/editar-os/[id]/page.tsx | 38 +++ src/app/dashboard/os-list.tsx | 112 +++++++++ src/app/dashboard/page.tsx | 69 ++---- src/app/globals.css | 217 +++++++++++++++++- src/app/tv/kanban-board.tsx | 120 ++++++++++ src/app/tv/page.tsx | 55 +---- 8 files changed, 715 insertions(+), 115 deletions(-) create mode 100644 src/app/dashboard/editar-os/[id]/edit-form.tsx create mode 100644 src/app/dashboard/editar-os/[id]/page.tsx create mode 100644 src/app/dashboard/os-list.tsx create mode 100644 src/app/tv/kanban-board.tsx diff --git a/src/app/actions.ts b/src/app/actions.ts index 806e683..0dc8d7a 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -18,15 +18,20 @@ export async function createOS(formData: FormData) { 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( `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 - ($1, NOW(), $2, $3, false, NOW())`, + ($1, NOW(), $2, $3, $4, false, NOW())`, [ nextNumero, 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) { const hash = process.env.LICENSE_HASH || "c7d2fdc0bafc21b291e11c132c08a446"; + console.log(`[Auth] Validando licença: ${license}`); + 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", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ licenca: license, hash: hash }), + signal: controller.signal }); + clearTimeout(timeoutId); const data = await response.json(); + console.log("[Auth] Resposta da API:", data); 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"); - 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=")) { - envContent = envContent.replace(/LICENSE_NUMBER=.*/, `LICENSE_NUMBER=${license}`); + envContent = envContent.replace(/LICENSE_NUMBER=.*/, licenseLine); } else { - envContent += `\nLICENSE_NUMBER=${license}`; + envContent += `\n${licenseLine}`; } fs.writeFileSync(envPath, envContent); + console.log("[Auth] .env atualizado com nova licença."); return { success: true }; } 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) { - console.error("Erro na validação da licença:", error); + } catch (error: any) { + 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." }; } } @@ -77,3 +107,69 @@ export async function validateAndSaveLicense(license: string) { export async function getAppLicense() { 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 []; + } +} diff --git a/src/app/dashboard/editar-os/[id]/edit-form.tsx b/src/app/dashboard/editar-os/[id]/edit-form.tsx new file mode 100644 index 0000000..c017b26 --- /dev/null +++ b/src/app/dashboard/editar-os/[id]/edit-form.tsx @@ -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) => { + 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 ( +
+
+ +
+

Ordem de serviço {os.numero_os}

+
+ {statuses.find(s => s.id === os.id_status)?.status || "Sem Status"} +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Cancelar + + +
+
+
+ ); +} diff --git a/src/app/dashboard/editar-os/[id]/page.tsx b/src/app/dashboard/editar-os/[id]/page.tsx new file mode 100644 index 0000000..a073771 --- /dev/null +++ b/src/app/dashboard/editar-os/[id]/page.tsx @@ -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 ( +
+

Ordem de Serviço não encontrada

+ Voltar para Dashboard +
+ ); + } + + return ( +
+
+
+ + + + + + Voltar + +

Editar Ordem de Serviço

+
+
+ + +
+ ); +} diff --git a/src/app/dashboard/os-list.tsx b/src/app/dashboard/os-list.tsx new file mode 100644 index 0000000..a41d68b --- /dev/null +++ b/src/app/dashboard/os-list.tsx @@ -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(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 ( + <> +
+ {orders.map((order, i) => ( +
+
+ Ordem de serviço {order.numero_os} + + {order.status || 'Aberta'} + +
+ +
+
+ + + + + + + {order.data_abertura ? new Date(order.data_abertura).toLocaleDateString('pt-BR') : '-'} +
+
+ +
+
+ {((parseFloat(order.totalproduto || '0') + parseFloat(order.totalservico || '0'))).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })} +
+
+ + + + + + + +
+
+
+ ))} +
+ + {isDeleting && ( +
+
+

Confirmar Exclusão

+

+ Tem certeza que deseja excluir a Ordem de Serviço #{orders.find(o => o.id === isDeleting)?.numero_os}? Esta ação não pode ser desfeita. +

+
+ + +
+
+
+ )} + + ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index a751633..94f5f52 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import pool from "@/lib/db"; +import OSList from "./os-list"; export const dynamic = 'force-dynamic'; @@ -16,7 +17,7 @@ async function getOpenServiceOrders() { o.totalservico FROM public.os o 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 LIMIT 100 `); @@ -31,7 +32,7 @@ export default async function DashboardPage() { const orders = await getOpenServiceOrders(); return ( -
+ <>

Ordens de Serviço

@@ -46,54 +47,20 @@ export default async function DashboardPage() {
-
- {orders.length === 0 ? ( -
- - - - - - - -

Nenhuma ordem de serviço aberta encontrada.

-

Clique em "Nova OS" para criar uma.

-
- ) : ( - - - - - - - - - - - - {orders.map((order) => ( - - - - - - - - ))} - -
Nº OSData AberturaStatusTotal (R$)Ação
#{order.numero_os} - {order.data_abertura ? new Date(order.data_abertura).toLocaleDateString('pt-BR') : '-'} - - - {order.status || 'Aberta'} - - - {((parseFloat(order.totalproduto || '0') + parseFloat(order.totalservico || '0'))).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })} - - -
- )} -
-
+ {orders.length === 0 ? ( +
+ + + + + + +

Nenhuma ordem de serviço aberta encontrada.

+

Clique em "Nova OS" para criar uma.

+
+ ) : ( + + )} + ); } diff --git a/src/app/globals.css b/src/app/globals.css index 4d538d4..269d9f6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -131,10 +131,13 @@ input:focus, textarea:focus, select:focus { } .status-badge { - padding: 0.25rem 0.75rem; + padding: 0.35rem 0.85rem; border-radius: var(--radius-full); - font-size: 0.875rem; - font-weight: 500; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; + white-space: nowrap; } .status-open { @@ -204,14 +207,210 @@ input:focus, textarea:focus, select:focus { /* Animations */ @keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } + from { opacity: 0; } + to { opacity: 1; } } .animate-fade-in { 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) */ @media (max-width: 768px) { .dashboard-layout { @@ -273,5 +472,13 @@ input:focus, textarea:focus, select:focus { .tv-header div { font-size: 1rem !important; } + + .os-grid { + grid-template-columns: 1fr; + } + + .kanban-board { + flex-direction: column; + } } diff --git a/src/app/tv/kanban-board.tsx b/src/app/tv/kanban-board.tsx new file mode 100644 index 0000000..9bf1b88 --- /dev/null +++ b/src/app/tv/kanban-board.tsx @@ -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(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 ( +
+
+
+

AutoAPP

+

Painel de Atendimento

+
+ +
+
+
+ {new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })} +
+
+ {new Date().toLocaleDateString('pt-BR')} +
+
+ + +
+
+ +
+ {COLUMNS.map((colName) => { + const colOrders = getOrdersByStatus(colName); + return ( +
+
+

{colName}

+ {colOrders.length} +
+ +
+ {colOrders.length === 0 ? ( +
+ Nenhuma OS +
+ ) : ( + colOrders.map((order, i) => ( +
+
+ + OS {order.numero_os} + +
+
+ {order.data_abertura ? new Date(order.data_abertura).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '-'} +
+
+ )) + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/src/app/tv/page.tsx b/src/app/tv/page.tsx index 713d345..5440bbc 100644 --- a/src/app/tv/page.tsx +++ b/src/app/tv/page.tsx @@ -1,5 +1,5 @@ import pool from "@/lib/db"; -import TVClientWrapper from "./tv-client-wrapper"; +import KanbanBoard from "./kanban-board"; export const dynamic = 'force-dynamic'; export const revalidate = 0; @@ -10,12 +10,13 @@ async function getTVOrders() { SELECT o.numero_os, o.data_abertura, - s.status + s.status, + o.id_status FROM public.os o 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 - LIMIT 20 + LIMIT 100 `); return res.rows; } catch (error) { @@ -27,49 +28,5 @@ async function getTVOrders() { export default async function TVPage() { const orders = await getTVOrders(); - return ( - -
-
-

AutoAPP

-

Painel de Atendimento

-
- {new Date().toLocaleDateString('pt-BR')} -
-
- - {orders.length === 0 ? ( -
-

Nenhuma Ordem de Serviço Aberta

-
- ) : ( -
- {orders.map((order, i) => ( -
-
- - #{order.numero_os} - -
-
- {order.data_abertura ? new Date(order.data_abertura).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '-'} -
-
- {order.status || 'Em Atendimento'} -
-
- ))} -
- )} -
-
- ); + return ; }