- {{ labelMap[entry.type] ?? entry.type }}
+ {{ entry.type_name ?? '—' }} – {{ formatEventDateTime(entry.occurred_at) }}
{{ formatDate(entry.created_at) }}
{{ entry.author_name }}
@@ -38,23 +45,44 @@ const formatDate = (iso: string) =>
(bearb. {{ entry.updated_by_name }})
-
-
- {{ editing ? 'Abbrechen' : 'Bearbeiten' }}
+
+
+
+
-
- Löschen
+
+
{{ entry.body }}
-
+
+
+ Datum/Uhrzeit
+
+
-
+
Speichern
diff --git a/frontend/components/history/HistoryForm.vue b/frontend/components/history/HistoryForm.vue
index 7c90c19..b575017 100644
--- a/frontend/components/history/HistoryForm.vue
+++ b/frontend/components/history/HistoryForm.vue
@@ -1,24 +1,30 @@
@@ -28,10 +34,18 @@ const submit = async () => {
Typ
-
- {{ t.label }}
+
+ {{ t.name }}
+
+ Datum/Uhrzeit
+
+
Eintrag
-
+
Eintrag speichern
diff --git a/frontend/composables/useApiFetch.ts b/frontend/composables/useApiFetch.ts
new file mode 100644
index 0000000..4820bfd
--- /dev/null
+++ b/frontend/composables/useApiFetch.ts
@@ -0,0 +1,30 @@
+/**
+ * Central API fetch wrapper for the Sanctum SPA.
+ *
+ * - `credentials: 'include'` sends the session + XSRF cookies.
+ * - `Accept` / `X-Requested-With` make Laravel's `expectsJson()` return true,
+ * so an unauthenticated request yields a clean 401 instead of redirecting
+ * to the (non-existent) `login` route.
+ * - The decoded `XSRF-TOKEN` cookie is forwarded as `X-XSRF-TOKEN` so mutating
+ * requests pass CSRF verification.
+ */
+export const useApiFetch =
(
+ request: string,
+ options: Record = {},
+): Promise => {
+ const getXsrfToken = (): string => {
+ const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/)
+ return match ? decodeURIComponent(match[1]) : ''
+ }
+
+ return $fetch(request, {
+ credentials: 'include',
+ ...options,
+ headers: {
+ Accept: 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'X-XSRF-TOKEN': getXsrfToken(),
+ ...(options.headers as Record | undefined),
+ },
+ })
+}
diff --git a/frontend/composables/useAuth.ts b/frontend/composables/useAuth.ts
index 33123d5..b9a26f1 100644
--- a/frontend/composables/useAuth.ts
+++ b/frontend/composables/useAuth.ts
@@ -4,19 +4,12 @@ export const useAuth = () => {
const user = useState('auth.user', () => null)
const getCsrfCookie = async () => {
- await $fetch('/sanctum/csrf-cookie', { credentials: 'include' })
- }
-
- const getXsrfToken = (): string => {
- const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/)
- return match ? decodeURIComponent(match[1]) : ''
+ await useApiFetch('/sanctum/csrf-cookie')
}
const fetchUser = async (): Promise => {
try {
- const response = await $fetch<{ data: User }>('/api/user', {
- credentials: 'include',
- })
+ const response = await useApiFetch<{ data: User }>('/api/user')
user.value = response.data
} catch {
user.value = null
@@ -25,23 +18,21 @@ export const useAuth = () => {
const login = async (email: string, password: string): Promise => {
await getCsrfCookie()
- await $fetch('/api/login', {
+ await useApiFetch('/api/login', {
method: 'POST',
body: { email, password },
- credentials: 'include',
- headers: { 'X-XSRF-TOKEN': getXsrfToken() },
})
await fetchUser()
}
const logout = async (): Promise => {
- await $fetch('/api/logout', {
- method: 'POST',
- credentials: 'include',
- headers: { 'X-XSRF-TOKEN': getXsrfToken() },
- })
- user.value = null
- await navigateTo('/login')
+ try {
+ await useApiFetch('/api/logout', { method: 'POST' })
+ } finally {
+ // Always clear local state and leave, even if the request failed.
+ user.value = null
+ await navigateTo('/login')
+ }
}
return { user, login, logout, fetchUser }
diff --git a/frontend/composables/useClient.ts b/frontend/composables/useClient.ts
index 00efb77..f04280c 100644
--- a/frontend/composables/useClient.ts
+++ b/frontend/composables/useClient.ts
@@ -4,15 +4,8 @@ export const useClient = () => {
const clients = useState('clients.list', () => [])
const searchQuery = ref('')
- const getXsrfToken = (): string => {
- const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/)
- return match ? decodeURIComponent(match[1]) : ''
- }
-
const fetchClients = async (): Promise => {
- const response = await $fetch>('/api/clients', {
- credentials: 'include',
- })
+ const response = await useApiFetch>('/api/clients')
clients.value = response.data
}
@@ -21,39 +14,44 @@ export const useClient = () => {
await fetchClients()
return
}
- const response = await $fetch<{ data: Client[] }>('/api/search', {
+ const response = await useApiFetch<{ data: Client[] }>('/api/search', {
query: { q },
- credentials: 'include',
})
clients.value = response.data
}
const fetchClient = async (id: number): Promise => {
- const response = await $fetch<{ data: Client }>(`/api/clients/${id}`, {
- credentials: 'include',
- })
+ const response = await useApiFetch<{ data: Client }>(`/api/clients/${id}`)
return response.data
}
const createClient = async (payload: { client_number: string; name: string; notes?: Record }): Promise => {
- const response = await $fetch<{ data: Client }>('/api/clients', {
+ const response = await useApiFetch<{ data: Client }>('/api/clients', {
method: 'POST',
body: payload,
- credentials: 'include',
- headers: { 'X-XSRF-TOKEN': getXsrfToken() },
})
return response.data
}
const updateClient = async (id: number, payload: Partial>): Promise => {
- const response = await $fetch<{ data: Client }>(`/api/clients/${id}`, {
+ const response = await useApiFetch<{ data: Client }>(`/api/clients/${id}`, {
method: 'PUT',
body: payload,
- credentials: 'include',
- headers: { 'X-XSRF-TOKEN': getXsrfToken() },
})
return response.data
}
- return { clients, searchQuery, fetchClients, search, fetchClient, createClient, updateClient }
+ const updateCustomFieldValue = async (
+ clientId: number,
+ definitionId: number,
+ value: string | null,
+ ): Promise => {
+ const response = await useApiFetch<{ data: Client }>(
+ `/api/clients/${clientId}/custom-fields/${definitionId}`,
+ { method: 'PUT', body: { value } },
+ )
+ return response.data
+ }
+
+ return { clients, searchQuery, fetchClients, search, fetchClient, createClient, updateClient, updateCustomFieldValue }
}
diff --git a/frontend/composables/useCustomFields.ts b/frontend/composables/useCustomFields.ts
new file mode 100644
index 0000000..716ded4
--- /dev/null
+++ b/frontend/composables/useCustomFields.ts
@@ -0,0 +1,42 @@
+import type { CustomFieldDefinition, CustomFieldType } from '~/types'
+
+export interface CustomFieldDefinitionPayload {
+ label: string
+ type: CustomFieldType
+ options?: string[]
+ is_required?: boolean
+}
+
+export const useCustomFields = () => {
+ const definitions = useState('customFields.definitions', () => [])
+
+ const fetchDefinitions = async (): Promise => {
+ const response = await useApiFetch<{ data: CustomFieldDefinition[] }>('/api/custom-fields')
+ definitions.value = response.data
+ }
+
+ const createDefinition = async (payload: CustomFieldDefinitionPayload): Promise => {
+ await useApiFetch('/api/custom-fields', { method: 'POST', body: payload })
+ await fetchDefinitions()
+ }
+
+ const updateDefinition = async (id: number, payload: CustomFieldDefinitionPayload): Promise => {
+ await useApiFetch(`/api/custom-fields/${id}`, { method: 'PUT', body: payload })
+ await fetchDefinitions()
+ }
+
+ const deleteDefinition = async (id: number): Promise => {
+ await useApiFetch(`/api/custom-fields/${id}`, { method: 'DELETE' })
+ definitions.value = definitions.value.filter(d => d.id !== id)
+ }
+
+ const reorderDefinitions = async (ids: number[]): Promise => {
+ const response = await useApiFetch<{ data: CustomFieldDefinition[] }>('/api/custom-fields/reorder', {
+ method: 'POST',
+ body: { ids },
+ })
+ definitions.value = response.data
+ }
+
+ return { definitions, fetchDefinitions, createDefinition, updateDefinition, deleteDefinition, reorderDefinitions }
+}
diff --git a/frontend/composables/useHistory.ts b/frontend/composables/useHistory.ts
index b08080a..8d1723c 100644
--- a/frontend/composables/useHistory.ts
+++ b/frontend/composables/useHistory.ts
@@ -3,43 +3,37 @@ import type { HistoryEntry } from '~/types'
export const useHistory = (clientId: number) => {
const entries = ref([])
- const getXsrfToken = (): string => {
- const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/)
- return match ? decodeURIComponent(match[1]) : ''
- }
-
const fetchEntries = async (): Promise => {
- const response = await $fetch<{ data: HistoryEntry[] }>(`/api/clients/${clientId}/history`, {
- credentials: 'include',
- })
+ const response = await useApiFetch<{ data: HistoryEntry[] }>(`/api/clients/${clientId}/history`)
entries.value = response.data
}
- const addEntry = async (payload: { type: string; body: string }): Promise => {
- await $fetch(`/api/clients/${clientId}/history`, {
+ const addEntry = async (payload: {
+ history_entry_type_id: number
+ body: string
+ occurred_at: string | null
+ }): Promise => {
+ await useApiFetch(`/api/clients/${clientId}/history`, {
method: 'POST',
body: payload,
- credentials: 'include',
- headers: { 'X-XSRF-TOKEN': getXsrfToken() },
})
await fetchEntries()
}
- const updateEntry = async (id: number, body: string): Promise => {
- await $fetch(`/api/history/${id}`, {
+ const updateEntry = async (
+ id: number,
+ payload: { body: string; occurred_at: string | null }
+ ): Promise => {
+ await useApiFetch(`/api/history/${id}`, {
method: 'PUT',
- body: { body },
- credentials: 'include',
- headers: { 'X-XSRF-TOKEN': getXsrfToken() },
+ body: payload,
})
await fetchEntries()
}
const deleteEntry = async (id: number): Promise => {
- await $fetch(`/api/history/${id}`, {
+ await useApiFetch(`/api/history/${id}`, {
method: 'DELETE',
- credentials: 'include',
- headers: { 'X-XSRF-TOKEN': getXsrfToken() },
})
entries.value = entries.value.filter(e => e.id !== id)
}
diff --git a/frontend/composables/useHistoryTypes.ts b/frontend/composables/useHistoryTypes.ts
new file mode 100644
index 0000000..8d7ef6e
--- /dev/null
+++ b/frontend/composables/useHistoryTypes.ts
@@ -0,0 +1,40 @@
+import type { HistoryType } from '~/types'
+
+export interface HistoryTypePayload {
+ name: string
+ requires_datetime?: boolean
+}
+
+export const useHistoryTypes = () => {
+ const types = useState('historyTypes.list', () => [])
+
+ const fetchTypes = async (): Promise => {
+ const response = await useApiFetch<{ data: HistoryType[] }>('/api/history-types')
+ types.value = response.data
+ }
+
+ const createType = async (payload: HistoryTypePayload): Promise => {
+ await useApiFetch('/api/history-types', { method: 'POST', body: payload })
+ await fetchTypes()
+ }
+
+ const updateType = async (id: number, payload: HistoryTypePayload): Promise => {
+ await useApiFetch(`/api/history-types/${id}`, { method: 'PUT', body: payload })
+ await fetchTypes()
+ }
+
+ const deleteType = async (id: number): Promise => {
+ await useApiFetch(`/api/history-types/${id}`, { method: 'DELETE' })
+ types.value = types.value.filter(t => t.id !== id)
+ }
+
+ const reorderTypes = async (ids: number[]): Promise => {
+ const response = await useApiFetch<{ data: HistoryType[] }>('/api/history-types/reorder', {
+ method: 'POST',
+ body: { ids },
+ })
+ types.value = response.data
+ }
+
+ return { types, fetchTypes, createType, updateType, deleteType, reorderTypes }
+}
diff --git a/frontend/composables/useImport.ts b/frontend/composables/useImport.ts
new file mode 100644
index 0000000..dada608
--- /dev/null
+++ b/frontend/composables/useImport.ts
@@ -0,0 +1,16 @@
+import type { ImportResult } from '~/types'
+
+export const useImport = () => {
+ const importClients = async (file: File): Promise => {
+ const form = new FormData()
+ form.append('file', file)
+
+ const response = await useApiFetch<{ data: ImportResult }>('/api/clients/import', {
+ method: 'POST',
+ body: form,
+ })
+ return response.data
+ }
+
+ return { importClients }
+}
diff --git a/frontend/composables/useUsers.ts b/frontend/composables/useUsers.ts
index acb64fd..c857fab 100644
--- a/frontend/composables/useUsers.ts
+++ b/frontend/composables/useUsers.ts
@@ -4,7 +4,7 @@ export const useUsers = () => {
const users = useState('users.list', () => [])
const fetchUsers = async (): Promise => {
- const { data } = await $fetch('/api/users', { credentials: 'include' })
+ const { data } = await useApiFetch<{ data: User[] }>('/api/users')
users.value = data
}
@@ -15,9 +15,8 @@ export const useUsers = () => {
password_confirmation: string
role: 'admin' | 'staff'
}): Promise => {
- const { data } = await $fetch('/api/users', {
+ const { data } = await useApiFetch<{ data: User }>('/api/users', {
method: 'POST',
- credentials: 'include',
body: payload,
})
users.value.push(data)
@@ -34,9 +33,8 @@ export const useUsers = () => {
role: 'admin' | 'staff'
}
): Promise => {
- const { data } = await $fetch(`/api/users/${id}`, {
+ const { data } = await useApiFetch<{ data: User }>(`/api/users/${id}`, {
method: 'PUT',
- credentials: 'include',
body: payload,
})
const index = users.value.findIndex((u) => u.id === id)
@@ -45,10 +43,7 @@ export const useUsers = () => {
}
const deleteUser = async (id: number): Promise => {
- await $fetch(`/api/users/${id}`, {
- method: 'DELETE',
- credentials: 'include',
- })
+ await useApiFetch(`/api/users/${id}`, { method: 'DELETE' })
users.value = users.value.filter((u) => u.id !== id)
}
diff --git a/frontend/middleware/admin.ts b/frontend/middleware/admin.ts
new file mode 100644
index 0000000..dcad507
--- /dev/null
+++ b/frontend/middleware/admin.ts
@@ -0,0 +1,15 @@
+export default defineNuxtRouteMiddleware(async () => {
+ const { user, fetchUser } = useAuth()
+
+ if (!user.value) {
+ await fetchUser()
+ }
+
+ if (!user.value) {
+ return navigateTo('/login')
+ }
+
+ if (user.value.role !== 'admin') {
+ return navigateTo('/clients')
+ }
+})
diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts
index 19851da..aa33914 100644
--- a/frontend/nuxt.config.ts
+++ b/frontend/nuxt.config.ts
@@ -1,5 +1,8 @@
export default defineNuxtConfig({
ssr: false,
+ experimental: {
+ viteEnvironmentApi: true,
+ },
modules: ['@nuxtjs/tailwindcss'],
devServer: {
port: 3000,
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index bdb11f4..0fef5b3 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,7 +11,7 @@
"nuxt": "^3.21.8",
"tailwindcss": "^3.4.19",
"vue": "^3.5.38",
- "vue-router": "^5.1.0"
+ "vue-router": "^4.6.4"
}
},
"node_modules/@alloc/quick-lru": {
@@ -3614,12 +3614,6 @@
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT"
},
- "node_modules/@types/jsesc": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
- "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==",
- "license": "MIT"
- },
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@@ -3853,13 +3847,10 @@
}
},
"node_modules/@vue/devtools-api": {
- "version": "8.1.3",
- "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.3.tgz",
- "integrity": "sha512-73NMCvxXh8Hyozc/jiwqTFWVcCMyi11U1zmrq4DoukQJnuo8JHt6FsNu4HdeUDa8SpIp5vb7Q22GWgIq0efsXg==",
- "license": "MIT",
- "dependencies": {
- "@vue/devtools-kit": "^8.1.3"
- }
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+ "license": "MIT"
},
"node_modules/@vue/devtools-core": {
"version": "8.1.3",
@@ -4267,23 +4258,6 @@
"url": "https://github.com/sponsors/sxzz"
}
},
- "node_modules/ast-walker-scope": {
- "version": "0.9.0",
- "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.9.0.tgz",
- "integrity": "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==",
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.29.2",
- "@babel/types": "^7.29.0",
- "ast-kit": "^2.2.0"
- },
- "engines": {
- "node": ">=20.19.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sxzz"
- }
- },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -7808,12 +7782,6 @@
"node": "^14.18.0 || >=16.10.0"
}
},
- "node_modules/nuxt/node_modules/@vue/devtools-api": {
- "version": "6.6.4",
- "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
- "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
- "license": "MIT"
- },
"node_modules/nuxt/node_modules/ast-walker-scope": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz",
@@ -7886,21 +7854,6 @@
"node": ">=18.12.0"
}
},
- "node_modules/nuxt/node_modules/vue-router": {
- "version": "4.6.4",
- "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
- "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
- "license": "MIT",
- "dependencies": {
- "@vue/devtools-api": "^6.6.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/posva"
- },
- "peerDependencies": {
- "vue": "^3.5.0"
- }
- },
"node_modules/nypm": {
"version": "0.6.7",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.7.tgz",
@@ -11814,115 +11767,18 @@
"license": "MIT"
},
"node_modules/vue-router": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.1.0.tgz",
- "integrity": "sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==",
+ "version": "4.6.4",
+ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
+ "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
- "@babel/generator": "^8.0.0-rc.4",
- "@vue-macros/common": "^3.1.1",
- "@vue/devtools-api": "^8.1.2",
- "ast-walker-scope": "^0.9.0",
- "chokidar": "^5.0.0",
- "json5": "^2.2.3",
- "local-pkg": "^1.1.2",
- "magic-string": "^0.30.21",
- "mlly": "^1.8.2",
- "muggle-string": "^0.4.1",
- "pathe": "^2.0.3",
- "picomatch": "^4.0.3",
- "scule": "^1.3.0",
- "tinyglobby": "^0.2.16",
- "unplugin": "^3.0.0",
- "unplugin-utils": "^0.3.1",
- "yaml": "^2.9.0"
+ "@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
- "@pinia/colada": ">=0.21.2",
- "@vue/compiler-sfc": "^3.5.34",
- "pinia": "^3.0.4",
- "vite": "^7.0.0 || ^8.0.0",
- "vue": "^3.5.34"
- },
- "peerDependenciesMeta": {
- "@pinia/colada": {
- "optional": true
- },
- "@vue/compiler-sfc": {
- "optional": true
- },
- "pinia": {
- "optional": true
- },
- "vite": {
- "optional": true
- }
- }
- },
- "node_modules/vue-router/node_modules/@babel/generator": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz",
- "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==",
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^8.0.0",
- "@babel/types": "^8.0.0",
- "@jridgewell/gen-mapping": "^0.3.12",
- "@jridgewell/trace-mapping": "^0.3.28",
- "@types/jsesc": "^2.5.0",
- "jsesc": "^3.0.2"
- },
- "engines": {
- "node": "^22.18.0 || >=24.11.0"
- }
- },
- "node_modules/vue-router/node_modules/@babel/helper-string-parser": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz",
- "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==",
- "license": "MIT",
- "engines": {
- "node": "^22.18.0 || >=24.11.0"
- }
- },
- "node_modules/vue-router/node_modules/@babel/helper-validator-identifier": {
- "version": "8.0.2",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.2.tgz",
- "integrity": "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==",
- "license": "MIT",
- "engines": {
- "node": "^22.18.0 || >=24.11.0"
- }
- },
- "node_modules/vue-router/node_modules/@babel/parser": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0.tgz",
- "integrity": "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==",
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^8.0.0"
- },
- "bin": {
- "parser": "bin/babel-parser.js"
- },
- "engines": {
- "node": "^22.18.0 || >=24.11.0"
- }
- },
- "node_modules/vue-router/node_modules/@babel/types": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0.tgz",
- "integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==",
- "license": "MIT",
- "dependencies": {
- "@babel/helper-string-parser": "^8.0.0",
- "@babel/helper-validator-identifier": "^8.0.0"
- },
- "engines": {
- "node": "^22.18.0 || >=24.11.0"
+ "vue": "^3.5.0"
}
},
"node_modules/webidl-conversions": {
diff --git a/frontend/package.json b/frontend/package.json
index 4fbb8b0..552e419 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,6 +14,6 @@
"nuxt": "^3.21.8",
"tailwindcss": "^3.4.19",
"vue": "^3.5.38",
- "vue-router": "^5.1.0"
+ "vue-router": "^4.6.4"
}
}
diff --git a/frontend/pages/clients/[id].vue b/frontend/pages/clients/[id].vue
deleted file mode 100644
index 2a9c677..0000000
--- a/frontend/pages/clients/[id].vue
+++ /dev/null
@@ -1,99 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
Stammdaten
-
- {{ editingFields ? 'Abbrechen' : 'Bearbeiten' }}
-
-
-
-
-
-
-
- Verlauf
-
-
-
-
- Noch keine Einträge.
-
-
-
-
-
-
diff --git a/frontend/pages/clients/[id]/index.vue b/frontend/pages/clients/[id]/index.vue
new file mode 100644
index 0000000..0f1b3a0
--- /dev/null
+++ b/frontend/pages/clients/[id]/index.vue
@@ -0,0 +1,196 @@
+
+
+
+
+
+
+
+
+
+ Stammdaten
+
+
+
+
+ Keine Felder konfiguriert.
+
+
+
+
+
+
+
Verlauf
+
+ + Neuer Eintrag
+
+
+
+
+
+
+
+
+
+
+ Noch keine Einträge.
+
+
+ Keine Treffer für „{{ search }}".
+
+
+ Keine Einträge sichtbar – Stammdaten-Änderungen einblenden.
+
+
+
+
+
+
diff --git a/frontend/pages/clients/[id]/print.vue b/frontend/pages/clients/[id]/print.vue
index 6670ba7..fda1ad1 100644
--- a/frontend/pages/clients/[id]/print.vue
+++ b/frontend/pages/clients/[id]/print.vue
@@ -13,20 +13,23 @@ const client = ref(null)
client.value = await fetchClient(clientId)
await fetchEntries()
-const labelMap: Record = {
- phone_call: 'Telefonat',
- consultation: 'Beratungsgespräch',
- recommendation: 'Empfehlung',
- instruction: 'Anweisung',
- remark: 'Bemerkung',
- other: 'Sonstiges',
-}
-
const formatDate = (iso: string) =>
new Date(iso).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
-onMounted(() => {
- window.print()
+const formatValue = (field: { type: string; value: string | null }) => {
+ if (field.value === null || field.value === '') return '—'
+ if (field.type === 'date') {
+ const d = new Date(field.value)
+ return Number.isNaN(d.getTime()) ? field.value : d.toLocaleDateString('de-DE', { dateStyle: 'medium' })
+ }
+ return field.value
+}
+
+onMounted(async () => {
+ // Wait for the freshly navigated DOM to render and paint before opening the
+ // print dialog, otherwise the preview can capture a blank page.
+ await nextTick()
+ requestAnimationFrame(() => requestAnimationFrame(() => window.print()))
})
@@ -44,10 +47,10 @@ onMounted(() => {
Name
{{ client.name }}
-
+
- {{ key }}
- {{ val }}
+ {{ field.label }}
+ {{ formatValue(field) }}
@@ -55,7 +58,9 @@ onMounted(() => {
Verlauf
- {{ labelMap[entry.type] ?? entry.type }}
+
+ {{ entry.type_name ?? '—' }} – {{ formatEventDateTime(entry.occurred_at) }}
+
{{ formatDate(entry.created_at) }}
{{ entry.author_name }}
diff --git a/frontend/pages/settings/accounts.vue b/frontend/pages/settings/accounts.vue
index 4d219e0..705a81b 100644
--- a/frontend/pages/settings/accounts.vue
+++ b/frontend/pages/settings/accounts.vue
@@ -22,6 +22,7 @@ const form = ref<{
role: 'staff',
})
const error = ref('')
+const showPassword = ref(false)
const resetForm = () => {
editingId.value = null
@@ -33,11 +34,13 @@ const resetForm = () => {
role: 'staff',
}
error.value = ''
+ showPassword.value = false
}
const startEdit = (user: User) => {
editingId.value = user.id
error.value = ''
+ showPassword.value = false
form.value = {
name: user.name,
email: user.email,
@@ -175,7 +178,8 @@ const roleLabels = {
@@ -183,20 +187,44 @@ const roleLabels = {
{{ editingId ? 'Passwort (leer = nicht ändern)' : 'Passwort' }}
-
+
+
+
+
+
+
+
diff --git a/frontend/pages/settings/fields.vue b/frontend/pages/settings/fields.vue
new file mode 100644
index 0000000..c0b781e
--- /dev/null
+++ b/frontend/pages/settings/fields.vue
@@ -0,0 +1,195 @@
+
+
+
+
+
+
+
+
+ Stammdatenfelder konfigurieren
+
+
+
+
+
+
+
+
+ Feldname
+ Typ
+ Pflicht
+ Aktionen
+
+
+
+
+ {{ def.label }}
+ {{ typeLabels[def.type] }}
+ {{ def.is_required ? 'Ja' : 'Nein' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Noch keine Felder konfiguriert.
+
+
+
+
+
+
+
+
+
+ {{ editingId ? 'Feld bearbeiten' : 'Neues Feld' }}
+
+
+
+
+ Feldname
+
+
+
+ Typ
+
+ {{ label }}
+
+
+
+
+
+ Optionen (eine pro Zeile)
+
+
+
+
+
+
+ Pflichtfeld
+
+
+
+ {{ error }}
+
+
+
+ {{ editingId ? 'Änderungen speichern' : 'Feld anlegen' }}
+
+ Abbrechen
+
+
+
+
+
diff --git a/frontend/pages/settings/history-types.vue b/frontend/pages/settings/history-types.vue
new file mode 100644
index 0000000..45d032c
--- /dev/null
+++ b/frontend/pages/settings/history-types.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+ Verlaufstypen konfigurieren
+
+
+
+
+
+
+
+
+ Name
+ Datum/Uhrzeit nötig
+ Aktionen
+
+
+
+
+ {{ type.name }}
+ {{ type.requires_datetime ? 'Ja' : 'Nein' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Noch keine Verlaufstypen konfiguriert.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/pages/settings/import.vue b/frontend/pages/settings/import.vue
new file mode 100644
index 0000000..7e9fc2e
--- /dev/null
+++ b/frontend/pages/settings/import.vue
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+ Mandanten-Import
+
+
+
+
+
+
+ CSV-Datei (Semikolon-getrennt) hochladen. Bekannte Mandantennummern werden
+ übersprungen – außer die Spalte „Ueberschreiben“ enthält „ja“, dann werden
+ die Stammdatenfelder aktualisiert.
+
+
+
+ CSV-Datei
+
+
+
+ {{ error }}
+
+
+
+ {{ uploading ? 'Importiere…' : 'Import starten' }}
+
+
+
+
+
+
+ Ergebnis
+
+
+
{{ result.total }}
+
Zeilen
+
+
+
{{ result.created }}
+
Neu angelegt
+
+
+
{{ result.updated }}
+
Aktualisiert
+
+
+
{{ result.skipped }}
+
Übersprungen
+
+
+
+
+
Fehler ({{ result.errors.length }})
+
+
+
+ Zeile
+ Mandantennr.
+ Meldung
+
+
+
+
+ {{ err.row }}
+ {{ err.client_number ?? '—' }}
+ {{ err.message }}
+
+
+
+
+
+
+
+
diff --git a/frontend/types/index.ts b/frontend/types/index.ts
index 8a99cf4..3faff6a 100644
--- a/frontend/types/index.ts
+++ b/frontend/types/index.ts
@@ -5,20 +5,60 @@ export interface User {
role: 'admin' | 'staff'
}
+export type CustomFieldType = 'text' | 'textarea' | 'number' | 'date' | 'select'
+
+export interface CustomFieldDefinition {
+ id: number
+ label: string
+ key: string
+ type: CustomFieldType
+ options: string[]
+ is_required: boolean
+ sort_order: number
+}
+
+export interface ClientCustomField {
+ definition_id: number
+ key: string
+ label: string
+ type: CustomFieldType
+ options: string[]
+ is_required: boolean
+ value: string | null
+ author_name: string | null
+ updated_at: string | null
+}
+
export interface Client {
id: number
client_number: string
name: string
notes: Record
+ custom_fields?: ClientCustomField[]
+ // Present on list/search responses only.
+ history_count?: number
+ last_history_at?: string | null
created_at: string
updated_at: string
}
+export interface HistoryType {
+ id: number
+ name: string
+ requires_datetime: boolean
+ sort_order: number
+}
+
export interface HistoryEntry {
id: number
client_id: number
- type: 'phone_call' | 'consultation' | 'recommendation' | 'instruction' | 'remark' | 'other'
+ is_field_change: boolean
+ history_entry_type_id: number | null
+ type_name: string | null
+ requires_datetime: boolean
+ occurred_at: string | null
body: string
+ custom_field_definition_id: number | null
author_id: number
author_name: string | null
updated_by: number | null
@@ -27,6 +67,20 @@ export interface HistoryEntry {
updated_at: string
}
+export interface ImportError {
+ row: number
+ client_number: string | null
+ message: string
+}
+
+export interface ImportResult {
+ total: number
+ created: number
+ updated: number
+ skipped: number
+ errors: ImportError[]
+}
+
export interface PaginatedResponse {
data: T[]
meta: {
diff --git a/frontend/utils/datetime.ts b/frontend/utils/datetime.ts
new file mode 100644
index 0000000..61fc318
--- /dev/null
+++ b/frontend/utils/datetime.ts
@@ -0,0 +1,17 @@
+// Renders a naive local datetime string (e.g. "2026-06-25T14:30:00", no Z/offset)
+// as "DD.MM.YYYY HH:MM". Because occurred_at carries no timezone, new Date()
+// parses it as local time and the user-typed HH:MM is preserved.
+export const formatEventDateTime = (s: string): string => {
+ const d = new Date(s)
+ if (Number.isNaN(d.getTime())) return s
+ return d
+ .toLocaleString('de-DE', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+ // de-DE renders as "25.06.2026, 14:30" — drop the comma to get "25.06.2026 14:30".
+ .replace(',', '')
+}