refactor: Move billing module assets to vendor package
BREAKING CHANGE: All billing-related frontend code moved to billing module Changes: - Remove billing Vue components from core (InvoiceForm, InvoiceManagement, etc.) - Remove billing config from core (billing_module.yaml) - Update router.js to dynamically load billing routes from module - Update Dashboard.vue to dynamically load InvoicesDashboardWidget - Add webpack alias '@billing-module' pointing to vendor/mycrm/billing-module - Billing module is now fully self-contained The billing module now exports: - routes.js: Route definitions for /billing/* paths - components.js: Reusable components (InvoicesDashboardWidget) - index.js: Main module exports Frontend assets are loaded via '@billing-module' alias in webpack. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
82b022ba3b
commit
77ce2c3043
@ -7,7 +7,15 @@ import ProjectStatusManagement from './views/ProjectStatusManagement.vue';
|
||||
import UserManagement from './views/UserManagement.vue';
|
||||
import RoleManagement from './views/RoleManagement.vue';
|
||||
import SettingsManagement from './views/SettingsManagement.vue';
|
||||
import InvoiceManagement from './views/InvoiceManagement.vue';
|
||||
|
||||
// Import Billing Module Routes
|
||||
let billingRoutes = [];
|
||||
try {
|
||||
const billingModule = require('@billing-module/assets/js/routes.js');
|
||||
billingRoutes = billingModule.default || [];
|
||||
} catch (e) {
|
||||
console.warn('Billing module not available:', e.message);
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'dashboard', component: Dashboard },
|
||||
@ -18,8 +26,8 @@ const routes = [
|
||||
{ path: '/users', name: 'users', component: UserManagement, meta: { requiresAdmin: true } },
|
||||
{ path: '/roles', name: 'roles', component: RoleManagement, meta: { requiresAdmin: true } },
|
||||
{ path: '/settings', name: 'settings', component: SettingsManagement, meta: { requiresAdmin: true } },
|
||||
// Billing Module Routes
|
||||
{ path: '/billing/invoices', name: 'invoices', component: InvoiceManagement, meta: { requiresPermission: { module: 'billing', action: 'view' } } },
|
||||
// Dynamically loaded module routes
|
||||
...billingRoutes,
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@ -128,6 +128,18 @@
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Open Invoices Widget (nur wenn Berechtigung vorhanden) -->
|
||||
<template v-if="authStore.hasPermission('billing.view')">
|
||||
<InvoicesDashboardWidget />
|
||||
</template>
|
||||
<div v-else-if="authStore.user">
|
||||
<!-- Debug Info (kann später entfernt werden) -->
|
||||
<div style="display: none;">
|
||||
DEBUG: User has no billing.view permission.
|
||||
User permissions: {{ JSON.stringify(authStore.user.modulePermissions) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Projects -->
|
||||
<Card>
|
||||
<template #content>
|
||||
@ -327,12 +339,24 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
import Tag from 'primevue/tag'
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
|
||||
// Import Billing Module Widget dynamically
|
||||
let InvoicesDashboardWidget = null
|
||||
try {
|
||||
const billingComponents = require('@billing-module/assets/js/components.js')
|
||||
InvoicesDashboardWidget = billingComponents.InvoicesDashboardWidget
|
||||
} catch (e) {
|
||||
console.warn('Billing module components not available:', e.message)
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Reactive data
|
||||
const stats = ref({
|
||||
totalContacts: 0,
|
||||
|
||||
@ -1,645 +0,0 @@
|
||||
<template>
|
||||
<div class="invoice-form">
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Basic Information Panel -->
|
||||
<Panel header="Grunddaten" class="invoice-panel">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="invoiceNumber">Rechnungsnummer *</label>
|
||||
<InputText
|
||||
id="invoiceNumber"
|
||||
v-model="form.invoiceNumber"
|
||||
:class="{ 'p-invalid': submitted && !form.invoiceNumber }"
|
||||
:disabled="saving"
|
||||
/>
|
||||
<small v-if="submitted && !form.invoiceNumber" class="p-error">Rechnungsnummer ist erforderlich</small>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="status">Status</label>
|
||||
<Dropdown
|
||||
id="status"
|
||||
v-model="form.status"
|
||||
:options="statusOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:disabled="saving"
|
||||
>
|
||||
<template #value="slotProps">
|
||||
<div v-if="slotProps.value" class="flex align-items-center">
|
||||
<Tag :value="getStatusLabel(slotProps.value)" :severity="getStatusSeverity(slotProps.value)" />
|
||||
</div>
|
||||
</template>
|
||||
<template #option="slotProps">
|
||||
<div class="flex align-items-center">
|
||||
<Tag :value="slotProps.option.label" :severity="getStatusSeverity(slotProps.option.value)" />
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="contact">Kunde *</label>
|
||||
<Dropdown
|
||||
id="contact"
|
||||
v-model="form.contactId"
|
||||
:options="contacts"
|
||||
option-label="companyName"
|
||||
option-value="id"
|
||||
filter
|
||||
:filter-fields="['companyName', 'email']"
|
||||
placeholder="Kunde auswählen"
|
||||
:class="{ 'p-invalid': submitted && !form.contactId }"
|
||||
:loading="loadingContacts"
|
||||
:disabled="saving"
|
||||
show-clear
|
||||
>
|
||||
<template #value="slotProps">
|
||||
<div v-if="slotProps.value" class="flex align-items-center">
|
||||
<i class="pi pi-building mr-2"></i>
|
||||
<span>{{ getContactName(slotProps.value) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #option="slotProps">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-building"></i>
|
||||
<div>
|
||||
<div class="font-semibold">{{ slotProps.option.companyName }}</div>
|
||||
<div class="text-sm text-500">{{ slotProps.option.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<small v-if="submitted && !form.contactId" class="p-error">Bitte wählen Sie einen Kunden aus</small>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 col-span-full">
|
||||
<label for="notes">Notizen</label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
v-model="form.notes"
|
||||
rows="3"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Dates Panel -->
|
||||
<Panel header="Daten" class="invoice-panel">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="invoiceDate">Rechnungsdatum *</label>
|
||||
<Calendar
|
||||
id="invoiceDate"
|
||||
v-model="form.invoiceDate"
|
||||
date-format="dd.mm.yy"
|
||||
:disabled="saving"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="dueDate">Fälligkeitsdatum *</label>
|
||||
<Calendar
|
||||
id="dueDate"
|
||||
v-model="form.dueDate"
|
||||
date-format="dd.mm.yy"
|
||||
:disabled="saving"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-500">Zahlungsziel</label>
|
||||
<div class="flex align-items-center h-full pt-2">
|
||||
<Tag :value="`${paymentTermDays} Tage`" severity="info" class="text-base px-3 py-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Invoice Items Panel -->
|
||||
<Panel header="Rechnungspositionen" class="invoice-panel">
|
||||
|
||||
<div v-if="form.items.length === 0" class="p-4 border-round border-1 surface-border text-center text-500 mb-3">
|
||||
<i class="pi pi-inbox text-3xl mb-2"></i>
|
||||
<p>Noch keine Positionen hinzugefügt</p>
|
||||
</div>
|
||||
|
||||
<DataTable v-else :value="form.items" responsiveLayout="scroll" striped-rows>
|
||||
<Column field="description" header="Beschreibung" style="min-width: 200px">
|
||||
<template #body="slotProps">
|
||||
<Textarea
|
||||
v-model="slotProps.data.description"
|
||||
rows="2"
|
||||
class="w-full"
|
||||
placeholder="Beschreibung der Position"
|
||||
@input="calculateTotals"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="quantity" header="Menge" style="width: 120px">
|
||||
<template #body="slotProps">
|
||||
<InputNumber
|
||||
v-model="slotProps.data.quantity"
|
||||
:min-fraction-digits="2"
|
||||
:max-fraction-digits="2"
|
||||
:min="0"
|
||||
class="w-full"
|
||||
@input="calculateTotals"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="unitPrice" header="Einzelpreis" style="width: 150px">
|
||||
<template #body="slotProps">
|
||||
<InputNumber
|
||||
v-model="slotProps.data.unitPrice"
|
||||
mode="currency"
|
||||
currency="EUR"
|
||||
locale="de-DE"
|
||||
class="w-full"
|
||||
@input="calculateTotals"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="taxRate" header="MwSt %" style="width: 120px">
|
||||
<template #body="slotProps">
|
||||
<Dropdown
|
||||
v-model="slotProps.data.taxRate"
|
||||
:options="taxRates"
|
||||
class="w-full"
|
||||
@change="calculateTotals"
|
||||
>
|
||||
<template #value="{ value }">
|
||||
{{ value }}%
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
{{ option }}%
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Gesamt" style="width: 150px">
|
||||
<template #body="slotProps">
|
||||
<div class="font-semibold">
|
||||
{{ formatCurrency(calculateItemTotal(slotProps.data)) }}
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column style="width: 10%">
|
||||
<template #body="slotProps">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
size="small"
|
||||
text
|
||||
severity="danger"
|
||||
@click="removeItem(slotProps.index)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<!-- Add Item Button -->
|
||||
<div class="mt-3">
|
||||
<Button
|
||||
label="Position hinzufügen"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
outlined
|
||||
@click="addItem"
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Totals Panel -->
|
||||
<Panel v-if="form.items.length > 0" header="Summen" class="invoice-panel totals-panel">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div></div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex justify-between p-3 border-round surface-50">
|
||||
<span class="text-600 font-medium">Nettobetrag:</span>
|
||||
<span class="font-semibold">{{ formatCurrency(calculatedTotals.netAmount) }}</span>
|
||||
</div>
|
||||
<div v-for="(tax, rate) in calculatedTotals.taxBreakdown" :key="rate" class="flex justify-between p-3 border-round surface-50">
|
||||
<span class="text-600 font-medium">MwSt {{ rate }}%:</span>
|
||||
<span class="font-semibold">{{ formatCurrency(tax) }}</span>
|
||||
</div>
|
||||
<Divider class="my-2" />
|
||||
<div class="flex justify-between p-4 border-round bg-primary-50 border-2 border-primary-200">
|
||||
<span class="font-bold text-xl text-primary-700">Gesamtbetrag:</span>
|
||||
<span class="font-bold text-2xl text-primary-700">{{ formatCurrency(calculatedTotals.total) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<div class="flex justify-end gap-3 pt-4 border-top-1 surface-border mt-6">
|
||||
<Button label="Abbrechen" @click="cancel" text severity="secondary" :disabled="saving" />
|
||||
<Button label="Speichern" @click="save" :loading="saving" icon="pi pi-check" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import Calendar from 'primevue/calendar'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Button from 'primevue/button'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import Tag from 'primevue/tag'
|
||||
import Panel from 'primevue/panel'
|
||||
import Divider from 'primevue/divider'
|
||||
|
||||
const props = defineProps({
|
||||
invoice: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save', 'cancel'])
|
||||
|
||||
const toast = useToast()
|
||||
const submitted = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadingContacts = ref(false)
|
||||
|
||||
const form = ref({
|
||||
invoiceNumber: '',
|
||||
status: 'draft',
|
||||
contactId: null,
|
||||
invoiceDate: new Date(),
|
||||
dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // +14 Tage
|
||||
notes: '',
|
||||
items: []
|
||||
})
|
||||
|
||||
const contacts = ref([])
|
||||
const calculatedTotals = ref({
|
||||
netAmount: 0,
|
||||
taxBreakdown: {},
|
||||
total: 0
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Entwurf', value: 'draft' },
|
||||
{ label: 'Offen', value: 'open' },
|
||||
{ label: 'Bezahlt', value: 'paid' },
|
||||
{ label: 'Teilweise bezahlt', value: 'partial' },
|
||||
{ label: 'Überfällig', value: 'overdue' },
|
||||
{ label: 'Storniert', value: 'cancelled' }
|
||||
]
|
||||
|
||||
const taxRates = ['0.00', '7.00', '19.00']
|
||||
|
||||
// Computed Properties
|
||||
const paymentTermDays = computed(() => {
|
||||
if (!form.value.invoiceDate || !form.value.dueDate) return 0
|
||||
const diff = form.value.dueDate - form.value.invoiceDate
|
||||
return Math.round(diff / (1000 * 60 * 60 * 24))
|
||||
})
|
||||
|
||||
// Helper Functions
|
||||
const getStatusLabel = (status) => {
|
||||
const option = statusOptions.find(opt => opt.value === status)
|
||||
return option ? option.label : status
|
||||
}
|
||||
|
||||
const getStatusSeverity = (status) => {
|
||||
const severities = {
|
||||
draft: 'secondary',
|
||||
open: 'info',
|
||||
paid: 'success',
|
||||
partial: 'warning',
|
||||
overdue: 'danger',
|
||||
cancelled: 'secondary'
|
||||
}
|
||||
return severities[status] || 'info'
|
||||
}
|
||||
|
||||
const getContactName = (contactId) => {
|
||||
const contact = contacts.value.find(c => c.id === contactId)
|
||||
return contact ? contact.companyName : ''
|
||||
}
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount && amount !== 0) return '0,00 €'
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(parseFloat(amount))
|
||||
}
|
||||
|
||||
const calculateItemTotal = (item) => {
|
||||
const quantity = parseFloat(item.quantity) || 0
|
||||
const unitPrice = parseFloat(item.unitPrice) || 0
|
||||
const taxRate = parseFloat(item.taxRate) || 0
|
||||
const net = quantity * unitPrice
|
||||
const tax = net * (taxRate / 100)
|
||||
return net + tax
|
||||
}
|
||||
|
||||
const calculateTotals = () => {
|
||||
let netAmount = 0
|
||||
const taxBreakdown = {}
|
||||
|
||||
form.value.items.forEach(item => {
|
||||
const quantity = parseFloat(item.quantity) || 0
|
||||
const unitPrice = parseFloat(item.unitPrice) || 0
|
||||
const taxRate = parseFloat(item.taxRate) || 0
|
||||
|
||||
const itemNet = quantity * unitPrice
|
||||
const itemTax = itemNet * (taxRate / 100)
|
||||
|
||||
netAmount += itemNet
|
||||
|
||||
if (taxBreakdown[taxRate]) {
|
||||
taxBreakdown[taxRate] += itemTax
|
||||
} else {
|
||||
taxBreakdown[taxRate] = itemTax
|
||||
}
|
||||
})
|
||||
|
||||
const totalTax = Object.values(taxBreakdown).reduce((sum, tax) => sum + tax, 0)
|
||||
|
||||
calculatedTotals.value = {
|
||||
netAmount,
|
||||
taxBreakdown,
|
||||
total: netAmount + totalTax
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Load contacts
|
||||
loadingContacts.value = true
|
||||
try {
|
||||
const response = await fetch('/api/contacts?itemsPerPage=1000', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/ld+json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Fehler beim Laden der Kontakte')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
// API Platform kann 'hydra:member' oder 'member' verwenden
|
||||
contacts.value = data['hydra:member'] || data.member || []
|
||||
|
||||
console.log('Loaded contacts:', contacts.value.length)
|
||||
} catch (error) {
|
||||
console.error('Error loading contacts:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Fehler',
|
||||
detail: 'Kontakte konnten nicht geladen werden',
|
||||
life: 3000
|
||||
})
|
||||
} finally {
|
||||
loadingContacts.value = false
|
||||
}
|
||||
|
||||
// Load existing invoice data
|
||||
if (props.invoice) {
|
||||
form.value = { ...props.invoice }
|
||||
|
||||
// Extract contact ID from IRI if needed
|
||||
if (props.invoice.contact) {
|
||||
if (typeof props.invoice.contact === 'string') {
|
||||
// Extract ID from IRI like "/api/contacts/123"
|
||||
const matches = props.invoice.contact.match(/\/api\/contacts\/(\d+)/)
|
||||
form.value.contactId = matches ? parseInt(matches[1]) : null
|
||||
} else if (props.invoice.contact.id) {
|
||||
form.value.contactId = props.invoice.contact.id
|
||||
}
|
||||
}
|
||||
|
||||
if (props.invoice.invoiceDate) {
|
||||
form.value.invoiceDate = new Date(props.invoice.invoiceDate)
|
||||
}
|
||||
if (props.invoice.dueDate) {
|
||||
form.value.dueDate = new Date(props.invoice.dueDate)
|
||||
}
|
||||
|
||||
// Calculate initial totals if items exist
|
||||
if (form.value.items && form.value.items.length > 0) {
|
||||
calculateTotals()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const addItem = () => {
|
||||
form.value.items.push({
|
||||
description: '',
|
||||
quantity: 1.00,
|
||||
unitPrice: 0.00,
|
||||
taxRate: '19.00'
|
||||
})
|
||||
calculateTotals()
|
||||
}
|
||||
|
||||
const removeItem = (index) => {
|
||||
form.value.items.splice(index, 1)
|
||||
calculateTotals()
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
submitted.value = true
|
||||
|
||||
// Validation
|
||||
if (!form.value.invoiceNumber || !form.value.contactId) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Validierung fehlgeschlagen',
|
||||
detail: 'Bitte füllen Sie alle Pflichtfelder aus',
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (form.value.items.length === 0) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Validierung fehlgeschlagen',
|
||||
detail: 'Bitte fügen Sie mindestens eine Position hinzu',
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const method = props.invoice ? 'PUT' : 'POST'
|
||||
const url = props.invoice ? `/api/invoices/${props.invoice.id}` : '/api/invoices'
|
||||
|
||||
// Konvertiere alle numerischen Werte in Items zu Strings (für DECIMAL Felder)
|
||||
const items = form.value.items.map(item => ({
|
||||
description: item.description,
|
||||
quantity: String(item.quantity),
|
||||
unitPrice: String(item.unitPrice),
|
||||
taxRate: String(item.taxRate)
|
||||
}))
|
||||
|
||||
const payload = {
|
||||
invoiceNumber: form.value.invoiceNumber,
|
||||
contact: `/api/contacts/${form.value.contactId}`,
|
||||
status: form.value.status,
|
||||
invoiceDate: form.value.invoiceDate.toISOString().split('T')[0],
|
||||
dueDate: form.value.dueDate.toISOString().split('T')[0],
|
||||
notes: form.value.notes,
|
||||
items: items
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/ld+json',
|
||||
'Accept': 'application/ld+json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error['hydra:description'] || error.message || 'Fehler beim Speichern')
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Erfolg',
|
||||
detail: `Rechnung wurde ${props.invoice ? 'aktualisiert' : 'erstellt'}`,
|
||||
life: 3000
|
||||
})
|
||||
|
||||
emit('save', form.value)
|
||||
} catch (error) {
|
||||
console.error('Error saving invoice:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Fehler',
|
||||
detail: error.message || 'Rechnung konnte nicht gespeichert werden',
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.invoice-form {
|
||||
padding: 1.5rem;
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
|
||||
/* Panel Styling */
|
||||
.invoice-panel :deep(.p-panel-header) {
|
||||
background: var(--surface-card);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.invoice-panel :deep(.p-panel-content) {
|
||||
background: var(--surface-card);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.totals-panel :deep(.p-panel-header) {
|
||||
background: var(--primary-50);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.totals-panel :deep(.p-panel-content) {
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.p-4.border-round.border-1.surface-border.text-center {
|
||||
background: var(--surface-50);
|
||||
border-style: dashed !important;
|
||||
}
|
||||
|
||||
/* DataTable Styling */
|
||||
:deep(.p-datatable) {
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.p-datatable-thead > tr > th) {
|
||||
background: var(--surface-100);
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
:deep(.p-datatable-tbody > tr > td) {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
:deep(.p-datatable-tbody > tr:hover) {
|
||||
background: var(--surface-50);
|
||||
}
|
||||
|
||||
:deep(.p-inputnumber),
|
||||
:deep(.p-dropdown) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.p-inputtext),
|
||||
:deep(.p-dropdown),
|
||||
:deep(.p-calendar) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Footer Actions */
|
||||
.border-top-1 {
|
||||
border-top-width: 1px !important;
|
||||
border-top-style: solid !important;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.invoice-form {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.invoice-panel :deep(.p-panel-content) {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
:deep(.p-panel-header) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.grid.grid-cols-1.md\\:grid-cols-2,
|
||||
.grid.grid-cols-1.md\\:grid-cols-3 {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation */
|
||||
.invoice-panel {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.invoice-panel:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
@ -1,298 +0,0 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<h2>Rechnungsverwaltung</h2>
|
||||
|
||||
<CrudDataTable
|
||||
title="Rechnungen"
|
||||
entity-name="Rechnung"
|
||||
entity-name-article="eine"
|
||||
data-source="/api/invoices"
|
||||
storage-key="invoiceTableColumns"
|
||||
:columns="columns"
|
||||
:show-create-button="canCreate"
|
||||
@create="openCreateDialog"
|
||||
@edit="openEditDialog"
|
||||
@delete="deleteInvoice"
|
||||
>
|
||||
<template #body-invoiceNumber="{ data }">
|
||||
<router-link :to="`/billing/invoices/${data.id}`" class="text-primary">
|
||||
{{ data.invoiceNumber }}
|
||||
</router-link>
|
||||
</template>
|
||||
<template #body-contact.companyName="{ data }">
|
||||
{{ data.contact?.companyName || '-' }}
|
||||
</template>
|
||||
<template #body-invoiceDate="{ data }">
|
||||
{{ formatDate(data.invoiceDate) }}
|
||||
</template>
|
||||
<template #body-dueDate="{ data }">
|
||||
{{ formatDate(data.dueDate) }}
|
||||
</template>
|
||||
<template #body-total="{ data }">
|
||||
{{ formatCurrency(data.total) }}
|
||||
</template>
|
||||
<template #body-openAmount="{ data }">
|
||||
{{ formatCurrency(data.openAmount) }}
|
||||
</template>
|
||||
<template #body-status="{ data }">
|
||||
<Tag :value="getStatusLabel(data.status)" :severity="getStatusSeverity(data.status)" />
|
||||
</template>
|
||||
<template #body-actions="{ data }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="data.pdfPath"
|
||||
icon="pi pi-file-pdf"
|
||||
outlined
|
||||
severity="secondary"
|
||||
@click="viewPDF(data)"
|
||||
v-tooltip.top="'PDF anzeigen'"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
icon="pi pi-upload"
|
||||
outlined
|
||||
severity="info"
|
||||
@click="uploadPDF(data)"
|
||||
v-tooltip.top="'PDF hochladen'"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-money-bill"
|
||||
outlined
|
||||
severity="success"
|
||||
@click="addPayment(data)"
|
||||
v-tooltip.top="'Zahlung hinzufügen'"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
outlined
|
||||
severity="info"
|
||||
@click="openEditDialog(data)"
|
||||
v-tooltip.top="'Bearbeiten'"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
outlined
|
||||
severity="danger"
|
||||
@click="deleteInvoice(data)"
|
||||
v-tooltip.top="'Löschen'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</CrudDataTable>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="dialogVisible"
|
||||
:header="dialogTitle"
|
||||
:modal="true"
|
||||
:style="{width: '90vw', maxWidth: '1200px'}"
|
||||
:maximizable="true"
|
||||
:dismissableMask="true"
|
||||
class="invoice-dialog"
|
||||
>
|
||||
<InvoiceForm
|
||||
v-if="dialogVisible"
|
||||
:invoice="currentInvoice"
|
||||
@save="saveInvoice"
|
||||
@cancel="closeDialog"
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
<!-- Payment Dialog -->
|
||||
<Dialog v-model:visible="paymentDialogVisible" header="Zahlung hinzufügen" :modal="true" :style="{width: '40vw'}">
|
||||
<PaymentForm
|
||||
v-if="paymentDialogVisible"
|
||||
:invoice="currentInvoice"
|
||||
@save="savePayment"
|
||||
@cancel="paymentDialogVisible = false"
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
<!-- PDF Upload Dialog -->
|
||||
<Dialog v-model:visible="pdfUploadDialogVisible" header="PDF hochladen" :modal="true" :style="{width: '40vw'}">
|
||||
<PDFUploadForm
|
||||
v-if="pdfUploadDialogVisible"
|
||||
:invoice="currentInvoice"
|
||||
@save="savePDFUpload"
|
||||
@cancel="pdfUploadDialogVisible = false"
|
||||
/>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import CrudDataTable from '@/components/CrudDataTable.vue'
|
||||
import Column from 'primevue/column'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Tag from 'primevue/tag'
|
||||
import InvoiceForm from './InvoiceForm.vue'
|
||||
import PaymentForm from './PaymentForm.vue'
|
||||
import PDFUploadForm from './PDFUploadForm.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const currentInvoice = ref(null)
|
||||
const paymentDialogVisible = ref(false)
|
||||
const pdfUploadDialogVisible = ref(false)
|
||||
|
||||
const canCreate = computed(() => authStore.hasPermission('billing', 'create'))
|
||||
|
||||
const columns = [
|
||||
{ key: 'invoiceNumber', field: 'invoiceNumber', label: 'Rechnungsnummer', default: true },
|
||||
{ key: 'contact.companyName', field: 'contact.companyName', label: 'Kunde', default: true },
|
||||
{ key: 'invoiceDate', field: 'invoiceDate', label: 'Rechnungsdatum', default: true },
|
||||
{ key: 'dueDate', field: 'dueDate', label: 'Fälligkeitsdatum', default: true },
|
||||
{ key: 'total', field: 'total', label: 'Gesamtbetrag', default: true },
|
||||
{ key: 'openAmount', field: 'openAmount', label: 'Offen', default: true },
|
||||
{ key: 'status', field: 'status', label: 'Status', default: true }
|
||||
]
|
||||
|
||||
const openCreateDialog = () => {
|
||||
currentInvoice.value = null
|
||||
dialogTitle.value = 'Neue Rechnung'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const openEditDialog = (invoice) => {
|
||||
currentInvoice.value = invoice
|
||||
dialogTitle.value = 'Rechnung bearbeiten'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
dialogVisible.value = false
|
||||
currentInvoice.value = null
|
||||
}
|
||||
|
||||
const saveInvoice = async (invoice) => {
|
||||
// Save logic handled by InvoiceForm
|
||||
closeDialog()
|
||||
// Refresh table
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const deleteInvoice = async (invoice) => {
|
||||
if (confirm(`Rechnung ${invoice.invoiceNumber} wirklich löschen?`)) {
|
||||
await fetch(`/api/invoices/${invoice.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
const addPayment = (invoice) => {
|
||||
currentInvoice.value = invoice
|
||||
paymentDialogVisible.value = true
|
||||
}
|
||||
|
||||
const savePayment = async () => {
|
||||
paymentDialogVisible.value = false
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const uploadPDF = (invoice) => {
|
||||
currentInvoice.value = invoice
|
||||
pdfUploadDialogVisible.value = true
|
||||
}
|
||||
|
||||
const savePDFUpload = async () => {
|
||||
pdfUploadDialogVisible.value = false
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const viewPDF = (invoice) => {
|
||||
window.open(invoice.pdfPath, '_blank')
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('de-DE')
|
||||
}
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount) return '0,00 €'
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(parseFloat(amount))
|
||||
}
|
||||
|
||||
const getStatusLabel = (status) => {
|
||||
const labels = {
|
||||
draft: 'Entwurf',
|
||||
open: 'Offen',
|
||||
paid: 'Bezahlt',
|
||||
partial: 'Teilweise bezahlt',
|
||||
overdue: 'Überfällig',
|
||||
cancelled: 'Storniert'
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
const getStatusSeverity = (status) => {
|
||||
const severities = {
|
||||
draft: 'secondary',
|
||||
open: 'info',
|
||||
paid: 'success',
|
||||
partial: 'warning',
|
||||
overdue: 'danger',
|
||||
cancelled: 'secondary'
|
||||
}
|
||||
return severities[status] || 'info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-primary {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-primary:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Dialog adjustments */
|
||||
.invoice-dialog :deep(.p-dialog-content) {
|
||||
padding: 0;
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
|
||||
.invoice-dialog :deep(.p-dialog-header) {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.invoice-dialog :deep(.p-dialog-header .p-dialog-title) {
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.invoice-dialog :deep(.p-dialog-header-icons button) {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.invoice-dialog :deep(.p-dialog-header-icons button:hover) {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.invoice-dialog :deep(.p-dialog) {
|
||||
width: 95vw !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,102 +0,0 @@
|
||||
<template>
|
||||
<div class="pdf-upload-form">
|
||||
<Message severity="info">
|
||||
PDF-Upload Funktionalität (Phase 1 MVP - wird in späteren Phasen implementiert)
|
||||
</Message>
|
||||
|
||||
<div class="grid p-fluid mt-3">
|
||||
<div class="col-12">
|
||||
<p>
|
||||
<strong>Rechnung:</strong> {{ invoice?.invoiceNumber }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<div class="col-12">
|
||||
<label for="pdfFile">PDF-Datei hochladen</label>
|
||||
<input
|
||||
id="pdfFile"
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
@change="handleFileChange"
|
||||
class="p-inputtext p-component"
|
||||
/>
|
||||
<small class="text-muted">Nur PDF-Dateien erlaubt (max. 10 MB)</small>
|
||||
</div>
|
||||
|
||||
<div class="col-12" v-if="selectedFile">
|
||||
<Message severity="success">
|
||||
Datei ausgewählt: {{ selectedFile.name }} ({{ formatFileSize(selectedFile.size) }})
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button label="Hochladen" icon="pi pi-upload" @click="upload" :disabled="!selectedFile" />
|
||||
<Button label="Abbrechen" icon="pi pi-times" severity="secondary" text @click="cancel" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
|
||||
const props = defineProps({
|
||||
invoice: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save', 'cancel'])
|
||||
|
||||
const selectedFile = ref(null)
|
||||
|
||||
const handleFileChange = (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (file && file.type === 'application/pdf') {
|
||||
selectedFile.value = file
|
||||
} else {
|
||||
alert('Bitte wählen Sie eine PDF-Datei aus.')
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
|
||||
}
|
||||
|
||||
const upload = async () => {
|
||||
if (!selectedFile.value) return
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('pdf', selectedFile.value)
|
||||
formData.append('invoiceId', props.invoice.id)
|
||||
|
||||
// TODO: Implement upload endpoint
|
||||
// await fetch('/api/invoices/upload-pdf', {
|
||||
// method: 'POST',
|
||||
// body: formData
|
||||
// })
|
||||
|
||||
alert('PDF-Upload wird in Phase 2 implementiert. Für Phase 1 MVP können Sie den Pfad manuell in der Datenbank setzen.')
|
||||
|
||||
emit('save')
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pdf-upload-form {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@ -1,121 +0,0 @@
|
||||
<template>
|
||||
<div class="payment-form">
|
||||
<div class="grid p-fluid">
|
||||
<div class="col-12">
|
||||
<p>
|
||||
<strong>Rechnung:</strong> {{ invoice?.invoiceNumber }}<br />
|
||||
<strong>Offener Betrag:</strong> {{ formatCurrency(invoice?.openAmount) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Zahlungsdatum -->
|
||||
<div class="col-12">
|
||||
<label for="paymentDate">Zahlungsdatum *</label>
|
||||
<Calendar id="paymentDate" v-model="form.paymentDate" date-format="dd.mm.yy" />
|
||||
</div>
|
||||
|
||||
<!-- Betrag -->
|
||||
<div class="col-12">
|
||||
<label for="amount">Betrag *</label>
|
||||
<InputNumber
|
||||
id="amount"
|
||||
v-model="form.amount"
|
||||
mode="currency"
|
||||
currency="EUR"
|
||||
locale="de-DE"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Zahlungsart -->
|
||||
<div class="col-12">
|
||||
<label for="paymentMethod">Zahlungsart</label>
|
||||
<Dropdown
|
||||
id="paymentMethod"
|
||||
v-model="form.paymentMethod"
|
||||
:options="paymentMethods"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Notizen -->
|
||||
<div class="col-12">
|
||||
<label for="notes">Notizen</label>
|
||||
<Textarea id="notes" v-model="form.notes" rows="3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button label="Speichern" icon="pi pi-check" @click="save" />
|
||||
<Button label="Abbrechen" icon="pi pi-times" severity="secondary" text @click="cancel" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import Calendar from 'primevue/calendar'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const props = defineProps({
|
||||
invoice: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save', 'cancel'])
|
||||
|
||||
const form = ref({
|
||||
paymentDate: new Date(),
|
||||
amount: parseFloat(props.invoice?.openAmount || 0),
|
||||
paymentMethod: 'bank_transfer',
|
||||
notes: ''
|
||||
})
|
||||
|
||||
const paymentMethods = [
|
||||
{ label: 'Überweisung', value: 'bank_transfer' },
|
||||
{ label: 'Bar', value: 'cash' },
|
||||
{ label: 'Karte', value: 'card' },
|
||||
{ label: 'PayPal', value: 'paypal' },
|
||||
{ label: 'SEPA-Lastschrift', value: 'sepa' },
|
||||
{ label: 'Sonstiges', value: 'other' }
|
||||
]
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount) return '0,00 €'
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(parseFloat(amount))
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const payload = {
|
||||
invoice: `/api/invoices/${props.invoice.id}`,
|
||||
paymentDate: form.value.paymentDate.toISOString().split('T')[0],
|
||||
amount: form.value.amount.toString(),
|
||||
paymentMethod: form.value.paymentMethod,
|
||||
notes: form.value.notes
|
||||
}
|
||||
|
||||
await fetch('/api/payments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
emit('save')
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.payment-form {
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
@ -1,6 +0,0 @@
|
||||
services:
|
||||
MyCRM\BillingModule\BillingModulePlugin:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
tags:
|
||||
- { name: 'app.module_plugin' }
|
||||
@ -78,7 +78,8 @@ Encore
|
||||
// Alias für '@/images' und '@' auf 'assets' setzen
|
||||
.addAliases({
|
||||
'@': path.resolve(__dirname, 'assets/js'),
|
||||
'@images': path.resolve(__dirname, 'assets/images')
|
||||
'@images': path.resolve(__dirname, 'assets/images'),
|
||||
'@billing-module': path.resolve(__dirname, 'vendor/mycrm/billing-module')
|
||||
})
|
||||
;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user