Update and syc dev branch #3

Merged
KYRBOT merged 44 commits from main into dev 2026-04-05 18:55:50 +00:00
102 changed files with 11451 additions and 798 deletions
Showing only changes of commit 38c4f97fa4 - Show all commits

38
.atl/skill-registry.md Normal file
View File

@@ -0,0 +1,38 @@
# Skill Registry — OmniOilPersonal
Generated: 2026-04-02
## Active Skills (by context)
| Context | Skill | Path |
|---------|-------|------|
| React components, hooks, JSX | react-19 | `~/.claude/skills/react-19/SKILL.md` |
| TypeScript types, interfaces | typescript | `~/.claude/skills/typescript/SKILL.md` |
| Tailwind classes, styling | tailwind-4 | `~/.claude/skills/tailwind-4/SKILL.md` |
| Zod schemas, validation | zod-4 | `~/.claude/skills/zod-4/SKILL.md` |
| Zustand stores, state | zustand-5 | `~/.claude/skills/zustand-5/SKILL.md` |
| Next.js, app router | nextjs-15 | `~/.claude/skills/nextjs-15/SKILL.md` |
| AI SDK, streaming | ai-sdk-5 | `~/.claude/skills/ai-sdk-5/SKILL.md` |
| Playwright E2E tests | playwright | `~/.claude/skills/playwright/SKILL.md` |
| Pytest, Python testing | pytest | `~/.claude/skills/pytest/SKILL.md` |
| Django, DRF, REST API | django-drf | `~/.claude/skills/django-drf/SKILL.md` |
| Go testing, Bubbletea | go-testing | `~/.claude/skills/go-testing/SKILL.md` |
## SDD Skills
| Skill | Path |
|-------|------|
| sdd-init | `~/.claude/skills/sdd-init/SKILL.md` |
| sdd-explore | `~/.claude/skills/sdd-explore/SKILL.md` |
| sdd-propose | `~/.claude/skills/sdd-propose/SKILL.md` |
| sdd-spec | `~/.claude/skills/sdd-spec/SKILL.md` |
| sdd-design | `~/.claude/skills/sdd-design/SKILL.md` |
| sdd-tasks | `~/.claude/skills/sdd-tasks/SKILL.md` |
| sdd-apply | `~/.claude/skills/sdd-apply/SKILL.md` |
| sdd-verify | `~/.claude/skills/sdd-verify/SKILL.md` |
| sdd-archive | `~/.claude/skills/sdd-archive/SKILL.md` |
## Project Conventions
- Global CLAUDE.md: `~/.claude/CLAUDE.md`
- No project-level CLAUDE.md found

View File

@@ -1,10 +1,11 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="es">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" href="/pwa-192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <meta name="theme-color" content="#06b6d4" />
<title>OmniOil</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,7 @@
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.4.2",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -38,6 +39,7 @@
"globals": "^16.5.0", "globals": "^16.5.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"vite": "^7.2.4" "vite": "^7.2.4",
"vite-plugin-pwa": "^1.2.0"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@@ -0,0 +1,67 @@
import { Outlet, NavLink } from 'react-router-dom'
import { Home, FlaskConical, ArrowLeftRight, CircleOff, RefreshCw, Droplets } from 'lucide-react'
import { useSyncStore } from '@/lib/sync/sync-store'
import { useNetworkStatus } from '@/lib/sync/use-network-status'
const navItems = [
{ to: '/app/field', label: 'Inicio', icon: Home, end: true },
{ to: '/app/field/measurements', label: 'Medición', icon: FlaskConical, end: false },
{ to: '/app/field/movements', label: 'Movimientos', icon: ArrowLeftRight, end: false },
{ to: '/app/field/well-shutdowns', label: 'Paradas', icon: CircleOff, end: false },
{ to: '/app/field/sync', label: 'Sync', icon: RefreshCw, end: false },
]
export default function FieldLayout() {
useNetworkStatus()
const isOnline = useSyncStore((s) => s.isOnline)
const pendingCount = useSyncStore((s) => s.pendingCount)
return (
<div className="flex flex-col h-screen bg-[var(--background)]">
{/* Header */}
<header className="flex items-center justify-between h-14 px-4 border-b border-[var(--border)] bg-[var(--card)] shrink-0">
<div className="flex items-center gap-2">
<Droplets className="h-5 w-5 text-cyan-400" />
<span className="text-base font-bold text-white tracking-tight">OMNIOIL</span>
</div>
<div className="flex items-center gap-2">
{pendingCount > 0 && (
<span className="flex items-center gap-1 rounded-full bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-400">
<RefreshCw className="h-3 w-3" />
{pendingCount}
</span>
)}
<span
className={`h-2 w-2 rounded-full ${isOnline ? 'bg-emerald-400' : 'bg-red-400'}`}
title={isOnline ? 'Online' : 'Sin conexión'}
/>
</div>
</header>
{/* Content */}
<main className="flex-1 overflow-y-auto p-4">
<Outlet />
</main>
{/* Bottom Nav */}
<nav className="flex border-t border-[var(--border)] bg-[var(--card)] shrink-0">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex flex-1 flex-col items-center justify-center gap-1 py-2.5 text-[10px] font-medium transition-colors ${
isActive ? 'text-cyan-400' : 'text-gray-500 hover:text-gray-300'
}`
}
>
<item.icon className="h-5 w-5" />
{item.label}
</NavLink>
))}
</nav>
</div>
)
}

View File

@@ -1,6 +1,8 @@
import { createBrowserRouter, Navigate } from 'react-router-dom' import { createBrowserRouter, Navigate } from 'react-router-dom'
import LoginPage from '@/features/auth/pages/LoginPage' import LoginPage from '@/features/auth/pages/LoginPage'
import AdminLayout from '@/app/layouts/AdminLayout' import AdminLayout from '@/app/layouts/AdminLayout'
import FieldLayout from '@/app/layouts/FieldLayout'
import OfflinePage from '@/features/offline/OfflinePage'
import DashboardPage from '@/features/admin/dashboard/pages/DashboardPage' import DashboardPage from '@/features/admin/dashboard/pages/DashboardPage'
import ProjectListPage from '@/features/admin/projects/pages/ProjectListPage' import ProjectListPage from '@/features/admin/projects/pages/ProjectListPage'
import CreateProjectPage from '@/features/admin/projects/pages/CreateProjectPage' import CreateProjectPage from '@/features/admin/projects/pages/CreateProjectPage'
@@ -24,6 +26,10 @@ export const router = createBrowserRouter([
path: '/login', path: '/login',
element: <LoginPage />, element: <LoginPage />,
}, },
{
path: '/offline',
element: <OfflinePage />,
},
{ {
path: '/app/admin', path: '/app/admin',
element: <ProtectedRoute allowedRoles={['admin']} />, element: <ProtectedRoute allowedRoles={['admin']} />,
@@ -48,7 +54,16 @@ export const router = createBrowserRouter([
path: '/app/field', path: '/app/field',
element: <ProtectedRoute allowedRoles={['operador']} />, element: <ProtectedRoute allowedRoles={['operador']} />,
children: [ children: [
{ index: true, element: <ComingSoon title="Portal de Campo" /> }, {
element: <FieldLayout />,
children: [
{ index: true, element: <ComingSoon title="Portal de Campo" /> },
{ path: 'measurements', element: <ComingSoon title="Registro de Medición" /> },
{ path: 'movements', element: <ComingSoon title="Registro de Movimientos" /> },
{ path: 'well-shutdowns', element: <ComingSoon title="Paradas de Pozo" /> },
{ path: 'sync', element: <ComingSoon title="Estado de Sincronización" /> },
],
},
], ],
}, },
{ {

View File

@@ -0,0 +1,41 @@
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { WifiOff, RefreshCw } from 'lucide-react'
import { useSyncStore } from '@/lib/sync/sync-store'
import { useNetworkStatus } from '@/lib/sync/use-network-status'
export default function OfflinePage() {
useNetworkStatus()
const isOnline = useSyncStore((s) => s.isOnline)
const navigate = useNavigate()
const wasOffline = useRef(!isOnline)
useEffect(() => {
if (isOnline && wasOffline.current) {
navigate('/app/field', { replace: true })
}
if (!isOnline) {
wasOffline.current = true
}
}, [isOnline, navigate])
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-[var(--background)] px-6 text-center">
<div className="rounded-full bg-red-500/10 p-6 mb-6">
<WifiOff className="h-12 w-12 text-red-400" />
</div>
<h1 className="text-2xl font-bold text-white mb-2">Sin conexión</h1>
<p className="text-gray-400 mb-8 max-w-xs">
No hay conexión a internet. Tus datos guardados localmente están seguros.
</p>
<button
onClick={() => window.location.reload()}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-cyan-500 transition-all"
>
<RefreshCw className="h-4 w-4" />
Reintentar
</button>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import Dexie, { type Table } from 'dexie'
export interface SyncQueueItem {
id: string
operation_type: 'measurement' | 'movement' | 'shutdown'
payload: object
timestamp: number
status: 'draft' | 'pending_sync' | 'syncing' | 'synced' | 'rejected' | 'conflicted'
retry_count: number
last_error: string | null
}
export interface FormDraft {
id: string
form_type: 'measurement' | 'movement' | 'shutdown'
asset_id: string
data: object
saved_at: number
}
export interface ReferenceDataEntry {
key: string
data: object
cached_at: number
}
class OmniOilDB extends Dexie {
sync_queue!: Table<SyncQueueItem, string>
form_drafts!: Table<FormDraft, string>
reference_data!: Table<ReferenceDataEntry, string>
constructor() {
super('omni-oil-db')
this.version(1).stores({
sync_queue: 'id, status, timestamp',
form_drafts: 'id, form_type, asset_id',
reference_data: 'key',
})
}
}
export const db = new OmniOilDB()

View File

@@ -0,0 +1,59 @@
import { create } from 'zustand'
import { db, type SyncQueueItem } from '@/lib/db'
interface SyncState {
isOnline: boolean
pendingCount: number
lastSync: number | null
setOnline: (online: boolean) => void
setPendingCount: (count: number) => void
setLastSync: (ts: number) => void
refreshPendingCount: () => Promise<void>
addToQueue: (entry: Pick<SyncQueueItem, 'operation_type' | 'payload' | 'status'>) => Promise<void>
updateQueueItem: (id: string, patch: Partial<SyncQueueItem>) => Promise<void>
}
export const useSyncStore = create<SyncState>()((set) => ({
isOnline: navigator.onLine,
pendingCount: 0,
lastSync: null,
setOnline: (online) => set({ isOnline: online }),
setPendingCount: (count) => set({ pendingCount: count }),
setLastSync: (ts) => set({ lastSync: ts }),
refreshPendingCount: async () => {
const count = await db.sync_queue
.where('status')
.anyOf(['pending_sync', 'syncing'])
.count()
set({ pendingCount: count })
},
addToQueue: async (entry) => {
const item: SyncQueueItem = {
id: crypto.randomUUID(),
operation_type: entry.operation_type,
payload: entry.payload,
timestamp: Date.now(),
status: entry.status ?? 'pending_sync',
retry_count: 0,
last_error: null,
}
await db.sync_queue.add(item)
const count = await db.sync_queue
.where('status')
.anyOf(['pending_sync', 'syncing'])
.count()
set({ pendingCount: count })
},
updateQueueItem: async (id, patch) => {
await db.sync_queue.update(id, patch)
const count = await db.sync_queue
.where('status')
.anyOf(['pending_sync', 'syncing'])
.count()
set({ pendingCount: count })
},
}))

View File

@@ -0,0 +1,23 @@
import { useEffect } from 'react'
import { useSyncStore } from './sync-store'
export function useNetworkStatus(): void {
const setOnline = useSyncStore((s) => s.setOnline)
const refreshPendingCount = useSyncStore((s) => s.refreshPendingCount)
useEffect(() => {
setOnline(navigator.onLine)
refreshPendingCount()
const handleOnline = () => setOnline(true)
const handleOffline = () => setOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [setOnline, refreshPendingCount])
}

View File

@@ -1,3 +1,4 @@
import { registerSW } from 'virtual:pwa-register'
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { RouterProvider } from 'react-router-dom' import { RouterProvider } from 'react-router-dom'
@@ -14,6 +15,8 @@ const queryClient = new QueryClient({
}, },
}) })
registerSW({ immediate: true })
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>

View File

@@ -1 +1,2 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />

View File

@@ -1,9 +1,48 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path' import path from 'path'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
devOptions: { enabled: true },
manifest: {
name: 'OmniOil',
short_name: 'OmniOil',
theme_color: '#06b6d4',
background_color: '#0f172a',
id: '/',
display: 'standalone',
start_url: '/',
icons: [
{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
runtimeCaching: [
{
urlPattern: /^https?:\/\/.*\/api\/assets/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'omnioil-reference',
expiration: { maxAgeSeconds: 86400 },
},
},
{
urlPattern: /^https?:\/\/.*\/api\/auth/,
handler: 'NetworkFirst',
options: { cacheName: 'omnioil-auth' },
},
],
},
}),
],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),