Produkte: Frontend-Verwaltung & Editor-Produktauswahl

- ProductsView: CRUD-Liste (eigene editierbar, globale read-only),
  Format-Defaults je Produktart, Nav-Eintrag (Plattform/Reseller)
- Card-Editor: Produktauswahl, Design je Firma+Produkt laden/speichern
  (?product=), Format vom Produkt geerbt (read-only) inkl. Asset/PDF-Calls

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-02 15:58:11 +02:00
parent f5807aefce
commit 6e8dcaff4e
4 changed files with 263 additions and 16 deletions

View File

@ -13,6 +13,7 @@ const nav = computed<NavItem[]>(() => [
{ label: 'Reseller', to: '/app/resellers', icon: 'M3 21h18M5 21V7l8-4v18M19 21V11l-6-3', show: auth.isPlatformAdmin }, { label: 'Reseller', to: '/app/resellers', icon: 'M3 21h18M5 21V7l8-4v18M19 21V11l-6-3', show: auth.isPlatformAdmin },
{ label: 'Firmen', to: '/app/companies', icon: 'M3 7h18v13H3zM8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', show: auth.isResellerAdmin || auth.isPlatformAdmin }, { label: 'Firmen', to: '/app/companies', icon: 'M3 7h18v13H3zM8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', show: auth.isResellerAdmin || auth.isPlatformAdmin },
{ label: 'Mitarbeiter', to: '/app/employees', icon: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', show: true }, { label: 'Mitarbeiter', to: '/app/employees', icon: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', show: true },
{ label: 'Produkte', to: '/app/products', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM3.27 6.96 12 12.01l8.73-5.05M12 22.08V12', show: auth.isResellerAdmin || auth.isPlatformAdmin },
{ label: 'Visitenkarten', to: '/app/card-editor', icon: 'M3 5h18v14H3zM3 10h18M7 15h5', show: true }, { label: 'Visitenkarten', to: '/app/card-editor', icon: 'M3 5h18v14H3zM3 10h18M7 15h5', show: true },
{ label: 'Standorte', to: '/app/locations', icon: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', show: auth.isCompanyAdmin || auth.isResellerAdmin }, { label: 'Standorte', to: '/app/locations', icon: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', show: auth.isCompanyAdmin || auth.isResellerAdmin },
{ label: 'Domains', to: '/app/domains', icon: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z', show: auth.isCompanyAdmin || auth.isResellerAdmin }, { label: 'Domains', to: '/app/domains', icon: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z', show: auth.isCompanyAdmin || auth.isResellerAdmin },

View File

@ -19,6 +19,7 @@ const router = createRouter({
{ path: 'resellers', name: 'resellers', component: () => import('@/views/ResellersView.vue') }, { path: 'resellers', name: 'resellers', component: () => import('@/views/ResellersView.vue') },
{ path: 'companies', name: 'companies', component: () => import('@/views/CompaniesView.vue') }, { path: 'companies', name: 'companies', component: () => import('@/views/CompaniesView.vue') },
{ path: 'employees', name: 'employees', component: () => import('@/views/EmployeesView.vue') }, { path: 'employees', name: 'employees', component: () => import('@/views/EmployeesView.vue') },
{ path: 'products', name: 'products', component: () => import('@/views/ProductsView.vue') },
{ path: 'card-editor', name: 'card-editor', component: () => import('@/views/CardEditorView.vue') }, { path: 'card-editor', name: 'card-editor', component: () => import('@/views/CardEditorView.vue') },
{ path: 'locations', name: 'locations', component: () => import('@/views/LocationsView.vue') }, { path: 'locations', name: 'locations', component: () => import('@/views/LocationsView.vue') },
{ path: 'domains', name: 'domains', component: () => import('@/views/DomainsView.vue') }, { path: 'domains', name: 'domains', component: () => import('@/views/DomainsView.vue') },

View File

@ -14,6 +14,7 @@ interface Template {
front: El[]; back: El[] front: El[]; back: El[]
hasBackground?: boolean; fonts?: string[] hasBackground?: boolean; fonts?: string[]
} }
interface Product { '@id': string; id: string; kind: string; name: string; sides: number; nfcEnabled: boolean; active: boolean }
interface Company { '@id': string; id: string; name: string; slug: string; brandingConfig: Record<string, string> | unknown[] } interface Company { '@id': string; id: string; name: string; slug: string; brandingConfig: Record<string, string> | unknown[] }
interface Employee { id: string; firstName: string; lastName: string; position: string | null; email: string | null; phone: string | null; mobile: string | null; company: string; department: string | null } interface Employee { id: string; firstName: string; lastName: string; position: string | null; email: string | null; phone: string | null; mobile: string | null; company: string; department: string | null }
@ -21,6 +22,8 @@ const SCALE = 6 // px pro mm
const companies = ref<Company[]>([]) const companies = ref<Company[]>([])
const selectedCompanyId = ref('') const selectedCompanyId = ref('')
const products = ref<Product[]>([])
const selectedProductId = ref('')
const tpl = ref<Template | null>(null) const tpl = ref<Template | null>(null)
const side = ref<'front' | 'back'>('front') const side = ref<'front' | 'back'>('front')
const selectedIndex = ref<number | null>(null) const selectedIndex = ref<number | null>(null)
@ -117,11 +120,14 @@ function elStyle(el: El): any {
} }
} }
const selectedProduct = computed(() => products.value.find((p) => p['@id'] === selectedProductId.value))
function qp() { return { params: { product: selectedProduct.value?.id } } }
// --- Laden --- // --- Laden ---
async function loadTemplate() { async function loadTemplate() {
if (!selectedCompanyId.value) return if (!selectedCompanyId.value || !selectedProductId.value) return
const cid = selectedCompany.value!.id const cid = selectedCompany.value!.id
const { data } = await client.get<Template>(`/companies/${cid}/card-template`) const { data } = await client.get<Template>(`/companies/${cid}/card-template`, qp())
tpl.value = data tpl.value = data
selectedIndex.value = null selectedIndex.value = null
history.value = [] history.value = []
@ -134,7 +140,7 @@ async function renderBackground() {
bgImages.value = { front: null, back: null } bgImages.value = { front: null, back: null }
if (!tpl.value?.hasBackground) return if (!tpl.value?.hasBackground) return
try { try {
const res = await client.get(`/companies/${selectedCompany.value!.id}/card-template/background`, { responseType: 'arraybuffer' }) const res = await client.get(`/companies/${selectedCompany.value!.id}/card-template/background`, { responseType: 'arraybuffer', params: { product: selectedProduct.value?.id } })
const doc = await pdfjsLib.getDocument({ data: new Uint8Array(res.data as ArrayBuffer) }).promise const doc = await pdfjsLib.getDocument({ data: new Uint8Array(res.data as ArrayBuffer) }).promise
const pxW = (tpl.value.widthMm + 2 * tpl.value.bleedMm) * SCALE const pxW = (tpl.value.widthMm + 2 * tpl.value.bleedMm) * SCALE
for (let p = 1; p <= Math.min(2, doc.numPages); p++) { for (let p = 1; p <= Math.min(2, doc.numPages); p++) {
@ -155,11 +161,13 @@ async function renderBackground() {
} }
async function load() { async function load() {
loading.value = true loading.value = true
;[companies.value, employees.value] = await Promise.all([ ;[companies.value, employees.value, products.value] = await Promise.all([
list<Company>('companies').then((r) => r.member), list<Company>('companies').then((r) => r.member),
list<Employee>('employees').then((r) => r.member).catch(() => []), list<Employee>('employees').then((r) => r.member).catch(() => []),
list<Product>('products').then((r) => r.member.filter((p) => p.active)).catch(() => []),
]) ])
if (companies.value[0]) selectedCompanyId.value = companies.value[0]['@id'] if (companies.value[0]) selectedCompanyId.value = companies.value[0]['@id']
if (products.value[0]) selectedProductId.value = products.value[0]['@id']
await loadTemplate() await loadTemplate()
loading.value = false loading.value = false
} }
@ -237,7 +245,7 @@ async function save() {
if (!tpl.value || !selectedCompany.value) return if (!tpl.value || !selectedCompany.value) return
saving.value = true; saved.value = false saving.value = true; saved.value = false
try { try {
const { data } = await client.put<Template>(`/companies/${selectedCompany.value.id}/card-template`, tpl.value) const { data } = await client.put<Template>(`/companies/${selectedCompany.value.id}/card-template`, tpl.value, qp())
tpl.value = data tpl.value = data
saved.value = true saved.value = true
} finally { saving.value = false } } finally { saving.value = false }
@ -252,14 +260,14 @@ async function uploadBackground(e: Event) {
uploading.value = true uploading.value = true
try { try {
const fd = new FormData(); fd.append('file', file) const fd = new FormData(); fd.append('file', file)
await client.post(`/companies/${cidPath()}/card-template/background`, fd) await client.post(`/companies/${cidPath()}/card-template/background`, fd, qp())
tpl.value.hasBackground = true tpl.value.hasBackground = true
await renderBackground() await renderBackground()
} catch { alert('Upload fehlgeschlagen (nur PDF).') } finally { uploading.value = false; (e.target as HTMLInputElement).value = '' } } catch { alert('Upload fehlgeschlagen (nur PDF).') } finally { uploading.value = false; (e.target as HTMLInputElement).value = '' }
} }
async function removeBackground() { async function removeBackground() {
if (!tpl.value || !confirm('Hintergrund-PDF entfernen?')) return if (!tpl.value || !confirm('Hintergrund-PDF entfernen?')) return
await client.delete(`/companies/${cidPath()}/card-template/background`) await client.delete(`/companies/${cidPath()}/card-template/background`, qp())
tpl.value.hasBackground = false tpl.value.hasBackground = false
bgImages.value = { front: null, back: null } bgImages.value = { front: null, back: null }
} }
@ -271,14 +279,14 @@ async function uploadFont(e: Event) {
uploading.value = true uploading.value = true
try { try {
const fd = new FormData(); fd.append('file', file); fd.append('family', family) const fd = new FormData(); fd.append('file', file); fd.append('family', family)
await client.post(`/companies/${cidPath()}/card-template/font`, fd) await client.post(`/companies/${cidPath()}/card-template/font`, fd, qp())
tpl.value.fonts = [...(tpl.value.fonts ?? []).filter((f) => f !== family), family] tpl.value.fonts = [...(tpl.value.fonts ?? []).filter((f) => f !== family), family]
} catch { alert('Upload fehlgeschlagen (nur TTF/OTF).') } finally { uploading.value = false; (e.target as HTMLInputElement).value = '' } } catch { alert('Upload fehlgeschlagen (nur TTF/OTF).') } finally { uploading.value = false; (e.target as HTMLInputElement).value = '' }
} }
async function previewPdf() { async function previewPdf() {
if (!sample.value) { alert('Kein Mitarbeiter in dieser Firma für die Vorschau.'); return } if (!sample.value) { alert('Kein Mitarbeiter in dieser Firma für die Vorschau.'); return }
const res = await client.get(`/employees/${sample.value.id}/card.pdf`, { responseType: 'blob' }) const res = await client.get(`/employees/${sample.value.id}/card.pdf`, { responseType: 'blob', params: { product: selectedProduct.value?.id } })
window.open(URL.createObjectURL(res.data as Blob), '_blank') window.open(URL.createObjectURL(res.data as Blob), '_blank')
} }
@ -292,11 +300,14 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
<template> <template>
<section> <section>
<div class="page-head"> <div class="page-head">
<div><h1>Visitenkarten-Editor</h1><p class="muted">Layout pro Firma · druckfertiges PDF</p></div> <div><h1>Produkt-Editor</h1><p class="muted">Design je Firma &amp; Produkt · druckfertiges PDF</p></div>
<div class="head-actions"> <div class="head-actions">
<select v-if="companies.length > 1" class="input" v-model="selectedCompanyId" @change="loadTemplate"> <select v-if="companies.length > 1" class="input" v-model="selectedCompanyId" @change="loadTemplate">
<option v-for="c in companies" :key="c['@id']" :value="c['@id']">{{ c.name }}</option> <option v-for="c in companies" :key="c['@id']" :value="c['@id']">{{ c.name }}</option>
</select> </select>
<select class="input" v-model="selectedProductId" @change="loadTemplate" :disabled="!products.length">
<option v-for="p in products" :key="p['@id']" :value="p['@id']">{{ p.name }}</option>
</select>
<button class="btn btn-ghost" :disabled="!history.length" @click="undo" title="Rückgängig (Strg+Z)"></button> <button class="btn btn-ghost" :disabled="!history.length" @click="undo" title="Rückgängig (Strg+Z)"></button>
<button class="btn btn-ghost" @click="previewPdf">PDF-Vorschau</button> <button class="btn btn-ghost" @click="previewPdf">PDF-Vorschau</button>
<button class="btn btn-primary" :disabled="saving" @click="save">{{ saving ? 'Speichern' : 'Speichern' }}</button> <button class="btn btn-primary" :disabled="saving" @click="save">{{ saving ? 'Speichern' : 'Speichern' }}</button>
@ -380,16 +391,16 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
<!-- Karten-/Format-Einstellungen, wenn kein Element ausgewählt ist --> <!-- Karten-/Format-Einstellungen, wenn kein Element ausgewählt ist -->
<template v-if="!selected"> <template v-if="!selected">
<div class="field"><label>Name der Vorlage</label><input class="input" v-model="tpl.name" /></div> <div class="field"><label>Name des Designs</label><input class="input" v-model="tpl.name" /></div>
<div class="grid2"> <div class="grid2">
<div class="field"><label>Breite (mm)</label><input class="input" type="number" step="1" v-model.number="tpl.widthMm" /></div> <div class="field"><label>Breite (mm)</label><input class="input" type="number" :value="tpl.widthMm" disabled /></div>
<div class="field"><label>Höhe (mm)</label><input class="input" type="number" step="1" v-model.number="tpl.heightMm" /></div> <div class="field"><label>Höhe (mm)</label><input class="input" :value="tpl.heightMm" disabled /></div>
</div> </div>
<div class="grid2"> <div class="grid2">
<div class="field"><label>Beschnitt (mm)</label><input class="input" type="number" step="0.5" min="0" v-model.number="tpl.bleedMm" /></div> <div class="field"><label>Beschnitt (mm)</label><input class="input" :value="tpl.bleedMm" disabled /></div>
<div class="field"><label>Sicherheit (mm)</label><input class="input" type="number" step="0.5" min="0" v-model.number="tpl.safeMm" /></div> <div class="field"><label>Sicherheit (mm)</label><input class="input" :value="tpl.safeMm" disabled /></div>
</div> </div>
<p class="muted small">Standard: 85×55&nbsp;mm, 2&nbsp;mm Beschnitt. Wird beim Speichern übernommen und gilt fürs Druck-PDF. Element anklicken, um es zu bearbeiten.</p> <p class="muted small">Format wird vom Produkt <strong>{{ selectedProduct?.name }}</strong> vorgegeben. Element anklicken, um es zu bearbeiten.</p>
</template> </template>
<template v-if="selected"> <template v-if="selected">

View File

@ -0,0 +1,234 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { list, create, update, remove } from '@/api/resources'
import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue'
interface Product {
'@id': string
id: string
kind: string
name: string
description: string | null
widthMm: number
heightMm: number
bleedMm: number
safeMm: number
sides: number
nfcEnabled: boolean
printEnabled: boolean
active: boolean
sortOrder: number
global: boolean
}
const KIND_LABEL: Record<string, string> = {
business_card: 'Visitenkarte',
name_tag: 'Namensschild',
nfc_card: 'NFC-Karte',
}
// Sinnvolle Format-Vorgaben je Produktart (KONZEPT §13)
const KIND_DEFAULTS: Record<string, Partial<Product>> = {
business_card: { widthMm: 85, heightMm: 55, bleedMm: 2, safeMm: 4, sides: 2, nfcEnabled: false },
name_tag: { widthMm: 90, heightMm: 55, bleedMm: 0, safeMm: 3, sides: 1, nfcEnabled: false },
nfc_card: { widthMm: 85.6, heightMm: 54, bleedMm: 2, safeMm: 4, sides: 2, nfcEnabled: true },
}
const auth = useAuthStore()
const products = ref<Product[]>([])
const loading = ref(true)
const error = ref('')
const saving = ref(false)
const showEdit = ref(false)
const editing = ref<Product | null>(null)
const blank = () => ({
kind: 'business_card', name: '', description: '',
widthMm: 85, heightMm: 55, bleedMm: 2, safeMm: 4,
sides: 2, nfcEnabled: false, printEnabled: true, active: true, sortOrder: 0,
})
const form = ref<ReturnType<typeof blank>>(blank())
// Reseller sehen nur global + eigene; alles Nicht-Globale ist also editierbar.
function editable(p: Product) {
return auth.isPlatformAdmin || !p.global
}
const sorted = computed(() =>
[...products.value].sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)),
)
async function load() {
loading.value = true
products.value = (await list<Product>('products')).member
loading.value = false
}
function applyKindDefaults() {
Object.assign(form.value, KIND_DEFAULTS[form.value.kind] ?? {})
}
function openCreate() {
editing.value = null
form.value = blank()
error.value = ''
showEdit.value = true
}
function openEdit(p: Product) {
editing.value = p
form.value = {
kind: p.kind, name: p.name, description: p.description ?? '',
widthMm: p.widthMm, heightMm: p.heightMm, bleedMm: p.bleedMm, safeMm: p.safeMm,
sides: p.sides, nfcEnabled: p.nfcEnabled, printEnabled: p.printEnabled,
active: p.active, sortOrder: p.sortOrder,
}
error.value = ''
showEdit.value = true
}
async function submit() {
error.value = ''
saving.value = true
const payload = {
kind: form.value.kind,
name: form.value.name,
description: form.value.description || null,
widthMm: Number(form.value.widthMm),
heightMm: Number(form.value.heightMm),
bleedMm: Number(form.value.bleedMm),
safeMm: Number(form.value.safeMm),
sides: Number(form.value.sides),
nfcEnabled: form.value.nfcEnabled,
printEnabled: form.value.printEnabled,
active: form.value.active,
sortOrder: Number(form.value.sortOrder),
}
try {
if (editing.value) await update(editing.value['@id'], payload)
else await create('products', payload)
showEdit.value = false
await load()
} catch {
error.value = 'Speichern fehlgeschlagen.'
} finally {
saving.value = false
}
}
async function del(p: Product) {
if (!confirm(`Produkt „${p.name}" wirklich löschen?`)) return
await remove(p['@id'])
await load()
}
onMounted(load)
</script>
<template>
<section>
<div class="page-head">
<div>
<h1>Produkte</h1>
<p class="muted">
{{ auth.isPlatformAdmin ? 'Globale Produkte für alle Reseller' : 'Eigene Produkte (globale sind read-only)' }}
</p>
</div>
<button class="btn btn-primary" @click="openCreate">+ Produkt hinzufügen</button>
</div>
<div class="card">
<table class="tbl">
<thead>
<tr><th>Name</th><th>Art</th><th>Format</th><th>Seiten</th><th>NFC</th><th>Sichtbarkeit</th><th></th></tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="7" class="empty">Lädt</td></tr>
<tr v-else-if="!sorted.length"><td colspan="7" class="empty">Noch keine Produkte.</td></tr>
<tr v-for="p in sorted" :key="p.id">
<td><strong>{{ p.name }}</strong><span v-if="!p.active" class="muted sm"> · inaktiv</span></td>
<td>{{ KIND_LABEL[p.kind] ?? p.kind }}</td>
<td class="muted nowrap">{{ p.widthMm }}×{{ p.heightMm }} mm<span v-if="p.bleedMm"> · {{ p.bleedMm }} Bl.</span></td>
<td>{{ p.sides === 2 ? 'V/R' : 'nur V' }}</td>
<td>{{ p.nfcEnabled ? '✓' : '' }}</td>
<td>
<span class="badge" :class="p.global ? 'badge-global' : 'badge-own'">{{ p.global ? 'Global' : 'Eigen' }}</span>
</td>
<td class="right">
<template v-if="editable(p)">
<button class="btn btn-ghost btn-sm" @click="openEdit(p)">Bearbeiten</button>
<button class="btn btn-ghost btn-sm del-btn" title="Löschen" @click="del(p)"></button>
</template>
<span v-else class="muted sm" title="Globales Produkt nur Plattform"></span>
</td>
</tr>
</tbody>
</table>
</div>
<Modal v-if="showEdit" :title="editing ? 'Produkt bearbeiten' : 'Produkt hinzufügen'" @close="showEdit = false">
<form @submit.prevent="submit">
<div class="grid2">
<div class="field">
<label>Produktart</label>
<select class="input" v-model="form.kind" :disabled="!!editing" @change="applyKindDefaults">
<option value="business_card">Visitenkarte</option>
<option value="name_tag">Namensschild</option>
<option value="nfc_card">NFC-Karte</option>
</select>
</div>
<div class="field">
<label>Name</label>
<input class="input" v-model="form.name" required placeholder="z. B. Premium-Visitenkarte" />
</div>
</div>
<div class="field">
<label>Beschreibung (optional)</label>
<input class="input" v-model="form.description" placeholder="Kurzbeschreibung für die Auswahl" />
</div>
<div class="grid4">
<div class="field"><label>Breite (mm)</label><input class="input" type="number" step="0.1" v-model="form.widthMm" /></div>
<div class="field"><label>Höhe (mm)</label><input class="input" type="number" step="0.1" v-model="form.heightMm" /></div>
<div class="field"><label>Beschnitt (mm)</label><input class="input" type="number" step="0.1" v-model="form.bleedMm" /></div>
<div class="field"><label>Sicherheit (mm)</label><input class="input" type="number" step="0.1" v-model="form.safeMm" /></div>
</div>
<div class="grid4">
<div class="field">
<label>Seiten</label>
<select class="input" v-model.number="form.sides"><option :value="1">nur Vorderseite</option><option :value="2">Vorder-/Rückseite</option></select>
</div>
<div class="field"><label>Reihenfolge</label><input class="input" type="number" v-model="form.sortOrder" /></div>
<label class="check"><input type="checkbox" v-model="form.nfcEnabled" /> NFC</label>
<label class="check"><input type="checkbox" v-model="form.active" /> Aktiv</label>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button type="button" class="btn btn-ghost" @click="showEdit = false">Abbrechen</button>
<button class="btn btn-primary" :disabled="saving">{{ saving ? 'Speichern…' : 'Speichern' }}</button>
</div>
</form>
</Modal>
</section>
</template>
<style scoped>
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.4rem; }
.page-head .muted { margin: .2rem 0 0; }
.tbl { width: 100%; border-collapse: collapse; }
.tbl th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); padding: .65rem .6rem; border-bottom: 1px solid var(--line); white-space: nowrap; }
.tbl td { padding: .65rem .6rem; border-bottom: 1px solid #f4f4f4; }
.del-btn { color: var(--danger); padding-left: .5rem; padding-right: .5rem; }
.tbl tr:last-child td { border-bottom: none; }
.nowrap { white-space: nowrap; }
.right { text-align: right; white-space: nowrap; }
.empty { text-align: center; color: var(--muted); padding: 2rem; }
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
.error { color: var(--danger); font-size: .88rem; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; }
.grid4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: .8rem; align-items: end; }
.check { display: flex; align-items: center; gap: .4rem; font-size: .9rem; font-weight: 600; padding-bottom: .6rem; }
.badge-global { background: #eef2ff; color: #3730a3; }
.badge-own { background: #fff3e6; color: var(--psc-orange-dark); }
.sm { font-size: .8rem; }
</style>