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:
parent
f5807aefce
commit
6e8dcaff4e
@ -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: '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: '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: '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 },
|
||||
|
||||
@ -19,6 +19,7 @@ const router = createRouter({
|
||||
{ path: 'resellers', name: 'resellers', component: () => import('@/views/ResellersView.vue') },
|
||||
{ path: 'companies', name: 'companies', component: () => import('@/views/CompaniesView.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: 'locations', name: 'locations', component: () => import('@/views/LocationsView.vue') },
|
||||
{ path: 'domains', name: 'domains', component: () => import('@/views/DomainsView.vue') },
|
||||
|
||||
@ -14,6 +14,7 @@ interface Template {
|
||||
front: El[]; back: El[]
|
||||
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 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 selectedCompanyId = ref('')
|
||||
const products = ref<Product[]>([])
|
||||
const selectedProductId = ref('')
|
||||
const tpl = ref<Template | null>(null)
|
||||
const side = ref<'front' | 'back'>('front')
|
||||
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 ---
|
||||
async function loadTemplate() {
|
||||
if (!selectedCompanyId.value) return
|
||||
if (!selectedCompanyId.value || !selectedProductId.value) return
|
||||
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
|
||||
selectedIndex.value = null
|
||||
history.value = []
|
||||
@ -134,7 +140,7 @@ async function renderBackground() {
|
||||
bgImages.value = { front: null, back: null }
|
||||
if (!tpl.value?.hasBackground) return
|
||||
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 pxW = (tpl.value.widthMm + 2 * tpl.value.bleedMm) * SCALE
|
||||
for (let p = 1; p <= Math.min(2, doc.numPages); p++) {
|
||||
@ -155,11 +161,13 @@ async function renderBackground() {
|
||||
}
|
||||
async function load() {
|
||||
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<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 (products.value[0]) selectedProductId.value = products.value[0]['@id']
|
||||
await loadTemplate()
|
||||
loading.value = false
|
||||
}
|
||||
@ -237,7 +245,7 @@ async function save() {
|
||||
if (!tpl.value || !selectedCompany.value) return
|
||||
saving.value = true; saved.value = false
|
||||
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
|
||||
saved.value = true
|
||||
} finally { saving.value = false }
|
||||
@ -252,14 +260,14 @@ async function uploadBackground(e: Event) {
|
||||
uploading.value = true
|
||||
try {
|
||||
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
|
||||
await renderBackground()
|
||||
} catch { alert('Upload fehlgeschlagen (nur PDF).') } finally { uploading.value = false; (e.target as HTMLInputElement).value = '' }
|
||||
}
|
||||
async function removeBackground() {
|
||||
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
|
||||
bgImages.value = { front: null, back: null }
|
||||
}
|
||||
@ -271,14 +279,14 @@ async function uploadFont(e: Event) {
|
||||
uploading.value = true
|
||||
try {
|
||||
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]
|
||||
} catch { alert('Upload fehlgeschlagen (nur TTF/OTF).') } finally { uploading.value = false; (e.target as HTMLInputElement).value = '' }
|
||||
}
|
||||
|
||||
async function previewPdf() {
|
||||
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')
|
||||
}
|
||||
|
||||
@ -292,11 +300,14 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
<template>
|
||||
<section>
|
||||
<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 & Produkt · druckfertiges PDF</p></div>
|
||||
<div class="head-actions">
|
||||
<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>
|
||||
</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" @click="previewPdf">PDF-Vorschau</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 -->
|
||||
<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="field"><label>Breite (mm)</label><input class="input" type="number" step="1" v-model.number="tpl.widthMm" /></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>Breite (mm)</label><input class="input" type="number" :value="tpl.widthMm" disabled /></div>
|
||||
<div class="field"><label>Höhe (mm)</label><input class="input" :value="tpl.heightMm" disabled /></div>
|
||||
</div>
|
||||
<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>Sicherheit (mm)</label><input class="input" type="number" step="0.5" min="0" v-model.number="tpl.safeMm" /></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" :value="tpl.safeMm" disabled /></div>
|
||||
</div>
|
||||
<p class="muted small">Standard: 85×55 mm, 2 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 v-if="selected">
|
||||
|
||||
234
frontend/src/views/ProductsView.vue
Normal file
234
frontend/src/views/ProductsView.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user