feat: Add all module frontend views

Views added:
- ShiftsView (Schichtplanung)
- PatrolsView (Wächterkontrolle)
- IncidentsView (Vorfallberichte)
- VehiclesView (Fahrzeuge)
- DocumentsView (Dokumente)
- CustomersView (Kunden/CRM)
- BillingView (Abrechnung)
- ObjectsView (enhanced with contacts, instructions)

Updated:
- Router with all new routes
- Sidebar with complete navigation
This commit is contained in:
2026-03-12 21:23:01 +00:00
parent e5d09e9c80
commit 3ca75cc4f2
39 changed files with 1272 additions and 700 deletions

View File

@@ -21,18 +21,34 @@ const navigation = computed(() => {
]
if (authStore.canManageUsers) {
items.push({ name: 'Mitarbeiter', href: '/users', icon: '👥' })
items.push(
{ name: 'Mitarbeiter', href: '/users', icon: '👥' },
{ name: 'Schichtplanung', href: '/shifts', icon: '📅' }
)
}
items.push(
{ name: 'Verfügbarkeit', href: '/availability', icon: '📅' },
{ name: 'Verfügbarkeit', href: '/availability', icon: '🗓️' },
{ name: 'Stundenzettel', href: '/timesheets', icon: '⏱️' },
{ name: 'Qualifikationen', href: '/qualifications', icon: '🎓' },
{ name: 'Objekte', href: '/objects', icon: '🏢' },
{ name: 'Rundgänge', href: '/patrols', icon: '📍' },
{ name: 'Vorfälle', href: '/incidents', icon: '🚨' },
{ name: 'Dokumente', href: '/documents', icon: '📁' },
)
if (authStore.canManageUsers) {
items.push(
{ name: 'Fahrzeuge', href: '/vehicles', icon: '🚗' },
{ name: 'Kunden', href: '/customers', icon: '🤝' }
)
}
if (authStore.isChef) {
items.push({ name: 'Module', href: '/modules', icon: '⚙️' })
items.push(
{ name: 'Abrechnung', href: '/billing', icon: '💰' },
{ name: 'Module', href: '/modules', icon: '⚙️' }
)
}
items.push(

View File

@@ -88,6 +88,45 @@ const router = createRouter({
path: 'objects',
name: 'objects',
component: () => import('@/views/ObjectsView.vue')
},
{
path: 'shifts',
name: 'shifts',
component: () => import('@/views/ShiftsView.vue'),
meta: { roles: ['chef', 'disponent'] }
},
{
path: 'patrols',
name: 'patrols',
component: () => import('@/views/PatrolsView.vue')
},
{
path: 'incidents',
name: 'incidents',
component: () => import('@/views/IncidentsView.vue')
},
{
path: 'vehicles',
name: 'vehicles',
component: () => import('@/views/VehiclesView.vue'),
meta: { roles: ['chef', 'disponent'] }
},
{
path: 'documents',
name: 'documents',
component: () => import('@/views/DocumentsView.vue')
},
{
path: 'customers',
name: 'customers',
component: () => import('@/views/CustomersView.vue'),
meta: { roles: ['chef', 'disponent'] }
},
{
path: 'billing',
name: 'billing',
component: () => import('@/views/BillingView.vue'),
meta: { roles: ['chef'] }
}
]
}

165
src/views/BillingView.vue Normal file
View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/api'
const loading = ref(true)
const invoices = ref<any[]>([])
const rates = ref<any[]>([])
const stats = ref<any>({})
const activeTab = ref<'invoices' | 'rates'>('invoices')
onMounted(async () => { await loadData() })
async function loadData() {
loading.value = true
try {
const [invRes, ratesRes, statsRes] = await Promise.all([
api.get<any>('/billing/invoices'),
api.get<any>('/billing/rates'),
api.get<any>('/billing/stats')
])
invoices.value = invRes.data.invoices || []
rates.value = ratesRes.data.rates || []
stats.value = statsRes.data.stats || {}
} catch (e) { console.error(e) }
loading.value = false
}
async function sendInvoice(id: string) {
try {
await api.put(`/billing/invoices/${id}/send`, {})
await loadData()
} catch (e: any) { alert('Fehler: ' + e.message) }
}
async function markPaid(id: string) {
try {
await api.put(`/billing/invoices/${id}/pay`, { payment_method: 'Überweisung' })
await loadData()
} catch (e: any) { alert('Fehler: ' + e.message) }
}
function statusBadge(s: string) {
const map: Record<string, any> = {
draft: { text: 'Entwurf', class: 'bg-gray-100 text-gray-700' },
sent: { text: 'Gesendet', class: 'bg-blue-100 text-blue-700' },
paid: { text: 'Bezahlt', class: 'bg-green-100 text-green-700' },
overdue: { text: 'Überfällig', class: 'bg-red-100 text-red-700' },
cancelled: { text: 'Storniert', class: 'bg-gray-100 text-gray-700' }
}
return map[s] || { text: s, class: 'bg-gray-100' }
}
function formatCurrency(n: number): string {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n || 0)
}
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">💰 Abrechnung</h1>
<p class="text-gray-500">Rechnungen & Sätze</p>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Offene Rechnungen</p>
<p class="text-2xl font-bold text-blue-600">{{ stats.open_invoices || 0 }}</p>
<p class="text-sm text-gray-500">{{ formatCurrency(stats.open_amount) }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Überfällig</p>
<p class="text-2xl font-bold text-red-600">{{ stats.overdue_invoices || 0 }}</p>
<p class="text-sm text-gray-500">{{ formatCurrency(stats.overdue_amount) }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Bezahlt (30 Tage)</p>
<p class="text-2xl font-bold text-green-600">{{ formatCurrency(stats.paid_last_30_days) }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Stundensätze</p>
<p class="text-2xl font-bold">{{ rates.length }}</p>
</div>
</div>
<!-- Tabs -->
<div class="flex space-x-1 border-b mb-6">
<button @click="activeTab = 'invoices'"
:class="['px-4 py-2 font-medium border-b-2 -mb-px', activeTab === 'invoices' ? 'border-blue-600 text-blue-600' : 'border-transparent']">
Rechnungen
</button>
<button @click="activeTab = 'rates'"
:class="['px-4 py-2 font-medium border-b-2 -mb-px', activeTab === 'rates' ? 'border-blue-600 text-blue-600' : 'border-transparent']">
Stundensätze
</button>
</div>
<div v-if="loading" class="text-center py-12">Laden...</div>
<!-- Invoices -->
<div v-else-if="activeTab === 'invoices'">
<div v-if="invoices.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">📄</p>
<p>Keine Rechnungen vorhanden</p>
</div>
<div v-else class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nr.</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Kunde</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Datum</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Betrag</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="inv in invoices" :key="inv.id" class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium">{{ inv.invoice_number }}</td>
<td class="px-4 py-3 text-sm">{{ inv.customer_name }}</td>
<td class="px-4 py-3 text-sm">{{ new Date(inv.invoice_date).toLocaleDateString('de-DE') }}</td>
<td class="px-4 py-3 text-sm text-right font-semibold">{{ formatCurrency(inv.total) }}</td>
<td class="px-4 py-3">
<span :class="['px-2 py-1 text-xs rounded', statusBadge(inv.status).class]">
{{ statusBadge(inv.status).text }}
</span>
</td>
<td class="px-4 py-3 text-right text-sm space-x-2">
<button v-if="inv.status === 'draft'" @click="sendInvoice(inv.id)" class="text-blue-600 hover:underline">Senden</button>
<button v-if="inv.status === 'sent' || inv.status === 'overdue'" @click="markPaid(inv.id)" class="text-green-600 hover:underline">Bezahlt</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Rates -->
<div v-else-if="activeTab === 'rates'">
<div v-if="rates.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">💵</p>
<p>Keine Stundensätze definiert</p>
</div>
<div v-else class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="r in rates" :key="r.id" class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold">{{ r.name }}</h3>
<p class="text-sm text-gray-500">{{ r.customer_name || 'Allgemein' }}</p>
</div>
<span v-if="r.is_default" class="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded">Standard</span>
</div>
<p class="text-2xl font-bold mt-2">{{ formatCurrency(r.amount) }}</p>
<p class="text-sm text-gray-500">pro {{ r.rate_type === 'hourly' ? 'Stunde' : r.rate_type === 'daily' ? 'Tag' : 'Monat' }}</p>
</div>
</div>
</div>
</div>
</template>

177
src/views/CustomersView.vue Normal file
View File

@@ -0,0 +1,177 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/api'
const loading = ref(true)
const customers = ref<any[]>([])
const showModal = ref(false)
const selectedCustomer = ref<any>(null)
const form = ref({ company_name: '', contact_person: '', email: '', phone: '', address: '', city: '', postal_code: '' })
onMounted(async () => { await loadData() })
async function loadData() {
loading.value = true
try {
const res = await api.get<any>('/customers')
customers.value = res.data.customers || []
} catch (e) { console.error(e) }
loading.value = false
}
async function loadCustomer(id: string) {
try {
const res = await api.get<any>(`/customers/${id}`)
selectedCustomer.value = res.data
} catch (e) { console.error(e) }
}
async function createCustomer() {
try {
await api.post('/customers', form.value)
showModal.value = false
form.value = { company_name: '', contact_person: '', email: '', phone: '', address: '', city: '', postal_code: '' }
await loadData()
} catch (e: any) { alert('Fehler: ' + e.message) }
}
function statusBadge(s: string) {
const map: Record<string, any> = {
active: { text: 'Aktiv', class: 'bg-green-100 text-green-700' },
inactive: { text: 'Inaktiv', class: 'bg-gray-100 text-gray-700' },
prospect: { text: 'Interessent', class: 'bg-blue-100 text-blue-700' }
}
return map[s] || { text: s, class: 'bg-gray-100' }
}
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">👥 Kunden / CRM</h1>
<p class="text-gray-500">Kundenverwaltung & Verträge</p>
</div>
<button @click="showModal = true" class="btn btn-primary">+ Kunde</button>
</div>
<div class="flex gap-6">
<!-- Customer List -->
<div class="w-1/2">
<div v-if="loading" class="text-center py-12">Laden...</div>
<div v-else-if="customers.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">👥</p>
<p>Keine Kunden vorhanden</p>
</div>
<div v-else class="space-y-2">
<div v-for="c in customers" :key="c.id"
@click="loadCustomer(c.id)"
:class="['bg-white rounded-lg shadow p-4 cursor-pointer hover:shadow-md transition-shadow',
selectedCustomer?.id === c.id ? 'ring-2 ring-blue-500' : '']">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold">{{ c.company_name }}</h3>
<p class="text-sm text-gray-500">{{ c.contact_person }}</p>
</div>
<span :class="['px-2 py-1 text-xs rounded', statusBadge(c.status).class]">
{{ statusBadge(c.status).text }}
</span>
</div>
<div class="mt-2 flex gap-4 text-xs text-gray-500">
<span v-if="c.active_contracts">📄 {{ c.active_contracts }} Verträge</span>
<span v-if="c.object_count">🏢 {{ c.object_count }} Objekte</span>
</div>
</div>
</div>
</div>
<!-- Customer Detail -->
<div class="w-1/2">
<div v-if="!selectedCustomer" class="bg-gray-50 rounded-lg p-12 text-center text-gray-400">
<p>Wähle einen Kunden aus</p>
</div>
<div v-else class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-bold mb-4">{{ selectedCustomer.company_name }}</h2>
<div class="space-y-4">
<div>
<h3 class="text-sm font-semibold text-gray-500 mb-2">Kontakt</h3>
<p>{{ selectedCustomer.contact_person }}</p>
<p class="text-sm">📧 {{ selectedCustomer.email || '-' }}</p>
<p class="text-sm">📞 {{ selectedCustomer.phone || '-' }}</p>
</div>
<div>
<h3 class="text-sm font-semibold text-gray-500 mb-2">Adresse</h3>
<p>{{ selectedCustomer.address }}</p>
<p>{{ selectedCustomer.postal_code }} {{ selectedCustomer.city }}</p>
</div>
<div v-if="selectedCustomer.contracts?.length">
<h3 class="text-sm font-semibold text-gray-500 mb-2">Verträge ({{ selectedCustomer.contracts.length }})</h3>
<div v-for="con in selectedCustomer.contracts" :key="con.id" class="text-sm p-2 bg-gray-50 rounded mb-1">
<span class="font-medium">{{ con.title || con.contract_number }}</span>
<span v-if="con.monthly_value" class="ml-2 text-green-600">{{ con.monthly_value }}/Monat</span>
</div>
</div>
<div v-if="selectedCustomer.objects?.length">
<h3 class="text-sm font-semibold text-gray-500 mb-2">Objekte ({{ selectedCustomer.objects.length }})</h3>
<div v-for="obj in selectedCustomer.objects" :key="obj.id" class="text-sm">
🏢 {{ obj.name }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div v-if="showModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg p-6">
<h2 class="text-xl font-bold mb-4">Neuer Kunde</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Firmenname *</label>
<input v-model="form.company_name" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Ansprechpartner</label>
<input v-model="form.contact_person" class="input" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">E-Mail</label>
<input v-model="form.email" type="email" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Telefon</label>
<input v-model="form.phone" class="input" />
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">Adresse</label>
<input v-model="form.address" class="input" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">PLZ</label>
<input v-model="form.postal_code" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Stadt</label>
<input v-model="form.city" class="input" />
</div>
</div>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button @click="showModal = false" class="btn">Abbrechen</button>
<button @click="createCustomer" class="btn btn-primary">Erstellen</button>
</div>
</div>
</div>
</div>
</template>

137
src/views/DocumentsView.vue Normal file
View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/api'
const loading = ref(true)
const documents = ref<any[]>([])
const categories = ref<any[]>([])
const pendingDocs = ref<any[]>([])
const showModal = ref(false)
const form = ref({ title: '', description: '', category_id: '', file_url: '', is_mandatory: false })
onMounted(async () => { await loadData() })
async function loadData() {
loading.value = true
try {
const [docRes, catRes, pendRes] = await Promise.all([
api.get<any>('/documents'),
api.get<any>('/documents/categories'),
api.get<any>('/documents/pending/list')
])
documents.value = docRes.data.documents || []
categories.value = catRes.data.categories || []
pendingDocs.value = pendRes.data.documents || []
} catch (e) { console.error(e) }
loading.value = false
}
async function acknowledge(docId: string) {
try {
await api.post(`/documents/${docId}/acknowledge`, {})
await loadData()
} catch (e: any) { alert('Fehler: ' + e.message) }
}
async function createDocument() {
try {
await api.post('/documents', form.value)
showModal.value = false
form.value = { title: '', description: '', category_id: '', file_url: '', is_mandatory: false }
await loadData()
} catch (e: any) { alert('Fehler: ' + e.message) }
}
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">📁 Dokumente</h1>
<p class="text-gray-500">Unterlagen & Bestätigungen</p>
</div>
<button @click="showModal = true" class="btn btn-primary">+ Dokument</button>
</div>
<!-- Pending Documents Alert -->
<div v-if="pendingDocs.length > 0" class="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-6">
<div class="flex">
<span class="text-yellow-600 text-xl mr-3"></span>
<div>
<h3 class="font-semibold text-yellow-800">{{ pendingDocs.length }} Dokument(e) zu bestätigen</h3>
<div class="mt-2 space-y-2">
<div v-for="doc in pendingDocs" :key="doc.id" class="flex items-center justify-between bg-white p-2 rounded">
<span>{{ doc.category_icon }} {{ doc.title }}</span>
<button @click="acknowledge(doc.id)" class="text-sm text-blue-600 hover:underline">Bestätigen</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="loading" class="text-center py-12">Laden...</div>
<div v-else-if="documents.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">📄</p>
<p>Keine Dokumente vorhanden</p>
</div>
<div v-else class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="doc in documents" :key="doc.id" class="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
<div class="flex items-start space-x-3">
<span class="text-2xl">{{ doc.category_icon || '📄' }}</span>
<div class="flex-1 min-w-0">
<h3 class="font-semibold truncate">{{ doc.title }}</h3>
<p class="text-sm text-gray-500">{{ doc.category_name }}</p>
<p v-if="doc.description" class="text-sm text-gray-400 mt-1 line-clamp-2">{{ doc.description }}</p>
</div>
</div>
<div class="mt-3 flex items-center justify-between text-xs">
<span v-if="doc.is_mandatory" class="px-2 py-1 bg-red-100 text-red-700 rounded">Pflicht</span>
<span v-if="doc.acknowledged" class="text-green-600"> Bestätigt</span>
<span v-else-if="doc.is_mandatory" class="text-orange-600">Ausstehend</span>
</div>
<a v-if="doc.file_url" :href="doc.file_url" target="_blank"
class="mt-3 block text-center text-sm text-blue-600 hover:underline">
📥 Herunterladen
</a>
</div>
</div>
<!-- Modal -->
<div v-if="showModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h2 class="text-xl font-bold mb-4">Neues Dokument</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Titel *</label>
<input v-model="form.title" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Kategorie</label>
<select v-model="form.category_id" class="input">
<option value="">-- Wählen --</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.icon }} {{ c.name }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Datei-URL</label>
<input v-model="form.file_url" class="input" placeholder="https://..." />
</div>
<div>
<label class="block text-sm font-medium mb-1">Beschreibung</label>
<textarea v-model="form.description" rows="2" class="input"></textarea>
</div>
<label class="flex items-center">
<input v-model="form.is_mandatory" type="checkbox" class="mr-2" />
<span class="text-sm">Pflichtdokument (Bestätigung erforderlich)</span>
</label>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button @click="showModal = false" class="btn">Abbrechen</button>
<button @click="createDocument" class="btn btn-primary">Erstellen</button>
</div>
</div>
</div>
</div>
</template>

134
src/views/IncidentsView.vue Normal file
View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/api'
const loading = ref(true)
const incidents = ref<any[]>([])
const categories = ref<any[]>([])
const showModal = ref(false)
const form = ref({ title: '', description: '', category_id: '', severity: 2, object_id: '' })
onMounted(async () => {
await loadData()
})
async function loadData() {
loading.value = true
try {
const [incRes, catRes] = await Promise.all([
api.get<any>('/incidents'),
api.get<any>('/incidents/categories')
])
incidents.value = incRes.data.incidents || []
categories.value = catRes.data.categories || []
} catch (e) { console.error(e) }
loading.value = false
}
async function createIncident() {
try {
await api.post('/incidents', form.value)
showModal.value = false
form.value = { title: '', description: '', category_id: '', severity: 2, object_id: '' }
await loadData()
} catch (e: any) { alert('Fehler: ' + e.message) }
}
function severityColor(s: number): string {
if (s >= 4) return 'bg-red-100 text-red-700'
if (s >= 3) return 'bg-orange-100 text-orange-700'
return 'bg-yellow-100 text-yellow-700'
}
function statusBadge(s: string): { text: string, class: string } {
const map: Record<string, any> = {
open: { text: 'Offen', class: 'bg-red-100 text-red-700' },
in_progress: { text: 'In Bearbeitung', class: 'bg-yellow-100 text-yellow-700' },
resolved: { text: 'Gelöst', class: 'bg-green-100 text-green-700' },
closed: { text: 'Geschlossen', class: 'bg-gray-100 text-gray-700' }
}
return map[s] || { text: s, class: 'bg-gray-100' }
}
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">🚨 Vorfallberichte</h1>
<p class="text-gray-500">Incidents dokumentieren und verfolgen</p>
</div>
<button @click="showModal = true" class="btn btn-primary">+ Vorfall melden</button>
</div>
<div v-if="loading" class="text-center py-12">Laden...</div>
<div v-else-if="incidents.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4"></p>
<p>Keine Vorfälle gemeldet</p>
</div>
<div v-else class="space-y-4">
<div v-for="inc in incidents" :key="inc.id" class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-start">
<div class="flex items-start space-x-3">
<span class="text-2xl">{{ inc.category_icon || '📋' }}</span>
<div>
<h3 class="font-semibold">{{ inc.title }}</h3>
<p class="text-sm text-gray-500">{{ inc.category_name }} · {{ inc.object_name || 'Ohne Objekt' }}</p>
<p class="text-sm text-gray-400 mt-1">{{ new Date(inc.occurred_at).toLocaleString('de-DE') }}</p>
</div>
</div>
<div class="flex flex-col items-end space-y-1">
<span :class="['px-2 py-1 text-xs rounded', statusBadge(inc.status).class]">
{{ statusBadge(inc.status).text }}
</span>
<span :class="['px-2 py-1 text-xs rounded', severityColor(inc.severity)]">
Stufe {{ inc.severity }}
</span>
</div>
</div>
<p v-if="inc.description" class="mt-3 text-sm text-gray-600">{{ inc.description }}</p>
<div class="mt-3 flex items-center text-xs text-gray-500">
<span>Gemeldet von {{ inc.reporter_first }} {{ inc.reporter_last }}</span>
<span v-if="inc.attachment_count" class="ml-4">📎 {{ inc.attachment_count }} Anhänge</span>
</div>
</div>
</div>
<!-- Modal -->
<div v-if="showModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg p-6">
<h2 class="text-xl font-bold mb-4">Vorfall melden</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Titel *</label>
<input v-model="form.title" class="input" placeholder="Kurze Beschreibung" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Kategorie</label>
<select v-model="form.category_id" class="input">
<option value="">-- Wählen --</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.icon }} {{ c.name }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Schweregrad (1-5)</label>
<input v-model.number="form.severity" type="range" min="1" max="5" class="w-full" />
<div class="flex justify-between text-xs text-gray-500">
<span>Gering</span><span>Kritisch</span>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">Beschreibung</label>
<textarea v-model="form.description" rows="3" class="input" placeholder="Details..."></textarea>
</div>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button @click="showModal = false" class="btn">Abbrechen</button>
<button @click="createIncident" class="btn btn-primary">Melden</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,718 +1,189 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { api } from '@/api'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
interface ObjectType {
key: string
name: string
icon: string
}
interface SecObject {
id: number
name: string
short_name?: string
object_number?: string
object_type: string
street?: string
house_number?: string
postal_code?: string
city?: string
phone?: string
email?: string
description?: string
customer_name?: string
status: string
contact_count: number
checkpoint_count: number
}
const loading = ref(true)
const objects = ref<SecObject[]>([])
const objectTypes = ref<ObjectType[]>([])
const objects = ref<any[]>([])
const selectedObject = ref<any>(null)
// Filters
const searchQuery = ref('')
const filterType = ref('')
const filterStatus = ref('active')
// Modal
const showModal = ref(false)
const showDetailModal = ref(false)
const editingObject = ref<any>(null)
const formData = ref({
name: '',
short_name: '',
object_number: '',
object_type: 'other',
street: '',
house_number: '',
postal_code: '',
city: '',
phone: '',
email: '',
description: '',
access_info: '',
parking_info: '',
customer_name: '',
size_sqm: null as number | null,
floors: null as number | null
})
const saving = ref(false)
const form = ref({ name: '', address: '', city: '', postal_code: '', customer_id: '' })
// Contact form
const showContactModal = ref(false)
const contactForm = ref({
name: '',
role: '',
company: '',
phone: '',
mobile: '',
email: '',
availability: '',
is_primary: false,
is_emergency: false,
notes: ''
})
onMounted(async () => { await loadData() })
// Instruction form
const showInstructionModal = ref(false)
const instructionForm = ref({
title: '',
category: 'general',
content: '',
is_critical: false
})
onMounted(async () => {
await Promise.all([
loadObjects(),
loadObjectTypes()
])
async function loadData() {
loading.value = true
try {
const res = await api.get<any>('/objects')
objects.value = res.data.objects || []
} catch (e) { console.error(e) }
loading.value = false
})
}
async function loadObjects() {
async function loadObject(id: string) {
try {
const params = new URLSearchParams()
if (filterStatus.value) params.append('status', filterStatus.value)
if (filterType.value) params.append('type', filterType.value)
if (searchQuery.value) params.append('search', searchQuery.value)
objects.value = await api.get(`/objects?${params}`)
} catch (e) {
console.error('Failed to load objects:', e)
}
const res = await api.get<any>(`/objects/${id}`)
selectedObject.value = res.data
} catch (e) { console.error(e) }
}
async function loadObjectTypes() {
async function createObject() {
try {
objectTypes.value = await api.get('/objects/types')
} catch (e) {
console.error('Failed to load types:', e)
}
}
async function loadObjectDetail(id: number) {
try {
selectedObject.value = await api.get(`/objects/${id}`)
showDetailModal.value = true
} catch (e) {
console.error('Failed to load object:', e)
}
}
const filteredObjects = computed(() => {
let result = [...objects.value]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(o =>
o.name.toLowerCase().includes(query) ||
o.city?.toLowerCase().includes(query) ||
o.object_number?.toLowerCase().includes(query) ||
o.customer_name?.toLowerCase().includes(query)
)
}
return result
})
function openAddModal() {
editingObject.value = null
formData.value = {
name: '',
short_name: '',
object_number: '',
object_type: 'other',
street: '',
house_number: '',
postal_code: '',
city: '',
phone: '',
email: '',
description: '',
access_info: '',
parking_info: '',
customer_name: '',
size_sqm: null,
floors: null
}
showModal.value = true
}
function openEditModal(obj: SecObject) {
editingObject.value = obj
formData.value = { ...obj } as any
showModal.value = true
}
async function saveObject() {
if (!formData.value.name) return
saving.value = true
try {
if (editingObject.value) {
await api.put(`/objects/${editingObject.value.id}`, formData.value)
} else {
await api.post('/objects', formData.value)
}
await api.post('/objects', form.value)
showModal.value = false
await loadObjects()
} catch (e) {
console.error('Failed to save:', e)
} finally {
saving.value = false
form.value = { name: '', address: '', city: '', postal_code: '', customer_id: '' }
await loadData()
} catch (e: any) { alert('Fehler: ' + e.message) }
}
function statusBadge(s: string) {
const map: Record<string, any> = {
active: { text: 'Aktiv', class: 'bg-green-100 text-green-700', icon: '🟢' },
inactive: { text: 'Inaktiv', class: 'bg-gray-100 text-gray-700', icon: '⚪' },
pending: { text: 'Ausstehend', class: 'bg-yellow-100 text-yellow-700', icon: '🟡' }
}
}
async function deleteObject(id: number) {
if (!confirm('Objekt wirklich archivieren?')) return
try {
await api.delete(`/objects/${id}`)
await loadObjects()
} catch (e) {
console.error('Failed to delete:', e)
}
}
async function addContact() {
if (!contactForm.value.name || !selectedObject.value) return
try {
await api.post(`/objects/${selectedObject.value.id}/contacts`, contactForm.value)
await loadObjectDetail(selectedObject.value.id)
showContactModal.value = false
contactForm.value = { name: '', role: '', company: '', phone: '', mobile: '', email: '', availability: '', is_primary: false, is_emergency: false, notes: '' }
} catch (e) {
console.error('Failed to add contact:', e)
}
}
async function deleteContact(contactId: number) {
if (!confirm('Kontakt löschen?')) return
try {
await api.delete(`/objects/${selectedObject.value.id}/contacts/${contactId}`)
await loadObjectDetail(selectedObject.value.id)
} catch (e) {
console.error('Failed to delete:', e)
}
}
async function addInstruction() {
if (!instructionForm.value.title || !instructionForm.value.content || !selectedObject.value) return
try {
await api.post(`/objects/${selectedObject.value.id}/instructions`, instructionForm.value)
await loadObjectDetail(selectedObject.value.id)
showInstructionModal.value = false
instructionForm.value = { title: '', category: 'general', content: '', is_critical: false }
} catch (e) {
console.error('Failed to add instruction:', e)
}
}
async function deleteInstruction(instructionId: number) {
if (!confirm('Dienstanweisung löschen?')) return
try {
await api.delete(`/objects/${selectedObject.value.id}/instructions/${instructionId}`)
await loadObjectDetail(selectedObject.value.id)
} catch (e) {
console.error('Failed to delete:', e)
}
}
function getTypeIcon(type: string): string {
const t = objectTypes.value.find(t => t.key === type)
return t?.icon || '📍'
}
function getTypeName(type: string): string {
const t = objectTypes.value.find(t => t.key === type)
return t?.name || type
}
function getInstructionCategoryName(cat: string): string {
const cats: Record<string, string> = {
general: 'Allgemein',
patrol: 'Rundgang',
emergency: 'Notfall',
access: 'Zugang',
reporting: 'Meldewesen'
}
return cats[cat] || cat
return map[s] || { text: s, class: 'bg-gray-100', icon: '' }
}
</script>
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">🏢 Objekte</h1>
<button
v-if="authStore.canManageUsers"
@click="openAddModal"
class="btn btn-primary"
>
Neues Objekt
</button>
</div>
<!-- Filters -->
<div class="card">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium mb-1">🔍 Suche</label>
<input
v-model="searchQuery"
type="text"
class="input"
placeholder="Name, Stadt, Kunde..."
@input="loadObjects"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">🏷 Typ</label>
<select v-model="filterType" class="input" @change="loadObjects">
<option value="">Alle Typen</option>
<option v-for="type in objectTypes" :key="type.key" :value="type.key">
{{ type.icon }} {{ type.name }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">📊 Status</label>
<select v-model="filterStatus" class="input" @change="loadObjects">
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
<option value="archived">Archiviert</option>
<option value="all">Alle</option>
</select>
</div>
<div class="flex items-end">
<p class="text-sm text-gray-500">
{{ filteredObjects.length }} Objekte
</p>
</div>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">🏢 Objektverwaltung</h1>
<p class="text-gray-500">Standorte, Kontakte & Anweisungen</p>
</div>
<button @click="showModal = true" class="btn btn-primary">+ Objekt</button>
</div>
<!-- Loading -->
<div v-if="loading" class="text-center py-12">
<div class="animate-spin text-4xl"></div>
</div>
<div class="flex gap-6">
<!-- Object List -->
<div class="w-1/2">
<div v-if="loading" class="text-center py-12">Laden...</div>
<div v-else-if="objects.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">🏢</p>
<p>Keine Objekte vorhanden</p>
</div>
<!-- Objects Grid -->
<div v-else-if="filteredObjects.length === 0" class="card text-center py-12 text-gray-500">
Keine Objekte gefunden
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="obj in filteredObjects"
:key="obj.id"
class="card hover:shadow-lg transition-all cursor-pointer"
@click="loadObjectDetail(obj.id)"
>
<div class="flex items-start gap-4">
<div class="text-4xl">{{ getTypeIcon(obj.object_type) }}</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 dark:text-white truncate">
{{ obj.name }}
</h3>
<p v-if="obj.city" class="text-sm text-gray-500">
📍 {{ obj.postal_code }} {{ obj.city }}
</p>
<p v-if="obj.customer_name" class="text-sm text-gray-500">
👤 {{ obj.customer_name }}
</p>
<div class="flex gap-2 mt-2">
<span class="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
{{ getTypeName(obj.object_type) }}
</span>
<span v-if="obj.contact_count" class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded">
👥 {{ obj.contact_count }}
</span>
<span v-if="obj.checkpoint_count" class="text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded">
📍 {{ obj.checkpoint_count }} CP
<div v-else class="space-y-2">
<div v-for="obj in objects" :key="obj.id"
@click="loadObject(obj.id)"
:class="['bg-white rounded-lg shadow p-4 cursor-pointer hover:shadow-md transition-shadow',
selectedObject?.id === obj.id ? 'ring-2 ring-blue-500' : '']">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold">{{ obj.name }}</h3>
<p class="text-sm text-gray-500">{{ obj.address }}</p>
<p class="text-sm text-gray-400">{{ obj.postal_code }} {{ obj.city }}</p>
</div>
<span :class="['px-2 py-1 text-xs rounded', statusBadge(obj.status).class]">
{{ statusBadge(obj.status).icon }} {{ statusBadge(obj.status).text }}
</span>
</div>
<div v-if="obj.customer_name" class="mt-2 text-xs text-gray-500">
👤 {{ obj.customer_name }}
</div>
</div>
</div>
</div>
<!-- Object Detail -->
<div class="w-1/2">
<div v-if="!selectedObject" class="bg-gray-50 rounded-lg p-12 text-center text-gray-400">
<p>Wähle ein Objekt aus</p>
</div>
<!-- Quick Actions -->
<div v-if="authStore.canManageUsers" class="flex gap-2 mt-4 pt-3 border-t dark:border-gray-700" @click.stop>
<button @click="openEditModal(obj)" class="text-sm text-blue-600 hover:underline">
Bearbeiten
</button>
<button @click="deleteObject(obj.id)" class="text-sm text-red-600 hover:underline">
🗑 Archivieren
</button>
</div>
</div>
</div>
<!-- Detail Modal -->
<div v-if="showDetailModal && selectedObject" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6">
<!-- Header -->
<div class="flex items-start justify-between mb-6">
<div class="flex items-center gap-4">
<span class="text-4xl">{{ getTypeIcon(selectedObject.object_type) }}</span>
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ selectedObject.name }}</h2>
<p v-if="selectedObject.city" class="text-gray-500">
{{ selectedObject.street }} {{ selectedObject.house_number }},
{{ selectedObject.postal_code }} {{ selectedObject.city }}
</p>
</div>
</div>
<button @click="showDetailModal = false" class="text-2xl hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded">
</button>
</div>
<!-- Info Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div v-if="selectedObject.phone" class="bg-gray-50 dark:bg-gray-700 p-3 rounded">
<p class="text-xs text-gray-500">📞 Telefon</p>
<p class="font-medium">{{ selectedObject.phone }}</p>
</div>
<div v-if="selectedObject.email" class="bg-gray-50 dark:bg-gray-700 p-3 rounded">
<p class="text-xs text-gray-500">📧 E-Mail</p>
<p class="font-medium truncate">{{ selectedObject.email }}</p>
</div>
<div v-if="selectedObject.customer_name" class="bg-gray-50 dark:bg-gray-700 p-3 rounded">
<p class="text-xs text-gray-500">👤 Kunde</p>
<p class="font-medium">{{ selectedObject.customer_name }}</p>
</div>
<div v-if="selectedObject.object_number" class="bg-gray-50 dark:bg-gray-700 p-3 rounded">
<p class="text-xs text-gray-500"># Objektnummer</p>
<p class="font-medium">{{ selectedObject.object_number }}</p>
</div>
</div>
<!-- Description -->
<div v-if="selectedObject.description" class="mb-6">
<h3 class="font-semibold mb-2">📝 Beschreibung</h3>
<p class="text-gray-600 dark:text-gray-300 whitespace-pre-wrap">{{ selectedObject.description }}</p>
</div>
<!-- Access Info -->
<div v-if="selectedObject.access_info || selectedObject.parking_info" class="grid grid-cols-2 gap-4 mb-6">
<div v-if="selectedObject.access_info" class="bg-yellow-50 dark:bg-yellow-900/20 p-4 rounded">
<h3 class="font-semibold mb-2">🔑 Zugang</h3>
<p class="text-sm whitespace-pre-wrap">{{ selectedObject.access_info }}</p>
</div>
<div v-if="selectedObject.parking_info" class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded">
<h3 class="font-semibold mb-2">🅿 Parken</h3>
<p class="text-sm whitespace-pre-wrap">{{ selectedObject.parking_info }}</p>
</div>
</div>
<!-- Contacts -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold">👥 Ansprechpartner</h3>
<button v-if="authStore.canManageUsers" @click="showContactModal = true" class="text-sm text-blue-600 hover:underline">
Hinzufügen
</button>
</div>
<div v-if="!selectedObject.contacts?.length" class="text-gray-500 text-sm">
Keine Kontakte hinterlegt
</div>
<div v-else class="grid gap-2">
<div
v-for="contact in selectedObject.contacts"
:key="contact.id"
class="flex items-center justify-between bg-gray-50 dark:bg-gray-700 p-3 rounded"
>
<div>
<p class="font-medium">
{{ contact.name }}
<span v-if="contact.is_primary" class="text-xs bg-blue-500 text-white px-1 rounded ml-1">Haupt</span>
<span v-if="contact.is_emergency" class="text-xs bg-red-500 text-white px-1 rounded ml-1">Notfall</span>
</p>
<p v-if="contact.role" class="text-sm text-gray-500">{{ contact.role }}</p>
<p v-if="contact.phone || contact.mobile" class="text-sm">
📞 {{ contact.phone || contact.mobile }}
</p>
</div>
<button v-if="authStore.canManageUsers" @click="deleteContact(contact.id)" class="text-red-500 hover:text-red-700">
🗑
</button>
</div>
</div>
</div>
<!-- Instructions -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold">📋 Dienstanweisungen</h3>
<button v-if="authStore.canManageUsers" @click="showInstructionModal = true" class="text-sm text-blue-600 hover:underline">
Hinzufügen
</button>
</div>
<div v-if="!selectedObject.instructions?.length" class="text-gray-500 text-sm">
Keine Dienstanweisungen hinterlegt
</div>
<div v-else class="space-y-3">
<div
v-for="instr in selectedObject.instructions"
:key="instr.id"
:class="['p-4 rounded border-l-4', instr.is_critical ? 'border-red-500 bg-red-50 dark:bg-red-900/20' : 'border-gray-300 bg-gray-50 dark:bg-gray-700']"
>
<div class="flex items-start justify-between">
<div>
<p class="font-medium">
{{ instr.title }}
<span class="text-xs text-gray-500 ml-2">{{ getInstructionCategoryName(instr.category) }}</span>
</p>
<p class="text-sm mt-1 whitespace-pre-wrap text-gray-600 dark:text-gray-300">{{ instr.content }}</p>
</div>
<button v-if="authStore.canManageUsers" @click="deleteInstruction(instr.id)" class="text-red-500 hover:text-red-700 ml-2">
🗑
</button>
<div v-else class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-bold mb-1">{{ selectedObject.name }}</h2>
<p class="text-gray-500 mb-4">{{ selectedObject.address }}, {{ selectedObject.postal_code }} {{ selectedObject.city }}</p>
<div class="space-y-6">
<!-- Contacts -->
<div>
<h3 class="text-sm font-semibold text-gray-500 mb-2 flex items-center">
📞 Ansprechpartner
<span class="ml-2 text-xs bg-gray-100 px-2 py-0.5 rounded">{{ selectedObject.contacts?.length || 0 }}</span>
</h3>
<div v-if="selectedObject.contacts?.length" class="space-y-2">
<div v-for="c in selectedObject.contacts" :key="c.id" class="p-2 bg-gray-50 rounded text-sm">
<div class="font-medium">{{ c.name }} <span class="text-gray-400 font-normal">{{ c.role }}</span></div>
<div class="text-gray-600">📞 {{ c.phone }} · 📧 {{ c.email }}</div>
</div>
</div>
<p v-else class="text-sm text-gray-400">Keine Kontakte hinterlegt</p>
</div>
</div>
<!-- Checkpoints -->
<div v-if="selectedObject.checkpoints?.length" class="mb-6">
<h3 class="font-semibold mb-3">📍 Kontrollpunkte</h3>
<div class="flex flex-wrap gap-2">
<span
v-for="cp in selectedObject.checkpoints"
:key="cp.id"
class="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-3 py-1 rounded text-sm"
>
{{ cp.name }}
</span>
<!-- Instructions -->
<div>
<h3 class="text-sm font-semibold text-gray-500 mb-2 flex items-center">
📋 Anweisungen
<span class="ml-2 text-xs bg-gray-100 px-2 py-0.5 rounded">{{ selectedObject.instructions?.length || 0 }}</span>
</h3>
<div v-if="selectedObject.instructions?.length" class="space-y-2">
<div v-for="ins in selectedObject.instructions" :key="ins.id"
:class="['p-2 rounded text-sm', ins.priority === 'critical' ? 'bg-red-50 border-l-4 border-red-500' :
ins.priority === 'high' ? 'bg-orange-50 border-l-4 border-orange-500' : 'bg-gray-50']">
<div class="font-medium">{{ ins.title }}</div>
<p class="text-gray-600">{{ ins.content }}</p>
</div>
</div>
<p v-else class="text-sm text-gray-400">Keine Anweisungen hinterlegt</p>
</div>
</div>
<!-- Close Button -->
<div class="flex justify-end pt-4 border-t dark:border-gray-700">
<button @click="showDetailModal = false" class="btn">
Schließen
</button>
<!-- Documents -->
<div v-if="selectedObject.documents?.length">
<h3 class="text-sm font-semibold text-gray-500 mb-2">📁 Dokumente</h3>
<div class="flex flex-wrap gap-2">
<a v-for="d in selectedObject.documents" :key="d.id"
:href="d.file_url" target="_blank"
class="px-2 py-1 bg-blue-50 text-blue-700 rounded text-sm hover:bg-blue-100">
{{ d.file_name || 'Dokument' }}
</a>
</div>
</div>
<!-- Checkpoints -->
<div v-if="selectedObject.checkpoints?.length">
<h3 class="text-sm font-semibold text-gray-500 mb-2">📍 Checkpoints</h3>
<div class="flex flex-wrap gap-2">
<span v-for="cp in selectedObject.checkpoints" :key="cp.id"
class="px-2 py-1 bg-gray-100 rounded text-sm">
{{ cp.name }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Add/Edit Object Modal -->
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6">
<h2 class="text-xl font-semibold mb-4">
{{ editingObject ? '✏️ Objekt bearbeiten' : ' Neues Objekt' }}
</h2>
<form @submit.prevent="saveObject" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="block text-sm font-medium mb-1">Name *</label>
<input v-model="formData.name" type="text" class="input" required />
</div>
<div>
<label class="block text-sm font-medium mb-1">Kurzname</label>
<input v-model="formData.short_name" type="text" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Objektnummer</label>
<input v-model="formData.object_number" type="text" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Typ</label>
<select v-model="formData.object_type" class="input">
<option v-for="type in objectTypes" :key="type.key" :value="type.key">
{{ type.icon }} {{ type.name }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Kunde</label>
<input v-model="formData.customer_name" type="text" class="input" />
</div>
</div>
<hr class="dark:border-gray-700">
<div class="grid grid-cols-4 gap-4">
<div class="col-span-2">
<label class="block text-sm font-medium mb-1">Straße</label>
<input v-model="formData.street" type="text" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Nr.</label>
<input v-model="formData.house_number" type="text" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">PLZ</label>
<input v-model="formData.postal_code" type="text" class="input" />
</div>
<div class="col-span-2">
<label class="block text-sm font-medium mb-1">Stadt</label>
<input v-model="formData.city" type="text" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Telefon</label>
<input v-model="formData.phone" type="tel" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">E-Mail</label>
<input v-model="formData.email" type="email" class="input" />
</div>
</div>
<!-- Modal -->
<div v-if="showModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h2 class="text-xl font-bold mb-4">Neues Objekt</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Name *</label>
<input v-model="form.name" class="input" placeholder="z.B. Hauptgebäude Musterstraße" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Adresse</label>
<input v-model="form.address" class="input" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Beschreibung</label>
<textarea v-model="formData.description" class="input" rows="2"></textarea>
<label class="block text-sm font-medium mb-1">PLZ</label>
<input v-model="form.postal_code" class="input" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">🔑 Zugangsinfos</label>
<textarea v-model="formData.access_info" class="input" rows="2" placeholder="Schlüssel, Codes..."></textarea>
</div>
<div>
<label class="block text-sm font-medium mb-1">🅿 Parkhinweise</label>
<textarea v-model="formData.parking_info" class="input" rows="2"></textarea>
</div>
<div>
<label class="block text-sm font-medium mb-1">Stadt</label>
<input v-model="form.city" class="input" />
</div>
<div class="flex gap-3 pt-4">
<button type="button" @click="showModal = false" class="btn flex-1">
Abbrechen
</button>
<button type="submit" :disabled="saving" class="btn btn-primary flex-1">
{{ saving ? 'Speichern...' : 'Speichern' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Add Contact Modal -->
<div v-if="showContactModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
<div class="p-6">
<h2 class="text-xl font-semibold mb-4">👤 Kontakt hinzufügen</h2>
<form @submit.prevent="addContact" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Name *</label>
<input v-model="contactForm.name" type="text" class="input" required />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Rolle</label>
<input v-model="contactForm.role" type="text" class="input" placeholder="z.B. Hausmeister" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Firma</label>
<input v-model="contactForm.company" type="text" class="input" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Telefon</label>
<input v-model="contactForm.phone" type="tel" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Mobil</label>
<input v-model="contactForm.mobile" type="tel" class="input" />
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">E-Mail</label>
<input v-model="contactForm.email" type="email" class="input" />
</div>
<div class="flex gap-4">
<label class="flex items-center gap-2">
<input v-model="contactForm.is_primary" type="checkbox" class="rounded" />
<span class="text-sm">Hauptkontakt</span>
</label>
<label class="flex items-center gap-2">
<input v-model="contactForm.is_emergency" type="checkbox" class="rounded" />
<span class="text-sm">Notfallkontakt</span>
</label>
</div>
<div class="flex gap-3">
<button type="button" @click="showContactModal = false" class="btn flex-1">Abbrechen</button>
<button type="submit" class="btn btn-primary flex-1">Hinzufügen</button>
</div>
</form>
</div>
</div>
</div>
<!-- Add Instruction Modal -->
<div v-if="showInstructionModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
<div class="p-6">
<h2 class="text-xl font-semibold mb-4">📋 Dienstanweisung hinzufügen</h2>
<form @submit.prevent="addInstruction" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Titel *</label>
<input v-model="instructionForm.title" type="text" class="input" required />
</div>
<div>
<label class="block text-sm font-medium mb-1">Kategorie</label>
<select v-model="instructionForm.category" class="input">
<option value="general">Allgemein</option>
<option value="patrol">Rundgang</option>
<option value="emergency">Notfall</option>
<option value="access">Zugang</option>
<option value="reporting">Meldewesen</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Inhalt *</label>
<textarea v-model="instructionForm.content" class="input" rows="4" required></textarea>
</div>
<label class="flex items-center gap-2">
<input v-model="instructionForm.is_critical" type="checkbox" class="rounded" />
<span class="text-sm text-red-600"> Als kritisch markieren</span>
</label>
<div class="flex gap-3">
<button type="button" @click="showInstructionModal = false" class="btn flex-1">Abbrechen</button>
<button type="submit" class="btn btn-primary flex-1">Hinzufügen</button>
</div>
</form>
<div class="flex justify-end space-x-2 mt-6">
<button @click="showModal = false" class="btn">Abbrechen</button>
<button @click="createObject" class="btn btn-primary">Erstellen</button>
</div>
</div>
</div>

142
src/views/PatrolsView.vue Normal file
View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/api'
const loading = ref(true)
const checkpoints = ref<any[]>([])
const routes = ref<any[]>([])
const logs = ref<any[]>([])
const activeTab = ref<'checkpoints' | 'routes' | 'logs'>('checkpoints')
onMounted(async () => {
await loadData()
})
async function loadData() {
loading.value = true
try {
const [cpRes, routesRes, logsRes] = await Promise.all([
api.get<any>('/patrols/checkpoints'),
api.get<any>('/patrols/routes'),
api.get<any>('/patrols/logs')
])
checkpoints.value = cpRes.data.checkpoints || []
routes.value = routesRes.data.routes || []
logs.value = logsRes.data.logs || []
} catch (e) {
console.error(e)
}
loading.value = false
}
function formatTime(ts: string): string {
return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">📍 Wächterkontrolle</h1>
<p class="text-gray-500">Checkpoints, Routen & Rundgänge</p>
</div>
</div>
<!-- Tabs -->
<div class="flex space-x-1 border-b mb-6">
<button @click="activeTab = 'checkpoints'"
:class="['px-4 py-2 font-medium border-b-2 -mb-px', activeTab === 'checkpoints' ? 'border-blue-600 text-blue-600' : 'border-transparent']">
Checkpoints ({{ checkpoints.length }})
</button>
<button @click="activeTab = 'routes'"
:class="['px-4 py-2 font-medium border-b-2 -mb-px', activeTab === 'routes' ? 'border-blue-600 text-blue-600' : 'border-transparent']">
Routen ({{ routes.length }})
</button>
<button @click="activeTab = 'logs'"
:class="['px-4 py-2 font-medium border-b-2 -mb-px', activeTab === 'logs' ? 'border-blue-600 text-blue-600' : 'border-transparent']">
Rundgänge
</button>
</div>
<div v-if="loading" class="text-center py-12">Laden...</div>
<!-- Checkpoints -->
<div v-else-if="activeTab === 'checkpoints'">
<div v-if="checkpoints.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">📍</p>
<p>Keine Checkpoints vorhanden</p>
<p class="text-sm mt-2">Erstelle Checkpoints mit QR-Codes für Rundgänge</p>
</div>
<div v-else class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="cp in checkpoints" :key="cp.id" class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold">{{ cp.name }}</h3>
<p class="text-sm text-gray-500">{{ cp.object_name }}</p>
<p class="text-xs text-gray-400 mt-1">{{ cp.location_description }}</p>
</div>
<span class="text-2xl">{{ cp.checkpoint_type === 'nfc' ? '📶' : '📱' }}</span>
</div>
<div class="mt-3 p-2 bg-gray-100 rounded text-xs font-mono break-all">
{{ cp.code }}
</div>
</div>
</div>
</div>
<!-- Routes -->
<div v-else-if="activeTab === 'routes'">
<div v-if="routes.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">🗺</p>
<p>Keine Routen definiert</p>
</div>
<div v-else class="space-y-4">
<div v-for="route in routes" :key="route.id" class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold">{{ route.name }}</h3>
<p class="text-sm text-gray-500">{{ route.object_name }}</p>
</div>
<span :class="['px-2 py-1 text-xs rounded', route.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100']">
{{ route.is_active ? 'Aktiv' : 'Inaktiv' }}
</span>
</div>
<div class="mt-3 flex gap-4 text-sm text-gray-600">
<span>📍 {{ route.checkpoint_count || 0 }} Checkpoints</span>
<span v-if="route.time_limit_minutes"> Max {{ route.time_limit_minutes }} Min</span>
<span v-if="route.interval_minutes">🔄 Alle {{ route.interval_minutes }} Min</span>
</div>
</div>
</div>
</div>
<!-- Logs -->
<div v-else-if="activeTab === 'logs'">
<div v-if="logs.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">📋</p>
<p>Keine Rundgänge heute</p>
</div>
<div v-else class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Zeit</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Mitarbeiter</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Checkpoint</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Objekt</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="log in logs" :key="log.id" class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm">{{ formatTime(log.scanned_at) }}</td>
<td class="px-4 py-3 text-sm">{{ log.first_name }} {{ log.last_name }}</td>
<td class="px-4 py-3 text-sm">{{ log.checkpoint_name }}</td>
<td class="px-4 py-3 text-sm text-gray-500">{{ log.object_name }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>

162
src/views/ShiftsView.vue Normal file
View File

@@ -0,0 +1,162 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { api } from '@/api'
const loading = ref(true)
const shifts = ref<any[]>([])
const assignments = ref<any[]>([])
const showModal = ref(false)
const currentWeek = ref(new Date())
const form = ref({
name: '',
start_time: '06:00',
end_time: '14:00',
break_minutes: 30,
color: '#3B82F6',
is_night_shift: false
})
onMounted(async () => {
await loadData()
})
async function loadData() {
loading.value = true
try {
const [shiftsRes, assignRes] = await Promise.all([
api.get<any>('/shifts/definitions'),
api.get<any>(`/shifts/assignments?start=${formatDate(getWeekStart())}&end=${formatDate(getWeekEnd())}`)
])
shifts.value = shiftsRes.data.shifts || []
assignments.value = assignRes.data.assignments || []
} catch (e) {
console.error(e)
}
loading.value = false
}
function getWeekStart(): Date {
const d = new Date(currentWeek.value)
d.setDate(d.getDate() - d.getDay() + 1)
return d
}
function getWeekEnd(): Date {
const d = new Date(getWeekStart())
d.setDate(d.getDate() + 6)
return d
}
function formatDate(d: Date): string {
return d.toISOString().split('T')[0]
}
const weekDays = computed(() => {
const days = []
const start = getWeekStart()
for (let i = 0; i < 7; i++) {
const d = new Date(start)
d.setDate(d.getDate() + i)
days.push({ date: formatDate(d), label: d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit' }) })
}
return days
})
async function createShift() {
try {
await api.post('/shifts/definitions', form.value)
showModal.value = false
form.value = { name: '', start_time: '06:00', end_time: '14:00', break_minutes: 30, color: '#3B82F6', is_night_shift: false }
await loadData()
} catch (e: any) {
alert('Fehler: ' + e.message)
}
}
function getAssignmentsForDay(date: string) {
return assignments.value.filter(a => a.date === date)
}
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">📅 Schichtplanung</h1>
<p class="text-gray-500">Dienstpläne verwalten</p>
</div>
<button @click="showModal = true" class="btn btn-primary">+ Schicht definieren</button>
</div>
<!-- Shift Definitions -->
<div class="bg-white rounded-lg shadow p-4 mb-6">
<h2 class="font-semibold mb-3">Schicht-Typen</h2>
<div class="flex flex-wrap gap-2">
<div v-for="s in shifts" :key="s.id"
:style="{ backgroundColor: s.color + '20', borderColor: s.color }"
class="px-3 py-1 rounded-full border text-sm">
{{ s.name }} ({{ s.start_time?.slice(0,5) }} - {{ s.end_time?.slice(0,5) }})
</div>
<div v-if="shifts.length === 0" class="text-gray-500">Keine Schichten definiert</div>
</div>
</div>
<!-- Week View -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="flex items-center justify-between p-4 border-b">
<button @click="currentWeek.setDate(currentWeek.getDate() - 7); loadData()" class="text-gray-600 hover:text-gray-900"> Vorherige</button>
<span class="font-semibold">{{ getWeekStart().toLocaleDateString('de-DE') }} - {{ getWeekEnd().toLocaleDateString('de-DE') }}</span>
<button @click="currentWeek.setDate(currentWeek.getDate() + 7); loadData()" class="text-gray-600 hover:text-gray-900">Nächste </button>
</div>
<div class="grid grid-cols-7 divide-x">
<div v-for="day in weekDays" :key="day.date" class="min-h-32 p-2">
<div class="text-xs font-semibold text-gray-500 mb-2">{{ day.label }}</div>
<div v-for="a in getAssignmentsForDay(day.date)" :key="a.id"
:style="{ backgroundColor: a.color + '40' }"
class="text-xs p-1 rounded mb-1">
{{ a.first_name }} {{ a.last_name?.charAt(0) }}.
</div>
</div>
</div>
</div>
<!-- Modal -->
<div v-if="showModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h2 class="text-xl font-bold mb-4">Neue Schicht definieren</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Name</label>
<input v-model="form.name" class="input" placeholder="z.B. Frühschicht" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Beginn</label>
<input v-model="form.start_time" type="time" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Ende</label>
<input v-model="form.end_time" type="time" class="input" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Pause (Min.)</label>
<input v-model.number="form.break_minutes" type="number" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Farbe</label>
<input v-model="form.color" type="color" class="w-full h-10 rounded" />
</div>
</div>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button @click="showModal = false" class="btn">Abbrechen</button>
<button @click="createShift" class="btn btn-primary">Erstellen</button>
</div>
</div>
</div>
</div>
</template>

122
src/views/VehiclesView.vue Normal file
View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/api'
const loading = ref(true)
const vehicles = ref<any[]>([])
const showModal = ref(false)
const form = ref({ license_plate: '', brand: '', model: '', year: 2024, color: '', fuel_type: 'diesel' })
onMounted(async () => { await loadData() })
async function loadData() {
loading.value = true
try {
const res = await api.get<any>('/vehicles')
vehicles.value = res.data.vehicles || []
} catch (e) { console.error(e) }
loading.value = false
}
async function createVehicle() {
try {
await api.post('/vehicles', form.value)
showModal.value = false
form.value = { license_plate: '', brand: '', model: '', year: 2024, color: '', fuel_type: 'diesel' }
await loadData()
} catch (e: any) { alert('Fehler: ' + e.message) }
}
function statusBadge(s: string): { text: string, class: string, icon: string } {
const map: Record<string, any> = {
available: { text: 'Verfügbar', class: 'bg-green-100 text-green-700', icon: '✅' },
in_use: { text: 'Im Einsatz', class: 'bg-blue-100 text-blue-700', icon: '🚗' },
maintenance: { text: 'Wartung', class: 'bg-yellow-100 text-yellow-700', icon: '🔧' },
retired: { text: 'Stillgelegt', class: 'bg-gray-100 text-gray-700', icon: '⛔' }
}
return map[s] || { text: s, class: 'bg-gray-100', icon: '' }
}
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">🚗 Fahrzeuge</h1>
<p class="text-gray-500">Fuhrpark verwalten</p>
</div>
<button @click="showModal = true" class="btn btn-primary">+ Fahrzeug</button>
</div>
<div v-if="loading" class="text-center py-12">Laden...</div>
<div v-else-if="vehicles.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">🚗</p>
<p>Keine Fahrzeuge vorhanden</p>
</div>
<div v-else class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="v in vehicles" :key="v.id" class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold text-lg">{{ v.license_plate }}</h3>
<p class="text-gray-600">{{ v.brand }} {{ v.model }}</p>
<p v-if="v.year" class="text-sm text-gray-400">Baujahr {{ v.year }}</p>
</div>
<span :class="['px-2 py-1 text-xs rounded', statusBadge(v.status).class]">
{{ statusBadge(v.status).icon }} {{ statusBadge(v.status).text }}
</span>
</div>
<div class="mt-4 grid grid-cols-2 gap-2 text-sm">
<div><span class="text-gray-500">KM-Stand:</span> {{ v.current_mileage?.toLocaleString() || '-' }}</div>
<div><span class="text-gray-500">Kraftstoff:</span> {{ v.fuel_type }}</div>
<div v-if="v.tuev_expires" :class="['col-span-2', new Date(v.tuev_expires) < new Date() ? 'text-red-600' : '']">
TÜV: {{ new Date(v.tuev_expires).toLocaleDateString('de-DE') }}
</div>
</div>
</div>
</div>
<!-- Modal -->
<div v-if="showModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h2 class="text-xl font-bold mb-4">Neues Fahrzeug</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Kennzeichen *</label>
<input v-model="form.license_plate" class="input" placeholder="B-AB 1234" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Marke</label>
<input v-model="form.brand" class="input" placeholder="VW" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Modell</label>
<input v-model="form.model" class="input" placeholder="Passat" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Baujahr</label>
<input v-model.number="form.year" type="number" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Kraftstoff</label>
<select v-model="form.fuel_type" class="input">
<option value="diesel">Diesel</option>
<option value="petrol">Benzin</option>
<option value="electric">Elektro</option>
<option value="hybrid">Hybrid</option>
</select>
</div>
</div>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button @click="showModal = false" class="btn">Abbrechen</button>
<button @click="createVehicle" class="btn btn-primary">Erstellen</button>
</div>
</div>
</div>
</div>
</template>