[License module] Fix casing in licenses, allow multiple uploads properly handle dirty states in frontend

This commit is contained in:
2026-07-23 14:24:26 +02:00
parent 35853171cb
commit d38ab2c2fc
5 changed files with 254 additions and 126 deletions
+86 -47
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { License } from '@/types'
import { newLicense } from '@/types/index.d'
import { toLocalDate, toShortISOString, hotkey } from '@/lib/utils'
import { hotkey, toShortISOString } from '@/lib/utils'
import Fuse from 'fuse.js'
import { onMounted, ref, computed } from 'vue'
import AppLayout from '@/layouts/AppLayout.vue'
@@ -13,52 +13,93 @@ 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'
import LicenseService from '@/services/LicenseService';
import { Download, Trash2, Plus, Delete, Search, CheckCircle, CheckCircle2, XCircle, CircleQuestionMark, BadgeQuestionMark, Eye } from 'lucide-vue-next'
import { alertStore } from "@/stores/alertStore"
import { toast } from 'vue-sonner';
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'
interface Props {
licenseData: License[]
}
const props = defineProps<Props>()
// Working copy of the licenses shown in the table. All form inputs bind against this array.
const licenses = ref([] as License[])
// 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[]
const searchQuery = ref('')
const searchField = ref()
const alert = alertStore()
onMounted(() => {
licenses.value = props.licenseData
licenses.value = cloneLicenses(props.licenseData)
originalLicenses.value = cloneLicenses(licenses.value)
searchField.value = document.getElementById('search')
hotkey('n', createLicense)
})
const fuse = computed(() => {
return new Fuse(props.licenseData, {
return new Fuse(licenses.value, {
keys: ['name', 'email', 'product', 'version'],
threshold: 0.3
threshold: 0.3,
})
})
const filteredLicenses = computed(() => {
let filteredLicenses = licenses.value
// Filter by search query
let filtered = licenses.value
if (searchQuery.value) {
filteredLicenses = fuse.value.search(searchQuery.value).map(result => result.item)
filtered = fuse.value.search(searchQuery.value).map((result) => result.item)
}
return filteredLicenses
return filtered
})
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)
}
const createLicense = () => {
LicenseService.createLicense(newLicense()).then(license => {
if (license) licenses.value.unshift(license)
LicenseService.createLicense(newLicense()).then((license) => {
if (license) {
licenses.value.unshift(license)
originalLicenses.value = cloneLicenses(licenses.value)
}
})
}
const updateLicense = (license: License) => {
LicenseService.updateLicense(license).then((response) => {
if (response) license = response
})
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)
}
const downloadLicense = (license: License) => {
@@ -66,32 +107,32 @@ const downloadLicense = (license: License) => {
}
const deleteLicense = (license: License) => {
if (license.id == 0) {
licenses.value = licenses.value.filter(l => l != license)
if (license.id === 0) {
licenses.value = licenses.value.filter((item) => item !== license)
return
}
alert.show(
"Möchtest Du diese Lizenz wirklich löschen?",
'Möchtest Du diese Lizenz wirklich löschen?',
null,
{
actionText: "Löschen",
actionVariant: "destructive",
actionText: 'Löschen',
actionVariant: 'destructive',
onAction: async () => {
LicenseService.deleteLicense(license.id).then(() => {
licenses.value = licenses.value.filter(license => license.id != license.id)
licenses.value = licenses.value.filter((item) => item.id !== license.id)
originalLicenses.value = cloneLicenses(licenses.value)
}).catch((error) => {
toast.error('Fehler beim Löschen der Lizenz', { duration: 5000, description: error.message })
})
}
}
},
},
)
}
const remToPx = (rem: number) => {
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize)
}
</script>
<template>
@@ -121,19 +162,21 @@ const remToPx = (rem: number) => {
<template #right>
<div class="flex gap-2 items-center">
<TooltipProvider>
<!-- Save button -->
<Button variant="action" @click="updateLicenses" v-if="isDirty">
<Save /> Speichern
</Button>
<!-- New button -->
<Tooltip>
<TooltipTrigger>
<Button @click="createLicense">
<Plus />
Neu
<Plus /> Neu
</Button>
</TooltipTrigger>
<TooltipContent>
<span>Neue Lizenz anlegen</span>
<KbdGroup class="ml-2">
<Kbd>N</Kbd>
</KbdGroup>
<KbdGroup class="ml-2"><Kbd>N</Kbd></KbdGroup>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -159,37 +202,33 @@ const remToPx = (rem: number) => {
<TableRow v-for="license in filteredLicenses" :key="license.id">
<TableCell class="text-center">
<CircleQuestionMark class="inline-block text-muted-foreground" :size="remToPx(1)"
<CircleQuestionMark class="size-5 inline-block text-muted-foreground" :size="remToPx(1)"
v-if="license.validationInfo === undefined" />
<CheckCircle2 class="inline-block text-success0" :size="remToPx(1)"
<CheckCircle2 class="size-5 inline-block text-success" :size="remToPx(1)"
v-else-if="license.validationInfo.isExpired === false" />
<XCircle class="inline-block text-destructive0" :size="remToPx(1)" v-else />
<XCircle class="size-5 inline-block text-destructive" :size="remToPx(1)" v-else />
</TableCell>
<TableCell class="text-center">
<input type="checkbox" v-model="license.isPerpetual"
v-on:change="updateLicense(license)" />
<input type="checkbox" v-model="license.isPerpetual" />
</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())"
v-on:blur="updateLicense(license)" placeholder="Datum" />
@update:model-value="(value) => license.expirationDate = value as string"
placeholder="Datum" />
</TableCell>
<TableCell>
<Input type="text" :modelValue="license.product" placeholder="Produkt Name"
v-on:blur="updateLicense(license)" />
<Input type="text" v-model="license.product" placeholder="Produkt Name" />
</TableCell>
<TableCell>
<Input type="text" :modelValue="license.version" placeholder="1.0.0"
v-on:blur="updateLicense(license)" />
<Input type="text" v-model="license.version" placeholder="1.0.0" />
</TableCell>
<TableCell>
<Input type="text" v-model="license.name" placeholder="Firma"
v-on:blur="updateLicense(license)" />
<Input type="text" v-model="license.name" placeholder="Firma" />
</TableCell>
<TableCell>
<Input type="email" :modelValue="license.email" placeholder="E-Mail"
v-on:blur="updateLicense(license)" />
<Input type="email" v-model="license.email" placeholder="E-Mail" />
</TableCell>
<TableCell class="print:hidden">
+20 -4
View File
@@ -1,5 +1,5 @@
import axios, { AxiosError } from 'axios';
import { License } from '@/types';
import { License, LicenseValidationInfo } from '@/types';
import { toast } from 'vue-sonner';
const API_URL = '/api/licenses';
@@ -53,7 +53,7 @@ export default {
},
/**
* Updates an existing license
* Updates one existing license
* @param license - The license data to update
* @returns Promise<License | null>
*/
@@ -68,6 +68,22 @@ export default {
}
},
/**
* Updates multiple licenses in one request.
* @param licenses - The dirty license data to update
* @returns Promise<License[] | null>
*/
async updateLicenses(licenses: License[]): Promise<License[] | null> {
try {
const response = await axios.put(API_URL, licenses);
return response.data;
} catch (error) {
toast.error('Fehler beim Aktualisieren der Lizenzen', { description: (error as AxiosError).message });
console.error(error);
return null;
}
},
/**
* Deletes a license by ID
* @param id - The ID of the license to delete
@@ -113,9 +129,9 @@ export default {
* @param licenseId - The ID of the license to validate
* @returns Promise<{ is_valid: boolean, is_signature_valid: boolean, is_expired: boolean } | null>
*/
async validateLicense(license: License): Promise<{ isValid: boolean, isSignatureValid: boolean, isExpired: boolean } | null> {
async validateLicense(license: License): Promise<LicenseValidationInfo | null> {
try {
const response = await axios.post('/api/licenses/validate', { license });
const response = await axios.post('/api/licenses/validate', license);
return response.data;
} catch (error) {
toast.error('Fehler beim Validieren der Lizenz', { description: (error as AxiosError).message });