Add External Link
Fix Plantafel
This commit is contained in:
2
backend/db/migrations/0027_product_supplier_link.sql
Normal file
2
backend/db/migrations/0027_product_supplier_link.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "products"
|
||||
ADD COLUMN "supplierLink" text;
|
||||
@@ -190,6 +190,13 @@
|
||||
"when": 1774393202000,
|
||||
"tag": "0026_statementallocation_depreciation_method",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1774602000000,
|
||||
"tag": "0027_product_supplier_link",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export const products = pgTable("products", {
|
||||
vendor_allocation: jsonb("vendorAllocation").default([]),
|
||||
|
||||
article_number: text("articleNumber"),
|
||||
supplier_link: text("supplierLink"),
|
||||
|
||||
barcodes: text("barcodes").array().notNull().default([]),
|
||||
|
||||
|
||||
48
frontend/components/columnRenderings/externalLink.vue
Normal file
48
frontend/components/columnRenderings/externalLink.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
row: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({})
|
||||
},
|
||||
keyName: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
})
|
||||
|
||||
const resolvedKey = computed(() => {
|
||||
if (props.keyName) return props.keyName
|
||||
if (typeof props.row?.supplier_link === "string") return "supplier_link"
|
||||
if (typeof props.row?.link === "string") return "link"
|
||||
if (typeof props.row?.url === "string") return "url"
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const normalizedUrl = computed(() => {
|
||||
const rawValue = resolvedKey.value ? props.row?.[resolvedKey.value] : null
|
||||
|
||||
if (!rawValue || typeof rawValue !== "string") return null
|
||||
|
||||
const trimmedValue = rawValue.trim()
|
||||
if (!trimmedValue) return null
|
||||
|
||||
if (/^https?:\/\//i.test(trimmedValue)) return trimmedValue
|
||||
|
||||
return `https://${trimmedValue}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
v-if="normalizedUrl"
|
||||
:href="normalizedUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:underline break-all"
|
||||
>
|
||||
{{ resolvedKey ? row[resolvedKey] : "" }}
|
||||
</a>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
@@ -51,6 +51,7 @@ const itemInfo = ref({
|
||||
dateOfPerformance: null,
|
||||
paymentDays: auth.activeTenantData.standardPaymentDays,
|
||||
payment_type: "transfer",
|
||||
availableInPortal: false,
|
||||
customSurchargePercentage: 0,
|
||||
created_by: auth.user.id,
|
||||
title: null,
|
||||
@@ -111,6 +112,18 @@ const serialIntervalItems = ['wöchentlich', '2 - wöchentlich', 'monatlich', 'v
|
||||
const serialDateDirectionItems = ['Rückwirkend', 'Im Voraus']
|
||||
const taxPercentItems = [19, 7, 0]
|
||||
const selectedCustomer = computed(() => customers.value.find(i => i.id === itemInfo.value.customer) || null)
|
||||
const normalizeExternalUrl = (value) => {
|
||||
if (!value || typeof value !== "string") return null
|
||||
|
||||
const trimmedValue = value.trim()
|
||||
if (!trimmedValue) return null
|
||||
|
||||
if (/^https?:\/\//i.test(trimmedValue)) return trimmedValue
|
||||
|
||||
return `https://${trimmedValue}`
|
||||
}
|
||||
|
||||
const getSelectedProduct = (row) => products.value.find((item) => item.id === row.product) || null
|
||||
const formatNumberLikeValue = (value) => {
|
||||
if (value === null || typeof value === 'undefined' || value === '') return '-'
|
||||
if (typeof value === 'string' || typeof value === 'number') return String(value)
|
||||
@@ -1438,7 +1451,8 @@ const saveSerialInvoice = async () => {
|
||||
contactPerson: itemInfo.value.contactPerson,
|
||||
serialConfig: itemInfo.value.serialConfig,
|
||||
letterhead: itemInfo.value.letterhead,
|
||||
taxType:itemInfo.value.taxType
|
||||
taxType:itemInfo.value.taxType,
|
||||
availableInPortal: itemInfo.value.availableInPortal
|
||||
}
|
||||
|
||||
let data = null
|
||||
@@ -1527,6 +1541,7 @@ const saveDocument = async (state, resetup = false) => {
|
||||
agriculture: itemInfo.value.agriculture,
|
||||
letterhead: itemInfo.value.letterhead,
|
||||
usedAdvanceInvoices: itemInfo.value.usedAdvanceInvoices,
|
||||
availableInPortal: itemInfo.value.availableInPortal,
|
||||
customSurchargePercentage: itemInfo.value.customSurchargePercentage,
|
||||
report: documentReport.value
|
||||
}
|
||||
@@ -2234,6 +2249,12 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
v-if="['invoices', 'advanceInvoices', 'cancellationInvoices'].includes(itemInfo.type)"
|
||||
label="Im Kundenportal anzeigen:"
|
||||
>
|
||||
<USwitch v-model="itemInfo.availableInPortal" />
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Objekt:"
|
||||
>
|
||||
@@ -2589,6 +2610,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
v-else-if="row.mode === 'normal'"
|
||||
>
|
||||
<InputGroup class="w-full">
|
||||
<div class="w-full min-w-0">
|
||||
<USelectMenu
|
||||
:disabled="itemInfo.type === 'cancellationInvoices'"
|
||||
class="w-full min-w-0"
|
||||
@@ -2603,10 +2625,24 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
||||
>
|
||||
<template #default>
|
||||
<span class="truncate">{{
|
||||
products.find(i => i.id === row.product) ? products.find(i => i.id === row.product).name : "Kein Produkt ausgewählt"
|
||||
getSelectedProduct(row) ? getSelectedProduct(row).name : "Kein Produkt ausgewählt"
|
||||
}}</span>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
<div
|
||||
v-if="normalizeExternalUrl(getSelectedProduct(row)?.supplier_link)"
|
||||
class="mt-1 text-xs"
|
||||
>
|
||||
<a
|
||||
:href="normalizeExternalUrl(getSelectedProduct(row)?.supplier_link)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:underline break-all"
|
||||
>
|
||||
Lieferantenlink öffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<EntityModalButtons
|
||||
type="products"
|
||||
:id="row.product"
|
||||
|
||||
@@ -3,6 +3,7 @@ import deLocale from "@fullcalendar/core/locales/de"
|
||||
import FullCalendar from "@fullcalendar/vue3"
|
||||
import interactionPlugin from "@fullcalendar/interaction"
|
||||
import resourceTimelinePlugin from "@fullcalendar/resource-timeline"
|
||||
import { parseDate } from "@internationalized/date"
|
||||
|
||||
const router = useRouter()
|
||||
const profileStore = useProfileStore()
|
||||
@@ -13,6 +14,10 @@ const { list: listStaffTimeSpans, createEntry, update: updateStaffTimeEntry } =
|
||||
const loading = ref(true)
|
||||
const savingAbsence = ref(false)
|
||||
const selectedType = ref("all")
|
||||
const calendarRef = ref(null)
|
||||
const calendarView = ref("resourceTimelineWeek")
|
||||
const calendarCurrentDate = ref($dayjs().format("YYYY-MM-DD"))
|
||||
const calendarTitle = ref("")
|
||||
const visibleRange = ref({
|
||||
from: $dayjs().startOf("month").format("YYYY-MM-DD"),
|
||||
to: $dayjs().endOf("month").format("YYYY-MM-DD")
|
||||
@@ -38,17 +43,72 @@ const setAbsenceDateToToday = (field) => {
|
||||
absenceForm[field] = $dayjs().format("YYYY-MM-DD")
|
||||
}
|
||||
|
||||
const startDateValue = computed({
|
||||
get: () => {
|
||||
if (!absenceForm.startDate) return null
|
||||
|
||||
try {
|
||||
return parseDate(absenceForm.startDate)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
set: (value) => {
|
||||
absenceForm.startDate = value ? value.toString() : ""
|
||||
}
|
||||
})
|
||||
|
||||
const endDateValue = computed({
|
||||
get: () => {
|
||||
if (!absenceForm.endDate) return null
|
||||
|
||||
try {
|
||||
return parseDate(absenceForm.endDate)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
set: (value) => {
|
||||
absenceForm.endDate = value ? value.toString() : ""
|
||||
}
|
||||
})
|
||||
|
||||
const resourceTypeOptions = [
|
||||
{ label: "Alle Ressourcen", value: "all" },
|
||||
{ label: "Profile", value: "Profile" },
|
||||
{ label: "Inventarartikel", value: "Inventarartikel" }
|
||||
]
|
||||
|
||||
const calendarViewOptions = [
|
||||
{ label: "Tag", value: "resourceTimelineDay" },
|
||||
{ label: "Woche", value: "resourceTimelineWeek" },
|
||||
{ label: "Monat", value: "resourceTimelineMonth" }
|
||||
]
|
||||
|
||||
const absenceTypeOptions = [
|
||||
{ label: "Urlaub", value: "vacation" },
|
||||
{ label: "Krank", value: "sick" }
|
||||
]
|
||||
|
||||
const calendarPickerValue = computed({
|
||||
get: () => {
|
||||
if (!calendarCurrentDate.value) return null
|
||||
|
||||
try {
|
||||
return parseDate(calendarCurrentDate.value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
set: (value) => {
|
||||
calendarCurrentDate.value = value ? value.toString() : ""
|
||||
if (value) {
|
||||
const api = calendarRef.value?.getApi?.()
|
||||
api?.gotoDate(value.toString())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const profileOptions = computed(() =>
|
||||
profiles.value
|
||||
.filter((profile) => !profile.archived && profile.user_id)
|
||||
@@ -83,12 +143,9 @@ const calendarOptions = computed(() => ({
|
||||
schedulerLicenseKey: "CC-Attribution-NonCommercial-NoDerivatives",
|
||||
locale: deLocale,
|
||||
plugins: [resourceTimelinePlugin, interactionPlugin],
|
||||
initialView: "resourceTimelineWeek",
|
||||
headerToolbar: {
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right: "resourceTimelineDay,resourceTimelineWeek,resourceTimelineMonth"
|
||||
},
|
||||
initialView: calendarView.value,
|
||||
initialDate: calendarCurrentDate.value,
|
||||
headerToolbar: false,
|
||||
resourceAreaWidth: "280px",
|
||||
resourceGroupField: "type",
|
||||
resourceOrder: "type,title",
|
||||
@@ -149,6 +206,10 @@ const calendarOptions = computed(() => ({
|
||||
const nextTo = $dayjs(info.end).subtract(1, "day").format("YYYY-MM-DD")
|
||||
const nextKey = `${nextFrom}:${nextTo}`
|
||||
|
||||
calendarView.value = info.view.type
|
||||
calendarCurrentDate.value = $dayjs(info.view.currentStart).format("YYYY-MM-DD")
|
||||
calendarTitle.value = info.view.title
|
||||
|
||||
if (nextKey === lastRangeKey.value) return
|
||||
|
||||
lastRangeKey.value = nextKey
|
||||
@@ -264,6 +325,31 @@ function resetAbsenceForm() {
|
||||
absenceForm.description = ""
|
||||
}
|
||||
|
||||
function getCalendarApi() {
|
||||
return calendarRef.value?.getApi?.()
|
||||
}
|
||||
|
||||
function changeCalendarView(view) {
|
||||
const api = getCalendarApi()
|
||||
if (!api || !view) return
|
||||
calendarView.value = view
|
||||
api.changeView(view)
|
||||
}
|
||||
|
||||
function moveCalendar(direction) {
|
||||
const api = getCalendarApi()
|
||||
if (!api) return
|
||||
|
||||
if (direction === "prev") api.prev()
|
||||
if (direction === "next") api.next()
|
||||
}
|
||||
|
||||
function moveCalendarToday() {
|
||||
const api = getCalendarApi()
|
||||
if (!api) return
|
||||
api.today()
|
||||
}
|
||||
|
||||
function openAbsenceModal(type = "vacation", preset = {}) {
|
||||
absenceForm.mode = preset.entry ? "edit" : "create"
|
||||
absenceForm.entry = preset.entry || null
|
||||
@@ -404,12 +490,49 @@ onMounted(() => {
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<USelectMenu
|
||||
v-model="selectedType"
|
||||
:options="resourceTypeOptions"
|
||||
value-attribute="value"
|
||||
option-attribute="label"
|
||||
:items="resourceTypeOptions"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
:clearable="false"
|
||||
class="min-w-[220px]"
|
||||
/>
|
||||
<USelectMenu
|
||||
v-model="calendarView"
|
||||
:items="calendarViewOptions"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
:clearable="false"
|
||||
class="min-w-[160px]"
|
||||
@update:model-value="changeCalendarView"
|
||||
/>
|
||||
<UPopover>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-heroicons-calendar-days"
|
||||
class="min-w-[180px] justify-between"
|
||||
>
|
||||
{{ calendarCurrentDate ? $dayjs(calendarCurrentDate).format("DD.MM.YYYY") : "Datum wählen" }}
|
||||
</UButton>
|
||||
|
||||
<template #content>
|
||||
<div class="p-2">
|
||||
<UCalendar v-model="calendarPickerValue" />
|
||||
<div class="flex justify-end border-t border-default pt-2">
|
||||
<UButton color="neutral" variant="ghost" size="sm" @click="moveCalendarToday">
|
||||
Heute
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
<div class="flex items-center gap-1">
|
||||
<UButton color="neutral" variant="ghost" icon="i-heroicons-chevron-left" @click="moveCalendar('prev')" />
|
||||
<UButton color="neutral" variant="ghost" icon="i-heroicons-chevron-right" @click="moveCalendar('next')" />
|
||||
</div>
|
||||
<span class="text-sm font-medium text-highlighted">
|
||||
{{ calendarTitle }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #right>
|
||||
@@ -449,6 +572,7 @@ onMounted(() => {
|
||||
|
||||
<FullCalendar
|
||||
v-else
|
||||
ref="calendarRef"
|
||||
:options="calendarOptions"
|
||||
/>
|
||||
</UDashboardPanelContent>
|
||||
@@ -475,33 +599,82 @@ onMounted(() => {
|
||||
<UFormField label="Profil">
|
||||
<USelectMenu
|
||||
v-model="absenceForm.userId"
|
||||
:options="profileOptions"
|
||||
value-attribute="value"
|
||||
option-attribute="label"
|
||||
searchable
|
||||
:items="profileOptions"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
class="w-full"
|
||||
:search-input="{ placeholder: 'Profil suchen...' }"
|
||||
:filter-fields="['label']"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Typ">
|
||||
<USelectMenu
|
||||
v-model="absenceForm.type"
|
||||
:options="absenceTypeOptions"
|
||||
value-attribute="value"
|
||||
option-attribute="label"
|
||||
:items="absenceTypeOptions"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="Start">
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput v-model="absenceForm.startDate" type="date" class="flex-1" />
|
||||
<UButton color="gray" variant="soft" label="Heute" @click="setAbsenceDateToToday('startDate')" />
|
||||
<UPopover>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-heroicons-calendar-days"
|
||||
class="min-w-0 flex-1 justify-between"
|
||||
>
|
||||
<span class="truncate text-left">
|
||||
{{ absenceForm.startDate ? $dayjs(absenceForm.startDate).format("DD.MM.YYYY") : "Kein Datum" }}
|
||||
</span>
|
||||
</UButton>
|
||||
|
||||
<template #content>
|
||||
<div class="p-2">
|
||||
<UCalendar v-model="startDateValue" />
|
||||
<div class="flex justify-end border-t border-default pt-2">
|
||||
<UButton color="neutral" variant="ghost" size="sm" @click="setAbsenceDateToToday('startDate')">
|
||||
Heute
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
|
||||
<UButton color="neutral" variant="soft" label="Heute" @click="setAbsenceDateToToday('startDate')" />
|
||||
</div>
|
||||
</UFormField>
|
||||
<UFormField label="Ende">
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput v-model="absenceForm.endDate" type="date" class="flex-1" />
|
||||
<UButton color="gray" variant="soft" label="Heute" @click="setAbsenceDateToToday('endDate')" />
|
||||
<UPopover>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-heroicons-calendar-days"
|
||||
class="min-w-0 flex-1 justify-between"
|
||||
>
|
||||
<span class="truncate text-left">
|
||||
{{ absenceForm.endDate ? $dayjs(absenceForm.endDate).format("DD.MM.YYYY") : "Kein Datum" }}
|
||||
</span>
|
||||
</UButton>
|
||||
|
||||
<template #content>
|
||||
<div class="p-2">
|
||||
<UCalendar v-model="endDateValue" />
|
||||
<div class="flex justify-end border-t border-default pt-2">
|
||||
<UButton color="neutral" variant="ghost" size="sm" @click="setAbsenceDateToToday('endDate')">
|
||||
Heute
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
|
||||
<UButton color="neutral" variant="soft" label="Heute" @click="setAbsenceDateToToday('endDate')" />
|
||||
</div>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,7 @@ import startDateTime from "~/components/columnRenderings/startDateTime.vue"
|
||||
import endDateTime from "~/components/columnRenderings/endDateTime.vue"
|
||||
import serviceCategories from "~/components/columnRenderings/serviceCategories.vue"
|
||||
import productcategoriesWithLoad from "~/components/columnRenderings/productcategoriesWithLoad.vue"
|
||||
import externalLink from "~/components/columnRenderings/externalLink.vue"
|
||||
import phase from "~/components/columnRenderings/phase.vue"
|
||||
import vehiclesWithLoad from "~/components/columnRenderings/vehiclesWithLoad.vue"
|
||||
import inventoryitemsWithLoad from "~/components/columnRenderings/inventoryitemsWithLoad.vue"
|
||||
@@ -1380,6 +1381,12 @@ export const useDataStore = defineStore('data', () => {
|
||||
label: "Beschreibung",
|
||||
inputType:"textarea"
|
||||
},
|
||||
{
|
||||
key: "supplier_link",
|
||||
label: "Link zum Lieferanten",
|
||||
inputType: "text",
|
||||
component: externalLink
|
||||
},
|
||||
],
|
||||
showTabs: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user