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… -
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

-
+ +
+
+
+
+ MVP Mobile +

ManutApp

+
+ +
+ +
+ +
+
+

Equipe técnica

+

Entre para gerenciar manutenções.

+

Use qualquer usuário e senha para testar o fluxo com chamadas fake de API.

+
+
+
+ + +
+
+ + +
+ +
+
+ +
+
+
+

Operação

+ Bem-vindo +
+ 0 manutenções +
+
+ + + + + +
+
+ +
+
+ + +
+
+
+ Nenhuma manutenção registrada. +

Crie a primeira manutenção escolhendo loja, forno e peças utilizadas.

+ +
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ Peças utilizadas +
+
+
+ + +
+ +
+
+ +
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
OS mockadas

O próximo passo é conectar ordens de serviço ao fluxo de manutenção.

+
+ +
+ PHP + UTC +
+
-
- Page updated: (UTC) -
+ 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 ?