Initial commit

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

View File

@@ -0,0 +1,204 @@
---
name: add-odoo-model
description: Add integration for an additional Odoo Studio model to an existing Odoo PWA project. Use when user wants to add support for another model, mentions "add new model", "integrate another Odoo model", or similar.
allowed-tools: Read, Write, Edit, Glob
---
# Add Odoo Model Integration
Add a new Odoo model integration to an existing Odoo PWA project, creating cache stores, API methods, and UI components.
## Prerequisites
- Existing Odoo PWA project (generated with create-odoo-pwa skill)
- New Odoo Studio model created with `x_` prefix
- Model name and display name from user
## Required User Input
Ask the user for:
1. **Model name** (required)
- Format: without `x_` prefix (e.g., "inventory", "tasks")
- Example: If Odoo model is `x_inventory`, user provides: `inventory`
2. **Model display name** (required)
- Human-readable singular name (e.g., "Inventory Item", "Task")
3. **Create UI pages** (optional)
- Ask if user wants to generate form and list pages
- Default: yes
## Detection Steps
Before generating, detect the project structure:
1. **Detect framework**:
- Check for `svelte.config.js` → SvelteKit
- Check for `vite.config.ts` with React → React
- Check for `nuxt.config.ts` → Vue/Nuxt
2. **Find existing files**:
- Locate `src/lib/odoo.js` (or equivalent)
- Find existing cache stores in `src/lib/stores/`
- Check routes structure
3. **Verify Odoo connection**:
- Check `.env` file has ODOO_URL and credentials
## Generation Steps
### Step 1: Create Cache Store
Generate `src/lib/stores/{{MODEL_NAME}}Cache.js`:
- Based on existing cache store pattern
- Replace model name throughout
- Update fields array with model-specific fields
- Include CRUD methods
### Step 2: Update Odoo API Client
Add model-specific methods to `src/lib/odoo.js`:
```javascript
/**
* Fetch {{MODEL_DISPLAY_NAME}} records
*/
async fetch{{MODEL_NAME|capitalize}}s(domain = [], fields = []) {
return await this.searchRecords('x_{{MODEL_NAME}}', domain, fields);
}
/**
* Create {{MODEL_DISPLAY_NAME}}
*/
async create{{MODEL_NAME|capitalize}}(fields) {
return await this.createRecord('x_{{MODEL_NAME}}', fields);
}
/**
* Update {{MODEL_DISPLAY_NAME}}
*/
async update{{MODEL_NAME|capitalize}}(id, values) {
return await this.updateRecord('x_{{MODEL_NAME}}', id, values);
}
/**
* Delete {{MODEL_DISPLAY_NAME}}
*/
async delete{{MODEL_NAME|capitalize}}(id) {
return await this.deleteRecord('x_{{MODEL_NAME}}', id);
}
```
### Step 3: Create UI Pages (if requested)
#### Add Form Page: `src/routes/{{MODEL_NAME}}/+page.svelte`
Generate form component:
- Import cache store
- Form fields for model
- Handle offline/online states
- Submit handler with validation
#### List Page: `src/routes/{{MODEL_NAME}}/list/+page.svelte`
Generate list component:
- Display records in table/card format
- Search/filter functionality
- Delete actions
- Sync status
### Step 4: Update Navigation
Update navigation in main layout or existing pages:
```svelte
<nav>
<!-- Existing links -->
<a href="/{{MODEL_NAME}}">{{MODEL_DISPLAY_NAME}}s</a>
</nav>
```
### Step 5: Update Environment Variables
Add to `.env.example` (if needed):
```env
# {{MODEL_DISPLAY_NAME}} Model
ODOO_{{MODEL_NAME|uppercase}}_MODEL=x_{{MODEL_NAME}}
```
## Post-Generation Instructions
Provide user with:
```
✅ {{MODEL_DISPLAY_NAME}} integration added successfully!
📋 Next Steps:
1. Verify Odoo Model Setup:
- Model name: x_{{MODEL_NAME}}
- Add custom fields with x_studio_ prefix in Odoo Studio
2. Update Cache Store:
- Edit src/lib/stores/{{MODEL_NAME}}Cache.js
- Add all model fields to the 'fields' array
3. Customize UI:
- Edit src/routes/{{MODEL_NAME}}/+page.svelte for form
- Edit src/routes/{{MODEL_NAME}}/list/+page.svelte for list view
- Add model-specific fields and validation
4. Test Integration:
npm run dev
- Navigate to /{{MODEL_NAME}}
- Test create, read, update, delete operations
- Verify offline functionality
📚 Model-Specific Files Created:
- src/lib/stores/{{MODEL_NAME}}Cache.js - Cache and sync logic
- src/routes/{{MODEL_NAME}}/+page.svelte - Add form
- src/routes/{{MODEL_NAME}}/list/+page.svelte - List view
🔗 Access:
- Add: http://localhost:5173/{{MODEL_NAME}}
- List: http://localhost:5173/{{MODEL_NAME}}/list
```
## Framework-Specific Notes
### SvelteKit
- Use Svelte 5 syntax with `$state`, `$derived`, `$effect`
- Cache stores use Svelte stores pattern
- Routes in `src/routes/`
### React
- Use React hooks (useState, useEffect)
- Context API for cache
- Routes configuration depends on router (React Router, etc.)
### Vue
- Use Vue 3 Composition API
- Composables for cache logic
- Routes in `src/pages/` or as configured
## Error Handling
If generation fails:
- Verify project has Odoo PWA structure
- Check for existing odoo.js file
- Ensure proper permissions for file creation
- Provide clear error messages
## Examples
User: "Add inventory model to track items"
- Model name: inventory
- Display name: Inventory Item
- Creates: inventoryCache.js, /inventory pages, API methods
User: "Integrate task management"
- Model name: task
- Display name: Task
- Creates: taskCache.js, /task pages, API methods

View 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

View File

@@ -0,0 +1,11 @@
# Odoo Instance Configuration (for backend server)
ODOO_URL=https://your-instance.odoo.com
ODOO_DB=your-database-name
ODOO_USERNAME=your-username
ODOO_API_KEY=your-api-key
# Primary Model (use x_ prefix for Odoo Studio models)
ODOO_PRIMARY_MODEL=x_{{MODEL_NAME}}
# Frontend: API endpoint (if frontend and backend are separate)
VITE_API_URL=http://localhost:3000

View File

@@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment
.env
.env.local
.env.production

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#667eea" />
<meta name="description" content="{{MODEL_DISPLAY_NAME}} management PWA with Odoo integration" />
<link rel="manifest" href="/manifest.json" />
<title>{{PROJECT_NAME}}</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,28 @@
{
"name": "{{PROJECT_NAME}}",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.2",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"vite": "^5.3.4",
"vite-plugin-pwa": "^0.20.0"
}
}

View File

@@ -0,0 +1,48 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,json,webp}']
},
manifest: {
name: '{{PROJECT_NAME}}',
short_name: '{{PROJECT_NAME}}',
description: 'PWA for {{MODEL_DISPLAY_NAME}} management with Odoo integration',
theme_color: '#667eea',
background_color: '#ffffff',
display: 'standalone',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/icon-512.png',
sizes: '512x512',
type: 'image/png'
}
]
}
})
],
resolve: {
alias: {
'@': '/src'
}
},
server: {
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:3000',
changeOrigin: true
}
}
}
});

View File

@@ -0,0 +1,311 @@
:root {
--primary-color: #667eea;
--primary-dark: #5568d3;
--bg-gradient-start: #667eea;
--bg-gradient-end: #764ba2;
--error-bg: #f8d7da;
--error-text: #721c24;
--success-bg: #d4edda;
--success-text: #155724;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu,
Cantarell, 'Helvetica Neue', sans-serif;
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
min-height: 100vh;
}
.app {
min-height: 100vh;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 16px;
}
h1 {
color: white;
text-align: center;
margin-bottom: 30px;
font-size: 2.5em;
}
h2 {
margin: 0;
color: #333;
font-size: 1.5em;
}
.nav {
display: flex;
gap: 10px;
margin-bottom: 30px;
background: white;
border-radius: 10px;
padding: 5px;
}
.nav a {
flex: 1;
text-align: center;
padding: 12px;
text-decoration: none;
color: var(--primary-color);
border-radius: 8px;
font-weight: 600;
transition: all 0.3s;
}
.nav a.active {
background: var(--primary-color);
color: white;
}
.offline-banner {
background: #e3f2fd;
color: #1565c0;
padding: 12px 20px;
border-radius: 10px;
margin-bottom: 20px;
text-align: center;
font-weight: 600;
border: 2px solid #64b5f6;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
.form, .list-container {
background: white;
padding: 24px;
border-radius: 15px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
}
input {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
input:focus {
outline: none;
border-color: var(--primary-color);
}
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
flex: 1;
min-width: 150px;
padding: 15px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
button:hover:not(:disabled) {
background: var(--primary-dark);
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.refresh-btn {
background: #f0f0f0;
color: var(--primary-color);
flex: 0 1 auto;
}
.refresh-btn:hover:not(:disabled) {
background: #e0e0e0;
color: var(--primary-dark);
}
.message {
padding: 12px;
border-radius: 8px;
margin-bottom: 15px;
background: var(--success-bg);
color: var(--success-text);
text-align: center;
}
.message.error {
background: var(--error-bg);
color: var(--error-text);
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 15px;
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.search-input {
padding: 10px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
min-width: 200px;
}
.refresh-btn-small {
padding: 10px 15px;
background: #f0f0f0;
color: var(--primary-color);
border: none;
border-radius: 8px;
font-size: 18px;
cursor: pointer;
transition: all 0.3s;
min-width: auto;
flex: 0;
}
.refresh-btn-small:hover:not(:disabled) {
background: #e0e0e0;
transform: rotate(360deg);
}
.loading, .empty {
text-align: center;
padding: 40px;
color: #666;
font-size: 16px;
}
.record-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.record-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border: 2px solid #e0e0e0;
border-radius: 10px;
transition: all 0.3s;
}
.record-card:hover {
border-color: var(--primary-color);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
}
.record-content {
flex: 1;
}
.record-content h3 {
margin: 0 0 8px 0;
color: #333;
font-size: 1.1em;
}
.record-meta {
margin: 0;
color: #666;
font-size: 0.9em;
}
.record-actions {
display: flex;
gap: 8px;
}
.delete-btn {
padding: 8px 12px;
background: #fff5f5;
color: #e53e3e;
border: 1px solid #feb2b2;
border-radius: 6px;
font-size: 18px;
cursor: pointer;
transition: all 0.3s;
min-width: auto;
flex: 0;
}
.delete-btn:hover {
background: #fed7d7;
transform: none;
box-shadow: none;
}
@media (max-width: 600px) {
.list-header {
flex-direction: column;
align-items: stretch;
}
.header-actions {
flex-direction: column;
align-items: stretch;
}
.search-input {
min-width: auto;
}
.record-card {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.record-actions {
align-self: flex-end;
}
}

View File

@@ -0,0 +1,41 @@
import { Routes, Route, Link, useLocation } from 'react-router-dom';
import AddPage from './pages/AddPage';
import ListPage from './pages/ListPage';
import OfflineBanner from './components/OfflineBanner';
import './App.css';
function App() {
const location = useLocation();
return (
<div className="app">
<div className="container">
<h1>📋 {{PROJECT_NAME}}</h1>
<OfflineBanner />
<nav className="nav">
<Link
to="/"
className={location.pathname === '/' ? 'active' : ''}
>
Add {{MODEL_DISPLAY_NAME}}
</Link>
<Link
to="/list"
className={location.pathname === '/list' ? 'active' : ''}
>
View All
</Link>
</nav>
<Routes>
<Route path="/" element={<AddPage />} />
<Route path="/list" element={<ListPage />} />
</Routes>
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,123 @@
/**
* Odoo API Client for React
* Communicates with backend server that proxies to Odoo
*/
class OdooAPI {
constructor() {
this.apiUrl = import.meta.env.VITE_API_URL
? `${import.meta.env.VITE_API_URL}/api/odoo`
: '/api/odoo';
}
/**
* Call the server-side API
* @param {string} action
* @param {any} data
* @returns {Promise<any>}
*/
async callApi(action, data) {
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ action, data })
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'API Error');
}
return result;
}
/**
* Create a new record in specified model
* @param {string} model - Odoo model name
* @param {Record<string, any>} fields - Record fields
* @returns {Promise<number>} - Created record ID
*/
async createRecord(model, fields) {
const result = await this.callApi('create', { model, fields });
return result.id;
}
/**
* Search and read records from specified model
* @param {string} model - Odoo model name
* @param {any[]} domain - Odoo domain filter
* @param {string[]} fields - Fields to retrieve
* @returns {Promise<any[]>} - Array of records
*/
async searchRecords(model, domain = [], fields = []) {
const result = await this.callApi('search', { model, domain, fields });
return result.results;
}
/**
* Generic search_read for any model
* @param {string} model
* @param {any[]} domain
* @param {string[]} fields
* @returns {Promise<any[]>}
*/
async searchModel(model, domain = [], fields = []) {
const result = await this.callApi('search_model', { model, domain, fields });
return result.results;
}
/**
* Fetch partner list (common operation)
* @returns {Promise<Array<{id:number, display_name:string}>>}
*/
async fetchPartners() {
return await this.searchModel('res.partner', [], ['id', 'display_name']);
}
/**
* Format a many2one field value
* @param {number|string|null|undefined} id
* @returns {number|false}
*/
formatMany2one(id) {
return id ? Number(id) : false;
}
/**
* Format a many2many field using the (6,0,[ids]) command
* @param {Array<number|string>} ids
* @returns {any[]}
*/
formatMany2many(ids) {
if (!Array.isArray(ids) || ids.length === 0) return [];
return [[6, 0, ids.map((i) => Number(i))]];
}
/**
* Update a record
* @param {string} model - Odoo model name
* @param {number} id - Record ID
* @param {Record<string, any>} values - Fields to update
* @returns {Promise<boolean>}
*/
async updateRecord(model, id, values) {
const result = await this.callApi('update', { model, id, values });
return result.result;
}
/**
* Delete a record
* @param {string} model - Odoo model name
* @param {number} id - Record ID
* @returns {Promise<boolean>}
*/
async deleteRecord(model, id) {
const result = await this.callApi('delete', { model, id });
return result.result;
}
}
export const odooClient = new OdooAPI();

View File

@@ -0,0 +1,28 @@
import { useState, useEffect } from 'react';
function OfflineBanner() {
const [isOffline, setIsOffline] = useState(!navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
if (!isOffline) return null;
return (
<div className="offline-banner">
📡 Offline Mode - Data will be synced when you're back online
</div>
);
}
export default OfflineBanner;

View File

@@ -0,0 +1,243 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { odooClient } from '../api/odoo';
const TaskContext = createContext();
// Cache configuration
const CACHE_KEY = '{{MODEL_NAME}}_cache_v1';
const CACHE_META_KEY = '{{MODEL_NAME}}_cache_meta_v1';
const CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes cache validity
const SYNC_INTERVAL_MS = 3 * 60 * 1000; // Background sync every 3 minutes
// Helper functions for localStorage
function loadFromStorage() {
try {
const cachedData = localStorage.getItem(CACHE_KEY);
const meta = localStorage.getItem(CACHE_META_KEY);
if (cachedData && meta) {
const records = JSON.parse(cachedData);
const metaData = JSON.parse(meta);
const now = Date.now();
const isStale = now - metaData.lastSyncTime > CACHE_DURATION_MS;
return { records, meta: { ...metaData, isStale } };
}
} catch (e) {
console.warn('Failed to load cache from storage:', e);
}
return {
records: [],
meta: {
lastSyncTime: 0,
lastRecordId: 0,
recordCount: 0,
isStale: true
}
};
}
function saveToStorage(records, meta) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(records));
localStorage.setItem(CACHE_META_KEY, JSON.stringify({
lastSyncTime: meta.lastSyncTime || Date.now(),
lastRecordId: meta.lastRecordId || 0,
recordCount: records.length,
isStale: false
}));
} catch (e) {
console.warn('Failed to save cache to storage:', e);
}
}
export function TaskProvider({ children }) {
const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState('');
const [meta, setMeta] = useState({
lastSyncTime: 0,
lastRecordId: 0,
recordCount: 0,
isStale: true
});
// Sync function - fetches new data from server
const sync = useCallback(async (forceFullRefresh = false) => {
setSyncing(true);
setError('');
try {
const fields = ['id', 'x_name'];
let domain = [];
let fetchedRecords = [];
if (!forceFullRefresh && meta.lastRecordId > 0) {
// Incremental fetch
domain = [['id', '>', meta.lastRecordId]];
try {
fetchedRecords = await odooClient.searchRecords('x_{{MODEL_NAME}}', domain, fields);
} catch (err) {
console.warn('Incremental fetch failed, falling back to full fetch:', err);
forceFullRefresh = true;
}
}
if (forceFullRefresh || meta.lastRecordId === 0) {
// Full refresh
fetchedRecords = await odooClient.searchRecords('x_{{MODEL_NAME}}', [], fields);
}
// Merge or replace records
let mergedRecords;
if (forceFullRefresh || meta.lastRecordId === 0) {
mergedRecords = fetchedRecords;
} else {
const existingIds = new Set(records.map(r => r.id));
const newRecords = fetchedRecords.filter(r => !existingIds.has(r.id));
mergedRecords = [...records, ...newRecords];
}
// Sort by ID
mergedRecords.sort((a, b) => a.id - b.id);
// Calculate new metadata
const lastRecordId = mergedRecords.length > 0
? Math.max(...mergedRecords.map(r => r.id))
: 0;
const newMeta = {
lastSyncTime: Date.now(),
lastRecordId,
recordCount: mergedRecords.length,
isStale: false
};
// Save to storage
saveToStorage(mergedRecords, newMeta);
// Update state
setRecords(mergedRecords);
setMeta(newMeta);
} catch (error) {
console.error('Sync failed:', error);
setError(error.message || 'Failed to sync data');
} finally {
setSyncing(false);
}
}, [meta.lastRecordId, records]);
// Initialize - load cache and start background sync
useEffect(() => {
const cachedState = loadFromStorage();
if (cachedState.records.length > 0) {
setRecords(cachedState.records);
setMeta(cachedState.meta);
// Sync in background if stale
if (cachedState.meta.isStale) {
sync();
}
} else {
setLoading(true);
sync(true).finally(() => setLoading(false));
}
// Set up periodic background sync
const syncInterval = setInterval(() => {
sync();
}, SYNC_INTERVAL_MS);
return () => clearInterval(syncInterval);
}, []);
// Force refresh
const forceRefresh = useCallback(async () => {
await sync(true);
}, [sync]);
// Create record
const createRecord = useCallback(async (fields) => {
try {
const id = await odooClient.createRecord('x_{{MODEL_NAME}}', fields);
// Optimistically add to cache
const newRecord = { id, ...fields };
setRecords(prev => [...prev, newRecord]);
// Sync to get full record
await sync();
return id;
} catch (error) {
console.error('Failed to create record:', error);
throw error;
}
}, [sync]);
// Update record
const updateRecord = useCallback(async (id, values) => {
try {
await odooClient.updateRecord('x_{{MODEL_NAME}}', id, values);
// Optimistically update cache
setRecords(prev =>
prev.map(r => r.id === id ? { ...r, ...values } : r)
);
// Sync to get full updated record
await sync();
return true;
} catch (error) {
console.error('Failed to update record:', error);
throw error;
}
}, [sync]);
// Delete record
const deleteRecord = useCallback(async (id) => {
try {
await odooClient.deleteRecord('x_{{MODEL_NAME}}', id);
// Optimistically remove from cache
setRecords(prev => prev.filter(r => r.id !== id));
return true;
} catch (error) {
console.error('Failed to delete record:', error);
throw error;
}
}, []);
const value = {
records,
loading,
syncing,
error,
meta,
sync,
forceRefresh,
createRecord,
updateRecord,
deleteRecord
};
return (
<TaskContext.Provider value={value}>
{children}
</TaskContext.Provider>
);
}
export function useTask() {
const context = useContext(TaskContext);
if (!context) {
throw new Error('useTask must be used within TaskProvider');
}
return context;
}

View File

@@ -0,0 +1,26 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
}

View File

@@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { TaskProvider } from './context/TaskContext';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<TaskProvider>
<App />
</TaskProvider>
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,78 @@
import { useState } from 'react';
import { useTask } from '../context/TaskContext';
function AddPage() {
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const { createRecord, forceRefresh } = useTask();
const handleSubmit = async (e) => {
e.preventDefault();
if (!name.trim()) {
setMessage('⚠️ Please fill in the name field');
return;
}
setLoading(true);
setMessage('');
try {
const payload = { x_name: name };
await createRecord(payload);
if (navigator.onLine) {
setMessage('✅ {{MODEL_DISPLAY_NAME}} added successfully!');
} else {
setMessage('✅ {{MODEL_DISPLAY_NAME}} saved locally! Will sync when online.');
}
// Reset form
setName('');
} catch (error) {
setMessage(`❌ Error: ${error.message}`);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="form">
<div className="form-group">
<label htmlFor="name">Name</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter {{MODEL_DISPLAY_NAME}} name"
required
/>
</div>
{message && (
<div className={`message ${message.includes('❌') ? 'error' : ''}`}>
{message}
</div>
)}
<div className="button-group">
<button type="submit" disabled={loading}>
{loading ? '⏳ Adding...' : ' Add {{MODEL_DISPLAY_NAME}}'}
</button>
{navigator.onLine && (
<button
type="button"
className="refresh-btn"
onClick={forceRefresh}
>
🔄 Refresh Data
</button>
)}
</div>
</form>
);
}
export default AddPage;

View File

@@ -0,0 +1,76 @@
import { useState } from 'react';
import { useTask } from '../context/TaskContext';
function ListPage() {
const [searchTerm, setSearchTerm] = useState('');
const { records, loading, syncing, forceRefresh, deleteRecord } = useTask();
const filteredRecords = records.filter(record =>
record.x_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleDelete = async (id) => {
if (!confirm('Are you sure you want to delete this {{MODEL_DISPLAY_NAME}}?')) {
return;
}
try {
await deleteRecord(id);
} catch (error) {
alert(`Failed to delete: ${error.message}`);
}
};
return (
<div className="list-container">
<div className="list-header">
<h2>All {{MODEL_DISPLAY_NAME}}s</h2>
<div className="header-actions">
<input
type="search"
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
<button
className="refresh-btn-small"
onClick={forceRefresh}
disabled={syncing}
>
{syncing ? '⏳' : '🔄'}
</button>
</div>
</div>
{loading ? (
<div className="loading">Loading...</div>
) : filteredRecords.length === 0 ? (
<div className="empty">
{searchTerm ? 'No matching records found' : 'No {{MODEL_DISPLAY_NAME}}s yet. Add your first one!'}
</div>
) : (
<div className="record-list">
{filteredRecords.map(record => (
<div key={record.id} className="record-card">
<div className="record-content">
<h3>{record.x_name}</h3>
<p className="record-meta">ID: {record.id}</p>
</div>
<div className="record-actions">
<button
className="delete-btn"
onClick={() => handleDelete(record.id)}
>
🗑️
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
export default ListPage;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
{
"buildCommand": "npm run build",
"outputDirectory": "build",
"framework": "sveltekit",
"regions": ["iad1"]
}

View File

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

View File

@@ -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)** 🚀

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
# Odoo Instance Configuration (for backend server)
ODOO_URL=https://your-instance.odoo.com
ODOO_DB=your-database-name
ODOO_USERNAME=your-username
ODOO_API_KEY=your-api-key
# Primary Model (use x_ prefix for Odoo Studio models)
ODOO_PRIMARY_MODEL=x_{{MODEL_NAME}}
# Frontend: API endpoint (if frontend and backend are separate)
VITE_API_URL=http://localhost:3000

View File

@@ -0,0 +1,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

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#667eea" />
<meta name="description" content="{{MODEL_DISPLAY_NAME}} management PWA with Odoo integration" />
<link rel="manifest" href="/manifest.json" />
<title>{{PROJECT_NAME}}</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

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

View File

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

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

View File

@@ -0,0 +1,123 @@
/**
* Odoo API Client for React
* Communicates with backend server that proxies to Odoo
*/
class OdooAPI {
constructor() {
this.apiUrl = import.meta.env.VITE_API_URL
? `${import.meta.env.VITE_API_URL}/api/odoo`
: '/api/odoo';
}
/**
* Call the server-side API
* @param {string} action
* @param {any} data
* @returns {Promise<any>}
*/
async callApi(action, data) {
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ action, data })
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'API Error');
}
return result;
}
/**
* Create a new record in specified model
* @param {string} model - Odoo model name
* @param {Record<string, any>} fields - Record fields
* @returns {Promise<number>} - Created record ID
*/
async createRecord(model, fields) {
const result = await this.callApi('create', { model, fields });
return result.id;
}
/**
* Search and read records from specified model
* @param {string} model - Odoo model name
* @param {any[]} domain - Odoo domain filter
* @param {string[]} fields - Fields to retrieve
* @returns {Promise<any[]>} - Array of records
*/
async searchRecords(model, domain = [], fields = []) {
const result = await this.callApi('search', { model, domain, fields });
return result.results;
}
/**
* Generic search_read for any model
* @param {string} model
* @param {any[]} domain
* @param {string[]} fields
* @returns {Promise<any[]>}
*/
async searchModel(model, domain = [], fields = []) {
const result = await this.callApi('search_model', { model, domain, fields });
return result.results;
}
/**
* Fetch partner list (common operation)
* @returns {Promise<Array<{id:number, display_name:string}>>}
*/
async fetchPartners() {
return await this.searchModel('res.partner', [], ['id', 'display_name']);
}
/**
* Format a many2one field value
* @param {number|string|null|undefined} id
* @returns {number|false}
*/
formatMany2one(id) {
return id ? Number(id) : false;
}
/**
* Format a many2many field using the (6,0,[ids]) command
* @param {Array<number|string>} ids
* @returns {any[]}
*/
formatMany2many(ids) {
if (!Array.isArray(ids) || ids.length === 0) return [];
return [[6, 0, ids.map((i) => Number(i))]];
}
/**
* Update a record
* @param {string} model - Odoo model name
* @param {number} id - Record ID
* @param {Record<string, any>} values - Fields to update
* @returns {Promise<boolean>}
*/
async updateRecord(model, id, values) {
const result = await this.callApi('update', { model, id, values });
return result.result;
}
/**
* Delete a record
* @param {string} model - Odoo model name
* @param {number} id - Record ID
* @returns {Promise<boolean>}
*/
async deleteRecord(model, id) {
const result = await this.callApi('delete', { model, id });
return result.result;
}
}
export const odooClient = new OdooAPI();

View File

@@ -0,0 +1,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>

View File

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

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

View File

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

View File

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

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