diff --git a/App.tsx b/App.tsx
new file mode 100644
index 0000000..318365e
--- /dev/null
+++ b/App.tsx
@@ -0,0 +1,6 @@
+import React from 'react';
+import AppNavigator from './src/navigation/AppNavigator';
+
+export default function App() {
+ return ;
+}
diff --git a/README-mobile.md b/README-mobile.md
new file mode 100644
index 0000000..25f9fec
--- /dev/null
+++ b/README-mobile.md
@@ -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.
diff --git a/api/mock.php b/api/mock.php
new file mode 100644
index 0000000..181b60b
--- /dev/null
+++ b/api/mock.php
@@ -0,0 +1,132 @@
+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);
+}
diff --git a/assets/css/custom.css b/assets/css/custom.css
index 789132e..46b2b18 100644
--- a/assets/css/custom.css
+++ b/assets/css/custom.css
@@ -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 {
- background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
- background-size: 400% 400%;
- animation: gradient 15s ease infinite;
- color: #212529;
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
- font-size: 14px;
- margin: 0;
- min-height: 100vh;
+ margin: 0;
+ min-height: 100vh;
+ background: var(--app-bg);
+ color: var(--app-text);
+ font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-size: 14px;
}
-.main-wrapper {
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 100vh;
- width: 100%;
- padding: 20px;
- box-sizing: border-box;
- position: relative;
- z-index: 1;
+.skip-link {
+ position: absolute;
+ left: 12px;
+ top: -48px;
+ z-index: 1000;
+ background: var(--app-text);
+ color: #fff;
+ padding: 8px 10px;
+ border-radius: var(--app-radius-sm);
+}
+.skip-link:focus { top: 12px; }
+
+.app-shell {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ padding: 20px 12px;
}
-@keyframes gradient {
- 0% {
- background-position: 0% 50%;
- }
- 50% {
- background-position: 100% 50%;
- }
- 100% {
- background-position: 0% 50%;
- }
+.phone-frame {
+ width: min(100%, 430px);
+ min-height: min(860px, calc(100vh - 40px));
+ background: var(--app-surface);
+ border: 1px solid var(--app-border);
+ border-radius: 22px;
+ box-shadow: var(--app-shadow);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
}
-.chat-container {
- width: 100%;
- max-width: 600px;
- background: rgba(255, 255, 255, 0.85);
- border: 1px solid rgba(255, 255, 255, 0.3);
- border-radius: 20px;
- display: flex;
- flex-direction: column;
- height: 85vh;
- box-shadow: 0 20px 40px rgba(0,0,0,0.2);
- backdrop-filter: blur(15px);
- -webkit-backdrop-filter: blur(15px);
- overflow: hidden;
+.app-topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 18px 18px 14px;
+ border-bottom: 1px solid var(--app-border);
+ background: rgba(255, 255, 255, 0.96);
}
-.chat-header {
- padding: 1.5rem;
- border-bottom: 1px solid rgba(0, 0, 0, 0.05);
- background: rgba(255, 255, 255, 0.5);
- font-weight: 700;
- font-size: 1.1rem;
- display: flex;
- justify-content: space-between;
- align-items: center;
+.eyebrow, .label {
+ margin: 0 0 4px;
+ color: var(--app-muted);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: .08em;
+ text-transform: uppercase;
}
-.chat-messages {
- flex: 1;
- overflow-y: auto;
- padding: 1.5rem;
- display: flex;
- flex-direction: column;
- gap: 1.25rem;
+h1, h2, p { margin-top: 0; }
+h1 { margin-bottom: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.02em; }
+h2 { font-size: 24px; line-height: 1.15; font-weight: 700; letter-spacing: -0.04em; }
+
+.screen {
+ display: none;
+ 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 */
-::-webkit-scrollbar {
- width: 6px;
+.hero-card {
+ padding: 22px;
+ margin-bottom: 18px;
+ background: #fcfcfd;
}
-::-webkit-scrollbar-track {
- background: transparent;
+.muted, .empty-state p, small { color: var(--app-muted); }
+.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 {
- background: rgba(255, 255, 255, 0.3);
- border-radius: 10px;
+.btn { border-radius: var(--app-radius-md); font-weight: 650; min-height: 40px; }
+.btn-dark { background: var(--app-text); border-color: var(--app-text); }
+
+.status-card {
+ padding: 14px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+ background: #fcfcfd;
}
-::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 255, 255, 0.5);
+.module-grid {
+ 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 {
- max-width: 85%;
- padding: 0.85rem 1.1rem;
- border-radius: 16px;
- line-height: 1.5;
- font-size: 0.95rem;
- box-shadow: 0 4px 15px rgba(0,0,0,0.05);
- animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+.list-stack { display: grid; gap: 10px; }
+.list-item {
+ width: 100%;
+ padding: 13px 14px;
+ text-align: left;
+ display: grid;
+ gap: 4px;
+ 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 {
- from { opacity: 0; transform: translateY(20px) scale(0.95); }
- to { opacity: 1; transform: translateY(0) scale(1); }
+@media (max-width: 460px) {
+ .app-shell { padding: 0; }
+ .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;
-}
\ No newline at end of file
diff --git a/assets/js/main.js b/assets/js/main.js
index d349598..67e5182 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -1,39 +1,202 @@
-document.addEventListener('DOMContentLoaded', () => {
- const chatForm = document.getElementById('chat-form');
- const chatInput = document.getElementById('chat-input');
- const chatMessages = document.getElementById('chat-messages');
+(() => {
+ const app = document.querySelector('[data-api-base]');
+ const apiBase = app.dataset.apiBase;
+ const state = {
+ user: null,
+ stores: [],
+ ovens: [],
+ parts: [],
+ maintenance: [],
+ };
- const appendMessage = (text, sender) => {
- const msgDiv = document.createElement('div');
- msgDiv.classList.add('message', sender);
- msgDiv.textContent = text;
- chatMessages.appendChild(msgDiv);
- chatMessages.scrollTop = chatMessages.scrollHeight;
- };
+ const $ = (selector) => document.querySelector(selector);
+ const $$ = (selector) => Array.from(document.querySelectorAll(selector));
- chatForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const message = chatInput.value.trim();
- if (!message) return;
+ function toast(message, type = 'success') {
+ const region = $('#toastRegion');
+ const el = document.createElement('div');
+ el.className = `app-toast ${type === 'error' ? 'error' : ''}`;
+ el.textContent = message;
+ region.appendChild(el);
+ setTimeout(() => el.remove(), 3200);
+ }
- appendMessage(message, 'visitor');
- chatInput.value = '';
+ function setScreen(screenId) {
+ $$('.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 {
- const response = await fetch('api/chat.php', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- 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');
- }
+ async function api(action, options = {}) {
+ const response = await fetch(`${apiBase}?action=${encodeURIComponent(action)}`, {
+ headers: { 'Content-Type': 'application/json' },
+ ...options,
});
-});
+ 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) => `
+ ${escapeHtml(store.name)}${escapeHtml(store.id)}
${escapeHtml(store.city)}
+ `).join('');
+
+ $('#partsList').innerHTML = state.parts.map((part) => `
+ ${escapeHtml(part.name)}${escapeHtml(part.sku)}
Peça mock para seleção em manutenção
+ `).join('');
+
+ $('#ovensList').innerHTML = state.ovens.map((oven) => {
+ const store = state.stores.find((item) => item.id === oven.store_id);
+ return `${escapeHtml(oven.name)}${escapeHtml(oven.model)}
${escapeHtml(store?.name || 'Loja')}`;
+ }).join('');
+ }
+
+ function renderFormOptions() {
+ $('#storeSelect').innerHTML = '' + state.stores.map((store) => ``).join('');
+ renderOvenOptions();
+ $('#partsChoices').innerHTML = state.parts.map((part) => `
+
+ `).join('');
+ }
+
+ function renderOvenOptions() {
+ const storeId = $('#storeSelect').value;
+ const ovens = state.ovens.filter((oven) => oven.store_id === storeId);
+ $('#ovenSelect').innerHTML = '' + ovens.map((oven) => ``).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) => `
+
+ `).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 = `
+
Registro #${escapeHtml(String(item.id))}
+ ${escapeHtml(item.store_name)}
+
+
Forno${escapeHtml(item.oven_name)}
+
Técnico${escapeHtml(item.technician)}
+
Tipo${escapeHtml(item.service_type)}
+
Peças${escapeHtml(parts)}
+
Observações${escapeHtml(item.notes || 'Sem observações.')}
+
Criado em${formatDate(item.created_at)}
+
+ `;
+ setScreen('detailScreen');
+ }
+
+ function resetMaintenanceForm() {
+ $('#maintenanceForm').reset();
+ renderOvenOptions();
+ }
+
+ function escapeHtml(value) {
+ return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char]));
+ }
+
+ function escapeAttr(value) {
+ return escapeHtml(value).replace(/`/g, '`');
+ }
+
+ 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();
+})();
diff --git a/index.php b/index.php
index 7205f3d..7b718f3 100644
--- a/index.php
+++ b/index.php
@@ -1,150 +1,166 @@
-
+
-
-
- New Style
-
-
-
-
-
-
-
-
-
+
+
+ = htmlspecialchars($projectName) ?> — App de Manutenção
+
+
+
+
+
-
-
-
-
+
+
-
-
+
+
+
-
-
-
Analyzing your requirements and generating your website…
-
- Loading…
-
-
= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-
This page will update automatically as the plan is implemented.
-
Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
-
+ Pular para o conteúdo
+
+
+
+
+
+
+
+
+
Equipe técnica
+
Entre para gerenciar manutenções.
+
Use qualquer usuário e senha para testar o fluxo com chamadas fake de API.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Nenhuma manutenção registrada.
+
Crie a primeira manutenção escolhendo loja, forno e peças utilizadas.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/src/api/fakeApi.ts b/src/api/fakeApi.ts
new file mode 100644
index 0000000..97b8a00
--- /dev/null
+++ b/src/api/fakeApi.ts
@@ -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 = [
+ { 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 = (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 { 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 { await wait(); return clone({ stores, ovens, parts, serviceOrders, ovenPartLinks, maintenances }); },
+ async createStore(input: Omit) { 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) { 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) { 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) { 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); },
+};
diff --git a/src/assets/README.md b/src/assets/README.md
new file mode 100644
index 0000000..8447672
--- /dev/null
+++ b/src/assets/README.md
@@ -0,0 +1,5 @@
+# Assets
+
+Pasta para imagens, ícones, fontes e estilos compartilhados do app Expo.
+
+- `styles/theme.ts`: tema visual do app.
diff --git a/src/assets/styles/theme.ts b/src/assets/styles/theme.ts
new file mode 100644
index 0000000..8d6bcba
--- /dev/null
+++ b/src/assets/styles/theme.ts
@@ -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 },
+});
diff --git a/src/components/ui.tsx b/src/components/ui.tsx
new file mode 100644
index 0000000..eb3187b
--- /dev/null
+++ b/src/components/ui.tsx
@@ -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 (
+
+ {title}
+
+ );
+}
+
+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 ? {meta} : null}
+ {title}
+ {subtitle ? {subtitle} : null}
+ {children}
+ >
+ );
+ if (onPress) return {content};
+ return {content};
+}
+
+type InputProps = TextInputProps & { label: string; value: string; onChangeText: (value: string) => void };
+export function Input({ label, multiline, style, ...props }: InputProps) {
+ return (
+
+ {label}
+
+
+ );
+}
+
+export function Screen({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+export function Header({ title, subtitle, back, action }: { title: string; subtitle?: string; back?: () => void; action?: ReactNode }) {
+ return (
+
+
+ {back ? : }
+ {action}
+
+
+ {title}
+ {subtitle ? {subtitle} : null}
+
+
+ );
+}
+
+export function Empty({ text }: { text: string }) {
+ return {text};
+}
diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx
new file mode 100644
index 0000000..b0610a4
--- /dev/null
+++ b/src/navigation/AppNavigator.tsx
@@ -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> = {
+ [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(null);
+ const [stack, setStack] = useState([{ 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 ;
+}
diff --git a/src/navigation/routes.ts b/src/navigation/routes.ts
new file mode 100644
index 0000000..5bc025b
--- /dev/null
+++ b/src/navigation/routes.ts
@@ -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;
+};
diff --git a/src/pages/_screens.tsx b/src/pages/_screens.tsx
new file mode 100644
index 0000000..2c4b6a3
--- /dev/null
+++ b/src/pages/_screens.tsx
@@ -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(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 ;
+}
+
+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 { setUser(null); resetTo(ROUTES.LOGIN); }} />} />{modules.map(([route, title, sub]) => navigate(route)} />)};
+}
+
+export function LojasScreen({ navigate, goBack, refreshKey }: AppScreenProps) { const data = useData(refreshKey); return navigate(ROUTES.NOVA_LOJA)} />} />{data.stores.map((s) => )}{!data.stores.length && }; }
+
+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 ; }
+
+export function PecasScreen({ navigate, goBack, refreshKey }: AppScreenProps) { const data = useData(refreshKey); return navigate(ROUTES.NOVA_PECA)} />} />{data.parts.map((p) => )}; }
+
+export function NovaPecaScreen({ goBack, bumpRefresh }: AppScreenProps) { const [descricao, setDescricao] = useState(''); const [localizacaoRef, setLocalizacaoRef] = useState('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 {PART_LOCATIONS.map((l) => setLocalizacaoRef(l.ref)} />)}; }
+
+export function FornosScreen({ navigate, goBack, refreshKey }: AppScreenProps) { const data = useData(refreshKey); const n = names(data); return navigate(ROUTES.NOVO_FORNO)} />} />{data.ovens.map((o) => )}; }
+
+export function NovoFornoScreen({ goBack, bumpRefresh, refreshKey }: AppScreenProps) { const data = useData(refreshKey); const [lojaId, setLojaId] = useState(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 {data.stores.map((s) => setLojaId(s.id)} />)}; }
+
+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 {data.ovens.map((o) => navigate(ROUTES.NOVA_PECA_FORNO, { fornoId: o.id })} />)}; }
+
+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([]); 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 {data.parts.map((p) => setSelected((old) => toggle(old, p.id))} />)}; }
+
+export function OrdemServicoScreen({ navigate, goBack, refreshKey }: AppScreenProps) { const data = useData(refreshKey); const n = names(data); return navigate(ROUTES.NOVA_ORDEM)} />} />{data.serviceOrders.map((o) => )}; }
+
+export function NovaOrdemScreen({ goBack, bumpRefresh, refreshKey }: AppScreenProps) { const data = useData(refreshKey); const [lojaId, setLojaId] = useState(null); const [fornoId, setFornoId] = useState(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 {data.stores.map((s) => setLojaId(s.id)} />)}Forno{ovens.map((o) => setFornoId(o.id)} />)}; }
+
+export function ManutencaoScreen({ navigate, goBack, refreshKey }: AppScreenProps) { const data = useData(refreshKey); const n = names(data); return navigate(ROUTES.NOVA_MANUTENCAO)} />} />{data.maintenances.map((m) => )}; }
+
+export function NovaManutencaoScreen({ user, goBack, bumpRefresh, refreshKey }: AppScreenProps) { const data = useData(refreshKey); const [lojaId, setLojaId] = useState(null); const [fornoId, setFornoId] = useState(null); const [pecaIds, setPecaIds] = useState([]); 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 {data.stores.map((s) => setLojaId(s.id)} />)}Forno{ovens.map((o) => setFornoId(o.id)} />)}Peças usadas{data.parts.map((p) => setPecaIds((old) => toggle(old, p.id))} />)}; }
diff --git a/src/pages/fornos/index.tsx b/src/pages/fornos/index.tsx
new file mode 100644
index 0000000..a8abb79
--- /dev/null
+++ b/src/pages/fornos/index.tsx
@@ -0,0 +1 @@
+export { FornosScreen as default } from '../_screens';
diff --git a/src/pages/fornos/novo-forno/index.tsx b/src/pages/fornos/novo-forno/index.tsx
new file mode 100644
index 0000000..ef3aa61
--- /dev/null
+++ b/src/pages/fornos/novo-forno/index.tsx
@@ -0,0 +1 @@
+export { NovoFornoScreen as default } from '../../_screens';
diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx
new file mode 100644
index 0000000..b8ab8b6
--- /dev/null
+++ b/src/pages/home/index.tsx
@@ -0,0 +1 @@
+export { HomeScreen as default } from '../_screens';
diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx
new file mode 100644
index 0000000..dc8a6af
--- /dev/null
+++ b/src/pages/login/index.tsx
@@ -0,0 +1 @@
+export { LoginScreen as default } from '../_screens';
diff --git a/src/pages/lojas/index.tsx b/src/pages/lojas/index.tsx
new file mode 100644
index 0000000..ba9f8db
--- /dev/null
+++ b/src/pages/lojas/index.tsx
@@ -0,0 +1 @@
+export { LojasScreen as default } from '../_screens';
diff --git a/src/pages/lojas/nova-loja/index.tsx b/src/pages/lojas/nova-loja/index.tsx
new file mode 100644
index 0000000..1633d98
--- /dev/null
+++ b/src/pages/lojas/nova-loja/index.tsx
@@ -0,0 +1 @@
+export { NovaLojaScreen as default } from '../../_screens';
diff --git a/src/pages/manutençao/index.tsx b/src/pages/manutençao/index.tsx
new file mode 100644
index 0000000..52d43b6
--- /dev/null
+++ b/src/pages/manutençao/index.tsx
@@ -0,0 +1 @@
+export { ManutencaoScreen as default } from '../_screens';
diff --git a/src/pages/manutençao/nova-manutenções/index.tsx b/src/pages/manutençao/nova-manutenções/index.tsx
new file mode 100644
index 0000000..154006c
--- /dev/null
+++ b/src/pages/manutençao/nova-manutenções/index.tsx
@@ -0,0 +1 @@
+export { NovaManutencaoScreen as default } from '../../_screens';
diff --git a/src/pages/ordem-de-serviço/index.tsx b/src/pages/ordem-de-serviço/index.tsx
new file mode 100644
index 0000000..b4f6df8
--- /dev/null
+++ b/src/pages/ordem-de-serviço/index.tsx
@@ -0,0 +1 @@
+export { OrdemServicoScreen as default } from '../_screens';
diff --git a/src/pages/ordem-de-serviço/nova-ordem/index.tsx b/src/pages/ordem-de-serviço/nova-ordem/index.tsx
new file mode 100644
index 0000000..223b826
--- /dev/null
+++ b/src/pages/ordem-de-serviço/nova-ordem/index.tsx
@@ -0,0 +1 @@
+export { NovaOrdemScreen as default } from '../../_screens';
diff --git a/src/pages/peças-forno/index.tsx b/src/pages/peças-forno/index.tsx
new file mode 100644
index 0000000..2d255b1
--- /dev/null
+++ b/src/pages/peças-forno/index.tsx
@@ -0,0 +1 @@
+export { PecasFornoScreen as default } from '../_screens';
diff --git a/src/pages/peças-forno/nova-peça/index.tsx b/src/pages/peças-forno/nova-peça/index.tsx
new file mode 100644
index 0000000..dd2e922
--- /dev/null
+++ b/src/pages/peças-forno/nova-peça/index.tsx
@@ -0,0 +1 @@
+export { NovaPecaFornoScreen as default } from '../../_screens';
diff --git a/src/pages/peças/index.tsx b/src/pages/peças/index.tsx
new file mode 100644
index 0000000..e7ffcb3
--- /dev/null
+++ b/src/pages/peças/index.tsx
@@ -0,0 +1 @@
+export { PecasScreen as default } from '../_screens';
diff --git a/src/pages/peças/nova-peça/index.tsx b/src/pages/peças/nova-peça/index.tsx
new file mode 100644
index 0000000..4ff8ccd
--- /dev/null
+++ b/src/pages/peças/nova-peça/index.tsx
@@ -0,0 +1 @@
+export { NovaPecaScreen as default } from '../../_screens';
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..1b3d97f
--- /dev/null
+++ b/src/types/index.ts
@@ -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;
+};