diff --git a/frontend/components/displayTaxSummary.vue b/frontend/components/displayTaxSummary.vue new file mode 100644 index 0000000..7adf9ac --- /dev/null +++ b/frontend/components/displayTaxSummary.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/frontend/composables/useChangelog.ts b/frontend/composables/useChangelog.ts new file mode 100644 index 0000000..c363d88 --- /dev/null +++ b/frontend/composables/useChangelog.ts @@ -0,0 +1,155 @@ +import { createSharedComposable } from '@vueuse/core' + +type ChangelogEntry = { + hash: string + shortHash: string + subject: string + authorName: string + committedAt: string +} + +type ChangelogSeenState = { + lastOpenedAt: string | null + latestSeenHash: string | null +} + +const defaultSeenState = (): ChangelogSeenState => ({ + lastOpenedAt: null, + latestSeenHash: null +}) + +let changelogRequest: Promise | null = null + +const _useChangelog = () => { + const auth = useAuthStore() + + const entries = useState('changelog:entries', () => []) + const pending = useState('changelog:pending', () => false) + const error = useState('changelog:error', () => null) + const loadedKey = useState('changelog:loaded-key', () => null) + const seenState = useState('changelog:seen-state', defaultSeenState) + + const scopeKey = computed(() => { + const userId = auth.user?.id + const tenantId = auth.activeTenant + + if (!userId || !tenantId) return null + + return `${userId}:${tenantId}` + }) + + const storageKey = computed(() => { + if (!scopeKey.value) return null + + return `fedeo:changelog:last-opened:${scopeKey.value}` + }) + + const latestEntry = computed(() => entries.value[0] || null) + + const hasUnread = computed(() => { + if (!latestEntry.value?.hash) return false + + return latestEntry.value.hash !== seenState.value.latestSeenHash + }) + + function loadSeenState() { + if (!process.client || !storageKey.value) { + seenState.value = defaultSeenState() + return + } + + try { + const raw = localStorage.getItem(storageKey.value) + + if (!raw) { + seenState.value = defaultSeenState() + return + } + + const parsed = JSON.parse(raw) + + seenState.value = { + lastOpenedAt: parsed?.lastOpenedAt || null, + latestSeenHash: parsed?.latestSeenHash || null + } + } catch (err) { + console.error('Could not parse changelog seen state', err) + seenState.value = defaultSeenState() + } + } + + async function refresh(force = false) { + if (!process.client || !scopeKey.value) return + if (!force && loadedKey.value === scopeKey.value && entries.value.length) return + if (changelogRequest) return changelogRequest + + changelogRequest = (async () => { + pending.value = true + error.value = null + + try { + const response = await useNuxtApp().$api('/api/functions/changelog', { + query: { limit: 20 } + }) + + entries.value = Array.isArray(response?.entries) ? response.entries : [] + loadedKey.value = scopeKey.value + } catch (err: any) { + error.value = err?.data?.error || err?.message || 'Changelog konnte nicht geladen werden.' + } finally { + pending.value = false + } + })() + + try { + await changelogRequest + } finally { + changelogRequest = null + } + } + + function markAsSeen() { + if (!process.client || !storageKey.value) return + + const nextState = { + lastOpenedAt: new Date().toISOString(), + latestSeenHash: latestEntry.value?.hash || null + } + + seenState.value = nextState + + try { + localStorage.setItem(storageKey.value, JSON.stringify(nextState)) + } catch (err) { + console.error('Could not persist changelog seen state', err) + } + } + + watch(storageKey, () => { + loadSeenState() + }, { immediate: true }) + + watch(scopeKey, (nextScopeKey, previousScopeKey) => { + if (!process.client || !nextScopeKey) return + + if (nextScopeKey !== previousScopeKey) { + entries.value = [] + loadedKey.value = null + } + + void refresh(true) + }, { immediate: true }) + + return { + entries, + pending, + error, + latestEntry, + hasUnread, + seenState, + refresh, + markAsSeen + } +} + +export const useChangelog = createSharedComposable(_useChangelog) diff --git a/frontend/composables/useTaxEvaluation.ts b/frontend/composables/useTaxEvaluation.ts new file mode 100644 index 0000000..189f276 --- /dev/null +++ b/frontend/composables/useTaxEvaluation.ts @@ -0,0 +1,162 @@ +import dayjs from "dayjs" +import customParseFormat from "dayjs/plugin/customParseFormat" + +dayjs.extend(customParseFormat) + +export const TAX_EVALUATION_PERIOD_OPTIONS = [ + { label: "Monatlich", value: "monthly" }, + { label: "Quartalsweise", value: "quarterly" }, + { label: "Jährlich", value: "yearly" }, +] + +export const normalizeTaxEvaluationPeriod = (value?: string) => { + if (value === "quarterly" || value === "yearly") return value + return "monthly" +} + +const ZERO_BREAKDOWN = () => ({ + net19: 0, + tax19: 0, + net7: 0, + tax7: 0, + net0: 0, +}) + +const isTaxFreeDocument = (taxType?: string | null) => { + return ["13b UStG", "19 UStG", "12.3 UStG"].includes(String(taxType || "")) +} + +export const getTaxEvaluationPeriodBounds = ( + referenceDate: dayjs.ConfigType, + period: string +) => { + const normalized = normalizeTaxEvaluationPeriod(period) + const base = dayjs(referenceDate) + + if (normalized === "yearly") { + return { + start: base.startOf("year"), + end: base.endOf("year"), + } + } + + if (normalized === "quarterly") { + const quarterStartMonth = Math.floor(base.month() / 3) * 3 + const start = base.month(quarterStartMonth).startOf("month") + + return { + start, + end: start.add(2, "month").endOf("month"), + } + } + + return { + start: base.startOf("month"), + end: base.endOf("month"), + } +} + +export const shiftTaxEvaluationPeriodStart = ( + periodStart: dayjs.ConfigType, + period: string, + offset: number +) => { + const normalized = normalizeTaxEvaluationPeriod(period) + const base = dayjs(periodStart) + + if (normalized === "yearly") return base.add(offset, "year").startOf("year") + if (normalized === "quarterly") return base.add(offset * 3, "month").startOf("month") + return base.add(offset, "month").startOf("month") +} + +export const formatTaxEvaluationPeriodLabel = ( + periodStart: dayjs.ConfigType, + period: string +) => { + const { start, end } = getTaxEvaluationPeriodBounds(periodStart, period) + const normalized = normalizeTaxEvaluationPeriod(period) + + if (normalized === "yearly") { + return start.format("YYYY") + } + + if (normalized === "quarterly") { + return `Q${Math.floor(start.month() / 3) + 1} ${start.format("YYYY")}` + } + + return start.format("MMMM YYYY") +} + +export const formatTaxEvaluationPeriodRange = ( + periodStart: dayjs.ConfigType, + period: string +) => { + const { start, end } = getTaxEvaluationPeriodBounds(periodStart, period) + return `${start.format("DD.MM.YYYY")} - ${end.format("DD.MM.YYYY")}` +} + +export const getCreatedDocumentTaxBreakdown = (doc: any) => { + const breakdown = ZERO_BREAKDOWN() + + if (!doc || isTaxFreeDocument(doc.taxType)) { + return breakdown + } + + ;(doc.rows || []).forEach((row: any) => { + if (!row || ["pagebreak", "title", "text"].includes(row.mode)) return + + const quantity = Number(row.quantity || 0) + const price = Number(row.price || 0) + const discountPercent = Number(row.discountPercent || 0) + const taxPercent = Number(row.taxPercent || 0) + const net = Number((quantity * price * (1 - discountPercent / 100)).toFixed(2)) + + if (!Number.isFinite(net) || net === 0) return + + if (taxPercent === 19) { + breakdown.net19 += net + breakdown.tax19 += Number((net * 0.19).toFixed(2)) + } else if (taxPercent === 7) { + breakdown.net7 += net + breakdown.tax7 += Number((net * 0.07).toFixed(2)) + } else { + breakdown.net0 += net + } + }) + + return { + net19: Number(breakdown.net19.toFixed(2)), + tax19: Number(breakdown.tax19.toFixed(2)), + net7: Number(breakdown.net7.toFixed(2)), + tax7: Number(breakdown.tax7.toFixed(2)), + net0: Number(breakdown.net0.toFixed(2)), + } +} + +export const getIncomingInvoiceTaxBreakdown = (invoice: any) => { + const breakdown = ZERO_BREAKDOWN() + + ;(invoice?.accounts || []).forEach((account: any) => { + const taxType = String(account?.taxType || "") + const amountNet = Number(account?.amountNet || 0) + const amountTax = Number(account?.amountTax || 0) + + if (taxType === "19") { + breakdown.net19 += amountNet + breakdown.tax19 += amountTax + } else if (taxType === "7") { + breakdown.net7 += amountNet + breakdown.tax7 += amountTax + } else { + breakdown.net0 += amountNet + } + }) + + return { + net19: Number(breakdown.net19.toFixed(2)), + tax19: Number(breakdown.tax19.toFixed(2)), + net7: Number(breakdown.net7.toFixed(2)), + tax7: Number(breakdown.tax7.toFixed(2)), + net0: Number(breakdown.net0.toFixed(2)), + } +} diff --git a/frontend/utils/handlebars.ts b/frontend/utils/handlebars.ts new file mode 100644 index 0000000..f36dbc1 --- /dev/null +++ b/frontend/utils/handlebars.ts @@ -0,0 +1,26 @@ +import Handlebars from "handlebars" + +const createDocumentTemplateHandlebars = () => { + const instance = Handlebars.create() + + instance.registerHelper("eq", (left, right) => left === right) + instance.registerHelper("ne", (left, right) => left !== right) + instance.registerHelper("gt", (left, right) => left > right) + instance.registerHelper("gte", (left, right) => left >= right) + instance.registerHelper("lt", (left, right) => left < right) + instance.registerHelper("lte", (left, right) => left <= right) + instance.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean)) + instance.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean)) + instance.registerHelper("not", (value) => !value) + instance.registerHelper("includes", (collection, value) => { + if (Array.isArray(collection) || typeof collection === 'string') { + return collection.includes(value) + } + + return false + }) + + return instance +} + +export const documentTemplateHandlebars = createDocumentTemplateHandlebars()