Merge branch 'devCorrected' into 'beta'
Dev corrected See merge request fedeo/software!59
This commit is contained in:
130
components/LabelPrintModal.vue
Normal file
130
components/LabelPrintModal.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import LabelPrinterButton from "~/components/LabelPrinterButton.vue";
|
||||
|
||||
const labelPrinter = useLabelPrinterStore()
|
||||
|
||||
const props = defineProps({
|
||||
context: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: {}
|
||||
}
|
||||
})
|
||||
|
||||
defineShortcuts({
|
||||
meta_p: {
|
||||
usingInput: true,
|
||||
handler: () => {
|
||||
if(!labelPrinter.connected) return
|
||||
printLabel()
|
||||
}
|
||||
},
|
||||
escape: {
|
||||
usingInput: true,
|
||||
handler: () => {
|
||||
modal.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(["printed"])
|
||||
const modal = useModal()
|
||||
const { $api } = useNuxtApp()
|
||||
|
||||
const loading = ref(true)
|
||||
const printing = ref(false)
|
||||
const labelData = ref<any>(null) // gerendertes Bild vom Backend
|
||||
|
||||
|
||||
|
||||
/** Label vom Backend rendern */
|
||||
async function loadLabel() {
|
||||
loading.value = true
|
||||
try {
|
||||
labelData.value = await $api(`/api/print/label`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
context: props.context || null
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Label render error", err)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
/** Drucken */
|
||||
async function printLabel() {
|
||||
if (!labelPrinter.connected) return
|
||||
|
||||
printing.value = true
|
||||
try {
|
||||
await labelPrinter.print(labelData.value.encoded, { density: 5, pages: 1 })
|
||||
modal.close()
|
||||
} catch (err) {
|
||||
console.error("Print error", err)
|
||||
}
|
||||
printing.value = false
|
||||
}
|
||||
const handleConnect = async () => {
|
||||
await loadLabel()
|
||||
await labelPrinter.connect("ble")
|
||||
}
|
||||
onMounted(() => {
|
||||
if(labelPrinter.connected) {
|
||||
loadLabel()
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal>
|
||||
<UCard>
|
||||
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">Label drucken</h3>
|
||||
<UButton icon="i-heroicons-x-mark" variant="ghost" @click="modal.close()" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!loading && labelPrinter.connected">
|
||||
<img
|
||||
:src="`data:image/png;base64,${labelData.base64}`"
|
||||
alt="Label Preview"
|
||||
class="max-w-full max-h-64 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="loading && !labelPrinter.connected">
|
||||
Kein Drucker verbunden
|
||||
|
||||
<LabelPrinterButton/>
|
||||
|
||||
</div>
|
||||
<UProgress animation="carousel" v-else/>
|
||||
|
||||
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-between">
|
||||
<UButton variant="ghost" @click="modal.close()">Abbrechen</UButton>
|
||||
|
||||
<UButton
|
||||
color="primary"
|
||||
:disabled="!labelPrinter.connected || printing"
|
||||
:loading="printing"
|
||||
@click="printLabel"
|
||||
>
|
||||
Drucken
|
||||
<UKbd>⌘</UKbd>
|
||||
<UKbd>P</UKbd>
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</UCard>
|
||||
</UModal>
|
||||
</template>
|
||||
53
components/LabelPrinterButton.vue
Normal file
53
components/LabelPrinterButton.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
const labelPrinter = useLabelPrinterStore()
|
||||
|
||||
const showPrinterInfo = ref(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
if(labelPrinter.connected) {
|
||||
showPrinterInfo.value = true
|
||||
} else {
|
||||
await labelPrinter.connect('ble')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Printer Button -->
|
||||
|
||||
<UModal v-model="showPrinterInfo">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">Drucker Informationen</h3>
|
||||
<UButton icon="i-heroicons-x-mark" variant="ghost" @click="showPrinterInfo = false" />
|
||||
</div>
|
||||
</template>
|
||||
<p>Seriennummer: {{labelPrinter.info.serial}}</p>
|
||||
<p>MAC: {{labelPrinter.info.mac}}</p>
|
||||
<p>Modell: {{labelPrinter.info.modelId}}</p>
|
||||
<p>Charge: {{labelPrinter.info.charge}}</p>
|
||||
<p>Hardware Version: {{labelPrinter.info.hardwareVersion}}</p>
|
||||
<p>Software Version: {{labelPrinter.info.softwareVersion}}</p>
|
||||
</UCard>
|
||||
</UModal>
|
||||
|
||||
<UButton
|
||||
:icon="labelPrinter.connected ? 'i-heroicons-printer' : 'i-heroicons-printer'"
|
||||
:color="labelPrinter.connected ? 'green' : 'gray'"
|
||||
variant="soft"
|
||||
class="w-full justify-start"
|
||||
:loading="labelPrinter.connectLoading"
|
||||
@click="handleClick"
|
||||
>
|
||||
<span v-if="labelPrinter.connected">Drucker verbunden</span>
|
||||
<span v-else>Drucker verbinden</span>
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
201
components/PublicDynamicForm.vue
Normal file
201
components/PublicDynamicForm.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<script setup>
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const props = defineProps({
|
||||
context: { type: Object, required: true },
|
||||
token: { type: String, required: true },
|
||||
pin: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
const { $api } = useNuxtApp()
|
||||
const toast = useToast()
|
||||
|
||||
const config = computed(() => props.context.config)
|
||||
const data = computed(() => props.context.data)
|
||||
|
||||
// Initiale Werte setzen
|
||||
const form = ref({
|
||||
profile: props.context.meta?.defaultProfileId || null,
|
||||
project: null,
|
||||
service: config.value?.defaults?.serviceId || null,
|
||||
// Wenn manualTime erlaubt, setze Startzeit auf jetzt, sonst null (wird im Backend gesetzt)
|
||||
startDate: config.value?.features?.timeTracking?.allowManualTime ? new Date() : null,
|
||||
endDate: config.value?.features?.timeTracking?.allowManualTime ? dayjs().add(1, 'hour').toDate() : null,
|
||||
dieselUsage: 0,
|
||||
description: ''
|
||||
})
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
const errors = ref({})
|
||||
|
||||
// Validierung basierend auf JSON Config
|
||||
const validate = () => {
|
||||
errors.value = {}
|
||||
let isValid = true
|
||||
const validationRules = config.value.validation || {}
|
||||
|
||||
// Standard-Validierung
|
||||
if (!form.value.project && data.value.projects?.length > 0) {
|
||||
errors.value.project = 'Pflichtfeld'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
if (!form.value.service && data.value.services?.length > 0) {
|
||||
errors.value.service = 'Pflichtfeld'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Profil nur validieren, wenn Auswahl möglich ist
|
||||
if (!form.value.profile && data.value.profiles?.length > 0 && !props.context.meta.defaultProfileId) {
|
||||
errors.value.profile = 'Bitte Mitarbeiter wählen'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Feature: Agriculture
|
||||
if (config.value.features?.agriculture?.showDieselUsage && validationRules.requireDiesel) {
|
||||
if (!form.value.dieselUsage || form.value.dieselUsage <= 0) {
|
||||
errors.value.diesel = 'Dieselverbrauch erforderlich'
|
||||
isValid = false
|
||||
}
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!validate()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload = { ...form.value }
|
||||
|
||||
// Headers vorbereiten (PIN mitsenden!)
|
||||
const headers = {}
|
||||
if (props.pin) headers['x-public-pin'] = props.pin
|
||||
|
||||
// An den Submit-Endpunkt senden (den müssen wir im Backend noch bauen!)
|
||||
await $fetch(`http://localhost:3100/workflows/submit/${props.token}`, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
headers
|
||||
})
|
||||
|
||||
emit('success')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ title: 'Fehler beim Speichern', color: 'red' })
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard :ui="{ body: { padding: 'p-6 sm:p-8' } }" v-if="props.context && props.token">
|
||||
<template #header>
|
||||
<div class="text-center">
|
||||
<h1 class="text-xl font-bold text-gray-900">{{ config?.ui?.title || 'Erfassung' }}</h1>
|
||||
<p v-if="config?.ui?.description" class="text-sm text-gray-500 mt-1">{{ config?.ui?.description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-5">
|
||||
|
||||
<UFormGroup
|
||||
v-if="!context?.meta?.defaultProfileId && data?.profiles?.length > 0"
|
||||
label="Mitarbeiter"
|
||||
:error="errors.profile"
|
||||
required
|
||||
>
|
||||
<USelectMenu
|
||||
v-model="form.profile"
|
||||
:options="data.profiles"
|
||||
option-attribute="fullName"
|
||||
value-attribute="id"
|
||||
placeholder="Name auswählen..."
|
||||
searchable
|
||||
searchable-placeholder="Suchen..."
|
||||
/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup
|
||||
v-if="data?.projects?.length > 0"
|
||||
:label="config.ui?.labels?.project || 'Projekt'"
|
||||
:error="errors.project"
|
||||
required
|
||||
>
|
||||
<USelectMenu
|
||||
v-model="form.project"
|
||||
:options="data.projects"
|
||||
option-attribute="name"
|
||||
value-attribute="id"
|
||||
placeholder="Wählen..."
|
||||
searchable
|
||||
/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup
|
||||
v-if="data?.services?.length > 0"
|
||||
:label="config?.ui?.labels?.service || 'Leistung'"
|
||||
:error="errors.service"
|
||||
required
|
||||
>
|
||||
<USelectMenu
|
||||
v-model="form.service"
|
||||
:options="data.services"
|
||||
option-attribute="name"
|
||||
value-attribute="id"
|
||||
placeholder="Wählen..."
|
||||
/>
|
||||
</UFormGroup>
|
||||
|
||||
<div v-if="config?.features?.timeTracking?.allowManualTime" class="grid grid-cols-2 gap-3">
|
||||
<UFormGroup label="Start">
|
||||
<input
|
||||
type="datetime-local"
|
||||
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-primary-600 sm:text-sm sm:leading-6 px-2"
|
||||
:value="dayjs(form.startDate).format('YYYY-MM-DDTHH:mm')"
|
||||
@input="e => form.startDate = new Date(e.target.value)"
|
||||
/>
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Ende">
|
||||
<input
|
||||
type="datetime-local"
|
||||
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-primary-600 sm:text-sm sm:leading-6 px-2"
|
||||
:value="dayjs(form.endDate).format('YYYY-MM-DDTHH:mm')"
|
||||
@input="e => form.endDate = new Date(e.target.value)"
|
||||
/>
|
||||
</UFormGroup>
|
||||
</div>
|
||||
|
||||
<UFormGroup
|
||||
v-if="config?.features?.agriculture?.showDieselUsage"
|
||||
label="Dieselverbrauch"
|
||||
:error="errors.diesel"
|
||||
:required="config?.validation?.requireDiesel"
|
||||
>
|
||||
<UInput v-model="form.dieselUsage" type="number" step="0.1" placeholder="0.0">
|
||||
<template #trailing>
|
||||
<span class="text-gray-500 text-xs">Liter</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup :label="config?.ui?.labels?.description || 'Notiz'">
|
||||
<UTextarea v-model="form.description" :rows="3" />
|
||||
</UFormGroup>
|
||||
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<UButton
|
||||
block
|
||||
size="xl"
|
||||
:loading="isSubmitting"
|
||||
@click="submit"
|
||||
:label="config?.ui?.submitButtonText || 'Speichern'"
|
||||
/>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -37,7 +37,7 @@ const calculateOpenSum = (statement) => {
|
||||
<tr v-for="account in bankaccounts.filter(i => !i.expired)">
|
||||
<td>{{ account.name }}:</td>
|
||||
<td>
|
||||
{{dayjs(account.synced_at).format("DD.MM.YY HH:mm")}}
|
||||
{{dayjs(account.syncedAt).format("DD.MM.YY HH:mm")}}
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="account.balance < 0" class=" text-nowrap text-rose-600 font-bold">{{useCurrency(account.balance)}}</span>
|
||||
|
||||
60
components/nimbot.vue
Normal file
60
components/nimbot.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
const labelPrinter = useLabelPrinterStore()
|
||||
|
||||
|
||||
const { $api } = useNuxtApp()
|
||||
|
||||
const labelWidth = ref(50)
|
||||
const labelHeight = ref(30)
|
||||
const zpl = ref(`^XA
|
||||
^FO5,5
|
||||
^GB545,325,3^FS
|
||||
^CF0,30
|
||||
^FO20,20^FDHello from Bluetooth!^FS
|
||||
^FO20,100^BY2
|
||||
^BXN,10,200
|
||||
^FD12345678901234567^FS
|
||||
^XZ`)
|
||||
|
||||
|
||||
|
||||
async function handlePrint() {
|
||||
const dpmm = 12
|
||||
const res = await $api("/api/print/label", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
zpl: zpl.value,
|
||||
width: labelWidth.value,
|
||||
height: labelHeight.value,
|
||||
dpmm,
|
||||
}),
|
||||
})
|
||||
await labelPrinter.print(res, { density: 5, pages: 1 })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard class="p-4">
|
||||
<div class="flex gap-2 mb-2">
|
||||
<UButton @click="labelPrinter.connect('serial')">Connect S </UButton>
|
||||
<UButton @click="labelPrinter.connect('ble')">Connect B</UButton>
|
||||
<UButton color="red" @click="labelPrinter.disconnect">Disconnect</UButton>
|
||||
<UButton color="primary" @click="handlePrint" :disabled="!labelPrinter.connected">Print</UButton>
|
||||
</div>
|
||||
|
||||
{{labelPrinter.info}}
|
||||
|
||||
{{labelPrinter.printProgress}}
|
||||
|
||||
<UFormGroup label="Breite">
|
||||
<UInput v-model="labelWidth"><template #trailing>mm</template></UInput>
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Höhe">
|
||||
<UInput v-model="labelHeight"><template #trailing>mm</template></UInput>
|
||||
</UFormGroup>
|
||||
<UFormGroup label="ZPL">
|
||||
<UTextarea v-model="zpl" rows="6" />
|
||||
</UFormGroup>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ import dayjs from "dayjs";
|
||||
import {useCapacitor} from "../composables/useCapacitor.js";
|
||||
import GlobalMessages from "~/components/GlobalMessages.vue";
|
||||
import TenantDropdown from "~/components/TenantDropdown.vue";
|
||||
import LabelPrinterButton from "~/components/LabelPrinterButton.vue";
|
||||
|
||||
const dataStore = useDataStore()
|
||||
const colorMode = useColorMode()
|
||||
@@ -14,6 +15,7 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const labelPrinter = useLabelPrinterStore()
|
||||
|
||||
|
||||
const month = dayjs().format("MM")
|
||||
@@ -240,13 +242,16 @@ const footerLinks = [
|
||||
|
||||
|
||||
<template #footer>
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
|
||||
<LabelPrinterButton/>
|
||||
|
||||
<!-- Footer Links -->
|
||||
<UDashboardSidebarLinks :links="footerLinks" />
|
||||
|
||||
<UDivider class="sticky bottom-0" />
|
||||
<UserDropdown style="margin-bottom: env(safe-area-inset-bottom, 10px) !important;"/>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</UDashboardSidebar>
|
||||
</UDashboardPanel>
|
||||
|
||||
@@ -697,11 +697,11 @@ const findDocumentErrors = computed(() => {
|
||||
|
||||
if (["normal", "service", "free"].includes(row.mode)) {
|
||||
|
||||
if (!row.taxPercent && typeof row.taxPercent !== "number") errors.push({
|
||||
if (!row.taxPercent && typeof row.taxPercent !== "number" && itemInfo.value.type !== "deliveryNotes") errors.push({
|
||||
message: `In Position ${row.pos} ist kein Steuersatz hinterlegt`,
|
||||
type: "breaking"
|
||||
})
|
||||
if (!row.price && typeof row.price !== "number") errors.push({
|
||||
if (!row.price && typeof row.price !== "number" && itemInfo.value.type !== "deliveryNotes") errors.push({
|
||||
message: `In Position ${row.pos} ist kein Preis hinterlegt`,
|
||||
type: "breaking"
|
||||
})
|
||||
|
||||
47
pages/export/create/sepa.vue
Normal file
47
pages/export/create/sepa.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
|
||||
const createddocuments = ref([])
|
||||
const selected = ref([])
|
||||
const setup = async () => {
|
||||
createddocuments.value = (await useEntities("createddocuments").select()).filter(i => i.payment_type === "direct-debit")
|
||||
selected.value = createddocuments.value
|
||||
}
|
||||
|
||||
setup()
|
||||
|
||||
const createExport = async () => {
|
||||
//NUMMERN MAPPEN ZU IDS UND AN BACKEND FUNKTION ÜBERGEBEN
|
||||
const ids = selected.value.map((i) => i.id)
|
||||
|
||||
const res = await useNuxtApp().$api("/api/exports/sepa", {
|
||||
method: "POST",
|
||||
body: {
|
||||
idsToExport: ids
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar title="SEPA Export erstellen">
|
||||
<template #right>
|
||||
<UButton @click="createExport">
|
||||
Erstellen
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
<UTable
|
||||
v-if="createddocuments.length > 0"
|
||||
:loading="true"
|
||||
v-model="selected"
|
||||
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
|
||||
:rows="createddocuments" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -56,16 +56,36 @@
|
||||
>
|
||||
<display-open-tasks/>
|
||||
</UDashboardCard>
|
||||
<UDashboardCard
|
||||
title="Label Test"
|
||||
>
|
||||
<UButton
|
||||
@click="modal.open(LabelPrintModal, {
|
||||
context: {
|
||||
datamatrix: '1234',
|
||||
text: 'FEDEO TEST'
|
||||
}
|
||||
})"
|
||||
icon="i-heroicons-printer"
|
||||
>
|
||||
Label Drucken
|
||||
</UButton>
|
||||
</UDashboardCard>
|
||||
</UPageGrid>
|
||||
</UDashboardPanelContent>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import Nimbot from "~/components/nimbot.vue";
|
||||
import LabelPrintModal from "~/components/LabelPrintModal.vue";
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'redirect-to-mobile-index'
|
||||
})
|
||||
|
||||
const modal = useModal();
|
||||
|
||||
const { isNotificationsSlideoverOpen } = useDashboard()
|
||||
|
||||
</script>
|
||||
|
||||
121
pages/workflows/[token].vue
Normal file
121
pages/workflows/[token].vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'blank', // Kein Menü, keine Sidebar
|
||||
middleware: [], // Keine Auth-Checks durch Nuxt
|
||||
auth: false // Falls du das nuxt-auth Modul nutzt
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const token = route.params.token
|
||||
const { $api } = useNuxtApp() // Dein Fetch-Wrapper
|
||||
const toast = useToast()
|
||||
|
||||
// States
|
||||
const status = ref('loading') // loading, pin_required, ready, error, success
|
||||
const pin = ref('')
|
||||
const context = ref(null)
|
||||
const errorMsg = ref('')
|
||||
|
||||
// Daten laden
|
||||
const loadContext = async () => {
|
||||
status.value = 'loading'
|
||||
errorMsg.value = ''
|
||||
|
||||
try {
|
||||
const headers = {}
|
||||
if (pin.value) headers['x-public-pin'] = pin.value
|
||||
|
||||
// Abruf an dein Fastify Backend
|
||||
// Pfad evtl. anpassen, wenn du Proxy nutzt
|
||||
const res = await $fetch(`http://localhost:3100/workflows/context/${token}`, { headers })
|
||||
|
||||
context.value = res
|
||||
status.value = 'ready'
|
||||
} catch (err) {
|
||||
if (err.statusCode === 401) {
|
||||
status.value = 'pin_required' // PIN nötig (aber noch keine eingegeben)
|
||||
} else if (err.statusCode === 403) {
|
||||
status.value = 'pin_required'
|
||||
errorMsg.value = 'Falsche PIN'
|
||||
pin.value = ''
|
||||
} else {
|
||||
status.value = 'error'
|
||||
errorMsg.value = 'Link ungültig oder abgelaufen.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialer Aufruf
|
||||
onMounted(() => {
|
||||
loadContext()
|
||||
})
|
||||
|
||||
const handlePinSubmit = () => {
|
||||
if (pin.value.length >= 4) loadContext()
|
||||
}
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
status.value = 'success'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center p-4 font-sans">
|
||||
|
||||
<div v-if="status === 'loading'" class="text-center">
|
||||
<UIcon name="i-heroicons-arrow-path" class="w-10 h-10 animate-spin text-primary-500 mx-auto" />
|
||||
<p class="mt-4 text-gray-500">Lade Formular...</p>
|
||||
</div>
|
||||
|
||||
<UCard v-else-if="status === 'error'" class="w-full max-w-md border-red-200">
|
||||
<div class="text-center text-red-600 space-y-2">
|
||||
<UIcon name="i-heroicons-exclamation-circle" class="w-12 h-12 mx-auto" />
|
||||
<h3 class="font-bold text-lg">Fehler</h3>
|
||||
<p>{{ errorMsg }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard v-else-if="status === 'pin_required'" class="w-full max-w-sm shadow-xl">
|
||||
<div class="text-center mb-6">
|
||||
<div class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<UIcon name="i-heroicons-lock-closed" class="w-6 h-6 text-primary-600" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-900">Geschützter Bereich</h2>
|
||||
<p class="text-sm text-gray-500">Bitte PIN eingeben</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handlePinSubmit" class="space-y-4">
|
||||
<UInput
|
||||
v-model="pin"
|
||||
type="password"
|
||||
placeholder="PIN"
|
||||
input-class="text-center text-lg tracking-widest"
|
||||
autofocus
|
||||
icon="i-heroicons-key"
|
||||
/>
|
||||
<div v-if="errorMsg" class="text-red-500 text-xs text-center font-medium">{{ errorMsg }}</div>
|
||||
<UButton type="submit" block label="Entsperren" size="lg" />
|
||||
</form>
|
||||
</UCard>
|
||||
|
||||
<UCard v-else-if="status === 'success'" class="w-full max-w-md text-center py-10">
|
||||
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<UIcon name="i-heroicons-check" class="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">Gespeichert!</h2>
|
||||
<p class="text-gray-500 mb-6">Die Daten wurden erfolgreich übertragen.</p>
|
||||
<UButton variant="outline" @click="() => window.location.reload()">Neuen Eintrag erfassen</UButton>
|
||||
</UCard>
|
||||
|
||||
<div v-else-if="status === 'ready'" class="w-full max-w-lg">
|
||||
<PublicDynamicForm
|
||||
v-if="context && token"
|
||||
:context="context"
|
||||
:token="token"
|
||||
:pin="pin"
|
||||
@success="handleFormSuccess"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
190
stores/labelPrinter.ts
Normal file
190
stores/labelPrinter.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { defineStore } from "pinia"
|
||||
import {
|
||||
Utils,
|
||||
RequestCommandId,
|
||||
ResponseCommandId,
|
||||
NiimbotBluetoothClient,
|
||||
NiimbotSerialClient
|
||||
} from "@mmote/niimbluelib"
|
||||
import { useToast } from "#imports"
|
||||
|
||||
export const useLabelPrinterStore = defineStore("labelPrinter", {
|
||||
state: () => ({
|
||||
client: null as NiimbotBluetoothClient | NiimbotSerialClient | null,
|
||||
connected: false,
|
||||
connectLoading: false,
|
||||
transportLastUsed: "",
|
||||
printProgress: 0,
|
||||
info: {} as any
|
||||
}),
|
||||
|
||||
actions: {
|
||||
|
||||
/** Logging Helper */
|
||||
logger(...args: any[]) {
|
||||
console.debug("[Printer]", ...args)
|
||||
},
|
||||
|
||||
/** --- Client erzeugen --- */
|
||||
newClient(transport: "ble" | "serial" = "serial") {
|
||||
const toast = useToast()
|
||||
|
||||
// alten Client trennen
|
||||
if (this.client) {
|
||||
try { this.client.disconnect() } catch {}
|
||||
}
|
||||
|
||||
// neuen Client erzeugen
|
||||
this.client =
|
||||
transport === "ble"
|
||||
? new NiimbotBluetoothClient()
|
||||
: new NiimbotSerialClient()
|
||||
|
||||
/** Events registrieren */
|
||||
|
||||
this.client.on("printerinfofetched", (e) => {
|
||||
console.log("printerInfoFetched")
|
||||
console.log(e.info)
|
||||
this.info = e.info
|
||||
})
|
||||
|
||||
this.client.on("connect", () => {
|
||||
this.connected = true
|
||||
toast.add({ title: "Drucker verbunden" })
|
||||
this.logger("connected")
|
||||
})
|
||||
|
||||
this.client.on("disconnect", () => {
|
||||
this.connected = false
|
||||
toast.add({ title: "Drucker getrennt" })
|
||||
this.logger("disconnected")
|
||||
})
|
||||
|
||||
this.client.on("printprogress", (e) => {
|
||||
if (e.pagePrintProgress) this.printProgress = e.pagePrintProgress
|
||||
this.logger(
|
||||
`Page ${e.page}/${e.pagesTotal}, Page print ${e.pagePrintProgress}%, Page feed ${e.pageFeedProgress}%`
|
||||
)
|
||||
})
|
||||
|
||||
return this.client
|
||||
},
|
||||
|
||||
/** --- Verbinden --- */
|
||||
async connect(transport: "ble" | "serial" = "serial") {
|
||||
const toast = useToast()
|
||||
|
||||
this.connectLoading = true
|
||||
|
||||
this.newClient(transport)
|
||||
|
||||
try {
|
||||
await this.client!.connect()
|
||||
this.transportLastUsed = transport
|
||||
this.connectLoading = false
|
||||
} catch (err) {
|
||||
console.error("[Printer] Connect failed:", err)
|
||||
toast.add({ title: "Verbindung fehlgeschlagen", color: "red" })
|
||||
this.connectLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
/** --- Trennen --- */
|
||||
async disconnect({ forget = false } = {}) {
|
||||
const toast = useToast()
|
||||
this.logger("Disconnect requested…")
|
||||
|
||||
if (!this.client) return
|
||||
|
||||
try {
|
||||
// Timer stoppen
|
||||
try {
|
||||
if (this.client.heartbeatTimer) {
|
||||
clearInterval(this.client.heartbeatTimer)
|
||||
this.client.heartbeatTimer = null
|
||||
}
|
||||
if (this.client.abstraction?.statusPollTimer) {
|
||||
clearInterval(this.client.abstraction.statusPollTimer)
|
||||
this.client.abstraction.statusPollTimer = null
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await this.client.disconnect?.()
|
||||
|
||||
// Serial-Port schließen
|
||||
const port = (this.client as any).port
|
||||
if (port) {
|
||||
try {
|
||||
if (port.readable) port.readable.cancel?.()
|
||||
if (port.writable) await port.writable.abort?.()
|
||||
await port.close?.()
|
||||
|
||||
if (forget && navigator.serial?.forgetPort) {
|
||||
await navigator.serial.forgetPort(port)
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger("Error closing port:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// BLE GATT
|
||||
if (
|
||||
this.client instanceof NiimbotBluetoothClient &&
|
||||
this.client.device?.gatt?.connected
|
||||
) {
|
||||
try {
|
||||
this.client.device.gatt.disconnect()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
this.connected = false
|
||||
this.client = null
|
||||
toast.add({ title: "Drucker getrennt" })
|
||||
} catch (err) {
|
||||
console.error("[Printer] Disconnect error", err)
|
||||
toast.add({ title: "Fehler beim Trennen", color: "red" })
|
||||
}
|
||||
},
|
||||
|
||||
/** Hilfsfunktion: EncodedImage reparieren */
|
||||
reviveEncodedImage(encoded: any) {
|
||||
if (!encoded?.rowsData) return encoded
|
||||
for (const row of encoded.rowsData) {
|
||||
if (row.rowData && !(row.rowData instanceof Uint8Array)) {
|
||||
row.rowData = new Uint8Array(Object.values(row.rowData))
|
||||
}
|
||||
}
|
||||
return encoded
|
||||
},
|
||||
|
||||
/** --- Drucken --- */
|
||||
async print(encoded: any, options?: { density?: number; pages?: number }) {
|
||||
const toast = useToast()
|
||||
|
||||
if (!this.client) throw new Error("Kein Drucker verbunden")
|
||||
|
||||
const fixed = this.reviveEncodedImage(encoded)
|
||||
const taskName = this.client.getPrintTaskType() ?? "B1"
|
||||
|
||||
const task = this.client.abstraction.newPrintTask(taskName, {
|
||||
totalPages: options?.pages ?? 1,
|
||||
statusPollIntervalMs: 100,
|
||||
statusTimeoutMs: 8000,
|
||||
density: options?.density ?? 5
|
||||
})
|
||||
|
||||
try {
|
||||
this.printProgress = 0
|
||||
await task.printInit()
|
||||
await task.printPage(fixed, options?.pages ?? 1)
|
||||
await task.waitForFinished()
|
||||
toast.add({ title: "Druck abgeschlossen" })
|
||||
} catch (e) {
|
||||
console.error("[Printer] print error", e)
|
||||
toast.add({ title: "Druckfehler", color: "red" })
|
||||
} finally {
|
||||
await task.printEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user