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:
parent
b948d51b59
commit
e5e0be57da
29 changed files with 13147 additions and 3 deletions
|
|
@ -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
|
||||||
|
|
@ -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
24
frontend/.gitignore
vendored
Normal 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
34
frontend/CLAUDE.md
Normal 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
75
frontend/README.md
Normal 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
3
frontend/app.vue
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<template>
|
||||||
|
<NuxtPage />
|
||||||
|
</template>
|
||||||
7
frontend/components/LogoutButton.vue
Normal file
7
frontend/components/LogoutButton.vue
Normal 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>
|
||||||
17
frontend/components/client/ClientCard.vue
Normal file
17
frontend/components/client/ClientCard.vue
Normal 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>
|
||||||
52
frontend/components/client/ClientFields.vue
Normal file
52
frontend/components/client/ClientFields.vue
Normal 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>
|
||||||
62
frontend/components/history/HistoryEntry.vue
Normal file
62
frontend/components/history/HistoryEntry.vue
Normal 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>
|
||||||
51
frontend/components/history/HistoryForm.vue
Normal file
51
frontend/components/history/HistoryForm.vue
Normal 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>
|
||||||
48
frontend/composables/useAuth.ts
Normal file
48
frontend/composables/useAuth.ts
Normal 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 }
|
||||||
|
}
|
||||||
59
frontend/composables/useClient.ts
Normal file
59
frontend/composables/useClient.ts
Normal 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 }
|
||||||
|
}
|
||||||
48
frontend/composables/useHistory.ts
Normal file
48
frontend/composables/useHistory.ts
Normal 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 }
|
||||||
|
}
|
||||||
13
frontend/middleware/auth.ts
Normal file
13
frontend/middleware/auth.ts
Normal 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
27
frontend/nuxt.config.ts
Normal 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
12205
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
99
frontend/pages/clients/[id].vue
Normal file
99
frontend/pages/clients/[id].vue
Normal 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>
|
||||||
73
frontend/pages/clients/[id]/print.vue
Normal file
73
frontend/pages/clients/[id]/print.vue
Normal 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>
|
||||||
92
frontend/pages/clients/index.vue
Normal file
92
frontend/pages/clients/index.vue
Normal 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
8
frontend/pages/index.vue
Normal 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
61
frontend/pages/login.vue
Normal 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
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
frontend/public/robots.txt
Normal file
2
frontend/public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
3
frontend/server/tsconfig.json
Normal file
3
frontend/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": "../.nuxt/tsconfig.server.json"
|
||||||
|
}
|
||||||
15
frontend/tailwind.config.ts
Normal file
15
frontend/tailwind.config.ts
Normal 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
4
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
38
frontend/types/index.ts
Normal file
38
frontend/types/index.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue