Initial commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Odoo Instance Configuration (for backend server)
|
||||
ODOO_URL=https://your-instance.odoo.com
|
||||
ODOO_DB=your-database-name
|
||||
ODOO_USERNAME=your-username
|
||||
ODOO_API_KEY=your-api-key
|
||||
|
||||
# Primary Model (use x_ prefix for Odoo Studio models)
|
||||
ODOO_PRIMARY_MODEL=x_{{MODEL_NAME}}
|
||||
|
||||
# Frontend: API endpoint (if frontend and backend are separate)
|
||||
VITE_API_URL=http://localhost:3000
|
||||
@@ -0,0 +1,29 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#667eea" />
|
||||
<meta name="description" content="{{MODEL_DISPLAY_NAME}} management PWA with Odoo integration" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>{{PROJECT_NAME}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "{{PROJECT_NAME}}",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.34.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"vite": "^5.3.4",
|
||||
"vite-plugin-pwa": "^0.20.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,json,webp}']
|
||||
},
|
||||
manifest: {
|
||||
name: '{{PROJECT_NAME}}',
|
||||
short_name: '{{PROJECT_NAME}}',
|
||||
description: 'PWA for {{MODEL_DISPLAY_NAME}} management with Odoo integration',
|
||||
theme_color: '#667eea',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
icons: [
|
||||
{
|
||||
src: '/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: '/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src'
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_URL || 'http://localhost:3000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
311
skills/create-odoo-pwa/templates/react/src/App.css.template
Normal file
311
skills/create-odoo-pwa/templates/react/src/App.css.template
Normal file
@@ -0,0 +1,311 @@
|
||||
:root {
|
||||
--primary-color: #667eea;
|
||||
--primary-dark: #5568d3;
|
||||
--bg-gradient-start: #667eea;
|
||||
--bg-gradient-end: #764ba2;
|
||||
--error-bg: #f8d7da;
|
||||
--error-text: #721c24;
|
||||
--success-bg: #d4edda;
|
||||
--success-text: #155724;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu,
|
||||
Cantarell, 'Helvetica Neue', sans-serif;
|
||||
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 30px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
text-decoration: none;
|
||||
color: var(--primary-color);
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.nav a.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.offline-banner {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
border: 2px solid #64b5f6;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.form, .list-container {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
padding: 15px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: #f0f0f0;
|
||||
color: var(--primary-color);
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: var(--error-bg);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 10px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.refresh-btn-small {
|
||||
padding: 10px 15px;
|
||||
background: #f0f0f0;
|
||||
color: var(--primary-color);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
min-width: auto;
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
.refresh-btn-small:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.record-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.record-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.record-content h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.record-meta {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.record-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
padding: 8px 12px;
|
||||
background: #fff5f5;
|
||||
color: #e53e3e;
|
||||
border: 1px solid #feb2b2;
|
||||
border-radius: 6px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
min-width: auto;
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #fed7d7;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.list-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.record-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
41
skills/create-odoo-pwa/templates/react/src/App.jsx.template
Normal file
41
skills/create-odoo-pwa/templates/react/src/App.jsx.template
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import AddPage from './pages/AddPage';
|
||||
import ListPage from './pages/ListPage';
|
||||
import OfflineBanner from './components/OfflineBanner';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="container">
|
||||
<h1>📋 {{PROJECT_NAME}}</h1>
|
||||
|
||||
<OfflineBanner />
|
||||
|
||||
<nav className="nav">
|
||||
<Link
|
||||
to="/"
|
||||
className={location.pathname === '/' ? 'active' : ''}
|
||||
>
|
||||
Add {{MODEL_DISPLAY_NAME}}
|
||||
</Link>
|
||||
<Link
|
||||
to="/list"
|
||||
className={location.pathname === '/list' ? 'active' : ''}
|
||||
>
|
||||
View All
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<AddPage />} />
|
||||
<Route path="/list" element={<ListPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
123
skills/create-odoo-pwa/templates/react/src/api/odoo.js.template
Normal file
123
skills/create-odoo-pwa/templates/react/src/api/odoo.js.template
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Odoo API Client for React
|
||||
* Communicates with backend server that proxies to Odoo
|
||||
*/
|
||||
|
||||
class OdooAPI {
|
||||
constructor() {
|
||||
this.apiUrl = import.meta.env.VITE_API_URL
|
||||
? `${import.meta.env.VITE_API_URL}/api/odoo`
|
||||
: '/api/odoo';
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the server-side API
|
||||
* @param {string} action
|
||||
* @param {any} data
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async callApi(action, data) {
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ action, data })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'API Error');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new record in specified model
|
||||
* @param {string} model - Odoo model name
|
||||
* @param {Record<string, any>} fields - Record fields
|
||||
* @returns {Promise<number>} - Created record ID
|
||||
*/
|
||||
async createRecord(model, fields) {
|
||||
const result = await this.callApi('create', { model, fields });
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search and read records from specified model
|
||||
* @param {string} model - Odoo model name
|
||||
* @param {any[]} domain - Odoo domain filter
|
||||
* @param {string[]} fields - Fields to retrieve
|
||||
* @returns {Promise<any[]>} - Array of records
|
||||
*/
|
||||
async searchRecords(model, domain = [], fields = []) {
|
||||
const result = await this.callApi('search', { model, domain, fields });
|
||||
return result.results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic search_read for any model
|
||||
* @param {string} model
|
||||
* @param {any[]} domain
|
||||
* @param {string[]} fields
|
||||
* @returns {Promise<any[]>}
|
||||
*/
|
||||
async searchModel(model, domain = [], fields = []) {
|
||||
const result = await this.callApi('search_model', { model, domain, fields });
|
||||
return result.results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch partner list (common operation)
|
||||
* @returns {Promise<Array<{id:number, display_name:string}>>}
|
||||
*/
|
||||
async fetchPartners() {
|
||||
return await this.searchModel('res.partner', [], ['id', 'display_name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a many2one field value
|
||||
* @param {number|string|null|undefined} id
|
||||
* @returns {number|false}
|
||||
*/
|
||||
formatMany2one(id) {
|
||||
return id ? Number(id) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a many2many field using the (6,0,[ids]) command
|
||||
* @param {Array<number|string>} ids
|
||||
* @returns {any[]}
|
||||
*/
|
||||
formatMany2many(ids) {
|
||||
if (!Array.isArray(ids) || ids.length === 0) return [];
|
||||
return [[6, 0, ids.map((i) => Number(i))]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a record
|
||||
* @param {string} model - Odoo model name
|
||||
* @param {number} id - Record ID
|
||||
* @param {Record<string, any>} values - Fields to update
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async updateRecord(model, id, values) {
|
||||
const result = await this.callApi('update', { model, id, values });
|
||||
return result.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a record
|
||||
* @param {string} model - Odoo model name
|
||||
* @param {number} id - Record ID
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async deleteRecord(model, id) {
|
||||
const result = await this.callApi('delete', { model, id });
|
||||
return result.result;
|
||||
}
|
||||
}
|
||||
|
||||
export const odooClient = new OdooAPI();
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
function OfflineBanner() {
|
||||
const [isOffline, setIsOffline] = useState(!navigator.onLine);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isOffline) return null;
|
||||
|
||||
return (
|
||||
<div className="offline-banner">
|
||||
📡 Offline Mode - Data will be synced when you're back online
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OfflineBanner;
|
||||
@@ -0,0 +1,243 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { odooClient } from '../api/odoo';
|
||||
|
||||
const TaskContext = createContext();
|
||||
|
||||
// Cache configuration
|
||||
const CACHE_KEY = '{{MODEL_NAME}}_cache_v1';
|
||||
const CACHE_META_KEY = '{{MODEL_NAME}}_cache_meta_v1';
|
||||
const CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes cache validity
|
||||
const SYNC_INTERVAL_MS = 3 * 60 * 1000; // Background sync every 3 minutes
|
||||
|
||||
// Helper functions for localStorage
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const cachedData = localStorage.getItem(CACHE_KEY);
|
||||
const meta = localStorage.getItem(CACHE_META_KEY);
|
||||
|
||||
if (cachedData && meta) {
|
||||
const records = JSON.parse(cachedData);
|
||||
const metaData = JSON.parse(meta);
|
||||
|
||||
const now = Date.now();
|
||||
const isStale = now - metaData.lastSyncTime > CACHE_DURATION_MS;
|
||||
|
||||
return { records, meta: { ...metaData, isStale } };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load cache from storage:', e);
|
||||
}
|
||||
|
||||
return {
|
||||
records: [],
|
||||
meta: {
|
||||
lastSyncTime: 0,
|
||||
lastRecordId: 0,
|
||||
recordCount: 0,
|
||||
isStale: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function saveToStorage(records, meta) {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(records));
|
||||
localStorage.setItem(CACHE_META_KEY, JSON.stringify({
|
||||
lastSyncTime: meta.lastSyncTime || Date.now(),
|
||||
lastRecordId: meta.lastRecordId || 0,
|
||||
recordCount: records.length,
|
||||
isStale: false
|
||||
}));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save cache to storage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function TaskProvider({ children }) {
|
||||
const [records, setRecords] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [meta, setMeta] = useState({
|
||||
lastSyncTime: 0,
|
||||
lastRecordId: 0,
|
||||
recordCount: 0,
|
||||
isStale: true
|
||||
});
|
||||
|
||||
// Sync function - fetches new data from server
|
||||
const sync = useCallback(async (forceFullRefresh = false) => {
|
||||
setSyncing(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const fields = ['id', 'x_name'];
|
||||
let domain = [];
|
||||
let fetchedRecords = [];
|
||||
|
||||
if (!forceFullRefresh && meta.lastRecordId > 0) {
|
||||
// Incremental fetch
|
||||
domain = [['id', '>', meta.lastRecordId]];
|
||||
try {
|
||||
fetchedRecords = await odooClient.searchRecords('x_{{MODEL_NAME}}', domain, fields);
|
||||
} catch (err) {
|
||||
console.warn('Incremental fetch failed, falling back to full fetch:', err);
|
||||
forceFullRefresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (forceFullRefresh || meta.lastRecordId === 0) {
|
||||
// Full refresh
|
||||
fetchedRecords = await odooClient.searchRecords('x_{{MODEL_NAME}}', [], fields);
|
||||
}
|
||||
|
||||
// Merge or replace records
|
||||
let mergedRecords;
|
||||
if (forceFullRefresh || meta.lastRecordId === 0) {
|
||||
mergedRecords = fetchedRecords;
|
||||
} else {
|
||||
const existingIds = new Set(records.map(r => r.id));
|
||||
const newRecords = fetchedRecords.filter(r => !existingIds.has(r.id));
|
||||
mergedRecords = [...records, ...newRecords];
|
||||
}
|
||||
|
||||
// Sort by ID
|
||||
mergedRecords.sort((a, b) => a.id - b.id);
|
||||
|
||||
// Calculate new metadata
|
||||
const lastRecordId = mergedRecords.length > 0
|
||||
? Math.max(...mergedRecords.map(r => r.id))
|
||||
: 0;
|
||||
|
||||
const newMeta = {
|
||||
lastSyncTime: Date.now(),
|
||||
lastRecordId,
|
||||
recordCount: mergedRecords.length,
|
||||
isStale: false
|
||||
};
|
||||
|
||||
// Save to storage
|
||||
saveToStorage(mergedRecords, newMeta);
|
||||
|
||||
// Update state
|
||||
setRecords(mergedRecords);
|
||||
setMeta(newMeta);
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error);
|
||||
setError(error.message || 'Failed to sync data');
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [meta.lastRecordId, records]);
|
||||
|
||||
// Initialize - load cache and start background sync
|
||||
useEffect(() => {
|
||||
const cachedState = loadFromStorage();
|
||||
|
||||
if (cachedState.records.length > 0) {
|
||||
setRecords(cachedState.records);
|
||||
setMeta(cachedState.meta);
|
||||
|
||||
// Sync in background if stale
|
||||
if (cachedState.meta.isStale) {
|
||||
sync();
|
||||
}
|
||||
} else {
|
||||
setLoading(true);
|
||||
sync(true).finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
// Set up periodic background sync
|
||||
const syncInterval = setInterval(() => {
|
||||
sync();
|
||||
}, SYNC_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(syncInterval);
|
||||
}, []);
|
||||
|
||||
// Force refresh
|
||||
const forceRefresh = useCallback(async () => {
|
||||
await sync(true);
|
||||
}, [sync]);
|
||||
|
||||
// Create record
|
||||
const createRecord = useCallback(async (fields) => {
|
||||
try {
|
||||
const id = await odooClient.createRecord('x_{{MODEL_NAME}}', fields);
|
||||
|
||||
// Optimistically add to cache
|
||||
const newRecord = { id, ...fields };
|
||||
setRecords(prev => [...prev, newRecord]);
|
||||
|
||||
// Sync to get full record
|
||||
await sync();
|
||||
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error('Failed to create record:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [sync]);
|
||||
|
||||
// Update record
|
||||
const updateRecord = useCallback(async (id, values) => {
|
||||
try {
|
||||
await odooClient.updateRecord('x_{{MODEL_NAME}}', id, values);
|
||||
|
||||
// Optimistically update cache
|
||||
setRecords(prev =>
|
||||
prev.map(r => r.id === id ? { ...r, ...values } : r)
|
||||
);
|
||||
|
||||
// Sync to get full updated record
|
||||
await sync();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update record:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [sync]);
|
||||
|
||||
// Delete record
|
||||
const deleteRecord = useCallback(async (id) => {
|
||||
try {
|
||||
await odooClient.deleteRecord('x_{{MODEL_NAME}}', id);
|
||||
|
||||
// Optimistically remove from cache
|
||||
setRecords(prev => prev.filter(r => r.id !== id));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete record:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
records,
|
||||
loading,
|
||||
syncing,
|
||||
error,
|
||||
meta,
|
||||
sync,
|
||||
forceRefresh,
|
||||
createRecord,
|
||||
updateRecord,
|
||||
deleteRecord
|
||||
};
|
||||
|
||||
return (
|
||||
<TaskContext.Provider value={value}>
|
||||
{children}
|
||||
</TaskContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTask() {
|
||||
const context = useContext(TaskContext);
|
||||
if (!context) {
|
||||
throw new Error('useTask must be used within TaskProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
}
|
||||
16
skills/create-odoo-pwa/templates/react/src/main.jsx.template
Normal file
16
skills/create-odoo-pwa/templates/react/src/main.jsx.template
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import { TaskProvider } from './context/TaskContext';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<TaskProvider>
|
||||
<App />
|
||||
</TaskProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import { useTask } from '../context/TaskContext';
|
||||
|
||||
function AddPage() {
|
||||
const [name, setName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const { createRecord, forceRefresh } = useTask();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
setMessage('⚠️ Please fill in the name field');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
const payload = { x_name: name };
|
||||
await createRecord(payload);
|
||||
|
||||
if (navigator.onLine) {
|
||||
setMessage('✅ {{MODEL_DISPLAY_NAME}} added successfully!');
|
||||
} else {
|
||||
setMessage('✅ {{MODEL_DISPLAY_NAME}} saved locally! Will sync when online.');
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setName('');
|
||||
} catch (error) {
|
||||
setMessage(`❌ Error: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter {{MODEL_DISPLAY_NAME}} name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`message ${message.includes('❌') ? 'error' : ''}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="button-group">
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? '⏳ Adding...' : '➕ Add {{MODEL_DISPLAY_NAME}}'}
|
||||
</button>
|
||||
{navigator.onLine && (
|
||||
<button
|
||||
type="button"
|
||||
className="refresh-btn"
|
||||
onClick={forceRefresh}
|
||||
>
|
||||
🔄 Refresh Data
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddPage;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { useTask } from '../context/TaskContext';
|
||||
|
||||
function ListPage() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { records, loading, syncing, forceRefresh, deleteRecord } = useTask();
|
||||
|
||||
const filteredRecords = records.filter(record =>
|
||||
record.x_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Are you sure you want to delete this {{MODEL_DISPLAY_NAME}}?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteRecord(id);
|
||||
} catch (error) {
|
||||
alert(`Failed to delete: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="list-container">
|
||||
<div className="list-header">
|
||||
<h2>All {{MODEL_DISPLAY_NAME}}s</h2>
|
||||
<div className="header-actions">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
<button
|
||||
className="refresh-btn-small"
|
||||
onClick={forceRefresh}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? '⏳' : '🔄'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">Loading...</div>
|
||||
) : filteredRecords.length === 0 ? (
|
||||
<div className="empty">
|
||||
{searchTerm ? 'No matching records found' : 'No {{MODEL_DISPLAY_NAME}}s yet. Add your first one!'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="record-list">
|
||||
{filteredRecords.map(record => (
|
||||
<div key={record.id} className="record-card">
|
||||
<div className="record-content">
|
||||
<h3>{record.x_name}</h3>
|
||||
<p className="record-meta">ID: {record.id}</p>
|
||||
</div>
|
||||
<div className="record-actions">
|
||||
<button
|
||||
className="delete-btn"
|
||||
onClick={() => handleDelete(record.id)}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListPage;
|
||||
Reference in New Issue
Block a user