Initial commit
This commit is contained in:
411
skills/create-odoo-pwa/SKILL.md
Normal file
411
skills/create-odoo-pwa/SKILL.md
Normal file
@@ -0,0 +1,411 @@
|
||||
---
|
||||
name: create-odoo-pwa
|
||||
description: Generate an offline-first Progressive Web App with Odoo Studio backend integration. Use when user wants to create new Odoo-backed application, mentions "PWA with Odoo", "offline Odoo app", "Odoo Studio PWA", or similar terms. Supports SvelteKit, React, and Vue frameworks.
|
||||
allowed-tools: Read, Write, Glob, Bash
|
||||
---
|
||||
|
||||
# Create Odoo PWA Application
|
||||
|
||||
Generate a production-ready Progressive Web App with Odoo Studio backend, featuring offline-first architecture, smart caching, and automatic synchronization.
|
||||
|
||||
## Before You Start
|
||||
|
||||
This skill generates a complete PWA project following proven architectural patterns:
|
||||
- **Three-layer data flow**: Component → Cache Store → API Client → Server Route → Odoo
|
||||
- **Offline-first**: IndexedDB/localStorage with background sync
|
||||
- **Smart caching**: Incremental fetch, stale detection, optimistic updates
|
||||
- **PWA-ready**: Service workers, manifest, installable
|
||||
|
||||
## Required User Input
|
||||
|
||||
Ask the user for the following information before generating:
|
||||
|
||||
1. **Project name** (required)
|
||||
- Format: kebab-case (e.g., "inventory-tracker", "expense-manager")
|
||||
- Used for directory name and package.json
|
||||
|
||||
2. **Framework** (required)
|
||||
- Options: `sveltekit` (recommended), `react`, `vue`
|
||||
- Default: sveltekit if not specified
|
||||
|
||||
3. **Primary Odoo model** (required)
|
||||
- The main custom model name WITHOUT the `x_` prefix
|
||||
- Example: If Odoo model is `x_inventory`, user provides: `inventory`
|
||||
- Will automatically add `x_` prefix in code
|
||||
|
||||
4. **Model display name** (required)
|
||||
- Human-readable singular name (e.g., "Inventory Item", "Expense")
|
||||
|
||||
5. **Deployment target** (optional)
|
||||
- Options: `vercel`, `github-pages`, `cloudflare`, `netlify`
|
||||
- Default: vercel if not specified
|
||||
|
||||
## Generation Steps
|
||||
|
||||
### Step 1: Project Initialization
|
||||
|
||||
Create the project directory and initialize the structure:
|
||||
|
||||
```bash
|
||||
mkdir {{PROJECT_NAME}}
|
||||
cd {{PROJECT_NAME}}
|
||||
```
|
||||
|
||||
Generate the appropriate structure based on framework:
|
||||
- **SvelteKit**: Use SvelteKit 2.x structure with `src/` directory
|
||||
- **React**: Use Vite + React structure
|
||||
- **Vue**: Use Vite + Vue structure
|
||||
|
||||
### Step 2: Base Configuration Files
|
||||
|
||||
Generate these files using templates from `skills/create-odoo-pwa/templates/{{FRAMEWORK}}/base/`:
|
||||
|
||||
#### For SvelteKit:
|
||||
- `package.json` - Dependencies including @sveltejs/kit, @vite-pwa/sveltekit, @sveltejs/adapter-static
|
||||
- `svelte.config.js` - SvelteKit configuration with adapter-static
|
||||
- `vite.config.js` - Vite + PWA plugin configuration
|
||||
- `jsconfig.json` or `tsconfig.json` - Path aliases and compiler options
|
||||
|
||||
#### For React:
|
||||
- `package.json` - Dependencies including React 18, Vite, vite-plugin-pwa
|
||||
- `vite.config.js` - React + PWA plugin configuration
|
||||
- `tsconfig.json` - TypeScript configuration
|
||||
|
||||
#### For Vue:
|
||||
- `package.json` - Dependencies including Vue 3, Vite, vite-plugin-pwa
|
||||
- `vite.config.js` - Vue + PWA plugin configuration
|
||||
- `tsconfig.json` - TypeScript configuration
|
||||
|
||||
### Step 3: Environment and Git Configuration
|
||||
|
||||
Create `.env.example`:
|
||||
```env
|
||||
# Odoo Instance Configuration
|
||||
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)
|
||||
ODOO_PRIMARY_MODEL=x_{{MODEL_NAME}}
|
||||
|
||||
# Optional: For static hosting (GitHub Pages, etc.)
|
||||
PUBLIC_API_URL=
|
||||
```
|
||||
|
||||
Create `.gitignore`:
|
||||
```
|
||||
node_modules/
|
||||
.env
|
||||
dist/
|
||||
build/
|
||||
.svelte-kit/
|
||||
.vercel/
|
||||
.DS_Store
|
||||
*.log
|
||||
```
|
||||
|
||||
### Step 4: Core Library Files
|
||||
|
||||
Generate these essential files from templates:
|
||||
|
||||
#### A. Odoo API Client (`src/lib/odoo.js`)
|
||||
Features:
|
||||
- API URL configuration (supports PUBLIC_API_URL for static hosts)
|
||||
- `callApi(action, data)` - Core API communication
|
||||
- `createRecord(model, fields)` - Create records
|
||||
- `searchRecords(model, domain, fields)` - Search/read records
|
||||
- `updateRecord(model, id, values)` - Update records
|
||||
- `deleteRecord(model, id)` - Delete records
|
||||
- `formatMany2one(id)` - Format single relation fields
|
||||
- `formatMany2many(ids)` - Format multi-relation fields
|
||||
- Model-specific convenience methods
|
||||
|
||||
#### B. IndexedDB Manager (`src/lib/db.js`)
|
||||
Features:
|
||||
- Database initialization with versioning
|
||||
- Store definitions for master data (partners, categories, config)
|
||||
- CRUD operations: `add()`, `get()`, `getAll()`, `update()`, `remove()`
|
||||
- Transaction helpers
|
||||
- Error handling
|
||||
|
||||
#### C. Smart Cache Store (`src/lib/stores/{{MODEL_NAME}}Cache.js`)
|
||||
Features:
|
||||
- Framework-specific store pattern (Svelte store/React context/Vue composable)
|
||||
- Dual storage strategy (localStorage for metadata, IndexedDB for master data)
|
||||
- `initialize()` - Load cache and start background sync
|
||||
- `sync(forceFullRefresh)` - Incremental sync with Odoo
|
||||
- `forceRefresh()` - Clear cache and full sync
|
||||
- Partner name resolution with caching
|
||||
- Optimistic UI updates
|
||||
- Stale detection (5-minute cache validity)
|
||||
- Background sync (3-minute intervals)
|
||||
- Derived stores for UI states
|
||||
|
||||
#### D. Utility Functions (`src/lib/{{MODEL_NAME}}Utils.js`)
|
||||
Features:
|
||||
- Business logic calculations
|
||||
- Data normalization helpers
|
||||
- Field formatters
|
||||
|
||||
### Step 5: Server-Side API Proxy
|
||||
|
||||
#### For SvelteKit: `src/routes/api/odoo/+server.js`
|
||||
Features:
|
||||
- JSON-RPC client for Odoo
|
||||
- UID caching to reduce auth calls
|
||||
- `execute(model, method, args, kwargs)` helper
|
||||
- POST request handler with actions:
|
||||
- `create` - Create new record
|
||||
- `search` - Search and read records
|
||||
- `search_model` - Search any Odoo model
|
||||
- `update` - Update existing record
|
||||
- `delete` - Delete record
|
||||
- Error handling with descriptive messages
|
||||
- Environment variable access
|
||||
|
||||
#### For React/Vue: `src/api/odoo.js` (server endpoint)
|
||||
Similar functionality adapted to framework conventions
|
||||
|
||||
### Step 6: UI Components and Routes
|
||||
|
||||
Generate starter components and pages:
|
||||
|
||||
#### SvelteKit Routes:
|
||||
- `src/routes/+layout.svelte` - Root layout with navigation
|
||||
- `src/routes/+layout.js` - SSR/CSR configuration (ssr: false, csr: true)
|
||||
- `src/routes/+page.svelte` - Main form for creating records
|
||||
- `src/routes/list/+page.svelte` - List/table view with filtering
|
||||
- `src/app.html` - HTML template with PWA meta tags
|
||||
|
||||
#### React/Vue Pages:
|
||||
Equivalent component structure adapted to framework conventions
|
||||
|
||||
#### Shared Components:
|
||||
- `OfflineBanner` - Shows online/offline status
|
||||
- `SyncStatus` - Displays sync state and last sync time
|
||||
- `LoadingSpinner` - Loading indicator
|
||||
- Form components with offline support
|
||||
|
||||
### Step 7: PWA Configuration
|
||||
|
||||
Generate PWA files:
|
||||
|
||||
#### `static/manifest.json` (or `public/manifest.json`):
|
||||
```json
|
||||
{
|
||||
"name": "{{PROJECT_NAME}}",
|
||||
"short_name": "{{PROJECT_NAME}}",
|
||||
"description": "{{MODEL_DISPLAY_NAME}} management app",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#667eea",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Configure service worker in `vite.config.js`:
|
||||
- Auto-update strategy
|
||||
- Cache all static assets
|
||||
- Offline support
|
||||
|
||||
### Step 8: Deployment Configuration
|
||||
|
||||
Generate deployment files based on target:
|
||||
|
||||
#### Vercel (`vercel.json`):
|
||||
```json
|
||||
{
|
||||
"buildCommand": "npm run build",
|
||||
"outputDirectory": "build",
|
||||
"framework": "sveltekit",
|
||||
"regions": ["iad1"]
|
||||
}
|
||||
```
|
||||
|
||||
#### GitHub Pages (`.github/workflows/deploy.yml`):
|
||||
```yaml
|
||||
name: Deploy to GitHub Pages
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- uses: actions/upload-pages-artifact@v2
|
||||
with:
|
||||
path: build
|
||||
```
|
||||
|
||||
#### Cloudflare/Netlify:
|
||||
Generate appropriate configuration files
|
||||
|
||||
### Step 9: Documentation
|
||||
|
||||
Generate comprehensive documentation:
|
||||
|
||||
#### `README.md`:
|
||||
- Project overview
|
||||
- Prerequisites (Node.js, Odoo account)
|
||||
- Installation steps
|
||||
- Odoo Studio model setup instructions
|
||||
- Development commands
|
||||
- Deployment guide
|
||||
- Architecture overview
|
||||
|
||||
#### `CLAUDE.md`:
|
||||
Complete architecture documentation following the expense-split-pwa pattern:
|
||||
- Project overview
|
||||
- Development commands
|
||||
- Environment setup
|
||||
- Architecture diagrams
|
||||
- Key architectural patterns
|
||||
- Odoo model structure
|
||||
- Important development notes
|
||||
- Common gotchas
|
||||
|
||||
#### `API.md`:
|
||||
- Odoo integration patterns
|
||||
- Available API methods
|
||||
- Field formatting examples
|
||||
- Common operations
|
||||
|
||||
### Step 10: Post-Generation Instructions
|
||||
|
||||
After generating all files, provide the user with:
|
||||
|
||||
```
|
||||
✅ Project '{{PROJECT_NAME}}' generated successfully!
|
||||
|
||||
📋 Next Steps:
|
||||
|
||||
1. Navigate to the project:
|
||||
cd {{PROJECT_NAME}}
|
||||
|
||||
2. Install dependencies:
|
||||
npm install
|
||||
|
||||
3. Configure Odoo credentials:
|
||||
cp .env.example .env
|
||||
# Edit .env with your Odoo instance details
|
||||
|
||||
4. Create Odoo Studio Model:
|
||||
- Log into your Odoo instance
|
||||
- Go to Studio
|
||||
- Create a new model named: x_{{MODEL_NAME}}
|
||||
- Add custom fields with x_studio_ prefix
|
||||
- Example fields:
|
||||
* x_name (Char) - Required
|
||||
* x_studio_description (Text)
|
||||
* x_studio_value (Float)
|
||||
* x_studio_date (Date)
|
||||
* x_studio_category (Many2one → res.partner or custom)
|
||||
|
||||
5. Start development server:
|
||||
npm run dev
|
||||
|
||||
6. Generate PWA icons:
|
||||
- Create 192x192 and 512x512 PNG icons
|
||||
- Place in static/ or public/ directory
|
||||
- Name them icon-192.png and icon-512.png
|
||||
|
||||
7. Deploy (optional):
|
||||
- Vercel: vercel
|
||||
- GitHub Pages: Push to main branch
|
||||
- Cloudflare: wrangler deploy
|
||||
- Netlify: netlify deploy
|
||||
|
||||
📚 Documentation:
|
||||
- README.md - Getting started guide
|
||||
- CLAUDE.md - Architecture documentation
|
||||
- API.md - Odoo integration patterns
|
||||
|
||||
🔗 Resources:
|
||||
- Odoo API Docs: https://www.odoo.com/documentation/
|
||||
- SvelteKit Docs: https://kit.svelte.dev/
|
||||
```
|
||||
|
||||
## Template Variables
|
||||
|
||||
When generating files, replace these placeholders:
|
||||
|
||||
- `{{PROJECT_NAME}}` - User's project name (kebab-case)
|
||||
- `{{MODEL_NAME}}` - Odoo model name without x_ prefix
|
||||
- `{{MODEL_DISPLAY_NAME}}` - Human-readable model name
|
||||
- `{{FRAMEWORK}}` - sveltekit/react/vue
|
||||
- `{{DEPLOYMENT_TARGET}}` - vercel/github-pages/cloudflare/netlify
|
||||
- `{{AUTHOR_NAME}}` - User's name (if provided)
|
||||
|
||||
## Common Patterns from expense-split-pwa
|
||||
|
||||
This skill implements proven patterns from the expense-split-pwa project:
|
||||
|
||||
1. **Smart Caching**: Load from cache immediately, sync in background if stale
|
||||
2. **Incremental Fetch**: Only fetch records with `id > lastRecordId`
|
||||
3. **Partner Resolution**: Batch-fetch and cache partner names
|
||||
4. **Dual-Phase Calculation**: Process settled/unsettled records separately
|
||||
5. **Optimistic Updates**: Update UI immediately, sync to server in background
|
||||
6. **Error Recovery**: Graceful degradation when offline
|
||||
|
||||
## Error Handling
|
||||
|
||||
If generation fails:
|
||||
- Verify all required input is provided
|
||||
- Check template files exist
|
||||
- Ensure proper permissions for file creation
|
||||
- Provide clear error messages to user
|
||||
|
||||
## Framework-Specific Notes
|
||||
|
||||
### SvelteKit
|
||||
- Use Svelte 5 runes syntax (`$state`, `$derived`, `$effect`)
|
||||
- Configure adapter-static for static deployment
|
||||
- Set `ssr: false` for client-side only apps
|
||||
- Use `$app/paths` for base path support
|
||||
|
||||
### React
|
||||
- Use React 18+ with hooks
|
||||
- Context API for global state
|
||||
- React Query for server state (optional)
|
||||
- Vite for build tooling
|
||||
|
||||
### Vue
|
||||
- Use Vue 3 Composition API
|
||||
- Pinia for state management
|
||||
- Composables for reusable logic
|
||||
- Vite for build tooling
|
||||
|
||||
## Testing the Generated Project
|
||||
|
||||
After generation, verify:
|
||||
1. `npm install` completes without errors
|
||||
2. `npm run dev` starts development server
|
||||
3. Form renders correctly
|
||||
4. Offline banner appears when disconnecting
|
||||
5. Data persists in localStorage/IndexedDB
|
||||
6. Sync works when back online
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Check CLAUDE.md for architecture details
|
||||
- Review generated code comments
|
||||
- Consult Odoo API documentation
|
||||
- Verify environment variables are set correctly
|
||||
@@ -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;
|
||||
@@ -0,0 +1,12 @@
|
||||
# Odoo Instance Configuration
|
||||
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}}
|
||||
|
||||
# Optional: For static hosting (GitHub Pages, Cloudflare Pages, etc.)
|
||||
# Set this to the full URL of your API server if frontend and backend are on different domains
|
||||
PUBLIC_API_URL=
|
||||
@@ -0,0 +1,15 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.local
|
||||
dist/
|
||||
build/
|
||||
.svelte-kit/
|
||||
.vercel/
|
||||
.netlify/
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": false,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "{{PROJECT_NAME}}",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^6.1.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@vite-pwa/sveltekit": "^1.0.1",
|
||||
"svelte": "^5.39.5",
|
||||
"svelte-check": "^4.3.2",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"vite-plugin-pwa": "^1.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: undefined,
|
||||
precompress: false,
|
||||
strict: true
|
||||
}),
|
||||
// Configure base path for GitHub Pages deployment
|
||||
paths: {
|
||||
base: process.argv.includes('dev') ? '' : process.env.PUBLIC_BASE_PATH || ''
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,json,webp}']
|
||||
},
|
||||
manifest: {
|
||||
id: '/',
|
||||
name: '{{PROJECT_NAME}}',
|
||||
short_name: '{{PROJECT_NAME}}',
|
||||
description: 'PWA for {{MODEL_DISPLAY_NAME}} management with Odoo integration',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#667eea',
|
||||
icons: [
|
||||
{
|
||||
src: '/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable'
|
||||
},
|
||||
{
|
||||
src: '/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
env:
|
||||
PUBLIC_BASE_PATH: /${{ github.event.repository.name }}
|
||||
ODOO_URL: ${{ secrets.ODOO_URL }}
|
||||
ODOO_DB: ${{ secrets.ODOO_DB }}
|
||||
ODOO_USERNAME: ${{ secrets.ODOO_USERNAME }}
|
||||
ODOO_API_KEY: ${{ secrets.ODOO_API_KEY }}
|
||||
ODOO_PRIMARY_MODEL: ${{ secrets.ODOO_PRIMARY_MODEL }}
|
||||
- uses: actions/upload-pages-artifact@v2
|
||||
with:
|
||||
path: build
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v2
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"buildCommand": "npm run build",
|
||||
"outputDirectory": "build",
|
||||
"framework": "sveltekit",
|
||||
"regions": ["iad1"]
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with this {{PROJECT_NAME}} codebase.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**{{PROJECT_NAME}}** - An offline-first Progressive Web App for {{MODEL_DISPLAY_NAME}} management, built with SvelteKit frontend and Odoo Studio backend.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Development
|
||||
```bash
|
||||
npm run dev # Start dev server at http://localhost:5173
|
||||
```
|
||||
|
||||
### Building
|
||||
```bash
|
||||
npm run build # Production build (outputs to /build)
|
||||
npm run preview # Preview production build locally
|
||||
```
|
||||
|
||||
### Type Checking
|
||||
```bash
|
||||
npm run check # Run svelte-check for type errors
|
||||
npm run check:watch # Watch mode for type checking
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Required environment variables in `.env`:
|
||||
```env
|
||||
ODOO_URL=https://your-instance.odoo.com
|
||||
ODOO_DB=your-database-name
|
||||
ODOO_USERNAME=your-username
|
||||
ODOO_API_KEY=your-api-key
|
||||
ODOO_PRIMARY_MODEL=x_{{MODEL_NAME}}
|
||||
```
|
||||
|
||||
**Important**: The app uses API keys (not passwords) for Odoo authentication. These are server-side only and never exposed to the client.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Three-Layer Data Flow
|
||||
|
||||
```
|
||||
Frontend Component (Svelte)
|
||||
↓
|
||||
Cache Store (Svelte Store)
|
||||
↓
|
||||
Odoo API Client (src/lib/odoo.js)
|
||||
↓
|
||||
Server Route (src/routes/api/odoo/+server.js)
|
||||
↓
|
||||
Odoo JSON-RPC Backend
|
||||
```
|
||||
|
||||
Data also persists to localStorage for offline access.
|
||||
|
||||
### Key Architectural Patterns
|
||||
|
||||
#### 1. **Smart Caching Layer** (`src/lib/stores/cache.js`)
|
||||
|
||||
The centerpiece of the frontend architecture. This Svelte store provides:
|
||||
|
||||
- **Immediate data availability**: Shows cached data from localStorage instantly on page load
|
||||
- **Background sync**: Automatically syncs with Odoo every 3 minutes if cache is stale (>5 minutes old)
|
||||
- **Incremental fetching**: Only fetches records with `id > lastRecordId` to minimize API calls
|
||||
- **Optimistic updates**: UI updates immediately, syncs to server in background
|
||||
|
||||
**Key functions**:
|
||||
- `initialize()` - Call in `onMount()`, loads cache and starts background sync
|
||||
- `sync()` - Incremental sync with Odoo
|
||||
- `forceRefresh()` - Clears cache and does full sync
|
||||
- `createRecord()`, `updateRecord()`, `deleteRecord()` - CRUD operations with optimistic updates
|
||||
|
||||
#### 2. **Server-Side API Proxy** (`src/routes/api/odoo/+server.js`)
|
||||
|
||||
SvelteKit server route that acts as a JSON-RPC proxy to Odoo. This pattern:
|
||||
|
||||
- Keeps credentials server-side (never exposed to client)
|
||||
- Caches Odoo UID to reduce authentication calls
|
||||
- Provides a simple `{ action, data }` interface for the frontend
|
||||
|
||||
**Supported actions**: `create`, `search`, `search_model`, `update`, `delete`
|
||||
|
||||
#### 3. **Odoo Field Formatting**
|
||||
|
||||
Odoo uses specific formats for relational fields:
|
||||
|
||||
- **Many2One (single relation)**: Send as integer ID, receive as `[id, "display_name"]` tuple
|
||||
- **Many2Many (multiple relations)**: Send as `[[6, 0, [id1, id2, ...]]]`, receive as array of tuples
|
||||
|
||||
Helper functions in `src/lib/odoo.js`:
|
||||
- `formatMany2one(id)` - Converts ID to integer or `false`
|
||||
- `formatMany2many(ids)` - Wraps IDs in Odoo command format `[[6, 0, [...]]]`
|
||||
|
||||
#### 4. **Offline-First Strategy**
|
||||
|
||||
Two-phase data loading:
|
||||
|
||||
1. **Immediate Load**: Show cached data from localStorage
|
||||
2. **Background Sync**: Fetch new data if cache is stale
|
||||
|
||||
```javascript
|
||||
// Phase 1: Load from cache immediately
|
||||
const cachedData = loadFromStorage();
|
||||
updateUI(cachedData);
|
||||
|
||||
// Phase 2: Background sync if stale
|
||||
if (cachedData.isStale) {
|
||||
syncInBackground();
|
||||
}
|
||||
```
|
||||
|
||||
### SvelteKit Configuration
|
||||
|
||||
- **Rendering**: `ssr: false`, `csr: true`, `prerender: true` in `src/routes/+layout.js`
|
||||
- **Adapter**: `adapter-static` configured for static output to `/build` directory
|
||||
- **Base path**: Configurable via `PUBLIC_BASE_PATH` env var (for GitHub Pages deployment)
|
||||
|
||||
### PWA Features
|
||||
|
||||
Configured in `vite.config.js`:
|
||||
- **Service Worker**: Auto-generated with Workbox, caches all static assets
|
||||
- **Auto-update**: New versions automatically activate
|
||||
- **Manifest**: Configured for standalone mode, installable on mobile
|
||||
- **Icons**: 192x192 and 512x512 PNG icons in `/static`
|
||||
|
||||
## Odoo Model Structure
|
||||
|
||||
### Main Model: `x_{{MODEL_NAME}}`
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `x_name` | Char | Record name |
|
||||
| Add your custom fields with x_studio_ prefix |
|
||||
|
||||
## Important Development Notes
|
||||
|
||||
### Working with the Cache
|
||||
|
||||
When modifying record-related features:
|
||||
|
||||
1. **Always call `{{MODEL_NAME}}Cache.sync()` after mutations** (create/update/delete)
|
||||
2. **Initialize the cache in page components**: `onMount(() => {{MODEL_NAME}}Cache.initialize())`
|
||||
3. **Clean up on unmount**: `onDestroy(() => {{MODEL_NAME}}Cache.destroy())`
|
||||
4. **Cache invalidation**: Increment cache version in `cache.js` if store schema changes
|
||||
|
||||
### Odoo API Patterns
|
||||
|
||||
When adding new Odoo operations:
|
||||
|
||||
1. **Frontend**: Add method to `src/lib/odoo.js` (calls `/api/odoo` endpoint)
|
||||
2. **Backend**: Add new action case in `src/routes/api/odoo/+server.js`
|
||||
3. **Use `execute()` helper** for model operations (wraps authentication)
|
||||
|
||||
Example:
|
||||
```javascript
|
||||
// Frontend (odoo.js)
|
||||
async customOperation(id) {
|
||||
return this.callApi('custom', { id });
|
||||
}
|
||||
|
||||
// Backend (+server.js)
|
||||
case 'custom':
|
||||
const result = await execute('x_{{MODEL_NAME}}', 'write', [[data.id], { custom_field: true }]);
|
||||
return json({ success: true, result });
|
||||
```
|
||||
|
||||
### PWA Manifest Updates
|
||||
|
||||
When changing app name, icons, or theme:
|
||||
|
||||
1. Update `vite.config.js` manifest section
|
||||
2. Update `/static/manifest.json`
|
||||
3. Replace `/static/icon-192.png` and `/static/icon-512.png`
|
||||
4. Run `npm run build` to regenerate service worker
|
||||
|
||||
### Deployment
|
||||
|
||||
**Vercel** (primary):
|
||||
- Automatically deploys from `main` branch
|
||||
- Set environment variables in Vercel dashboard
|
||||
- Outputs to `/build` directory (configured in `vercel.json`)
|
||||
|
||||
**GitHub Pages**:
|
||||
- Set `PUBLIC_BASE_PATH=/repo-name` in GitHub Actions secrets
|
||||
- Configure GitHub Pages source as "GitHub Actions"
|
||||
- Workflow auto-deploys on push to `main`
|
||||
|
||||
## File Structure Reference
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ ├── stores/
|
||||
│ │ └── cache.js # Core caching & sync logic
|
||||
│ ├── odoo.js # Frontend API client
|
||||
│ ├── db.js # IndexedDB manager
|
||||
│ └── utils.js # Utility functions
|
||||
├── routes/
|
||||
│ ├── +layout.js # Root layout config (SSR/CSR settings)
|
||||
│ ├── +layout.svelte # Root layout component
|
||||
│ ├── +page.svelte # Add record form
|
||||
│ ├── list/+page.svelte # List all records
|
||||
│ └── api/odoo/+server.js # Odoo JSON-RPC proxy endpoint
|
||||
└── app.html # HTML template with PWA meta tags
|
||||
```
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
1. **Odoo field naming**: All custom fields use `x_studio_` prefix (Odoo Studio convention)
|
||||
2. **Partner name format**: Odoo returns `[id, "name"]` tuples, must extract display name for UI
|
||||
3. **localStorage limits**: Browser typically allows 5-10MB, sufficient for hundreds of records
|
||||
4. **Service worker caching**: After PWA updates, users may need to close all tabs and reopen
|
||||
5. **Base path in production**: GitHub Pages deployments require `PUBLIC_BASE_PATH` env var set
|
||||
6. **API authentication**: Use API keys, not passwords. Generate in Odoo user settings.
|
||||
|
||||
## Adding New Features
|
||||
|
||||
### Add a New Field
|
||||
|
||||
1. Add field in Odoo Studio with `x_studio_` prefix
|
||||
2. Update `fields` array in `src/lib/stores/cache.js`
|
||||
3. Add form input in `src/routes/+page.svelte`
|
||||
4. Display field in `src/routes/list/+page.svelte`
|
||||
|
||||
### Add a New Page
|
||||
|
||||
1. Create `src/routes/new-page/+page.svelte`
|
||||
2. Add navigation link in layout or other pages
|
||||
3. Import and use cache store if accessing data
|
||||
|
||||
### Add Partner/Relation Field
|
||||
|
||||
1. Add Many2one or Many2many field in Odoo
|
||||
2. Use `odooClient.formatMany2one()` or `formatMany2many()` when saving
|
||||
3. Use `odooClient.fetchPartners()` to load options
|
||||
4. Display resolved names in UI
|
||||
|
||||
---
|
||||
|
||||
**Generated with Odoo PWA Generator**
|
||||
@@ -0,0 +1,225 @@
|
||||
# {{PROJECT_NAME}}
|
||||
|
||||
An offline-first Progressive Web App for {{MODEL_DISPLAY_NAME}} management with Odoo Studio backend integration.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 📱 **Progressive Web App** - Installable on mobile and desktop
|
||||
- 🔌 **Offline-first** - Works without internet connection
|
||||
- 🔄 **Auto-sync** - Background synchronization with Odoo
|
||||
- 💾 **Smart caching** - localStorage + IndexedDB for optimal performance
|
||||
- 🎨 **Responsive UI** - Works on all devices
|
||||
- ⚡ **Fast** - Instant UI updates with optimistic rendering
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ installed
|
||||
- Odoo Studio account with API access
|
||||
- Custom Odoo model created: `x_{{MODEL_NAME}}`
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone this repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd {{PROJECT_NAME}}
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Configure environment:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
4. Edit `.env` with your Odoo credentials:
|
||||
```env
|
||||
ODOO_URL=https://your-instance.odoo.com
|
||||
ODOO_DB=your-database-name
|
||||
ODOO_USERNAME=your-username
|
||||
ODOO_API_KEY=your-api-key
|
||||
ODOO_PRIMARY_MODEL=x_{{MODEL_NAME}}
|
||||
```
|
||||
|
||||
5. Start development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
6. Open [http://localhost:5173](http://localhost:5173)
|
||||
|
||||
## 🔧 Odoo Studio Setup
|
||||
|
||||
### Create Custom Model
|
||||
|
||||
1. Log into your Odoo instance
|
||||
2. Navigate to **Studio**
|
||||
3. Create a new model: `x_{{MODEL_NAME}}`
|
||||
4. Add these recommended fields:
|
||||
- `x_name` (Char) - Required - Record name
|
||||
- `x_studio_description` (Text) - Description
|
||||
- `x_studio_date` (Date) - Date field
|
||||
- `x_studio_value` (Float) - Numeric value
|
||||
- Add more custom fields as needed with `x_studio_` prefix
|
||||
|
||||
### Get API Credentials
|
||||
|
||||
1. Go to **Settings** → **Users & Companies** → **Users**
|
||||
2. Select your user
|
||||
3. Click **Generate API Key**
|
||||
4. Copy the key and add to `.env` file
|
||||
|
||||
## 📦 Build & Deployment
|
||||
|
||||
### Build for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Deploy to Vercel
|
||||
|
||||
```bash
|
||||
npm install -g vercel
|
||||
vercel
|
||||
```
|
||||
|
||||
Set environment variables in Vercel dashboard:
|
||||
- `ODOO_URL`
|
||||
- `ODOO_DB`
|
||||
- `ODOO_USERNAME`
|
||||
- `ODOO_API_KEY`
|
||||
- `ODOO_PRIMARY_MODEL`
|
||||
|
||||
### Deploy to GitHub Pages
|
||||
|
||||
1. Add secrets to GitHub repository:
|
||||
- `ODOO_URL`, `ODOO_DB`, `ODOO_USERNAME`, `ODOO_API_KEY`, `ODOO_PRIMARY_MODEL`
|
||||
|
||||
2. Enable GitHub Pages in repository settings:
|
||||
- Source: GitHub Actions
|
||||
|
||||
3. Push to main branch - auto-deploys via GitHub Actions
|
||||
|
||||
### Deploy to Cloudflare Pages
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
wrangler pages publish build
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
This app follows a three-layer architecture:
|
||||
|
||||
```
|
||||
Frontend Component
|
||||
↓
|
||||
Cache Store (Svelte Store)
|
||||
↓
|
||||
Odoo API Client (Frontend)
|
||||
↓
|
||||
Server Route (/api/odoo)
|
||||
↓
|
||||
Odoo JSON-RPC
|
||||
```
|
||||
|
||||
### Key Features:
|
||||
|
||||
- **Smart Caching**: Loads from cache instantly, syncs in background
|
||||
- **Incremental Sync**: Only fetches new records since last sync
|
||||
- **Offline Queue**: Saves changes locally when offline, syncs when online
|
||||
- **PWA**: Service worker caches all assets for offline use
|
||||
|
||||
See `CLAUDE.md` for detailed architecture documentation.
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Available Scripts
|
||||
|
||||
- `npm run dev` - Start development server
|
||||
- `npm run build` - Build for production
|
||||
- `npm run preview` - Preview production build
|
||||
- `npm run check` - Type check
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
{{PROJECT_NAME}}/
|
||||
├── src/
|
||||
│ ├── lib/
|
||||
│ │ ├── odoo.js # Odoo API client
|
||||
│ │ ├── db.js # IndexedDB manager
|
||||
│ │ ├── utils.js # Utility functions
|
||||
│ │ └── stores/
|
||||
│ │ └── cache.js # Smart cache store
|
||||
│ ├── routes/
|
||||
│ │ ├── +layout.svelte # Root layout
|
||||
│ │ ├── +page.svelte # Add record form
|
||||
│ │ ├── list/+page.svelte # List all records
|
||||
│ │ └── api/odoo/+server.js # Server API proxy
|
||||
│ └── app.html # HTML template
|
||||
├── static/
|
||||
│ ├── manifest.json # PWA manifest
|
||||
│ ├── icon-192.png # App icon 192x192
|
||||
│ └── icon-512.png # App icon 512x512
|
||||
├── .env # Environment variables (gitignored)
|
||||
├── .env.example # Environment template
|
||||
├── svelte.config.js # SvelteKit configuration
|
||||
├── vite.config.js # Vite + PWA configuration
|
||||
└── package.json # Dependencies
|
||||
```
|
||||
|
||||
## 📝 Adding Fields
|
||||
|
||||
To add custom fields to your model:
|
||||
|
||||
1. Add field in Odoo Studio (use `x_studio_` prefix)
|
||||
2. Update `src/lib/stores/cache.js` - add field to `fields` array
|
||||
3. Update `src/routes/+page.svelte` - add form input
|
||||
4. Update `src/routes/list/+page.svelte` - display field
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### "Authentication failed"
|
||||
- Verify `ODOO_API_KEY` is correct
|
||||
- Check `ODOO_USERNAME` matches your Odoo user
|
||||
- Ensure API access is enabled in Odoo
|
||||
|
||||
### "Model not found"
|
||||
- Verify model name uses `x_` prefix: `x_{{MODEL_NAME}}`
|
||||
- Check model exists in Odoo Studio
|
||||
- Ensure model is published
|
||||
|
||||
### Offline mode not working
|
||||
- Check service worker is registered (DevTools → Application)
|
||||
- Verify PWA manifest is loaded
|
||||
- Clear browser cache and reload
|
||||
|
||||
### Data not syncing
|
||||
- Check browser console for errors
|
||||
- Verify internet connection
|
||||
- Check Odoo API is accessible
|
||||
|
||||
## 📚 Learn More
|
||||
|
||||
- [SvelteKit Documentation](https://kit.svelte.dev/)
|
||||
- [Odoo API Documentation](https://www.odoo.com/documentation/)
|
||||
- [PWA Guide](https://web.dev/progressive-web-apps/)
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions welcome! Please open an issue or PR.
|
||||
|
||||
---
|
||||
|
||||
**Generated with [Odoo PWA Generator](https://github.com/jamshid/odoo-pwa-generator)** 🚀
|
||||
@@ -0,0 +1,134 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import {
|
||||
ODOO_URL,
|
||||
ODOO_DB,
|
||||
ODOO_USERNAME,
|
||||
ODOO_API_KEY
|
||||
} from '$env/static/private';
|
||||
|
||||
/** @type {number|null} */
|
||||
let cachedUid = null;
|
||||
|
||||
/**
|
||||
* Make JSON-RPC call to Odoo
|
||||
* @param {string} service
|
||||
* @param {string} method
|
||||
* @param {any[]} args
|
||||
*/
|
||||
async function callOdoo(service, method, args) {
|
||||
const response = await fetch(`${ODOO_URL}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'call',
|
||||
params: {
|
||||
service: service,
|
||||
method: method,
|
||||
args: args
|
||||
},
|
||||
id: Math.floor(Math.random() * 1000000)
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.data?.message || data.error.message || 'Odoo API Error');
|
||||
}
|
||||
|
||||
return data.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with Odoo and get UID
|
||||
*/
|
||||
async function authenticate() {
|
||||
if (cachedUid) return cachedUid;
|
||||
|
||||
const authMethod = ODOO_API_KEY;
|
||||
const uid = await callOdoo('common', 'login', [ODOO_DB, ODOO_USERNAME, authMethod]);
|
||||
|
||||
if (!uid) {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
|
||||
cachedUid = uid;
|
||||
return uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a method on Odoo model
|
||||
* @param {string} model
|
||||
* @param {string} method
|
||||
* @param {any[]} args
|
||||
* @param {Record<string, any>} kwargs
|
||||
*/
|
||||
async function execute(model, method, args = [], kwargs = {}) {
|
||||
const uid = await authenticate();
|
||||
const authMethod = ODOO_API_KEY;
|
||||
|
||||
return await callOdoo('object', 'execute_kw', [
|
||||
ODOO_DB,
|
||||
uid,
|
||||
authMethod,
|
||||
model,
|
||||
method,
|
||||
args,
|
||||
kwargs
|
||||
]);
|
||||
}
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export async function POST({ request }) {
|
||||
try {
|
||||
const { action, data } = await request.json();
|
||||
|
||||
switch (action) {
|
||||
case 'create': {
|
||||
const { model, fields } = data;
|
||||
const id = await execute(model, 'create', [fields]);
|
||||
return json({ success: true, id });
|
||||
}
|
||||
|
||||
case 'search': {
|
||||
const { model, domain = [], fields = [] } = data;
|
||||
const results = await execute(model, 'search_read', [domain], { fields });
|
||||
return json({ success: true, results });
|
||||
}
|
||||
|
||||
// Search any model (used by frontend to load res.partner list, etc.)
|
||||
case 'search_model': {
|
||||
const { model, domain = [], fields = [] } = data;
|
||||
const results = await execute(model, 'search_read', [domain], { fields });
|
||||
return json({ success: true, results });
|
||||
}
|
||||
|
||||
case 'update': {
|
||||
const { model, id, values } = data;
|
||||
const result = await execute(model, 'write', [[id], values]);
|
||||
return json({ success: true, result });
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const { model, id } = data;
|
||||
const result = await execute(model, 'unlink', [[id]]);
|
||||
return json({ success: true, result });
|
||||
}
|
||||
|
||||
default:
|
||||
return json({ success: false, error: 'Invalid action' }, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Odoo API Error:', error);
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
<script>
|
||||
import { {{MODEL_NAME}}Cache, cacheStatus } from '$lib/stores/cache';
|
||||
import { filterRecords } from '$lib/utils';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let searchTerm = $state('');
|
||||
let records = $derived(${{MODEL_NAME}}Cache.records);
|
||||
let filteredRecords = $derived(filterRecords(records, searchTerm, ['x_name']));
|
||||
let status = $derived($cacheStatus);
|
||||
|
||||
onMount(async () => {
|
||||
await {{MODEL_NAME}}Cache.initialize();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
{{MODEL_NAME}}Cache.destroy();
|
||||
});
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (!confirm('Are you sure you want to delete this {{MODEL_DISPLAY_NAME}}?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await {{MODEL_NAME}}Cache.deleteRecord(id);
|
||||
} catch (error) {
|
||||
alert(`Failed to delete: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
{{MODEL_NAME}}Cache.forceRefresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>All {{MODEL_DISPLAY_NAME}}s - {{PROJECT_NAME}}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
<h1>📋 {{PROJECT_NAME}}</h1>
|
||||
|
||||
<nav>
|
||||
<a href="/">Add {{MODEL_DISPLAY_NAME}}</a>
|
||||
<a href="/list" class="active">View All</a>
|
||||
</nav>
|
||||
|
||||
<div class="list-container">
|
||||
<div class="list-header">
|
||||
<h2>All {{MODEL_DISPLAY_NAME}}s</h2>
|
||||
<div class="header-actions">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
bind:value={searchTerm}
|
||||
class="search-input"
|
||||
/>
|
||||
<button class="refresh-btn" onclick={handleRefresh} disabled={status.isSyncing}>
|
||||
{status.isSyncing ? '⏳' : '🔄'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if status.isLoading}
|
||||
<div class="loading">Loading...</div>
|
||||
{:else if filteredRecords.length === 0}
|
||||
<div class="empty">
|
||||
{searchTerm ? 'No matching records found' : 'No {{MODEL_DISPLAY_NAME}}s yet. Add your first one!'}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="record-list">
|
||||
{#each filteredRecords as record (record.id)}
|
||||
<div class="record-card">
|
||||
<div class="record-content">
|
||||
<h3>{record.x_name}</h3>
|
||||
<!-- Add more fields to display -->
|
||||
<p class="record-meta">ID: {record.id}</p>
|
||||
</div>
|
||||
<div class="record-actions">
|
||||
<button class="delete-btn" onclick={() => handleDelete(record.id)}>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if status.lastSync > 0}
|
||||
<div class="sync-info">
|
||||
Last synced: {new Date(status.lastSync).toLocaleString()}
|
||||
{#if status.isStale}
|
||||
<span class="stale-badge">Stale</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.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: #667eea;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
nav a.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.list-container {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 10px 15px;
|
||||
background: #f0f0f0;
|
||||
color: #667eea;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.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: #667eea;
|
||||
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;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #fed7d7;
|
||||
}
|
||||
|
||||
.sync-info {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.stale-badge {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
padding: 2px 8px;
|
||||
background: #fef5e7;
|
||||
color: #f39c12;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
// Disable SSR for this app (client-side only with Odoo backend)
|
||||
export const ssr = false;
|
||||
export const csr = true;
|
||||
export const prerender = true;
|
||||
@@ -0,0 +1,24 @@
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{{PROJECT_NAME}} - {{MODEL_DISPLAY_NAME}} Manager</title>
|
||||
<meta name="description" content="Offline-first {{MODEL_DISPLAY_NAME}} management app with Odoo integration" />
|
||||
</svelte:head>
|
||||
|
||||
{@render children?.()}
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu,
|
||||
Cantarell, 'Helvetica Neue', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,271 @@
|
||||
<script>
|
||||
import { {{MODEL_NAME}}Cache } from '$lib/stores/cache';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let name = $state('');
|
||||
let loading = $state(false);
|
||||
let message = $state('');
|
||||
let isOffline = $state(!navigator.onLine);
|
||||
|
||||
// Listen for online/offline events
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', () => { isOffline = false; });
|
||||
window.addEventListener('offline', () => { isOffline = true; });
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await {{MODEL_NAME}}Cache.initialize();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
{{MODEL_NAME}}Cache.destroy();
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!name.trim()) {
|
||||
message = '⚠️ Please fill in the name field';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
message = '';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
x_name: name
|
||||
// Add more fields as needed
|
||||
};
|
||||
|
||||
await {{MODEL_NAME}}Cache.createRecord(payload);
|
||||
|
||||
if (navigator.onLine) {
|
||||
message = '✅ {{MODEL_DISPLAY_NAME}} added successfully!';
|
||||
} else {
|
||||
message = '✅ {{MODEL_DISPLAY_NAME}} saved locally! Will sync when online.';
|
||||
}
|
||||
|
||||
// Reset form
|
||||
name = '';
|
||||
} catch (error) {
|
||||
message = `❌ Error: ${error.message}`;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
{{MODEL_NAME}}Cache.forceRefresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Add {{MODEL_DISPLAY_NAME}} - {{PROJECT_NAME}}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
<h1>📋 {{PROJECT_NAME}}</h1>
|
||||
|
||||
<!-- Offline Indicator -->
|
||||
{#if isOffline}
|
||||
<div class="offline-banner">
|
||||
📡 Offline Mode - Data will be synced when you're back online
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<nav>
|
||||
<a href="/" class="active">Add {{MODEL_DISPLAY_NAME}}</a>
|
||||
<a href="/list">View All</a>
|
||||
</nav>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
bind:value={name}
|
||||
placeholder="Enter {{MODEL_DISPLAY_NAME}} name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Add more form fields based on your Odoo model -->
|
||||
|
||||
{#if message}
|
||||
<div class="message" class:error={message.includes('❌')}>{message}</div>
|
||||
{/if}
|
||||
|
||||
<div class="button-group">
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? '⏳ Adding...' : '➕ Add {{MODEL_DISPLAY_NAME}}'}
|
||||
</button>
|
||||
{#if !isOffline}
|
||||
<button type="button" class="refresh-btn" onclick={handleRefresh}>
|
||||
🔄 Refresh Data
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.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: #667eea;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
nav a.active {
|
||||
background: #667eea;
|
||||
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 {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
form {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
padding: 15px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #5568d3;
|
||||
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: #667eea;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
color: #5568d3;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "{{PROJECT_NAME}}",
|
||||
"short_name": "{{PROJECT_NAME}}",
|
||||
"description": "{{MODEL_DISPLAY_NAME}} management app with Odoo integration",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#667eea",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,35 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# 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="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "{{PROJECT_NAME}}",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.29",
|
||||
"vue-router": "^4.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"vite": "^5.3.1",
|
||||
"vite-plugin-pwa": "^0.20.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
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: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_URL || 'http://localhost:3000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
74
skills/create-odoo-pwa/templates/vue/src/App.vue.template
Normal file
74
skills/create-odoo-pwa/templates/vue/src/App.vue.template
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<div class="container">
|
||||
<h1>📋 {{PROJECT_NAME}}</h1>
|
||||
|
||||
<OfflineBanner />
|
||||
|
||||
<nav class="nav">
|
||||
<router-link
|
||||
to="/"
|
||||
:class="{ active: $route.path === '/' }"
|
||||
>
|
||||
Add {{MODEL_DISPLAY_NAME}}
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/list"
|
||||
:class="{ active: $route.path === '/list' }"
|
||||
>
|
||||
View All
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import OfflineBanner from './components/OfflineBanner.vue';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
}
|
||||
|
||||
.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: #667eea;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.nav a.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
123
skills/create-odoo-pwa/templates/vue/src/api/odoo.js.template
Normal file
123
skills/create-odoo-pwa/templates/vue/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,48 @@
|
||||
<template>
|
||||
<div v-if="isOffline" class="offline-banner">
|
||||
📡 Offline Mode - Data will be synced when you're back online
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
const isOffline = ref(!navigator.onLine);
|
||||
|
||||
const handleOnline = () => isOffline.value = false;
|
||||
const handleOffline = () => isOffline.value = true;
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,224 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { odooClient } from '../api/odoo';
|
||||
|
||||
// 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
|
||||
const SYNC_INTERVAL_MS = 3 * 60 * 1000; // 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:', 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:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function useTaskCache() {
|
||||
const records = ref([]);
|
||||
const loading = ref(false);
|
||||
const syncing = ref(false);
|
||||
const error = ref('');
|
||||
const meta = ref({
|
||||
lastSyncTime: 0,
|
||||
lastRecordId: 0,
|
||||
recordCount: 0,
|
||||
isStale: true
|
||||
});
|
||||
|
||||
let syncInterval = null;
|
||||
|
||||
// Sync function
|
||||
const sync = async (forceFullRefresh = false) => {
|
||||
syncing.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const fields = ['id', 'x_name'];
|
||||
let domain = [];
|
||||
let fetchedRecords = [];
|
||||
|
||||
if (!forceFullRefresh && meta.value.lastRecordId > 0) {
|
||||
domain = [['id', '>', meta.value.lastRecordId]];
|
||||
try {
|
||||
fetchedRecords = await odooClient.searchRecords('x_{{MODEL_NAME}}', domain, fields);
|
||||
} catch (err) {
|
||||
console.warn('Incremental fetch failed:', err);
|
||||
forceFullRefresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (forceFullRefresh || meta.value.lastRecordId === 0) {
|
||||
fetchedRecords = await odooClient.searchRecords('x_{{MODEL_NAME}}', [], fields);
|
||||
}
|
||||
|
||||
let mergedRecords;
|
||||
if (forceFullRefresh || meta.value.lastRecordId === 0) {
|
||||
mergedRecords = fetchedRecords;
|
||||
} else {
|
||||
const existingIds = new Set(records.value.map(r => r.id));
|
||||
const newRecords = fetchedRecords.filter(r => !existingIds.has(r.id));
|
||||
mergedRecords = [...records.value, ...newRecords];
|
||||
}
|
||||
|
||||
mergedRecords.sort((a, b) => a.id - b.id);
|
||||
|
||||
const lastRecordId = mergedRecords.length > 0
|
||||
? Math.max(...mergedRecords.map(r => r.id))
|
||||
: 0;
|
||||
|
||||
const newMeta = {
|
||||
lastSyncTime: Date.now(),
|
||||
lastRecordId,
|
||||
recordCount: mergedRecords.length,
|
||||
isStale: false
|
||||
};
|
||||
|
||||
saveToStorage(mergedRecords, newMeta);
|
||||
|
||||
records.value = mergedRecords;
|
||||
meta.value = newMeta;
|
||||
} catch (err) {
|
||||
console.error('Sync failed:', err);
|
||||
error.value = err.message || 'Failed to sync data';
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize
|
||||
const initialize = async () => {
|
||||
const cachedState = loadFromStorage();
|
||||
|
||||
if (cachedState.records.length > 0) {
|
||||
records.value = cachedState.records;
|
||||
meta.value = cachedState.meta;
|
||||
|
||||
if (cachedState.meta.isStale) {
|
||||
await sync();
|
||||
}
|
||||
} else {
|
||||
loading.value = true;
|
||||
await sync(true);
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// Set up periodic sync
|
||||
syncInterval = setInterval(() => {
|
||||
sync();
|
||||
}, SYNC_INTERVAL_MS);
|
||||
};
|
||||
|
||||
// Cleanup
|
||||
const cleanup = () => {
|
||||
if (syncInterval) {
|
||||
clearInterval(syncInterval);
|
||||
syncInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Force refresh
|
||||
const forceRefresh = async () => {
|
||||
await sync(true);
|
||||
};
|
||||
|
||||
// Create record
|
||||
const createRecord = async (fields) => {
|
||||
try {
|
||||
const id = await odooClient.createRecord('x_{{MODEL_NAME}}', fields);
|
||||
const newRecord = { id, ...fields };
|
||||
records.value = [...records.value, newRecord];
|
||||
await sync();
|
||||
return id;
|
||||
} catch (err) {
|
||||
console.error('Failed to create record:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Update record
|
||||
const updateRecord = async (id, values) => {
|
||||
try {
|
||||
await odooClient.updateRecord('x_{{MODEL_NAME}}', id, values);
|
||||
records.value = records.value.map(r =>
|
||||
r.id === id ? { ...r, ...values } : r
|
||||
);
|
||||
await sync();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to update record:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete record
|
||||
const deleteRecord = async (id) => {
|
||||
try {
|
||||
await odooClient.deleteRecord('x_{{MODEL_NAME}}', id);
|
||||
records.value = records.value.filter(r => r.id !== id);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to delete record:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-initialize and cleanup on component lifecycle
|
||||
onMounted(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
return {
|
||||
records,
|
||||
loading,
|
||||
syncing,
|
||||
error,
|
||||
meta,
|
||||
sync,
|
||||
forceRefresh,
|
||||
createRecord,
|
||||
updateRecord,
|
||||
deleteRecord
|
||||
};
|
||||
}
|
||||
18
skills/create-odoo-pwa/templates/vue/src/main.js.template
Normal file
18
skills/create-odoo-pwa/templates/vue/src/main.js.template
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import App from './App.vue';
|
||||
import AddPage from './pages/AddPage.vue';
|
||||
import ListPage from './pages/ListPage.vue';
|
||||
import './style.css';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: AddPage },
|
||||
{ path: '/list', component: ListPage }
|
||||
]
|
||||
});
|
||||
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.mount('#app');
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="form">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
v-model="name"
|
||||
placeholder="Enter {{MODEL_DISPLAY_NAME}} name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="message" :class="['message', { error: message.includes('❌') }]">
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button type="submit" :disabled="loading">
|
||||
{{ loading ? '⏳ Adding...' : '➕ Add {{MODEL_DISPLAY_NAME}}' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="isOnline"
|
||||
type="button"
|
||||
class="refresh-btn"
|
||||
@click="forceRefresh"
|
||||
>
|
||||
🔄 Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useTaskCache } from '../composables/useTaskCache';
|
||||
|
||||
const { createRecord, forceRefresh } = useTaskCache();
|
||||
|
||||
const name = ref('');
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
const isOnline = computed(() => navigator.onLine);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.value.trim()) {
|
||||
message.value = '⚠️ Please fill in the name field';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
|
||||
try {
|
||||
const payload = { x_name: name.value };
|
||||
await createRecord(payload);
|
||||
|
||||
if (navigator.onLine) {
|
||||
message.value = '✅ {{MODEL_DISPLAY_NAME}} added successfully!';
|
||||
} else {
|
||||
message.value = '✅ {{MODEL_DISPLAY_NAME}} saved locally! Will sync when online.';
|
||||
}
|
||||
|
||||
name.value = '';
|
||||
} catch (error) {
|
||||
message.value = `❌ Error: ${error.message}`;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form {
|
||||
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: #667eea;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
padding: 15px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #5568d3;
|
||||
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: #667eea;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
color: #5568d3;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="list-container">
|
||||
<div class="list-header">
|
||||
<h2>All {{MODEL_DISPLAY_NAME}}s</h2>
|
||||
<div class="header-actions">
|
||||
<input
|
||||
type="search"
|
||||
v-model="searchTerm"
|
||||
placeholder="Search..."
|
||||
class="search-input"
|
||||
/>
|
||||
<button
|
||||
class="refresh-btn-small"
|
||||
@click="forceRefresh"
|
||||
:disabled="syncing"
|
||||
>
|
||||
{{ syncing ? '⏳' : '🔄' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="filteredRecords.length === 0" class="empty">
|
||||
{{ searchTerm ? 'No matching records found' : 'No {{MODEL_DISPLAY_NAME}}s yet. Add your first one!' }}
|
||||
</div>
|
||||
<div v-else class="record-list">
|
||||
<div
|
||||
v-for="record in filteredRecords"
|
||||
:key="record.id"
|
||||
class="record-card"
|
||||
>
|
||||
<div class="record-content">
|
||||
<h3>{{ record.x_name }}</h3>
|
||||
<p class="record-meta">ID: {{ record.id }}</p>
|
||||
</div>
|
||||
<div class="record-actions">
|
||||
<button class="delete-btn" @click="handleDelete(record.id)">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useTaskCache } from '../composables/useTaskCache';
|
||||
|
||||
const { records, loading, syncing, forceRefresh, deleteRecord } = useTaskCache();
|
||||
|
||||
const searchTerm = ref('');
|
||||
|
||||
const filteredRecords = computed(() => {
|
||||
return records.value.filter(record =>
|
||||
record.x_name?.toLowerCase().includes(searchTerm.value.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}`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-container {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.refresh-btn-small {
|
||||
padding: 10px 15px;
|
||||
background: #f0f0f0;
|
||||
color: #667eea;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.refresh-btn-small:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
.refresh-btn-small:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.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: #667eea;
|
||||
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;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #fed7d7;
|
||||
}
|
||||
</style>
|
||||
29
skills/create-odoo-pwa/templates/vue/src/style.css.template
Normal file
29
skills/create-odoo-pwa/templates/vue/src/style.css.template
Normal file
@@ -0,0 +1,29 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu,
|
||||
Cantarell, 'Helvetica Neue', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
Reference in New Issue
Block a user