Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:50:06 +08:00
commit 5e3ca965d9
68 changed files with 11257 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -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"
}
}

View File

@@ -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
}
}
}
});

View 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;
}
}

View 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;

View 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();

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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%;
}

View 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>
);

View File

@@ -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;

View File

@@ -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;