Updating layout
This commit is contained in:
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
103
src/app/dashboard/editar-os/[id]/edit-form.tsx
Normal file
103
src/app/dashboard/editar-os/[id]/edit-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/app/dashboard/editar-os/[id]/page.tsx
Normal file
38
src/app/dashboard/editar-os/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/app/dashboard/os-list.tsx
Normal file
112
src/app/dashboard/os-list.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 style={{ textAlign: 'center', padding: '3rem 1rem', color: 'var(--text-muted)' }}>
|
<div className="glass-panel animate-fade-in" style={{ padding: '3rem 1rem', textAlign: 'center', 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>
|
||||||
<polyline points="10 9 9 9 8 9"></polyline>
|
|
||||||
</svg>
|
</svg>
|
||||||
<p>Nenhuma ordem de serviço aberta encontrada.</p>
|
<p>Nenhuma ordem de serviço aberta encontrada.</p>
|
||||||
<p style={{ fontSize: '0.875rem', marginTop: '0.5rem' }}>Clique em "Nova OS" para criar uma.</p>
|
<p style={{ fontSize: '0.875rem', marginTop: '0.5rem' }}>Clique em "Nova OS" para criar uma.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
|
<OSList initialOrders={orders} />
|
||||||
<thead>
|
|
||||||
<tr style={{ borderBottom: '1px solid var(--border-color)', color: 'var(--text-secondary)' }}>
|
|
||||||
<th style={{ padding: '1rem', fontWeight: 500 }}>Nº 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
120
src/app/tv/kanban-board.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user