This commit is contained in:
Flatlogic Bot 2026-07-01 19:23:54 +00:00
parent 55d3afa636
commit 850d636d8a
28 changed files with 1075 additions and 546 deletions

6
App.tsx Normal file
View File

@ -0,0 +1,6 @@
import React from 'react';
import AppNavigator from './src/navigation/AppNavigator';
export default function App() {
return <AppNavigator />;
}

49
README-mobile.md Normal file
View File

@ -0,0 +1,49 @@
# ManutApp — scaffold Expo/React Native
Estrutura `src/` criada conforme solicitado, com telas `.tsx` e chamadas fake em `src/api/fakeApi.ts`.
## Login fake
- `tecnico` / `123456`
- `admin` / `123456`
## Árvore principal
```text
src
├── api/fakeApi.ts
├── assets
│ ├── README.md
│ └── styles/theme.ts
├── components/ui.tsx
├── navigation
│ ├── AppNavigator.tsx
│ └── routes.ts
└── pages
├── login/index.tsx
├── home/index.tsx
├── ordem-de-serviço/index.tsx
├── ordem-de-serviço/nova-ordem/index.tsx
├── lojas/index.tsx
├── lojas/nova-loja/index.tsx
├── peças/index.tsx
├── peças/nova-peça/index.tsx
├── fornos/index.tsx
├── fornos/novo-forno/index.tsx
├── peças-forno/index.tsx
├── peças-forno/nova-peça/index.tsx
├── manutençao/index.tsx
└── manutençao/nova-manutenções/index.tsx
```
## Dados e regras mockados
- Loja: `id`, `descricao`, `endereco`, `nomeContato`, `numeroContato`.
- Peça: `id`, `descricao`, `localizacao`, `referencia`.
- Localizações: Câmara de Cocção (`CC`), Painel de controle (`PCU`), Painel de Comandos (`PCE`), Gerador de Vapor (`GV`), Estrutura externa (`EE`).
- Referência da peça: `ref + "00" + id`, exemplo `CC001`.
- Forno: `id`, `lojaId`, `descricao`, `modelo`, `numeroSerie`.
- Peças por forno: associação muitos-para-muitos com `fornoId` + `pecaIds`.
- Manutenção: escolhe loja, forno e uma ou várias peças utilizadas.
Para conectar ao backend real depois, substitua as funções de `fakeApi.ts` por `fetch`/Axios mantendo as mesmas assinaturas.

132
api/mock.php Normal file
View File

@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../db/config.php';
function respond(array $payload, int $status = 200): void {
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function ensure_schema(): void {
db()->exec("CREATE TABLE IF NOT EXISTS maintenance_records (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
store_id VARCHAR(32) NOT NULL,
store_name VARCHAR(160) NOT NULL,
oven_id VARCHAR(32) NOT NULL,
oven_name VARCHAR(160) NOT NULL,
technician VARCHAR(120) NOT NULL,
service_type VARCHAR(60) NOT NULL,
parts_json JSON NULL,
notes TEXT NULL,
status VARCHAR(40) NOT NULL DEFAULT 'Concluída',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
}
function seed_data(): array {
return [
'stores' => [
['id' => 'lj-001', 'name' => 'Loja Centro', 'city' => 'São Paulo'],
['id' => 'lj-002', 'name' => 'Loja Norte', 'city' => 'Guarulhos'],
['id' => 'lj-003', 'name' => 'Loja Sul', 'city' => 'Santo André'],
],
'ovens' => [
['id' => 'fn-101', 'store_id' => 'lj-001', 'name' => 'Forno Turbo 01', 'model' => 'FT-900'],
['id' => 'fn-102', 'store_id' => 'lj-001', 'name' => 'Forno Lastro 02', 'model' => 'FL-450'],
['id' => 'fn-201', 'store_id' => 'lj-002', 'name' => 'Forno Turbo 03', 'model' => 'FT-900'],
['id' => 'fn-301', 'store_id' => 'lj-003', 'name' => 'Forno Compacto 01', 'model' => 'FC-220'],
],
'parts' => [
['id' => 'pc-01', 'name' => 'Resistência', 'sku' => 'RES-220'],
['id' => 'pc-02', 'name' => 'Termostato', 'sku' => 'TER-110'],
['id' => 'pc-03', 'name' => 'Sensor de temperatura', 'sku' => 'SEN-300'],
['id' => 'pc-04', 'name' => 'Correia do motor', 'sku' => 'COR-040'],
],
];
}
function maintenance_rows(): array {
$stmt = db()->query('SELECT * FROM maintenance_records ORDER BY created_at DESC, id DESC LIMIT 50');
$rows = $stmt->fetchAll();
foreach ($rows as &$row) {
$row['parts'] = $row['parts_json'] ? json_decode((string)$row['parts_json'], true) : [];
unset($row['parts_json']);
}
return $rows;
}
try {
ensure_schema();
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$action = $_GET['action'] ?? 'bootstrap';
$seed = seed_data();
if ($method === 'GET' && $action === 'bootstrap') {
respond($seed + ['maintenance' => maintenance_rows()]);
}
if ($method === 'GET' && $action === 'maintenance') {
respond(['maintenance' => maintenance_rows()]);
}
if ($method === 'POST' && $action === 'maintenance') {
$input = json_decode(file_get_contents('php://input') ?: '{}', true);
if (!is_array($input)) {
respond(['success' => false, 'error' => 'Payload inválido.'], 400);
}
$storeId = trim((string)($input['store_id'] ?? ''));
$ovenId = trim((string)($input['oven_id'] ?? ''));
$technician = trim((string)($input['technician'] ?? ''));
$serviceType = trim((string)($input['service_type'] ?? ''));
$notes = trim((string)($input['notes'] ?? ''));
$parts = is_array($input['parts'] ?? null) ? array_values($input['parts']) : [];
$store = null;
foreach ($seed['stores'] as $candidate) {
if ($candidate['id'] === $storeId) { $store = $candidate; break; }
}
$oven = null;
foreach ($seed['ovens'] as $candidate) {
if ($candidate['id'] === $ovenId && $candidate['store_id'] === $storeId) { $oven = $candidate; break; }
}
if (!$store || !$oven || $technician === '' || $serviceType === '' || count($parts) < 1) {
respond(['success' => false, 'error' => 'Preencha loja, forno, técnico, tipo e pelo menos uma peça.'], 422);
}
$allowedParts = array_column($seed['parts'], null, 'id');
$cleanParts = [];
foreach ($parts as $partId) {
$partId = (string)$partId;
if (isset($allowedParts[$partId])) {
$cleanParts[] = $allowedParts[$partId];
}
}
if (!$cleanParts) {
respond(['success' => false, 'error' => 'Selecione uma peça válida.'], 422);
}
$stmt = db()->prepare('INSERT INTO maintenance_records (store_id, store_name, oven_id, oven_name, technician, service_type, parts_json, notes, status) VALUES (:store_id, :store_name, :oven_id, :oven_name, :technician, :service_type, :parts_json, :notes, :status)');
$stmt->execute([
':store_id' => $store['id'],
':store_name' => $store['name'],
':oven_id' => $oven['id'],
':oven_name' => $oven['name'],
':technician' => substr($technician, 0, 120),
':service_type' => substr($serviceType, 0, 60),
':parts_json' => json_encode($cleanParts, JSON_UNESCAPED_UNICODE),
':notes' => substr($notes, 0, 500),
':status' => 'Concluída',
]);
respond(['success' => true, 'id' => (int)db()->lastInsertId(), 'maintenance' => maintenance_rows()], 201);
}
respond(['success' => false, 'error' => 'Rota não encontrada.'], 404);
} catch (Throwable $e) {
error_log('mock api error: ' . $e->getMessage());
respond(['success' => false, 'error' => 'Erro interno ao processar a API mock.'], 500);
}

View File

@ -1,403 +1,221 @@
:root {
--app-bg: #f8fafc;
--app-surface: #ffffff;
--app-text: #111827;
--app-muted: #6b7280;
--app-border: #e5e7eb;
--app-soft: #f3f4f6;
--app-accent: #2563eb;
--app-radius-sm: 6px;
--app-radius-md: 10px;
--app-radius-lg: 14px;
--app-shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
}
* { box-sizing: border-box; }
body { body {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); margin: 0;
background-size: 400% 400%; min-height: 100vh;
animation: gradient 15s ease infinite; background: var(--app-bg);
color: #212529; color: var(--app-text);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px; font-size: 14px;
margin: 0;
min-height: 100vh;
} }
.main-wrapper { .skip-link {
display: flex; position: absolute;
align-items: center; left: 12px;
justify-content: center; top: -48px;
min-height: 100vh; z-index: 1000;
width: 100%; background: var(--app-text);
padding: 20px; color: #fff;
box-sizing: border-box; padding: 8px 10px;
position: relative; border-radius: var(--app-radius-sm);
z-index: 1; }
.skip-link:focus { top: 12px; }
.app-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 20px 12px;
} }
@keyframes gradient { .phone-frame {
0% { width: min(100%, 430px);
background-position: 0% 50%; min-height: min(860px, calc(100vh - 40px));
} background: var(--app-surface);
50% { border: 1px solid var(--app-border);
background-position: 100% 50%; border-radius: 22px;
} box-shadow: var(--app-shadow);
100% { overflow: hidden;
background-position: 0% 50%; display: flex;
} flex-direction: column;
} }
.chat-container { .app-topbar {
width: 100%; display: flex;
max-width: 600px; align-items: center;
background: rgba(255, 255, 255, 0.85); justify-content: space-between;
border: 1px solid rgba(255, 255, 255, 0.3); gap: 16px;
border-radius: 20px; padding: 18px 18px 14px;
display: flex; border-bottom: 1px solid var(--app-border);
flex-direction: column; background: rgba(255, 255, 255, 0.96);
height: 85vh;
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
overflow: hidden;
} }
.chat-header { .eyebrow, .label {
padding: 1.5rem; margin: 0 0 4px;
border-bottom: 1px solid rgba(0, 0, 0, 0.05); color: var(--app-muted);
background: rgba(255, 255, 255, 0.5); font-size: 11px;
font-weight: 700; font-weight: 700;
font-size: 1.1rem; letter-spacing: .08em;
display: flex; text-transform: uppercase;
justify-content: space-between;
align-items: center;
} }
.chat-messages { h1, h2, p { margin-top: 0; }
flex: 1; h1 { margin-bottom: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.02em; }
overflow-y: auto; h2 { font-size: 24px; line-height: 1.15; font-weight: 700; letter-spacing: -0.04em; }
padding: 1.5rem;
display: flex; .screen {
flex-direction: column; display: none;
gap: 1.25rem; flex: 1;
padding: 18px;
overflow-y: auto;
}
.screen.active { display: block; }
.hero-card, .status-card, .detail-card, .empty-state, .list-item, .module-card, .parts-box {
border: 1px solid var(--app-border);
border-radius: var(--app-radius-lg);
background: var(--app-surface);
} }
/* Custom Scrollbar */ .hero-card {
::-webkit-scrollbar { padding: 22px;
width: 6px; margin-bottom: 18px;
background: #fcfcfd;
} }
::-webkit-scrollbar-track { .muted, .empty-state p, small { color: var(--app-muted); }
background: transparent; .stack { display: grid; gap: 14px; }
.form-control, .form-select {
border-color: var(--app-border);
border-radius: var(--app-radius-md);
min-height: 44px;
font-size: 14px;
}
.form-control:focus, .form-select:focus, .btn:focus-visible, .module-card:focus-visible, .list-item:focus-visible {
border-color: var(--app-accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, .14);
outline: none;
} }
::-webkit-scrollbar-thumb { .btn { border-radius: var(--app-radius-md); font-weight: 650; min-height: 40px; }
background: rgba(255, 255, 255, 0.3); .btn-dark { background: var(--app-text); border-color: var(--app-text); }
border-radius: 10px;
.status-card {
padding: 14px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
background: #fcfcfd;
} }
::-webkit-scrollbar-thumb:hover { .module-grid {
background: rgba(255, 255, 255, 0.5); display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.module-card {
min-height: 112px;
padding: 14px;
text-align: left;
cursor: pointer;
transition: border-color .16s ease, transform .16s ease, background .16s ease;
}
.module-card:hover { border-color: #cbd5e1; transform: translateY(-1px); }
.module-card.featured { grid-column: span 2; background: #f9fafb; }
.module-card span { display: block; color: var(--app-accent); font-size: 18px; margin-bottom: 12px; }
.module-card strong, .module-card small { display: block; }
.screen-actions {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 14px;
} }
.message { .list-stack { display: grid; gap: 10px; }
max-width: 85%; .list-item {
padding: 0.85rem 1.1rem; width: 100%;
border-radius: 16px; padding: 13px 14px;
line-height: 1.5; text-align: left;
font-size: 0.95rem; display: grid;
box-shadow: 0 4px 15px rgba(0,0,0,0.05); gap: 4px;
animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); cursor: pointer;
}
.list-item:hover { background: #f9fafb; }
.list-item-row { display: flex; justify-content: space-between; gap: 10px; align-items: start; }
.empty-state { padding: 18px; text-align: center; background: #fcfcfd; }
.empty-state p { margin: 8px 0 14px; }
.parts-box { padding: 14px; }
.parts-box legend { float: none; width: auto; margin-bottom: 10px; font-size: 13px; font-weight: 700; }
.parts-list { display: grid; gap: 8px; }
.part-choice {
display: flex;
gap: 10px;
align-items: center;
padding: 10px;
border: 1px solid var(--app-border);
border-radius: var(--app-radius-md);
cursor: pointer;
}
.part-choice:hover { background: #f9fafb; }
.part-choice input { width: 16px; height: 16px; }
.detail-card { padding: 18px; }
.detail-grid { display: grid; gap: 12px; margin-top: 14px; }
.detail-row { padding-bottom: 10px; border-bottom: 1px solid var(--app-border); }
.detail-row:last-child { border-bottom: 0; padding-bottom: 0; }
.detail-row span { display: block; color: var(--app-muted); font-size: 12px; margin-bottom: 2px; }
.toast-region {
position: absolute;
width: min(390px, calc(100% - 48px));
left: 50%;
transform: translateX(-50%);
top: 78px;
z-index: 20;
display: grid;
gap: 8px;
}
.app-toast {
border: 1px solid var(--app-border);
background: var(--app-text);
color: #fff;
border-radius: var(--app-radius-md);
padding: 10px 12px;
box-shadow: var(--app-shadow);
font-size: 13px;
}
.app-toast.error { background: #991b1b; }
.app-footer {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 12px 18px;
border-top: 1px solid var(--app-border);
color: var(--app-muted);
font-size: 11px;
} }
@keyframes fadeIn { @media (max-width: 460px) {
from { opacity: 0; transform: translateY(20px) scale(0.95); } .app-shell { padding: 0; }
to { opacity: 1; transform: translateY(0) scale(1); } .phone-frame { min-height: 100vh; border: 0; border-radius: 0; }
} }
.message.visitor {
align-self: flex-end;
background: linear-gradient(135deg, #212529 0%, #343a40 100%);
color: #fff;
border-bottom-right-radius: 4px;
}
.message.bot {
align-self: flex-start;
background: #ffffff;
color: #212529;
border-bottom-left-radius: 4px;
}
.chat-input-area {
padding: 1.25rem;
background: rgba(255, 255, 255, 0.5);
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.chat-input-area form {
display: flex;
gap: 0.75rem;
}
.chat-input-area input {
flex: 1;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 0.75rem 1rem;
outline: none;
background: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
}
.chat-input-area input:focus {
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
}
.chat-input-area button {
background: #212529;
color: #fff;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
}
.chat-input-area button:hover {
background: #000;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
/* Background Animations */
.bg-animations {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.blob {
position: absolute;
width: 500px;
height: 500px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
filter: blur(80px);
animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
}
.blob-1 {
top: -10%;
left: -10%;
background: rgba(238, 119, 82, 0.4);
}
.blob-2 {
bottom: -10%;
right: -10%;
background: rgba(35, 166, 213, 0.4);
animation-delay: -7s;
width: 600px;
height: 600px;
}
.blob-3 {
top: 40%;
left: 30%;
background: rgba(231, 60, 126, 0.3);
animation-delay: -14s;
width: 450px;
height: 450px;
}
@keyframes move {
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
100% { transform: translate(0, 0) rotate(360deg) scale(1); }
}
.header-link {
font-size: 14px;
color: #fff;
text-decoration: none;
background: rgba(0, 0, 0, 0.2);
padding: 0.5rem 1rem;
border-radius: 8px;
transition: all 0.3s ease;
}
.header-link:hover {
background: rgba(0, 0, 0, 0.4);
text-decoration: none;
}
/* Admin Styles */
.admin-container {
max-width: 900px;
margin: 3rem auto;
padding: 2.5rem;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 24px;
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
border: 1px solid rgba(255, 255, 255, 0.4);
position: relative;
z-index: 1;
}
.admin-container h1 {
margin-top: 0;
color: #212529;
font-weight: 800;
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
margin-top: 1.5rem;
}
.table th {
background: transparent;
border: none;
padding: 1rem;
color: #6c757d;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 1px;
}
.table td {
background: #fff;
padding: 1rem;
border: none;
}
.table tr td:first-child { border-radius: 12px 0 0 12px; }
.table tr td:last-child { border-radius: 0 12px 12px 0; }
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
font-size: 0.9rem;
}
.form-control {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
background: #fff;
transition: all 0.3s ease;
box-sizing: border-box;
}
.form-control:focus {
outline: none;
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-links {
display: flex;
gap: 1rem;
}
.admin-card {
background: rgba(255, 255, 255, 0.6);
padding: 2rem;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.5);
margin-bottom: 2.5rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
}
.admin-card h3 {
margin-top: 0;
margin-bottom: 1.5rem;
font-weight: 700;
}
.btn-delete {
background: #dc3545;
color: white;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
}
.btn-add {
background: #212529;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
margin-top: 1rem;
}
.btn-save {
background: #0088cc;
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
width: 100%;
transition: all 0.3s ease;
}
.webhook-url {
font-size: 0.85em;
color: #555;
margin-top: 0.5rem;
}
.history-table-container {
overflow-x: auto;
background: rgba(255, 255, 255, 0.4);
padding: 1rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.history-table {
width: 100%;
}
.history-table-time {
width: 15%;
white-space: nowrap;
font-size: 0.85em;
color: #555;
}
.history-table-user {
width: 35%;
background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
padding: 8px;
}
.history-table-ai {
width: 50%;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
padding: 8px;
}
.no-messages {
text-align: center;
color: #777;
}

View File

@ -1,39 +1,202 @@
document.addEventListener('DOMContentLoaded', () => { (() => {
const chatForm = document.getElementById('chat-form'); const app = document.querySelector('[data-api-base]');
const chatInput = document.getElementById('chat-input'); const apiBase = app.dataset.apiBase;
const chatMessages = document.getElementById('chat-messages'); const state = {
user: null,
stores: [],
ovens: [],
parts: [],
maintenance: [],
};
const appendMessage = (text, sender) => { const $ = (selector) => document.querySelector(selector);
const msgDiv = document.createElement('div'); const $$ = (selector) => Array.from(document.querySelectorAll(selector));
msgDiv.classList.add('message', sender);
msgDiv.textContent = text;
chatMessages.appendChild(msgDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
};
chatForm.addEventListener('submit', async (e) => { function toast(message, type = 'success') {
e.preventDefault(); const region = $('#toastRegion');
const message = chatInput.value.trim(); const el = document.createElement('div');
if (!message) return; el.className = `app-toast ${type === 'error' ? 'error' : ''}`;
el.textContent = message;
region.appendChild(el);
setTimeout(() => el.remove(), 3200);
}
appendMessage(message, 'visitor'); function setScreen(screenId) {
chatInput.value = ''; $$('.screen').forEach((screen) => screen.classList.toggle('active', screen.id === screenId));
const active = document.getElementById(screenId);
$('#screenTitle').textContent = active?.dataset.title || 'ManutApp';
$('#logoutBtn').classList.toggle('d-none', screenId === 'loginScreen');
if (screenId === 'maintenanceScreen') renderMaintenance();
}
try { async function api(action, options = {}) {
const response = await fetch('api/chat.php', { const response = await fetch(`${apiBase}?action=${encodeURIComponent(action)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }, ...options,
body: JSON.stringify({ message })
});
const data = await response.json();
// Artificial delay for realism
setTimeout(() => {
appendMessage(data.reply, 'bot');
}, 500);
} catch (error) {
console.error('Error:', error);
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
}
}); });
}); const payload = await response.json();
if (!response.ok || payload.success === false) throw new Error(payload.error || 'Falha na API mock.');
return payload;
}
async function bootstrap() {
try {
const payload = await api('bootstrap');
state.stores = payload.stores || [];
state.ovens = payload.ovens || [];
state.parts = payload.parts || [];
state.maintenance = payload.maintenance || [];
renderSeedLists();
renderFormOptions();
renderMaintenance();
} catch (error) {
toast(error.message, 'error');
}
}
function renderSeedLists() {
$('#storesList').innerHTML = state.stores.map((store) => `
<article class="list-item" tabindex="0"><div class="list-item-row"><strong>${escapeHtml(store.name)}</strong><span class="badge text-bg-light">${escapeHtml(store.id)}</span></div><small>${escapeHtml(store.city)}</small></article>
`).join('');
$('#partsList').innerHTML = state.parts.map((part) => `
<article class="list-item" tabindex="0"><div class="list-item-row"><strong>${escapeHtml(part.name)}</strong><span class="badge text-bg-light">${escapeHtml(part.sku)}</span></div><small>Peça mock para seleção em manutenção</small></article>
`).join('');
$('#ovensList').innerHTML = state.ovens.map((oven) => {
const store = state.stores.find((item) => item.id === oven.store_id);
return `<article class="list-item" tabindex="0"><div class="list-item-row"><strong>${escapeHtml(oven.name)}</strong><span class="badge text-bg-light">${escapeHtml(oven.model)}</span></div><small>${escapeHtml(store?.name || 'Loja')}</small></article>`;
}).join('');
}
function renderFormOptions() {
$('#storeSelect').innerHTML = '<option value="">Selecione uma loja</option>' + state.stores.map((store) => `<option value="${escapeAttr(store.id)}">${escapeHtml(store.name)}</option>`).join('');
renderOvenOptions();
$('#partsChoices').innerHTML = state.parts.map((part) => `
<label class="part-choice">
<input type="checkbox" value="${escapeAttr(part.id)}">
<span><strong>${escapeHtml(part.name)}</strong><br><small>${escapeHtml(part.sku)}</small></span>
</label>
`).join('');
}
function renderOvenOptions() {
const storeId = $('#storeSelect').value;
const ovens = state.ovens.filter((oven) => oven.store_id === storeId);
$('#ovenSelect').innerHTML = '<option value="">Selecione um forno</option>' + ovens.map((oven) => `<option value="${escapeAttr(oven.id)}">${escapeHtml(oven.name)} · ${escapeHtml(oven.model)}</option>`).join('');
}
function renderMaintenance() {
$('#maintenanceCount').textContent = state.maintenance.length === 1 ? '1 manutenção' : `${state.maintenance.length} manutenções`;
$('#maintenanceEmpty').classList.toggle('d-none', state.maintenance.length > 0);
$('#maintenanceList').innerHTML = state.maintenance.map((item) => `
<button class="list-item" type="button" data-detail-id="${escapeAttr(String(item.id))}">
<div class="list-item-row">
<strong>#${escapeHtml(String(item.id))} · ${escapeHtml(item.store_name)}</strong>
<span class="badge text-bg-light">${escapeHtml(item.status || 'Concluída')}</span>
</div>
<small>${escapeHtml(item.oven_name)} · ${escapeHtml(item.service_type)} · ${formatDate(item.created_at)}</small>
</button>
`).join('');
}
function renderDetail(id) {
const item = state.maintenance.find((entry) => String(entry.id) === String(id));
if (!item) {
toast('Registro não encontrado.', 'error');
return;
}
const parts = Array.isArray(item.parts) ? item.parts.map((part) => part.name).join(', ') : 'Não informado';
$('#detailCard').innerHTML = `
<p class="label">Registro #${escapeHtml(String(item.id))}</p>
<h2>${escapeHtml(item.store_name)}</h2>
<div class="detail-grid">
<div class="detail-row"><span>Forno</span><strong>${escapeHtml(item.oven_name)}</strong></div>
<div class="detail-row"><span>Técnico</span><strong>${escapeHtml(item.technician)}</strong></div>
<div class="detail-row"><span>Tipo</span><strong>${escapeHtml(item.service_type)}</strong></div>
<div class="detail-row"><span>Peças</span><strong>${escapeHtml(parts)}</strong></div>
<div class="detail-row"><span>Observações</span><p class="mb-0">${escapeHtml(item.notes || 'Sem observações.')}</p></div>
<div class="detail-row"><span>Criado em</span><strong>${formatDate(item.created_at)}</strong></div>
</div>
`;
setScreen('detailScreen');
}
function resetMaintenanceForm() {
$('#maintenanceForm').reset();
renderOvenOptions();
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#039;', '"': '&quot;' }[char]));
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, '&#096;');
}
function formatDate(value) {
if (!value) return 'agora';
const normalized = String(value).replace(' ', 'T');
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
}
$('#loginForm').addEventListener('submit', (event) => {
event.preventDefault();
const username = $('#username').value.trim();
const password = $('#password').value.trim();
if (!username || !password) {
toast('Informe usuário e senha para continuar.', 'error');
return;
}
state.user = username;
$('#welcomeUser').textContent = `Olá, ${username}`;
toast('Login fake realizado com sucesso.');
setScreen('homeScreen');
});
$('#logoutBtn').addEventListener('click', () => {
state.user = null;
$('#loginForm').reset();
setScreen('loginScreen');
});
document.addEventListener('click', (event) => {
const nav = event.target.closest('[data-target]');
if (nav) setScreen(nav.dataset.target);
const detail = event.target.closest('[data-detail-id]');
if (detail) renderDetail(detail.dataset.detailId);
});
$('#storeSelect').addEventListener('change', renderOvenOptions);
$('#maintenanceForm').addEventListener('submit', async (event) => {
event.preventDefault();
const selectedParts = $$('#partsChoices input:checked').map((input) => input.value);
const payload = {
store_id: $('#storeSelect').value,
oven_id: $('#ovenSelect').value,
technician: $('#technicianInput').value.trim(),
service_type: $('#serviceTypeSelect').value,
parts: selectedParts,
notes: $('#notesInput').value.trim(),
};
if (!payload.store_id || !payload.oven_id || !payload.technician || selectedParts.length === 0) {
toast('Preencha loja, forno, técnico e pelo menos uma peça.', 'error');
return;
}
try {
const result = await api('maintenance', { method: 'POST', body: JSON.stringify(payload) });
state.maintenance = result.maintenance || [];
resetMaintenanceForm();
toast('Manutenção salva e listada com sucesso.');
setScreen('maintenanceScreen');
} catch (error) {
toast(error.message, 'error');
}
});
bootstrap();
})();

282
index.php
View File

@ -1,150 +1,166 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC'); @date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION; $projectName = $_SERVER['PROJECT_NAME'] ?? 'ManutApp';
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Protótipo mobile para gestão de lojas, fornos, peças, ordens de serviço e manutenções.';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
?> ?>
<!doctype html> <!doctype html>
<html lang="en"> <html lang="pt-BR">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>New Style</title> <title><?= htmlspecialchars($projectName) ?> — App de Manutenção</title>
<?php <meta name="description" content="<?= htmlspecialchars($projectDescription) ?>">
// Read project preview data from environment <meta property="og:title" content="<?= htmlspecialchars($projectName) ?> — App de Manutenção">
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? ''; <meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>">
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; <meta property="twitter:title" content="<?= htmlspecialchars($projectName) ?> — App de Manutenção">
?> <meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>">
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?> <?php if ($projectImageUrl): ?>
<!-- Open Graph image --> <meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>">
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" /> <meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>">
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?> <?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
:root { <link rel="stylesheet" href="assets/css/custom.css?v=2026070102">
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
</head> </head>
<body> <body>
<main> <a class="skip-link" href="#app-main">Pular para o conteúdo</a>
<div class="card"> <main id="app-main" class="app-shell" data-api-base="api/mock.php">
<h1>Analyzing your requirements and generating your website…</h1> <section class="phone-frame" aria-label="Protótipo do aplicativo mobile">
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <header class="app-topbar">
<span class="sr-only">Loading…</span> <div>
</div> <span class="eyebrow">MVP Mobile</span>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p> <h1 id="screenTitle">ManutApp</h1>
<p class="hint">This page will update automatically as the plan is implemented.</p> </div>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p> <button id="logoutBtn" class="btn btn-sm btn-outline-dark d-none" type="button">Sair</button>
</div> </header>
<div id="toastRegion" class="toast-region" aria-live="polite" aria-atomic="true"></div>
<section id="loginScreen" class="screen active" data-title="Login">
<div class="hero-card">
<p class="label">Equipe técnica</p>
<h2>Entre para gerenciar manutenções.</h2>
<p class="muted">Use qualquer usuário e senha para testar o fluxo com chamadas fake de API.</p>
</div>
<form id="loginForm" class="stack" novalidate>
<div>
<label class="form-label" for="username">Usuário</label>
<input class="form-control" id="username" name="username" autocomplete="username" placeholder="ex: tecnico01" required>
</div>
<div>
<label class="form-label" for="password">Senha</label>
<input class="form-control" id="password" name="password" type="password" autocomplete="current-password" placeholder="••••••" required>
</div>
<button class="btn btn-dark w-100" type="submit">Entrar</button>
</form>
</section>
<section id="homeScreen" class="screen" data-title="Home">
<div class="status-card">
<div>
<p class="label">Operação</p>
<strong id="welcomeUser">Bem-vindo</strong>
</div>
<span class="badge text-bg-light" id="maintenanceCount">0 manutenções</span>
</div>
<div class="module-grid" aria-label="Módulos do aplicativo">
<button class="module-card" data-target="storesScreen" type="button"><span></span><strong>Lojas</strong><small>Lista mock</small></button>
<button class="module-card" data-target="ordersScreen" type="button"><span></span><strong>OS</strong><small>Ordens</small></button>
<button class="module-card" data-target="partsScreen" type="button"><span></span><strong>Peças</strong><small>Estoque</small></button>
<button class="module-card" data-target="ovensScreen" type="button"><span></span><strong>Fornos</strong><small>Por loja</small></button>
<button class="module-card featured" data-target="maintenanceScreen" type="button"><span></span><strong>Manutenção</strong><small>Criar e listar</small></button>
</div>
</section>
<section id="maintenanceScreen" class="screen" data-title="Manutenções">
<div class="screen-actions">
<button class="btn btn-sm btn-outline-secondary" data-target="homeScreen" type="button">Voltar</button>
<button class="btn btn-sm btn-dark" data-target="newMaintenanceScreen" type="button">Nova manutenção</button>
</div>
<div id="maintenanceList" class="list-stack"></div>
<div id="maintenanceEmpty" class="empty-state d-none">
<strong>Nenhuma manutenção registrada.</strong>
<p>Crie a primeira manutenção escolhendo loja, forno e peças utilizadas.</p>
<button class="btn btn-dark btn-sm" data-target="newMaintenanceScreen" type="button">Criar agora</button>
</div>
</section>
<section id="newMaintenanceScreen" class="screen" data-title="Nova manutenção">
<div class="screen-actions">
<button class="btn btn-sm btn-outline-secondary" data-target="maintenanceScreen" type="button">Voltar</button>
</div>
<form id="maintenanceForm" class="stack" novalidate>
<div>
<label class="form-label" for="storeSelect">Loja</label>
<select class="form-select" id="storeSelect" required></select>
</div>
<div>
<label class="form-label" for="ovenSelect">Forno</label>
<select class="form-select" id="ovenSelect" required></select>
</div>
<div>
<label class="form-label" for="technicianInput">Técnico responsável</label>
<input class="form-control" id="technicianInput" placeholder="Nome do técnico" required>
</div>
<div>
<label class="form-label" for="serviceTypeSelect">Tipo de serviço</label>
<select class="form-select" id="serviceTypeSelect" required>
<option value="Preventiva">Preventiva</option>
<option value="Corretiva">Corretiva</option>
<option value="Inspeção">Inspeção</option>
</select>
</div>
<fieldset class="parts-box">
<legend>Peças utilizadas</legend>
<div id="partsChoices" class="parts-list"></div>
</fieldset>
<div>
<label class="form-label" for="notesInput">Observações</label>
<textarea class="form-control" id="notesInput" rows="3" maxlength="500" placeholder="Resumo do serviço executado"></textarea>
</div>
<button class="btn btn-dark w-100" type="submit">Salvar manutenção</button>
</form>
</section>
<section id="detailScreen" class="screen" data-title="Detalhe da manutenção">
<div class="screen-actions">
<button class="btn btn-sm btn-outline-secondary" data-target="maintenanceScreen" type="button">Voltar</button>
</div>
<article id="detailCard" class="detail-card"></article>
</section>
<section id="storesScreen" class="screen" data-title="Lojas">
<div class="screen-actions"><button class="btn btn-sm btn-outline-secondary" data-target="homeScreen" type="button">Voltar</button><button class="btn btn-sm btn-light" disabled>Nova loja em breve</button></div>
<div id="storesList" class="list-stack"></div>
</section>
<section id="partsScreen" class="screen" data-title="Peças">
<div class="screen-actions"><button class="btn btn-sm btn-outline-secondary" data-target="homeScreen" type="button">Voltar</button><button class="btn btn-sm btn-light" disabled>Nova peça em breve</button></div>
<div id="partsList" class="list-stack"></div>
</section>
<section id="ovensScreen" class="screen" data-title="Fornos">
<div class="screen-actions"><button class="btn btn-sm btn-outline-secondary" data-target="homeScreen" type="button">Voltar</button><button class="btn btn-sm btn-light" disabled>Novo forno em breve</button></div>
<div id="ovensList" class="list-stack"></div>
</section>
<section id="ordersScreen" class="screen" data-title="Ordens de serviço">
<div class="screen-actions"><button class="btn btn-sm btn-outline-secondary" data-target="homeScreen" type="button">Voltar</button><button class="btn btn-sm btn-light" disabled>Nova OS em breve</button></div>
<div class="empty-state"><strong>OS mockadas</strong><p>O próximo passo é conectar ordens de serviço ao fluxo de manutenção.</p></div>
</section>
<footer class="app-footer">
<span>PHP <?= htmlspecialchars(PHP_VERSION) ?></span>
<span>UTC <?= htmlspecialchars($now) ?></span>
</footer>
</section>
</main> </main>
<footer> <script src="assets/js/main.js?v=2026070102" defer></script>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
</footer>
</body> </body>
</html> </html>

48
src/api/fakeApi.ts Normal file
View File

@ -0,0 +1,48 @@
import { AuthUser, Maintenance, Oven, OvenPartLink, Part, PartLocation, ServiceOrder, Store } from '../types';
export const PART_LOCATIONS: PartLocation[] = [
{ descricao: 'Câmara de Cocção', ref: 'CC' },
{ descricao: 'Painel de controle', ref: 'PCU' },
{ descricao: 'Painel de Comandos', ref: 'PCE' },
{ descricao: 'Gerador de Vapor', ref: 'GV' },
{ descricao: 'Estrutura externa', ref: 'EE' },
];
type Bootstrap = { stores: Store[]; ovens: Oven[]; parts: Part[]; serviceOrders: ServiceOrder[]; ovenPartLinks: OvenPartLink[]; maintenances: Maintenance[] };
const users: Array<AuthUser & { senha: string }> = [
{ id: 1, nome: 'Técnico Demo', usuario: 'tecnico', senha: '123456', perfil: 'tecnico' },
{ id: 2, nome: 'Administrador', usuario: 'admin', senha: '123456', perfil: 'admin' },
];
let stores: Store[] = [
{ id: 1, descricao: 'Loja Centro', endereco: 'Av. Central, 100', nomeContato: 'Marina Alves', numeroContato: '(11) 90000-1000' },
{ id: 2, descricao: 'Loja Norte', endereco: 'Rua Norte, 245', nomeContato: 'Paulo Lima', numeroContato: '(11) 90000-2000' },
];
const ref = (loc: PartLocation, id: number) => `${loc.ref}00${id}`;
const part = (id: number, descricao: string, localizacao: PartLocation): Part => ({ id, descricao, localizacao, referencia: ref(localizacao, id) });
let parts: Part[] = [part(1, 'Resistência superior', PART_LOCATIONS[0]), part(2, 'Controlador eletrônico', PART_LOCATIONS[1]), part(3, 'Válvula de vapor', PART_LOCATIONS[3])];
let ovens: Oven[] = [
{ id: 1, lojaId: 1, descricao: 'Forno Turbo 01', modelo: 'FT-900', numeroSerie: 'FT900-001' },
{ id: 2, lojaId: 1, descricao: 'Forno Lastro 02', modelo: 'FL-450', numeroSerie: 'FL450-002' },
{ id: 3, lojaId: 2, descricao: 'Forno Compacto 01', modelo: 'FC-220', numeroSerie: 'FC220-003' },
];
let serviceOrders: ServiceOrder[] = [{ id: 1, lojaId: 1, fornoId: 1, titulo: 'Revisão preventiva', descricao: 'Checar aquecimento e vedação.', status: 'Aberta', criadoEm: new Date().toISOString() }];
let ovenPartLinks: OvenPartLink[] = [{ fornoId: 1, pecaIds: [1, 2] }, { fornoId: 2, pecaIds: [1] }];
let maintenances: Maintenance[] = [{ id: 1, lojaId: 1, fornoId: 1, pecaIds: [1, 2], tecnico: 'Técnico Demo', observacao: 'Troca preventiva e teste de funcionamento.', status: 'Concluída', criadoEm: new Date().toISOString() }];
const wait = () => new Promise((ok) => setTimeout(ok, 250));
const clone = <T,>(data: T): T => JSON.parse(JSON.stringify(data));
const next = (items: Array<{ id: number }>) => items.length ? Math.max(...items.map((i) => i.id)) + 1 : 1;
const requireStore = (lojaId: number) => { if (!stores.some((s) => s.id === lojaId)) throw new Error('Loja inválida.'); };
const requireOven = (fornoId: number, lojaId: number) => { if (!ovens.some((o) => o.id === fornoId && o.lojaId === lojaId)) throw new Error('Forno inválido para esta loja.'); };
const requireParts = (ids: number[]) => { const valid = new Set(parts.map((p) => p.id)); if (!ids.length || ids.some((id) => !valid.has(id))) throw new Error('Selecione uma ou mais peças válidas.'); };
export const fakeApi = {
async login(usuario: string, senha: string): Promise<AuthUser> { await wait(); const found = users.find((u) => u.usuario === usuario.trim() && u.senha === senha.trim()); if (!found) throw new Error('Usuário ou senha inválidos. Use tecnico/123456.'); const safe: AuthUser = { id: found.id, nome: found.nome, usuario: found.usuario, perfil: found.perfil }; return clone(safe); },
async bootstrap(): Promise<Bootstrap> { await wait(); return clone({ stores, ovens, parts, serviceOrders, ovenPartLinks, maintenances }); },
async createStore(input: Omit<Store, 'id'>) { await wait(); if (!input.descricao.trim() || !input.endereco.trim()) throw new Error('Informe descrição e endereço.'); const item = { ...input, id: next(stores) }; stores = [...stores, item]; return clone(item); },
async createPart(input: { descricao: string; localizacaoRef: PartLocation['ref'] }) { await wait(); const loc = PART_LOCATIONS.find((l) => l.ref === input.localizacaoRef); if (!loc || !input.descricao.trim()) throw new Error('Informe descrição e localização.'); const item = part(next(parts), input.descricao.trim(), loc); parts = [...parts, item]; return clone(item); },
async createOven(input: Omit<Oven, 'id'>) { await wait(); requireStore(input.lojaId); if (!input.descricao.trim() || !input.modelo.trim()) throw new Error('Informe descrição e modelo.'); const item = { ...input, id: next(ovens) }; ovens = [...ovens, item]; return clone(item); },
async createServiceOrder(input: Omit<ServiceOrder, 'id' | 'status' | 'criadoEm'>) { await wait(); requireStore(input.lojaId); requireOven(input.fornoId, input.lojaId); if (!input.titulo.trim()) throw new Error('Informe o título.'); const item: ServiceOrder = { ...input, id: next(serviceOrders), status: 'Aberta', criadoEm: new Date().toISOString() }; serviceOrders = [item, ...serviceOrders]; return clone(item); },
async saveOvenParts(fornoId: number, pecaIds: number[]) { await wait(); if (!ovens.some((o) => o.id === fornoId)) throw new Error('Forno inválido.'); requireParts(pecaIds); const ids = Array.from(new Set(pecaIds)); ovenPartLinks = ovenPartLinks.filter((l) => l.fornoId !== fornoId).concat({ fornoId, pecaIds: ids }); return clone({ fornoId, pecaIds: ids }); },
async createMaintenance(input: Omit<Maintenance, 'id' | 'status' | 'criadoEm'>) { await wait(); requireStore(input.lojaId); requireOven(input.fornoId, input.lojaId); requireParts(input.pecaIds); if (!input.tecnico.trim()) throw new Error('Informe o técnico.'); const item: Maintenance = { ...input, id: next(maintenances), status: 'Registrada', criadoEm: new Date().toISOString() }; maintenances = [item, ...maintenances]; return clone(item); },
};

5
src/assets/README.md Normal file
View File

@ -0,0 +1,5 @@
# Assets
Pasta para imagens, ícones, fontes e estilos compartilhados do app Expo.
- `styles/theme.ts`: tema visual do app.

View File

@ -0,0 +1,33 @@
import { StyleSheet } from 'react-native';
export const colors = {
bg: '#F8FAFC',
surface: '#FFFFFF',
text: '#111827',
muted: '#6B7280',
border: '#E5E7EB',
accent: '#2563EB',
accentSoft: '#DBEAFE',
danger: '#DC2626',
};
export const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.bg },
content: { padding: 24, gap: 14 },
title: { color: colors.text, fontSize: 28, fontWeight: '800', lineHeight: 34 },
subtitle: { color: colors.muted, fontSize: 15, lineHeight: 22 },
label: { color: colors.text, fontWeight: '800', marginBottom: 6 },
input: { minHeight: 48, backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 14, paddingHorizontal: 14, color: colors.text },
area: { minHeight: 96, paddingTop: 12, textAlignVertical: 'top' },
row: { flexDirection: 'row', gap: 10, alignItems: 'center', justifyContent: 'space-between' },
card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 18, padding: 16, gap: 6 },
selected: { borderColor: colors.accent, backgroundColor: colors.accentSoft },
meta: { color: colors.accent, fontSize: 12, fontWeight: '800', textTransform: 'uppercase' },
cardTitle: { color: colors.text, fontSize: 17, fontWeight: '800' },
cardSub: { color: colors.muted, fontSize: 14, lineHeight: 20 },
btn: { minHeight: 48, borderRadius: 14, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16, backgroundColor: colors.text },
btnSecondary: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border },
btnText: { color: colors.surface, fontWeight: '800' },
btnTextSecondary: { color: colors.text },
empty: { padding: 20, alignItems: 'center', gap: 8 },
});

59
src/components/ui.tsx Normal file
View File

@ -0,0 +1,59 @@
import React, { ReactNode } from 'react';
import { Pressable, ScrollView, Text, TextInput, TextInputProps, View } from 'react-native';
import { s } from '../assets/styles/theme';
type ButtonProps = { title: string; onPress: () => void; secondary?: boolean; disabled?: boolean };
export function Button({ title, onPress, secondary, disabled }: ButtonProps) {
return (
<Pressable disabled={disabled} onPress={onPress} style={[s.btn, secondary && s.btnSecondary, disabled && { opacity: 0.55 }]}>
<Text style={[s.btnText, secondary && s.btnTextSecondary]}>{title}</Text>
</Pressable>
);
}
type CardProps = { title: string; subtitle?: string; meta?: string; onPress?: () => void; selected?: boolean; children?: ReactNode };
export function Card({ title, subtitle, meta, onPress, selected, children }: CardProps) {
const content = (
<>
{meta ? <Text style={s.meta}>{meta}</Text> : null}
<Text style={s.cardTitle}>{title}</Text>
{subtitle ? <Text style={s.cardSub}>{subtitle}</Text> : null}
{children}
</>
);
if (onPress) return <Pressable onPress={onPress} style={[s.card, selected && s.selected]}>{content}</Pressable>;
return <View style={[s.card, selected && s.selected]}>{content}</View>;
}
type InputProps = TextInputProps & { label: string; value: string; onChangeText: (value: string) => void };
export function Input({ label, multiline, style, ...props }: InputProps) {
return (
<View>
<Text style={s.label}>{label}</Text>
<TextInput multiline={multiline} style={[s.input, multiline && s.area, style]} {...props} />
</View>
);
}
export function Screen({ children }: { children: ReactNode }) {
return <ScrollView style={s.screen} contentContainerStyle={s.content} keyboardShouldPersistTaps="handled">{children}</ScrollView>;
}
export function Header({ title, subtitle, back, action }: { title: string; subtitle?: string; back?: () => void; action?: ReactNode }) {
return (
<View style={{ gap: 12 }}>
<View style={s.row}>
{back ? <Button title="Voltar" onPress={back} secondary /> : <View />}
{action}
</View>
<View>
<Text style={s.title}>{title}</Text>
{subtitle ? <Text style={s.subtitle}>{subtitle}</Text> : null}
</View>
</View>
);
}
export function Empty({ text }: { text: string }) {
return <View style={s.empty}><Text style={s.subtitle}>{text}</Text></View>;
}

View File

@ -0,0 +1,51 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { SafeAreaView, StatusBar } from 'react-native';
import { colors } from '../assets/styles/theme';
import { AuthUser } from '../types';
import { AppScreenProps, ROUTES, RouteName, RouteParams, RouteState } from './routes';
import LoginScreen from '../pages/login';
import HomeScreen from '../pages/home';
import OrdemServicoScreen from '../pages/ordem-de-serviço';
import NovaOrdemScreen from '../pages/ordem-de-serviço/nova-ordem';
import LojasScreen from '../pages/lojas';
import NovaLojaScreen from '../pages/lojas/nova-loja';
import PecasScreen from '../pages/peças';
import NovaPecaScreen from '../pages/peças/nova-peça';
import FornosScreen from '../pages/fornos';
import NovoFornoScreen from '../pages/fornos/novo-forno';
import PecasFornoScreen from '../pages/peças-forno';
import NovaPecaFornoScreen from '../pages/peças-forno/nova-peça';
import ManutencaoScreen from '../pages/manutençao';
import NovaManutencaoScreen from '../pages/manutençao/nova-manutenções';
const screens: Record<RouteName, React.ComponentType<AppScreenProps>> = {
[ROUTES.LOGIN]: LoginScreen,
[ROUTES.HOME]: HomeScreen,
[ROUTES.ORDEM_SERVICO]: OrdemServicoScreen,
[ROUTES.NOVA_ORDEM]: NovaOrdemScreen,
[ROUTES.LOJAS]: LojasScreen,
[ROUTES.NOVA_LOJA]: NovaLojaScreen,
[ROUTES.PECAS]: PecasScreen,
[ROUTES.NOVA_PECA]: NovaPecaScreen,
[ROUTES.FORNOS]: FornosScreen,
[ROUTES.NOVO_FORNO]: NovoFornoScreen,
[ROUTES.PECAS_FORNO]: PecasFornoScreen,
[ROUTES.NOVA_PECA_FORNO]: NovaPecaFornoScreen,
[ROUTES.MANUTENCAO]: ManutencaoScreen,
[ROUTES.NOVA_MANUTENCAO]: NovaManutencaoScreen,
};
export default function AppNavigator() {
const [user, setUser] = useState<AuthUser | null>(null);
const [stack, setStack] = useState<RouteState[]>([{ name: ROUTES.LOGIN }]);
const [refreshKey, setRefreshKey] = useState(0);
const route = stack[stack.length - 1];
const navigate = useCallback((name: RouteName, params?: RouteParams) => setStack((old) => [...old, { name, params }]), []);
const resetTo = useCallback((name: RouteName, params?: RouteParams) => setStack([{ name, params }]), []);
const goBack = useCallback(() => setStack((old) => old.length > 1 ? old.slice(0, -1) : old), []);
const bumpRefresh = useCallback(() => setRefreshKey((n) => n + 1), []);
useEffect(() => { if (!user && route.name !== ROUTES.LOGIN) resetTo(ROUTES.LOGIN); }, [resetTo, route.name, user]);
const Screen = screens[route.name];
const props = useMemo(() => ({ route, user, setUser, navigate, resetTo, goBack, refreshKey, bumpRefresh }), [route, user, navigate, resetTo, goBack, refreshKey, bumpRefresh]);
return <SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }}><StatusBar barStyle="dark-content" /><Screen {...props} /></SafeAreaView>;
}

33
src/navigation/routes.ts Normal file
View File

@ -0,0 +1,33 @@
import { AuthUser } from '../types';
export const ROUTES = {
LOGIN: 'login',
HOME: 'home',
ORDEM_SERVICO: 'ordem-de-serviço',
NOVA_ORDEM: 'ordem-de-serviço/nova-ordem',
LOJAS: 'lojas',
NOVA_LOJA: 'lojas/nova-loja',
PECAS: 'peças',
NOVA_PECA: 'peças/nova-peça',
FORNOS: 'fornos',
NOVO_FORNO: 'fornos/novo-forno',
PECAS_FORNO: 'peças-forno',
NOVA_PECA_FORNO: 'peças-forno/nova-peça',
MANUTENCAO: 'manutençao',
NOVA_MANUTENCAO: 'manutençao/nova-manutenções',
} as const;
export type RouteName = (typeof ROUTES)[keyof typeof ROUTES];
export type RouteParams = { fornoId?: number };
export type RouteState = { name: RouteName; params?: RouteParams };
export type AppScreenProps = {
route: RouteState;
user: AuthUser | null;
setUser: (user: AuthUser | null) => void;
navigate: (name: RouteName, params?: RouteParams) => void;
resetTo: (name: RouteName, params?: RouteParams) => void;
goBack: () => void;
refreshKey: number;
bumpRefresh: () => void;
};

53
src/pages/_screens.tsx Normal file
View File

@ -0,0 +1,53 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Text } from 'react-native';
import { fakeApi, PART_LOCATIONS } from '../api/fakeApi';
import { Button, Card, Empty, Header, Input, Screen } from '../components/ui';
import { AppScreenProps, ROUTES } from '../navigation/routes';
import { Maintenance, Oven, OvenPartLink, Part, PartLocation, ServiceOrder, Store } from '../types';
type Data = { stores: Store[]; ovens: Oven[]; parts: Part[]; serviceOrders: ServiceOrder[]; ovenPartLinks: OvenPartLink[]; maintenances: Maintenance[] };
const blank: Data = { stores: [], ovens: [], parts: [], serviceOrders: [], ovenPartLinks: [], maintenances: [] };
function useData(refreshKey: number) { const [data, setData] = useState<Data>(blank); useEffect(() => { fakeApi.bootstrap().then(setData); }, [refreshKey]); return data; }
const names = (data: Data) => ({ store: (id: number) => data.stores.find((s) => s.id === id)?.descricao ?? 'Loja', oven: (id: number) => data.ovens.find((o) => o.id === id)?.descricao ?? 'Forno', parts: (ids: number[]) => ids.map((id) => data.parts.find((p) => p.id === id)?.descricao).filter(Boolean).join(', ') || 'Sem peças' });
function toggle(list: number[], id: number) { return list.includes(id) ? list.filter((item) => item !== id) : [...list, id]; }
export function LoginScreen({ setUser, resetTo }: AppScreenProps) {
const [usuario, setUsuario] = useState('tecnico'); const [senha, setSenha] = useState('123456'); const [loading, setLoading] = useState(false);
async function submit() { try { setLoading(true); const user = await fakeApi.login(usuario, senha); setUser(user); resetTo(ROUTES.HOME); } catch (e) { Alert.alert('Login não autorizado', e instanceof Error ? e.message : 'Tente novamente.'); } finally { setLoading(false); } }
return <Screen><Header title="Entrar" subtitle="Login validado pela API fake. Use tecnico/123456 ou admin/123456." /><Input label="Usuário" value={usuario} onChangeText={setUsuario} autoCapitalize="none" /><Input label="Senha" value={senha} onChangeText={setSenha} secureTextEntry /><Button title={loading ? 'Validando...' : 'Entrar'} onPress={submit} disabled={loading} /></Screen>;
}
export function HomeScreen({ user, navigate, setUser, resetTo }: AppScreenProps) {
const modules = [[ROUTES.LOJAS, 'Lojas', 'Lista e cadastro'], [ROUTES.ORDEM_SERVICO, 'Ordens de serviço', 'Lista e nova OS'], [ROUTES.PECAS, 'Peças', 'Catálogo e localização'], [ROUTES.FORNOS, 'Fornos', 'Vinculados a lojas'], [ROUTES.PECAS_FORNO, 'Peças por forno', 'Associação muitos-para-muitos'], [ROUTES.MANUTENCAO, 'Manutenção', 'Registro com peças usadas']] as const;
return <Screen><Header title="Home" subtitle={`Bem-vindo, ${user?.nome ?? 'usuário'}.`} action={<Button title="Sair" secondary onPress={() => { setUser(null); resetTo(ROUTES.LOGIN); }} />} />{modules.map(([route, title, sub]) => <Card key={route} meta="Abrir" title={title} subtitle={sub} onPress={() => navigate(route)} />)}</Screen>;
}
export function LojasScreen({ navigate, goBack, refreshKey }: AppScreenProps) { const data = useData(refreshKey); return <Screen><Header title="Lojas" subtitle="Lista de lojas cadastradas." back={goBack} action={<Button title="Nova loja" onPress={() => navigate(ROUTES.NOVA_LOJA)} />} />{data.stores.map((s) => <Card key={s.id} meta={`Loja #${s.id}`} title={s.descricao} subtitle={`${s.endereco}
Contato: ${s.nomeContato} ${s.numeroContato}`} />)}{!data.stores.length && <Empty text="Nenhuma loja." />}</Screen>; }
export function NovaLojaScreen({ goBack, bumpRefresh }: AppScreenProps) { const [descricao, setDescricao] = useState(''); const [endereco, setEndereco] = useState(''); const [nomeContato, setNomeContato] = useState(''); const [numeroContato, setNumeroContato] = useState(''); async function submit() { try { await fakeApi.createStore({ descricao, endereco, nomeContato, numeroContato }); bumpRefresh(); Alert.alert('Loja salva'); goBack(); } catch (e) { Alert.alert('Erro', e instanceof Error ? e.message : 'Confira os dados.'); } } return <Screen><Header title="Nova loja" subtitle="Descrição, endereço e contato." back={goBack} /><Input label="Descrição" value={descricao} onChangeText={setDescricao} /><Input label="Endereço" value={endereco} onChangeText={setEndereco} /><Input label="Nome do contato" value={nomeContato} onChangeText={setNomeContato} /><Input label="Número do contato" value={numeroContato} onChangeText={setNumeroContato} keyboardType="phone-pad" /><Button title="Salvar loja" onPress={submit} /></Screen>; }
export function PecasScreen({ navigate, goBack, refreshKey }: AppScreenProps) { const data = useData(refreshKey); return <Screen><Header title="Peças" subtitle="Referência = localização.ref + 00 + id." back={goBack} action={<Button title="Nova peça" onPress={() => navigate(ROUTES.NOVA_PECA)} />} />{data.parts.map((p) => <Card key={p.id} meta={p.referencia} title={p.descricao} subtitle={`${p.localizacao.descricao} (${p.localizacao.ref})`} />)}</Screen>; }
export function NovaPecaScreen({ goBack, bumpRefresh }: AppScreenProps) { const [descricao, setDescricao] = useState(''); const [localizacaoRef, setLocalizacaoRef] = useState<PartLocation['ref']>('CC'); async function submit() { try { const p = await fakeApi.createPart({ descricao, localizacaoRef }); bumpRefresh(); Alert.alert('Peça salva', `Referência: ${p.referencia}`); goBack(); } catch (e) { Alert.alert('Erro', e instanceof Error ? e.message : 'Confira os dados.'); } } return <Screen><Header title="Nova peça" subtitle="Escolha a localização para gerar a referência." back={goBack} /><Input label="Descrição" value={descricao} onChangeText={setDescricao} />{PART_LOCATIONS.map((l) => <Card key={l.ref} selected={l.ref === localizacaoRef} title={l.descricao} subtitle={`Ref: ${l.ref}`} onPress={() => setLocalizacaoRef(l.ref)} />)}<Button title="Salvar peça" onPress={submit} /></Screen>; }
export function FornosScreen({ navigate, goBack, refreshKey }: AppScreenProps) { const data = useData(refreshKey); const n = names(data); return <Screen><Header title="Fornos" subtitle="Fornos vinculados a uma loja." back={goBack} action={<Button title="Novo forno" onPress={() => navigate(ROUTES.NOVO_FORNO)} />} />{data.ovens.map((o) => <Card key={o.id} meta={`Forno #${o.id}`} title={o.descricao} subtitle={`Loja: ${n.store(o.lojaId)}
Modelo: ${o.modelo} Série: ${o.numeroSerie}`} />)}</Screen>; }
export function NovoFornoScreen({ goBack, bumpRefresh, refreshKey }: AppScreenProps) { const data = useData(refreshKey); const [lojaId, setLojaId] = useState<number | null>(null); const [descricao, setDescricao] = useState(''); const [modelo, setModelo] = useState(''); const [numeroSerie, setNumeroSerie] = useState(''); useEffect(() => { if (!lojaId && data.stores[0]) setLojaId(data.stores[0].id); }, [data.stores, lojaId]); async function submit() { try { if (!lojaId) throw new Error('Escolha a loja.'); await fakeApi.createOven({ lojaId, descricao, modelo, numeroSerie }); bumpRefresh(); Alert.alert('Forno salvo'); goBack(); } catch (e) { Alert.alert('Erro', e instanceof Error ? e.message : 'Confira os dados.'); } } return <Screen><Header title="Novo forno" subtitle="Escolha a loja onde o forno será cadastrado." back={goBack} />{data.stores.map((s) => <Card key={s.id} selected={s.id === lojaId} title={s.descricao} subtitle={s.endereco} onPress={() => setLojaId(s.id)} />)}<Input label="Descrição" value={descricao} onChangeText={setDescricao} /><Input label="Modelo" value={modelo} onChangeText={setModelo} /><Input label="Número de série" value={numeroSerie} onChangeText={setNumeroSerie} /><Button title="Salvar forno" onPress={submit} /></Screen>; }
export function PecasFornoScreen({ navigate, goBack, refreshKey }: AppScreenProps) { const data = useData(refreshKey); const n = names(data); const partNames = (fornoId: number) => n.parts(data.ovenPartLinks.find((l) => l.fornoId === fornoId)?.pecaIds ?? []); return <Screen><Header title="Peças por forno" subtitle="Clique no forno para associar uma ou várias peças." back={goBack} />{data.ovens.map((o) => <Card key={o.id} meta="Editar peças" title={o.descricao} subtitle={`Loja: ${n.store(o.lojaId)}
Peças: ${partNames(o.id)}`} onPress={() => navigate(ROUTES.NOVA_PECA_FORNO, { fornoId: o.id })} />)}</Screen>; }
export function NovaPecaFornoScreen({ route, goBack, bumpRefresh, refreshKey }: AppScreenProps) { const data = useData(refreshKey); const fornoId = route.params?.fornoId; const oven = data.ovens.find((o) => o.id === fornoId); const [selected, setSelected] = useState<number[]>([]); useEffect(() => { setSelected(data.ovenPartLinks.find((l) => l.fornoId === fornoId)?.pecaIds ?? []); }, [data.ovenPartLinks, fornoId]); async function submit() { try { if (!fornoId) throw new Error('Forno não informado.'); await fakeApi.saveOvenParts(fornoId, selected); bumpRefresh(); Alert.alert('Associação salva'); goBack(); } catch (e) { Alert.alert('Erro', e instanceof Error ? e.message : 'Selecione ao menos uma peça.'); } } return <Screen><Header title="Adicionar peças" subtitle={oven?.descricao ?? 'Forno'} back={goBack} />{data.parts.map((p) => <Card key={p.id} selected={selected.includes(p.id)} meta={p.referencia} title={p.descricao} subtitle={p.localizacao.descricao} onPress={() => setSelected((old) => toggle(old, p.id))} />)}<Button title="Salvar peças do forno" onPress={submit} /></Screen>; }
export function OrdemServicoScreen({ navigate, goBack, refreshKey }: AppScreenProps) { const data = useData(refreshKey); const n = names(data); return <Screen><Header title="Ordens de serviço" subtitle="Lista de OS e formulário de nova ordem." back={goBack} action={<Button title="Nova OS" onPress={() => navigate(ROUTES.NOVA_ORDEM)} />} />{data.serviceOrders.map((o) => <Card key={o.id} meta={o.status} title={o.titulo} subtitle={`${n.store(o.lojaId)}${n.oven(o.fornoId)}
${o.descricao}`} />)}</Screen>; }
export function NovaOrdemScreen({ goBack, bumpRefresh, refreshKey }: AppScreenProps) { const data = useData(refreshKey); const [lojaId, setLojaId] = useState<number | null>(null); const [fornoId, setFornoId] = useState<number | null>(null); const [titulo, setTitulo] = useState(''); const [descricao, setDescricao] = useState(''); useEffect(() => { if (!lojaId && data.stores[0]) setLojaId(data.stores[0].id); }, [data.stores, lojaId]); const ovens = useMemo(() => data.ovens.filter((o) => o.lojaId === lojaId), [data.ovens, lojaId]); useEffect(() => { setFornoId(ovens[0]?.id ?? null); }, [ovens]); async function submit() { try { if (!lojaId || !fornoId) throw new Error('Escolha loja e forno.'); await fakeApi.createServiceOrder({ lojaId, fornoId, titulo, descricao }); bumpRefresh(); Alert.alert('OS criada'); goBack(); } catch (e) { Alert.alert('Erro', e instanceof Error ? e.message : 'Confira os dados.'); } } return <Screen><Header title="Nova ordem" subtitle="Escolha loja e forno." back={goBack} />{data.stores.map((s) => <Card key={s.id} selected={s.id === lojaId} title={s.descricao} subtitle={s.endereco} onPress={() => setLojaId(s.id)} />)}<Text>Forno</Text>{ovens.map((o) => <Card key={o.id} selected={o.id === fornoId} title={o.descricao} subtitle={o.modelo} onPress={() => setFornoId(o.id)} />)}<Input label="Título" value={titulo} onChangeText={setTitulo} /><Input label="Descrição" value={descricao} onChangeText={setDescricao} multiline /><Button title="Criar OS" onPress={submit} /></Screen>; }
export function ManutencaoScreen({ navigate, goBack, refreshKey }: AppScreenProps) { const data = useData(refreshKey); const n = names(data); return <Screen><Header title="Manutenções" subtitle="Histórico com loja, forno e peças usadas." back={goBack} action={<Button title="Nova manutenção" onPress={() => navigate(ROUTES.NOVA_MANUTENCAO)} />} />{data.maintenances.map((m) => <Card key={m.id} meta={m.status} title={`${n.store(m.lojaId)}${n.oven(m.fornoId)}`} subtitle={`Técnico: ${m.tecnico}
Peças: ${n.parts(m.pecaIds)}
${m.observacao}`} />)}</Screen>; }
export function NovaManutencaoScreen({ user, goBack, bumpRefresh, refreshKey }: AppScreenProps) { const data = useData(refreshKey); const [lojaId, setLojaId] = useState<number | null>(null); const [fornoId, setFornoId] = useState<number | null>(null); const [pecaIds, setPecaIds] = useState<number[]>([]); const [tecnico, setTecnico] = useState(user?.nome ?? ''); const [observacao, setObservacao] = useState(''); useEffect(() => { if (!lojaId && data.stores[0]) setLojaId(data.stores[0].id); }, [data.stores, lojaId]); const ovens = useMemo(() => data.ovens.filter((o) => o.lojaId === lojaId), [data.ovens, lojaId]); useEffect(() => { setFornoId(ovens[0]?.id ?? null); }, [ovens]); async function submit() { try { if (!lojaId || !fornoId) throw new Error('Escolha loja e forno.'); await fakeApi.createMaintenance({ lojaId, fornoId, pecaIds, tecnico, observacao }); bumpRefresh(); Alert.alert('Manutenção registrada'); goBack(); } catch (e) { Alert.alert('Erro', e instanceof Error ? e.message : 'Confira os dados.'); } } return <Screen><Header title="Nova manutenção" subtitle="Escolha loja, forno e peças usadas." back={goBack} />{data.stores.map((s) => <Card key={s.id} selected={s.id === lojaId} title={s.descricao} subtitle={s.endereco} onPress={() => setLojaId(s.id)} />)}<Text>Forno</Text>{ovens.map((o) => <Card key={o.id} selected={o.id === fornoId} title={o.descricao} subtitle={o.modelo} onPress={() => setFornoId(o.id)} />)}<Text>Peças usadas</Text>{data.parts.map((p) => <Card key={p.id} selected={pecaIds.includes(p.id)} meta={p.referencia} title={p.descricao} subtitle={p.localizacao.descricao} onPress={() => setPecaIds((old) => toggle(old, p.id))} />)}<Input label="Técnico" value={tecnico} onChangeText={setTecnico} /><Input label="Observação" value={observacao} onChangeText={setObservacao} multiline /><Button title="Registrar manutenção" onPress={submit} /></Screen>; }

View File

@ -0,0 +1 @@
export { FornosScreen as default } from '../_screens';

View File

@ -0,0 +1 @@
export { NovoFornoScreen as default } from '../../_screens';

1
src/pages/home/index.tsx Normal file
View File

@ -0,0 +1 @@
export { HomeScreen as default } from '../_screens';

View File

@ -0,0 +1 @@
export { LoginScreen as default } from '../_screens';

View File

@ -0,0 +1 @@
export { LojasScreen as default } from '../_screens';

View File

@ -0,0 +1 @@
export { NovaLojaScreen as default } from '../../_screens';

View File

@ -0,0 +1 @@
export { ManutencaoScreen as default } from '../_screens';

View File

@ -0,0 +1 @@
export { NovaManutencaoScreen as default } from '../../_screens';

View File

@ -0,0 +1 @@
export { OrdemServicoScreen as default } from '../_screens';

View File

@ -0,0 +1 @@
export { NovaOrdemScreen as default } from '../../_screens';

View File

@ -0,0 +1 @@
export { PecasFornoScreen as default } from '../_screens';

View File

@ -0,0 +1 @@
export { NovaPecaFornoScreen as default } from '../../_screens';

View File

@ -0,0 +1 @@
export { PecasScreen as default } from '../_screens';

View File

@ -0,0 +1 @@
export { NovaPecaScreen as default } from '../../_screens';

49
src/types/index.ts Normal file
View File

@ -0,0 +1,49 @@
export type AuthUser = { id: number; nome: string; usuario: string; perfil: 'admin' | 'tecnico' };
export type Store = {
id: number;
descricao: string;
endereco: string;
nomeContato: string;
numeroContato: string;
};
export type PartLocation = { descricao: string; ref: 'CC' | 'PCU' | 'PCE' | 'GV' | 'EE' };
export type Part = {
id: number;
descricao: string;
localizacao: PartLocation;
referencia: string;
};
export type Oven = {
id: number;
lojaId: number;
descricao: string;
modelo: string;
numeroSerie: string;
};
export type ServiceOrder = {
id: number;
lojaId: number;
fornoId: number;
titulo: string;
descricao: string;
status: 'Aberta' | 'Em andamento' | 'Concluída';
criadoEm: string;
};
export type OvenPartLink = { fornoId: number; pecaIds: number[] };
export type Maintenance = {
id: number;
lojaId: number;
fornoId: number;
pecaIds: number[];
tecnico: string;
observacao: string;
status: 'Registrada' | 'Concluída';
criadoEm: string;
};