feat(frontend): scaffold Nuxt 3 CSR SPA with full auth + client/history UI

- Nuxt 3 CSR (ssr:false), @nuxtjs/tailwindcss, dev proxy to backend
- useAuth composable: CSRF cookie, XSRF token, login/logout/fetchUser
- auth middleware: redirects to /login if no session
- Login page, client list with search + create modal
- Client detail with editable name, dynamic notes (JSON), history feed
- History form with type select, inline edit/delete per entry
- Print layout at /clients/:id/print (calls window.print on mount)
- TypeScript interfaces: User, Client, HistoryEntry, PaginatedResponse
- German UI labels throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tim Stollberg 2026-06-23 17:33:39 +07:00
parent b948d51b59
commit e5e0be57da
29 changed files with 13147 additions and 3 deletions

View file

@ -1,12 +1,12 @@
APP_NAME=MandantCRM APP_NAME=MandantCRM
APP_ENV=local APP_ENV=local
APP_KEY=base64:PLACEHOLDER_KEY APP_KEY=
APP_URL=http://localhost APP_URL=http://localhost
DB_HOST=mysql DB_HOST=mysql
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=mandantcrm DB_DATABASE=mandantcrm
DB_USERNAME=placeholder_user DB_USERNAME=mandantcrm
DB_PASSWORD=placeholder_pass DB_PASSWORD=secret
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000 SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000
SESSION_DOMAIN=localhost SESSION_DOMAIN=localhost
FRONTEND_URL=http://localhost:3000 FRONTEND_URL=http://localhost:3000

View file

@ -27,3 +27,8 @@ make shell-node # sh into frontend container
## Commit style ## Commit style
feat/fix/chore/refactor/test/docs — conventional commits feat/fix/chore/refactor/test/docs — conventional commits
Examples: `feat(client): add search endpoint`, `fix(history): soft delete cascade` Examples: `feat(client): add search endpoint`, `fix(history): soft delete cascade`
### Telegram Channel Guidelines
- Before calling any write_file, edit_file, or bash tools that trigger system permission prompts, you MUST send a Telegram text message summarizing exactly what you are about to modify and why.
- Never trigger a tool call blindly without explaining the context to the user first.

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

34
frontend/CLAUDE.md Normal file
View file

@ -0,0 +1,34 @@
# Frontend — Nuxt 3 CSR
## Structure
- `pages/` — file-based routing (CSR only, all behind auth middleware)
- `composables/useAuth.ts` — login/logout/user state (singleton via useState)
- `composables/useClient.ts` — CRUD + list
- `composables/useHistory.ts` — history entry CRUD per client
- `components/client/` — ClientCard, ClientFields
- `components/history/` — HistoryEntry, HistoryForm
- `middleware/auth.ts` — redirects to /login if no session
- `types/index.ts` — shared TypeScript interfaces
## Auth flow
1. `GET /sanctum/csrf-cookie` sets XSRF-TOKEN cookie
2. `POST /api/login` with credentials — Laravel sets session cookie
3. All subsequent requests: include both cookies via `credentials: 'include'`
4. XSRF-TOKEN decoded and sent as `X-XSRF-TOKEN` header on mutating requests
## API calls
Always use the `useApiFetch` composable pattern:
```ts
const data = await $fetch('/api/clients', { credentials: 'include' })
```
## Tailwind
Desktop-first, no mobile breakpoints in MVP.
Dense, data-focused UI — think DATEV/Outlook aesthetics.
## Commands
```
npm run dev # dev server on :3000
npm run build # production build
npm run typecheck
```

75
frontend/README.md Normal file
View file

@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

3
frontend/app.vue Normal file
View file

@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>

View file

@ -0,0 +1,7 @@
<script setup lang="ts">
const { logout } = useAuth()
</script>
<template>
<button @click="logout" class="text-sm text-gray-500 hover:text-gray-700">Abmelden</button>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { Client } from '~/types'
const props = defineProps<{ client: Client }>()
</script>
<template>
<NuxtLink
:to="`/clients/${client.id}`"
class="block border border-gray-200 bg-white px-4 py-3 hover:bg-blue-50 cursor-pointer"
>
<div class="flex items-baseline gap-4">
<span class="text-sm font-mono text-gray-500 w-20 shrink-0">{{ client.client_number }}</span>
<span class="text-sm font-medium text-gray-900">{{ client.name }}</span>
</div>
</NuxtLink>
</template>

View file

@ -0,0 +1,52 @@
<script setup lang="ts">
import type { Client } from '~/types'
const props = defineProps<{ client: Client; editing: boolean }>()
const emit = defineEmits<{ save: [notes: Record<string, string>] }>()
const localNotes = ref<Record<string, string>>({ ...props.client.notes })
const newKey = ref('')
const newVal = ref('')
const addField = () => {
if (!newKey.value.trim()) return
localNotes.value[newKey.value.trim()] = newVal.value
newKey.value = ''
newVal.value = ''
}
const removeField = (key: string) => {
const copy = { ...localNotes.value }
delete copy[key]
localNotes.value = copy
}
</script>
<template>
<div>
<div v-if="!editing" class="space-y-1">
<div v-for="(val, key) in client.notes" :key="key" class="flex gap-4 text-sm">
<span class="text-gray-500 w-40 shrink-0">{{ key }}</span>
<span class="text-gray-800">{{ val }}</span>
</div>
<p v-if="!Object.keys(client.notes ?? {}).length" class="text-sm text-gray-400 italic">
Keine Felder konfiguriert.
</p>
</div>
<div v-else class="space-y-2">
<div v-for="(val, key) in localNotes" :key="key" class="flex gap-2 items-center text-sm">
<span class="text-gray-600 w-40 shrink-0">{{ key }}</span>
<input v-model="localNotes[key]" class="flex-1 border border-gray-300 px-2 py-1 text-sm" />
<button @click="removeField(key)" class="text-red-500 text-xs hover:underline">Entfernen</button>
</div>
<div class="flex gap-2 items-center">
<input v-model="newKey" placeholder="Feldname" class="w-40 border border-gray-300 px-2 py-1 text-sm" />
<input v-model="newVal" placeholder="Wert" class="flex-1 border border-gray-300 px-2 py-1 text-sm" />
<button type="button" @click="addField" class="text-sm text-blue-600 hover:underline">Hinzufügen</button>
</div>
<button @click="emit('save', localNotes)" class="bg-blue-600 text-white px-3 py-1.5 text-sm hover:bg-blue-700">
Felder speichern
</button>
</div>
</div>
</template>

View file

@ -0,0 +1,62 @@
<script setup lang="ts">
import type { HistoryEntry } from '~/types'
const props = defineProps<{ entry: HistoryEntry }>()
const emit = defineEmits<{ delete: [id: number]; update: [id: number, body: string] }>()
const labelMap: Record<string, string> = {
phone_call: 'Telefonat',
consultation: 'Beratungsgespräch',
recommendation: 'Empfehlung',
instruction: 'Anweisung',
remark: 'Bemerkung',
other: 'Sonstiges',
}
const editing = ref(false)
const editBody = ref(props.entry.body)
const saveEdit = () => {
emit('update', props.entry.id, editBody.value)
editing.value = false
}
const formatDate = (iso: string) =>
new Date(iso).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
</script>
<template>
<div class="border border-gray-200 bg-white p-4">
<div class="flex items-start justify-between gap-2 mb-2">
<div class="flex items-center gap-2">
<span class="text-xs font-medium bg-gray-100 text-gray-600 px-2 py-0.5">
{{ labelMap[entry.type] ?? entry.type }}
</span>
<span class="text-xs text-gray-400">{{ formatDate(entry.created_at) }}</span>
<span class="text-xs text-gray-400">{{ entry.author_name }}</span>
<span v-if="entry.updated_by_name" class="text-xs text-gray-400 italic">
(bearb. {{ entry.updated_by_name }})
</span>
</div>
<div class="flex gap-2 shrink-0">
<button @click="editing = !editing" class="text-xs text-blue-600 hover:underline">
{{ editing ? 'Abbrechen' : 'Bearbeiten' }}
</button>
<button @click="emit('delete', entry.id)" class="text-xs text-red-500 hover:underline">
Löschen
</button>
</div>
</div>
<div v-if="!editing" class="text-sm text-gray-800 whitespace-pre-wrap">{{ entry.body }}</div>
<div v-else>
<textarea
v-model="editBody"
rows="4"
class="w-full border border-gray-300 px-3 py-2 text-sm resize-y"
/>
<button @click="saveEdit" class="mt-2 bg-blue-600 text-white px-3 py-1 text-sm hover:bg-blue-700">
Speichern
</button>
</div>
</div>
</template>

View file

@ -0,0 +1,51 @@
<script setup lang="ts">
const emit = defineEmits<{ submit: [payload: { type: string; body: string }] }>()
const type = ref('phone_call')
const body = ref('')
const submitting = ref(false)
const types = [
{ value: 'phone_call', label: 'Telefonat' },
{ value: 'consultation', label: 'Beratungsgespräch' },
{ value: 'recommendation', label: 'Empfehlung' },
{ value: 'instruction', label: 'Anweisung' },
{ value: 'remark', label: 'Bemerkung' },
{ value: 'other', label: 'Sonstiges' },
]
const submit = async () => {
if (!body.value.trim()) return
submitting.value = true
emit('submit', { type: type.value, body: body.value })
body.value = ''
submitting.value = false
}
</script>
<template>
<form @submit.prevent="submit" class="border border-gray-200 bg-white p-4 space-y-3">
<div class="flex gap-3 items-start">
<div class="shrink-0">
<label class="block text-xs font-medium text-gray-600 mb-1">Typ</label>
<select v-model="type" class="border border-gray-300 px-2 py-1.5 text-sm">
<option v-for="t in types" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-600 mb-1">Eintrag</label>
<textarea
v-model="body"
rows="3"
placeholder="Notiz eingeben…"
class="w-full border border-gray-300 px-3 py-2 text-sm resize-y"
/>
</div>
</div>
<div class="flex justify-end">
<button type="submit" :disabled="submitting || !body.trim()" class="bg-blue-600 text-white px-4 py-1.5 text-sm hover:bg-blue-700 disabled:opacity-50">
Eintrag speichern
</button>
</div>
</form>
</template>

View file

@ -0,0 +1,48 @@
import type { User } from '~/types'
export const useAuth = () => {
const user = useState<User | null>('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]) : ''
}
const fetchUser = async (): Promise<void> => {
try {
const response = await $fetch<{ data: User }>('/api/user', {
credentials: 'include',
})
user.value = response.data
} catch {
user.value = null
}
}
const login = async (email: string, password: string): Promise<void> => {
await getCsrfCookie()
await $fetch('/api/login', {
method: 'POST',
body: { email, password },
credentials: 'include',
headers: { 'X-XSRF-TOKEN': getXsrfToken() },
})
await fetchUser()
}
const logout = async (): Promise<void> => {
await $fetch('/api/logout', {
method: 'POST',
credentials: 'include',
headers: { 'X-XSRF-TOKEN': getXsrfToken() },
})
user.value = null
await navigateTo('/login')
}
return { user, login, logout, fetchUser }
}

View file

@ -0,0 +1,59 @@
import type { Client, PaginatedResponse } from '~/types'
export const useClient = () => {
const clients = useState<Client[]>('clients.list', () => [])
const searchQuery = ref('')
const getXsrfToken = (): string => {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/)
return match ? decodeURIComponent(match[1]) : ''
}
const fetchClients = async (): Promise<void> => {
const response = await $fetch<PaginatedResponse<Client>>('/api/clients', {
credentials: 'include',
})
clients.value = response.data
}
const search = async (q: string): Promise<void> => {
if (!q.trim()) {
await fetchClients()
return
}
const response = await $fetch<{ data: Client[] }>('/api/search', {
query: { q },
credentials: 'include',
})
clients.value = response.data
}
const fetchClient = async (id: number): Promise<Client> => {
const response = await $fetch<{ data: Client }>(`/api/clients/${id}`, {
credentials: 'include',
})
return response.data
}
const createClient = async (payload: { client_number: string; name: string; notes?: Record<string, string> }): Promise<Client> => {
const response = await $fetch<{ 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<Pick<Client, 'name' | 'notes'>>): Promise<Client> => {
const response = await $fetch<{ 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 }
}

View file

@ -0,0 +1,48 @@
import type { HistoryEntry } from '~/types'
export const useHistory = (clientId: number) => {
const entries = ref<HistoryEntry[]>([])
const getXsrfToken = (): string => {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/)
return match ? decodeURIComponent(match[1]) : ''
}
const fetchEntries = async (): Promise<void> => {
const response = await $fetch<{ data: HistoryEntry[] }>(`/api/clients/${clientId}/history`, {
credentials: 'include',
})
entries.value = response.data
}
const addEntry = async (payload: { type: string; body: string }): Promise<void> => {
await $fetch(`/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<void> => {
await $fetch(`/api/history/${id}`, {
method: 'PUT',
body: { body },
credentials: 'include',
headers: { 'X-XSRF-TOKEN': getXsrfToken() },
})
await fetchEntries()
}
const deleteEntry = async (id: number): Promise<void> => {
await $fetch(`/api/history/${id}`, {
method: 'DELETE',
credentials: 'include',
headers: { 'X-XSRF-TOKEN': getXsrfToken() },
})
entries.value = entries.value.filter(e => e.id !== id)
}
return { entries, fetchEntries, addEntry, updateEntry, deleteEntry }
}

View file

@ -0,0 +1,13 @@
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path === '/login') return
const { user, fetchUser } = useAuth()
if (!user.value) {
await fetchUser()
}
if (!user.value) {
return navigateTo('/login')
}
})

27
frontend/nuxt.config.ts Normal file
View file

@ -0,0 +1,27 @@
export default defineNuxtConfig({
ssr: false,
modules: ['@nuxtjs/tailwindcss'],
devServer: {
port: 3000,
host: '0.0.0.0',
},
nitro: {
devProxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
prependPath: false,
},
'/sanctum': {
target: 'http://backend:8000',
changeOrigin: true,
prependPath: false,
},
},
},
runtimeConfig: {
public: {
apiBase: '/api',
},
},
})

12205
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

19
frontend/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "nuxt-app",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
"nuxt": "^3.21.8",
"tailwindcss": "^3.4.19",
"vue": "^3.5.38",
"vue-router": "^5.1.0"
}
}

View file

@ -0,0 +1,99 @@
<script setup lang="ts">
import type { Client } from '~/types'
definePageMeta({ middleware: 'auth' })
const route = useRoute()
const clientId = Number(route.params.id)
const { fetchClient, updateClient } = useClient()
const { entries, fetchEntries, addEntry, updateEntry, deleteEntry } = useHistory(clientId)
const client = ref<Client | null>(null)
client.value = await fetchClient(clientId)
await fetchEntries()
const editingName = ref(false)
const editName = ref(client.value?.name ?? '')
const editingFields = ref(false)
const saveName = async () => {
if (!client.value) return
client.value = await updateClient(clientId, { name: editName.value })
editingName.value = false
}
const saveFields = async (notes: Record<string, string>) => {
if (!client.value) return
client.value = await updateClient(clientId, { notes })
editingFields.value = false
}
const onAddEntry = async (payload: { type: string; body: string }) => {
await addEntry(payload)
}
const onUpdateEntry = async (id: number, body: string) => {
await updateEntry(id, body)
}
const onDeleteEntry = async (id: number) => {
if (!confirm('Eintrag wirklich löschen?')) return
await deleteEntry(id)
}
</script>
<template>
<div v-if="client" class="min-h-screen bg-gray-50">
<header class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-4">
<NuxtLink to="/clients" class="text-sm text-blue-600 hover:underline"> Alle Mandanten</NuxtLink>
<h1 class="text-base font-semibold text-gray-800">
<span class="font-mono text-gray-500">{{ client.client_number }}</span>
<span class="mx-2 text-gray-400">·</span>
<span v-if="!editingName">{{ client.name }}</span>
<span v-else class="inline-flex gap-2 items-center">
<input v-model="editName" class="border border-gray-300 px-2 py-0.5 text-sm" />
<button @click="saveName" class="text-sm bg-blue-600 text-white px-2 py-0.5 hover:bg-blue-700">OK</button>
<button @click="editingName = false" class="text-sm text-gray-500">Abbrechen</button>
</span>
</h1>
<button v-if="!editingName" @click="editingName = true" class="text-xs text-gray-400 hover:text-gray-600">
Name ändern
</button>
<NuxtLink :to="`/clients/${clientId}/print`" class="ml-auto text-sm text-gray-500 hover:underline">
Drucken
</NuxtLink>
</header>
<div class="max-w-4xl mx-auto py-6 px-6 space-y-6">
<!-- Configurable fields -->
<section class="bg-white border border-gray-200 p-4">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-gray-700">Stammdaten</h2>
<button @click="editingFields = !editingFields" class="text-xs text-blue-600 hover:underline">
{{ editingFields ? 'Abbrechen' : 'Bearbeiten' }}
</button>
</div>
<ClientFields :client="client" :editing="editingFields" @save="saveFields" />
</section>
<!-- History -->
<section>
<h2 class="text-sm font-semibold text-gray-700 mb-3">Verlauf</h2>
<HistoryForm @submit="onAddEntry" />
<div class="mt-3 space-y-2">
<HistoryEntry
v-for="entry in entries"
:key="entry.id"
:entry="entry"
@update="onUpdateEntry"
@delete="onDeleteEntry"
/>
<p v-if="entries.length === 0" class="text-sm text-gray-400 italic py-4 text-center">
Noch keine Einträge.
</p>
</div>
</section>
</div>
</div>
</template>

View file

@ -0,0 +1,73 @@
<script setup lang="ts">
import type { Client, HistoryEntry } from '~/types'
definePageMeta({ middleware: 'auth' })
const route = useRoute()
const clientId = Number(route.params.id)
const { fetchClient } = useClient()
const { entries, fetchEntries } = useHistory(clientId)
const client = ref<Client | null>(null)
client.value = await fetchClient(clientId)
await fetchEntries()
const labelMap: Record<string, string> = {
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()
})
</script>
<template>
<div v-if="client" class="print-page p-8 max-w-3xl mx-auto font-sans text-sm text-gray-900">
<h1 class="text-lg font-bold mb-1">Mandantenakte</h1>
<p class="text-gray-500 mb-6 text-xs">Gedruckt: {{ new Date().toLocaleString('de-DE') }}</p>
<table class="w-full mb-6 border-collapse text-sm">
<tr class="border-b border-gray-200">
<td class="py-1 text-gray-500 w-40">Mandantennummer</td>
<td class="py-1 font-mono">{{ client.client_number }}</td>
</tr>
<tr class="border-b border-gray-200">
<td class="py-1 text-gray-500">Name</td>
<td class="py-1">{{ client.name }}</td>
</tr>
<template v-for="(val, key) in client.notes" :key="key">
<tr class="border-b border-gray-200">
<td class="py-1 text-gray-500">{{ key }}</td>
<td class="py-1">{{ val }}</td>
</tr>
</template>
</table>
<h2 class="font-semibold mb-3">Verlauf</h2>
<div v-for="entry in entries" :key="entry.id" class="mb-4 border-t border-gray-200 pt-3">
<div class="flex gap-3 text-xs text-gray-500 mb-1">
<span class="font-medium text-gray-700">{{ labelMap[entry.type] ?? entry.type }}</span>
<span>{{ formatDate(entry.created_at) }}</span>
<span>{{ entry.author_name }}</span>
</div>
<p class="whitespace-pre-wrap text-gray-800">{{ entry.body }}</p>
</div>
<p v-if="entries.length === 0" class="text-gray-400 italic">Keine Einträge.</p>
</div>
</template>
<style>
@media print {
.print-page { padding: 0; }
@page { margin: 2cm; }
}
</style>

View file

@ -0,0 +1,92 @@
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
const { clients, search, fetchClients } = useClient()
const q = ref('')
let debounceTimer: ReturnType<typeof setTimeout>
await fetchClients()
const onSearch = () => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => search(q.value), 250)
}
const showCreate = ref(false)
const newNumber = ref('')
const newName = ref('')
const creating = ref(false)
const createError = ref('')
const { createClient } = useClient()
const submitCreate = async () => {
creating.value = true
createError.value = ''
try {
const client = await createClient({ client_number: newNumber.value, name: newName.value })
showCreate.value = false
newNumber.value = ''
newName.value = ''
await navigateTo(`/clients/${client.id}`)
} catch (e: any) {
createError.value = e?.data?.message ?? 'Fehler beim Erstellen.'
} finally {
creating.value = false
}
}
</script>
<template>
<div class="min-h-screen bg-gray-50">
<header class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-4">
<h1 class="text-base font-semibold text-gray-800">Mandant CRM</h1>
<input
v-model="q"
@input="onSearch"
type="search"
placeholder="Mandant suchen (Name oder Nr.)…"
class="flex-1 border border-gray-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 max-w-md"
/>
<button
@click="showCreate = true"
class="bg-blue-600 text-white px-3 py-1.5 text-sm hover:bg-blue-700"
>
+ Neuer Mandant
</button>
<LogoutButton />
</header>
<main class="max-w-4xl mx-auto py-4 px-6 space-y-1">
<p v-if="clients.length === 0" class="text-sm text-gray-500 py-8 text-center">
Keine Mandanten gefunden.
</p>
<ClientCard v-for="client in clients" :key="client.id" :client="client" />
</main>
<!-- Create modal -->
<div v-if="showCreate" class="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
<div class="bg-white shadow-lg p-6 w-full max-w-sm">
<h2 class="text-base font-semibold mb-4">Neuer Mandant</h2>
<form @submit.prevent="submitCreate" class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700">Mandantennummer</label>
<input v-model="newNumber" required class="mt-1 block w-full border border-gray-300 px-3 py-2 text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Name</label>
<input v-model="newName" required class="mt-1 block w-full border border-gray-300 px-3 py-2 text-sm" />
</div>
<p v-if="createError" class="text-sm text-red-600">{{ createError }}</p>
<div class="flex gap-2 justify-end">
<button type="button" @click="showCreate = false" class="px-3 py-1.5 text-sm border border-gray-300">
Abbrechen
</button>
<button type="submit" :disabled="creating" class="px-3 py-1.5 text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">
Anlegen
</button>
</div>
</form>
</div>
</div>
</div>
</template>

8
frontend/pages/index.vue Normal file
View file

@ -0,0 +1,8 @@
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
await navigateTo('/clients')
</script>
<template>
<div></div>
</template>

61
frontend/pages/login.vue Normal file
View file

@ -0,0 +1,61 @@
<script setup lang="ts">
const { login, user } = useAuth()
if (user.value) {
await navigateTo('/clients')
}
const email = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
const submit = async () => {
error.value = ''
loading.value = true
try {
await login(email.value, password.value)
await navigateTo('/clients')
} catch {
error.value = 'E-Mail oder Passwort ungültig.'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-100">
<div class="bg-white shadow p-8 w-full max-w-sm">
<h1 class="text-xl font-semibold mb-6">Mandant CRM Anmeldung</h1>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">E-Mail</label>
<input
v-model="email"
type="email"
required
class="mt-1 block w-full border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Passwort</label>
<input
v-model="password"
type="password"
required
class="mt-1 block w-full border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<button
type="submit"
:disabled="loading"
class="w-full bg-blue-600 text-white py-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{{ loading ? 'Wird angemeldet…' : 'Anmelden' }}
</button>
</form>
</div>
</div>
</template>

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,2 @@
User-Agent: *
Disallow:

View file

@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}

View file

@ -0,0 +1,15 @@
import type { Config } from 'tailwindcss'
export default {
content: [
'./components/**/*.{vue,ts}',
'./composables/**/*.ts',
'./pages/**/*.vue',
'./layouts/**/*.vue',
'./app.vue',
],
theme: {
extend: {},
},
plugins: [],
} satisfies Config

4
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

38
frontend/types/index.ts Normal file
View file

@ -0,0 +1,38 @@
export interface User {
id: number
name: string
email: string
role: 'admin' | 'staff'
}
export interface Client {
id: number
client_number: string
name: string
notes: Record<string, string>
created_at: string
updated_at: string
}
export interface HistoryEntry {
id: number
client_id: number
type: 'phone_call' | 'consultation' | 'recommendation' | 'instruction' | 'remark' | 'other'
body: string
author_id: number
author_name: string | null
updated_by: number | null
updated_by_name: string | null
created_at: string
updated_at: string
}
export interface PaginatedResponse<T> {
data: T[]
meta: {
current_page: number
last_page: number
total: number
per_page: number
}
}