2026-05-25 22:45:23 +02:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { License } from '@/types'
|
2026-07-10 16:55:48 +02:00
|
|
|
|
import { newLicense } from '@/types/index.d'
|
2026-07-23 14:24:26 +02:00
|
|
|
|
import { hotkey, toShortISOString } from '@/lib/utils'
|
2026-05-25 22:45:23 +02:00
|
|
|
|
import Fuse from 'fuse.js'
|
|
|
|
|
|
import { onMounted, ref, computed } from 'vue'
|
|
|
|
|
|
import AppLayout from '@/layouts/AppLayout.vue'
|
|
|
|
|
|
import AppHeader from '@/components/AppHeader.vue'
|
|
|
|
|
|
import { Head } from '@inertiajs/vue3'
|
2026-07-10 16:55:48 +02:00
|
|
|
|
import { Table, TableRow, TableBody, TableCell, TableHead, TableHeader } from '@/components/ui/crm-table'
|
2026-05-25 22:45:23 +02:00
|
|
|
|
import { Input } from '@/components/ui/crm-input'
|
|
|
|
|
|
import Button from '@/components/ui/crm-button/Button.vue'
|
|
|
|
|
|
import { ButtonGroup } from '@/components/ui/button-group'
|
|
|
|
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
|
|
|
|
|
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
2026-07-23 14:24:26 +02:00
|
|
|
|
import LicenseService from '@/services/LicenseService'
|
|
|
|
|
|
import { Download, Trash2, Plus, Delete, Search, CheckCircle2, XCircle, CircleQuestionMark, Save } from 'lucide-vue-next'
|
|
|
|
|
|
import { alertStore } from '@/stores/alertStore'
|
|
|
|
|
|
import { toast } from 'vue-sonner'
|
2026-05-25 22:45:23 +02:00
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
licenseData: License[]
|
|
|
|
|
|
}
|
2026-07-23 14:24:26 +02:00
|
|
|
|
|
2026-05-25 22:45:23 +02:00
|
|
|
|
const props = defineProps<Props>()
|
2026-07-23 14:24:26 +02:00
|
|
|
|
|
|
|
|
|
|
// Working copy of the licenses shown in the table. All form inputs bind against this array.
|
2026-05-25 22:45:23 +02:00
|
|
|
|
const licenses = ref([] as License[])
|
2026-07-23 14:24:26 +02:00
|
|
|
|
// Snapshot of the initial server data used to detect whether a license has been changed.
|
|
|
|
|
|
const originalLicenses = ref([] as License[])
|
|
|
|
|
|
// Deep-clone the incoming license list so edits to the UI do not mutate the original props object.
|
|
|
|
|
|
const cloneLicenses = (licenseList: License[]) => JSON.parse(JSON.stringify(licenseList)) as License[]
|
|
|
|
|
|
|
2026-05-25 22:45:23 +02:00
|
|
|
|
const searchQuery = ref('')
|
|
|
|
|
|
const searchField = ref()
|
|
|
|
|
|
const alert = alertStore()
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
2026-07-23 14:24:26 +02:00
|
|
|
|
licenses.value = cloneLicenses(props.licenseData)
|
|
|
|
|
|
originalLicenses.value = cloneLicenses(licenses.value)
|
2026-07-10 16:55:48 +02:00
|
|
|
|
searchField.value = document.getElementById('search')
|
|
|
|
|
|
hotkey('n', createLicense)
|
2026-05-25 22:45:23 +02:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const fuse = computed(() => {
|
2026-07-23 14:24:26 +02:00
|
|
|
|
return new Fuse(licenses.value, {
|
2026-05-25 22:45:23 +02:00
|
|
|
|
keys: ['name', 'email', 'product', 'version'],
|
2026-07-23 14:24:26 +02:00
|
|
|
|
threshold: 0.3,
|
2026-05-25 22:45:23 +02:00
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const filteredLicenses = computed(() => {
|
2026-07-23 14:24:26 +02:00
|
|
|
|
let filtered = licenses.value
|
|
|
|
|
|
|
2026-05-25 22:45:23 +02:00
|
|
|
|
if (searchQuery.value) {
|
2026-07-23 14:24:26 +02:00
|
|
|
|
filtered = fuse.value.search(searchQuery.value).map((result) => result.item)
|
2026-05-25 22:45:23 +02:00
|
|
|
|
}
|
2026-07-23 14:24:26 +02:00
|
|
|
|
|
|
|
|
|
|
return filtered
|
2026-05-25 22:45:23 +02:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-23 14:24:26 +02:00
|
|
|
|
|
|
|
|
|
|
const isDirty = computed(() => {
|
|
|
|
|
|
return licenses.value.some(isLicenseDirty)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const isLicenseDirty = (license: License) => {
|
|
|
|
|
|
const originalLicense = originalLicenses.value.find((item) => item.id === license.id)
|
|
|
|
|
|
|
|
|
|
|
|
if (!originalLicense) {
|
|
|
|
|
|
return license.id === 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return JSON.stringify(originalLicense) !== JSON.stringify(license)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 16:55:48 +02:00
|
|
|
|
const createLicense = () => {
|
2026-07-23 14:24:26 +02:00
|
|
|
|
LicenseService.createLicense(newLicense()).then((license) => {
|
|
|
|
|
|
if (license) {
|
|
|
|
|
|
licenses.value.unshift(license)
|
|
|
|
|
|
originalLicenses.value = cloneLicenses(licenses.value)
|
|
|
|
|
|
}
|
2026-07-10 16:55:48 +02:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 14:24:26 +02:00
|
|
|
|
const updateLicenses = async () => {
|
|
|
|
|
|
const dirtyLicenses = licenses.value.filter(isLicenseDirty)
|
|
|
|
|
|
|
|
|
|
|
|
if (dirtyLicenses.length === 0) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const response = await LicenseService.updateLicenses(dirtyLicenses)
|
|
|
|
|
|
|
|
|
|
|
|
if (response) {
|
|
|
|
|
|
response.forEach((updatedLicense) => {
|
|
|
|
|
|
var i = licenses.value.findIndex(license => license.id == updatedLicense.id)
|
|
|
|
|
|
licenses.value[i] = updatedLicense
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
originalLicenses.value = cloneLicenses(licenses.value)
|
2026-07-10 16:55:48 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const downloadLicense = (license: License) => {
|
|
|
|
|
|
window.open('/licenses/download/' + license.id)
|
2026-05-25 22:45:23 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 16:55:48 +02:00
|
|
|
|
const deleteLicense = (license: License) => {
|
2026-07-23 14:24:26 +02:00
|
|
|
|
if (license.id === 0) {
|
|
|
|
|
|
licenses.value = licenses.value.filter((item) => item !== license)
|
2026-07-10 16:55:48 +02:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 22:45:23 +02:00
|
|
|
|
alert.show(
|
2026-07-23 14:24:26 +02:00
|
|
|
|
'Möchtest Du diese Lizenz wirklich löschen?',
|
2026-05-25 22:45:23 +02:00
|
|
|
|
null,
|
|
|
|
|
|
{
|
2026-07-23 14:24:26 +02:00
|
|
|
|
actionText: 'Löschen',
|
|
|
|
|
|
actionVariant: 'destructive',
|
2026-05-25 22:45:23 +02:00
|
|
|
|
onAction: async () => {
|
2026-07-10 16:55:48 +02:00
|
|
|
|
LicenseService.deleteLicense(license.id).then(() => {
|
2026-07-23 14:24:26 +02:00
|
|
|
|
licenses.value = licenses.value.filter((item) => item.id !== license.id)
|
|
|
|
|
|
originalLicenses.value = cloneLicenses(licenses.value)
|
2026-05-25 22:45:23 +02:00
|
|
|
|
}).catch((error) => {
|
|
|
|
|
|
toast.error('Fehler beim Löschen der Lizenz', { duration: 5000, description: error.message })
|
|
|
|
|
|
})
|
2026-07-23 14:24:26 +02:00
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-05-25 22:45:23 +02:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 16:55:48 +02:00
|
|
|
|
const remToPx = (rem: number) => {
|
2026-07-23 14:24:26 +02:00
|
|
|
|
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize)
|
2026-07-10 16:55:48 +02:00
|
|
|
|
}
|
2026-05-25 22:45:23 +02:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
|
|
|
|
|
|
<Head title="Lizenzen" />
|
|
|
|
|
|
|
|
|
|
|
|
<AppLayout title="Lizenzen">
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Header functions -->
|
|
|
|
|
|
<AppHeader>
|
|
|
|
|
|
<template #left>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<template #middle>
|
|
|
|
|
|
<!-- Search field -->
|
|
|
|
|
|
<Input ref="search-field" id="search" type="text" placeholder="Filtern" class="px-8 bg-background"
|
|
|
|
|
|
v-model="searchQuery" />
|
|
|
|
|
|
<span class="absolute start-0 inset-y-0 flex items-center justify-center px-2">
|
|
|
|
|
|
<Search class="size-4 text-muted-foreground" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="absolute end-0 inset-y-0 flex items-center justify-center px-0 mr-1">
|
|
|
|
|
|
<Button :size="'sm'" :variant="'ghost'" @click="searchQuery = ''; searchField.focus();">
|
|
|
|
|
|
<Delete class="size-4 text-muted-foreground" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template #right>
|
2026-07-10 16:55:48 +02:00
|
|
|
|
<div class="flex gap-2 items-center">
|
|
|
|
|
|
<TooltipProvider>
|
2026-07-23 14:24:26 +02:00
|
|
|
|
<!-- Save button -->
|
|
|
|
|
|
<Button variant="action" @click="updateLicenses" v-if="isDirty">
|
|
|
|
|
|
<Save /> Speichern
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
2026-07-10 16:55:48 +02:00
|
|
|
|
<!-- New button -->
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger>
|
|
|
|
|
|
<Button @click="createLicense">
|
2026-07-23 14:24:26 +02:00
|
|
|
|
<Plus /> Neu
|
2026-07-10 16:55:48 +02:00
|
|
|
|
</Button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>
|
|
|
|
|
|
<span>Neue Lizenz anlegen</span>
|
2026-07-23 14:24:26 +02:00
|
|
|
|
<KbdGroup class="ml-2"><Kbd>N</Kbd></KbdGroup>
|
2026-07-10 16:55:48 +02:00
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
|
</div>
|
2026-05-25 22:45:23 +02:00
|
|
|
|
</template>
|
|
|
|
|
|
</AppHeader>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- License table -->
|
|
|
|
|
|
<Table>
|
|
|
|
|
|
<TableHeader>
|
|
|
|
|
|
<TableRow>
|
2026-07-10 16:55:48 +02:00
|
|
|
|
<TableHead class="w-1/100 text-center">Gültig</TableHead>
|
|
|
|
|
|
<TableHead class="w-1/100">Perm.</TableHead>
|
|
|
|
|
|
<TableHead class="pl-7">Ablaufdatum</TableHead>
|
|
|
|
|
|
<TableHead class="pl-7">Produkt</TableHead>
|
|
|
|
|
|
<TableHead class="pl-7">Version</TableHead>
|
|
|
|
|
|
<TableHead class="pl-7" colspan="2">Lizenznehmer</TableHead>
|
2026-05-25 22:45:23 +02:00
|
|
|
|
<TableHead class="w-1/100 print:hidden"></TableHead>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
|
|
|
|
|
|
<TableBody>
|
|
|
|
|
|
|
|
|
|
|
|
<TableRow v-for="license in filteredLicenses" :key="license.id">
|
|
|
|
|
|
<TableCell class="text-center">
|
2026-07-23 14:24:26 +02:00
|
|
|
|
<CircleQuestionMark class="size-5 inline-block text-muted-foreground" :size="remToPx(1)"
|
2026-07-10 16:55:48 +02:00
|
|
|
|
v-if="license.validationInfo === undefined" />
|
2026-07-23 14:24:26 +02:00
|
|
|
|
<CheckCircle2 class="size-5 inline-block text-success" :size="remToPx(1)"
|
2026-07-10 16:55:48 +02:00
|
|
|
|
v-else-if="license.validationInfo.isExpired === false" />
|
2026-07-23 14:24:26 +02:00
|
|
|
|
<XCircle class="size-5 inline-block text-destructive" :size="remToPx(1)" v-else />
|
2026-07-10 16:55:48 +02:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell class="text-center">
|
2026-07-23 14:24:26 +02:00
|
|
|
|
<input type="checkbox" v-model="license.isPerpetual" />
|
2026-07-10 16:55:48 +02:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell class="whitespace-nowrap">
|
|
|
|
|
|
<div class="text-center" v-if="license.isPerpetual">–</div>
|
|
|
|
|
|
<Input v-else type="date" :modelValue="toShortISOString(license.expirationDate || new Date())"
|
2026-07-23 14:24:26 +02:00
|
|
|
|
@update:model-value="(value) => license.expirationDate = value as string"
|
|
|
|
|
|
placeholder="Datum" />
|
2026-05-25 22:45:23 +02:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell>
|
2026-07-23 14:24:26 +02:00
|
|
|
|
<Input type="text" v-model="license.product" placeholder="Produkt Name" />
|
2026-05-25 22:45:23 +02:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell>
|
2026-07-23 14:24:26 +02:00
|
|
|
|
<Input type="text" v-model="license.version" placeholder="1.0.0" />
|
2026-05-25 22:45:23 +02:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
<TableCell>
|
2026-07-23 14:24:26 +02:00
|
|
|
|
<Input type="text" v-model="license.name" placeholder="Firma" />
|
2026-05-25 22:45:23 +02:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell>
|
2026-07-23 14:24:26 +02:00
|
|
|
|
<Input type="email" v-model="license.email" placeholder="E-Mail" />
|
2026-05-25 22:45:23 +02:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
<TableCell class="print:hidden">
|
|
|
|
|
|
<ButtonGroup>
|
2026-07-10 16:55:48 +02:00
|
|
|
|
<Button class="size-7" variant="ghost" @click="downloadLicense(license)">
|
2026-05-25 22:45:23 +02:00
|
|
|
|
<Download class="text-muted-foreground" />
|
|
|
|
|
|
</Button>
|
2026-07-10 16:55:48 +02:00
|
|
|
|
<Button class="size-7" variant="ghost" @click="deleteLicense(license)">
|
2026-05-25 22:45:23 +02:00
|
|
|
|
<Trash2 class="text-muted-foreground" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</ButtonGroup>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
</AppLayout>
|
|
|
|
|
|
</template>
|