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