- flask plugin new now scaffolds the frontend too: List/Detail/Form views on the global styles, a gated route module (ADR-009), and an api-client snippet emitted into the plugin dir. Views are written before the route file so a partially generated plugin cannot 500 the dev server. - docs/PLUGIN-EXTERNAL-REPO.md + scripts/test-external-plugin.sh: how a sister site develops a plugin in its own repo and runs the framework contract tests in CI against a pinned framework ref (script verified to fail on a broken core_version pin). - docs/CONTRACT-STABILITY.md: settled vs churning contract surface and the provisional 1.0 criteria. - CLAUDE.md active-state refresh (contract 0.6.0, 11 plugins, 340 tests, measuringtools done). Known limitation documented: Path.rglob does not descend symlinks, so the import-surface contract test skips symlinked external plugins. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
150 lines
4.3 KiB
Cheetah
150 lines
4.3 KiB
Cheetah
<template>
|
|
<div class="detail-page" v-if="item">
|
|
<div class="hero-card">
|
|
<div class="hero-content">
|
|
<div class="hero-title-row">
|
|
<h1 class="hero-title">{{ item.name || item.assetnumber || '${Name}' }}</h1>
|
|
<router-link
|
|
v-if="authStore.isAuthenticated"
|
|
:to="`/${name}/${itemId}/edit`"
|
|
class="btn btn-secondary"
|
|
>
|
|
Edit
|
|
</router-link>
|
|
</div>
|
|
<div class="hero-details">
|
|
<div class="detail-item" v-if="item.assetnumber">
|
|
<span class="label">Asset #</span>
|
|
<span class="value">{{ item.assetnumber }}</span>
|
|
</div>
|
|
<div class="detail-item" v-if="item.serialnumber">
|
|
<span class="label">Serial</span>
|
|
<span class="value mono">{{ item.serialnumber }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content-grid">
|
|
<div class="content-column">
|
|
<div class="section-card">
|
|
<h3 class="section-title">${Name} Information</h3>
|
|
<div class="info-list">
|
|
<div class="info-row">
|
|
<span class="info-label">Example Field</span>
|
|
<span class="info-value">{{ item.examplefield || '-' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content-column">
|
|
<div class="section-card">
|
|
<h3 class="section-title">Asset Information</h3>
|
|
<div class="info-list">
|
|
<div class="info-row">
|
|
<span class="info-label">Asset Number</span>
|
|
<span class="info-value">{{ item.assetnumber || '-' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Name</span>
|
|
<span class="info-value">{{ item.name || '-' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Serial Number</span>
|
|
<span class="info-value mono">{{ item.serialnumber || '-' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="action-bar" v-if="authStore.isAuthenticated">
|
|
<router-link :to="`/${name}/${itemId}/edit`" class="btn btn-primary">Edit</router-link>
|
|
<button @click="confirmDelete" class="btn btn-danger">Delete</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="loading" class="loading-container">
|
|
<div class="loading">Loading...</div>
|
|
</div>
|
|
|
|
<div v-else class="error-container">
|
|
<p>Record not found</p>
|
|
<router-link to="/${name}" class="btn btn-secondary">Back to ${Name}</router-link>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import api from '../../api'
|
|
import { useAuthStore } from '../../stores/auth'
|
|
|
|
// local api client. move into src/api/index.js (see
|
|
// plugins/${name}/frontend-api-snippet.js) then swap for:
|
|
// import { ${name}Api } from '../../api'
|
|
const ${name}Api = {
|
|
list(params = {}) { return api.get('/${name}', { params }) },
|
|
get(itemId) { return api.get(`/${name}/${itemId}`) },
|
|
create(data) { return api.post('/${name}', data) },
|
|
update(itemId, data) { return api.put(`/${name}/${itemId}`, data) },
|
|
remove(itemId) { return api.delete(`/${name}/${itemId}`) }
|
|
}
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const authStore = useAuthStore()
|
|
|
|
const itemId = route.params.id
|
|
const item = ref(null)
|
|
const loading = ref(true)
|
|
|
|
onMounted(loadItem)
|
|
|
|
async function loadItem() {
|
|
loading.value = true
|
|
try {
|
|
const response = await ${name}Api.get(itemId)
|
|
item.value = response.data.data
|
|
} catch (error) {
|
|
console.error('Error loading ${name}:', error)
|
|
item.value = null
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (confirm('Delete this record?')) {
|
|
try {
|
|
await ${name}Api.remove(itemId)
|
|
router.push('/${name}')
|
|
} catch (error) {
|
|
console.error('Error deleting ${name}:', error)
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.mono {
|
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
|
}
|
|
|
|
.action-bar {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-top: 2rem;
|
|
padding-top: 1.5rem;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
.loading-container,
|
|
.error-container {
|
|
text-align: center;
|
|
padding: 3rem;
|
|
color: var(--text-light);
|
|
}
|
|
</style>
|