feat: Add employee assignment UI in order detail
- Add employees/subcontractors directly from order view - Modal with selectable user list (checkmarks) - Shows role badges (Mitarbeiter, Subunternehmer, etc) - Remove assignments with trash icon - i18n translations
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { api } from '@/api'
|
import { api } from '@/api'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -11,26 +13,95 @@ const authStore = useAuthStore()
|
|||||||
const order = ref<any>(null)
|
const order = ref<any>(null)
|
||||||
const assignments = ref<any[]>([])
|
const assignments = ref<any[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const showAssignModal = ref(false)
|
||||||
|
const availableUsers = ref<any[]>([])
|
||||||
|
const loadingUsers = ref(false)
|
||||||
|
const selectedUsers = ref<string[]>([])
|
||||||
|
const assigning = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await loadOrder()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadOrder() {
|
||||||
try {
|
try {
|
||||||
const res = await api.get<{ order: any; assignments: any[] }>(`/orders/${route.params.id}`)
|
const res = await api.get<{ order: any; assignments: any[] }>(`/orders/${route.params.id}`)
|
||||||
order.value = res.data.order
|
order.value = res.data.order
|
||||||
assignments.value = res.data.assignments
|
assignments.value = res.data.assignments || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
router.push('/orders')
|
router.push('/orders')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
async function loadAvailableUsers() {
|
||||||
|
loadingUsers.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ users: any[] }>('/users')
|
||||||
|
// Filter out already assigned users
|
||||||
|
const assignedIds = assignments.value.map(a => a.user_id)
|
||||||
|
availableUsers.value = res.data.users.filter(u =>
|
||||||
|
!assignedIds.includes(u.id) && u.active
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loadingUsers.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAssignModal() {
|
||||||
|
selectedUsers.value = []
|
||||||
|
showAssignModal.value = true
|
||||||
|
loadAvailableUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUserSelection(userId: string) {
|
||||||
|
const idx = selectedUsers.value.indexOf(userId)
|
||||||
|
if (idx > -1) {
|
||||||
|
selectedUsers.value.splice(idx, 1)
|
||||||
|
} else {
|
||||||
|
selectedUsers.value.push(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignSelectedUsers() {
|
||||||
|
if (selectedUsers.value.length === 0) return
|
||||||
|
|
||||||
|
assigning.value = true
|
||||||
|
try {
|
||||||
|
// Assign each selected user
|
||||||
|
for (const userId of selectedUsers.value) {
|
||||||
|
await api.post(`/orders/${route.params.id}/assign`, { userId })
|
||||||
|
}
|
||||||
|
showAssignModal.value = false
|
||||||
|
await loadOrder()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : t('messages.error'))
|
||||||
|
} finally {
|
||||||
|
assigning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAssignment(userId: string) {
|
||||||
|
if (!confirm(t('messages.confirmDelete'))) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/orders/${route.params.id}/assign/${userId}`)
|
||||||
|
await loadOrder()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : t('messages.error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function updateStatus(status: string) {
|
async function updateStatus(status: string) {
|
||||||
try {
|
try {
|
||||||
await api.put(`/orders/${route.params.id}`, { status })
|
await api.put(`/orders/${route.params.id}`, { status })
|
||||||
order.value.status = status
|
order.value.status = status
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Fehler')
|
alert(e instanceof Error ? e.message : t('messages.error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,27 +115,44 @@ async function confirmAssignment(confirm: boolean) {
|
|||||||
myAssignment.status = confirm ? 'confirmed' : 'declined'
|
myAssignment.status = confirm ? 'confirmed' : 'declined'
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Fehler')
|
alert(e instanceof Error ? e.message : t('messages.error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusLabel(status: string) {
|
function getStatusLabel(status: string) {
|
||||||
const labels: Record<string, string> = {
|
const key = `orders.statuses.${status}`
|
||||||
draft: 'Entwurf', published: 'Veröffentlicht', in_progress: 'In Bearbeitung',
|
const translated = t(key)
|
||||||
completed: 'Abgeschlossen', cancelled: 'Abgesagt',
|
if (translated !== key) return translated
|
||||||
|
|
||||||
|
// Fallback for assignment statuses
|
||||||
|
const fallback: Record<string, string> = {
|
||||||
pending: 'Ausstehend', confirmed: 'Bestätigt', declined: 'Abgelehnt'
|
pending: 'Ausstehend', confirmed: 'Bestätigt', declined: 'Abgelehnt'
|
||||||
}
|
}
|
||||||
return labels[status] || status
|
return fallback[status] || status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRoleBadge(role: string) {
|
||||||
|
const badges: Record<string, { class: string; label: string }> = {
|
||||||
|
chef: { class: 'bg-purple-100 text-purple-700', label: 'Chef' },
|
||||||
|
disponent: { class: 'bg-blue-100 text-blue-700', label: 'Disponent' },
|
||||||
|
mitarbeiter: { class: 'bg-green-100 text-green-700', label: 'Mitarbeiter' },
|
||||||
|
subunternehmer: { class: 'bg-orange-100 text-orange-700', label: 'Subunternehmer' }
|
||||||
|
}
|
||||||
|
return badges[role] || { class: 'bg-gray-100 text-gray-700', label: role }
|
||||||
|
}
|
||||||
|
|
||||||
|
const spotsRemaining = computed(() => {
|
||||||
|
return (order.value?.required_staff || 0) - assignments.value.length
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="loading" class="text-center py-12 text-gray-500">Lädt...</div>
|
<div v-if="loading" class="text-center py-12 text-gray-500">{{ t('app.loading') }}</div>
|
||||||
|
|
||||||
<div v-else-if="order" class="space-y-6">
|
<div v-else-if="order" class="space-y-6">
|
||||||
<!-- Back button -->
|
<!-- Back button -->
|
||||||
<router-link to="/orders" class="text-primary-600 hover:text-primary-700 text-sm">
|
<router-link to="/orders" class="text-primary-600 hover:text-primary-700 text-sm">
|
||||||
← Zurück zu Aufträge
|
← {{ t('app.back') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -87,54 +175,83 @@ function getStatusLabel(status: string) {
|
|||||||
<!-- Info Grid -->
|
<!-- Info Grid -->
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||||
<div v-if="order.location">
|
<div v-if="order.location">
|
||||||
<p class="text-sm text-gray-500">Ort</p>
|
<p class="text-sm text-gray-500">{{ t('orders.location') }}</p>
|
||||||
<p class="font-medium">📍 {{ order.location }}</p>
|
<p class="font-medium">📍 {{ order.location }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="order.start_time">
|
<div v-if="order.start_time">
|
||||||
<p class="text-sm text-gray-500">Start</p>
|
<p class="text-sm text-gray-500">{{ t('orders.startDate') }}</p>
|
||||||
<p class="font-medium">{{ new Date(order.start_time).toLocaleString('de-DE') }}</p>
|
<p class="font-medium">{{ new Date(order.start_time).toLocaleString() }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="order.client_name">
|
<div v-if="order.client_name">
|
||||||
<p class="text-sm text-gray-500">Kunde</p>
|
<p class="text-sm text-gray-500">{{ t('orders.client') }}</p>
|
||||||
<p class="font-medium">{{ order.client_name }}</p>
|
<p class="font-medium">{{ order.client_name }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-500">Benötigte MA</p>
|
<p class="text-sm text-gray-500">{{ t('users.employees') }}</p>
|
||||||
<p class="font-medium">{{ assignments.length }}/{{ order.required_staff }}</p>
|
<p class="font-medium">
|
||||||
|
{{ assignments.length }}/{{ order.required_staff }}
|
||||||
|
<span v-if="spotsRemaining > 0" class="text-orange-500 text-sm ml-1">
|
||||||
|
({{ spotsRemaining }} {{ t('app.none') }})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status actions for management -->
|
<!-- Status actions for management -->
|
||||||
<div v-if="authStore.canManageOrders" class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
<div v-if="authStore.canManageOrders" class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
<p class="text-sm text-gray-500 mb-2">Status ändern:</p>
|
<p class="text-sm text-gray-500 mb-2">{{ t('app.status') }}:</p>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button class="btn btn-secondary text-sm" @click="updateStatus('draft')">Entwurf</button>
|
<button class="btn btn-secondary text-sm" @click="updateStatus('draft')">{{ t('orders.statuses.draft') }}</button>
|
||||||
<button class="btn btn-primary text-sm" @click="updateStatus('published')">Veröffentlichen</button>
|
<button class="btn btn-primary text-sm" @click="updateStatus('published')">{{ t('orders.statuses.published') }}</button>
|
||||||
<button class="btn btn-warning text-sm" @click="updateStatus('in_progress')">In Bearbeitung</button>
|
<button class="btn btn-warning text-sm" @click="updateStatus('in_progress')">{{ t('orders.statuses.in_progress') }}</button>
|
||||||
<button class="btn btn-success text-sm" @click="updateStatus('completed')">Abschließen</button>
|
<button class="btn btn-success text-sm" @click="updateStatus('completed')">{{ t('orders.statuses.completed') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Assignments -->
|
<!-- Assignments -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
👥 Zugewiesene Mitarbeiter
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
</h2>
|
👥 {{ t('orders.assignedTo') }}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
v-if="authStore.canManageOrders"
|
||||||
|
@click="openAssignModal"
|
||||||
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
+ {{ t('app.add') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="assignments.length === 0" class="text-center py-4 text-gray-500">
|
<div v-if="assignments.length === 0" class="text-center py-8 text-gray-500">
|
||||||
Noch keine Mitarbeiter zugewiesen
|
<p class="text-4xl mb-2">👥</p>
|
||||||
|
<p>{{ t('messages.noData') }}</p>
|
||||||
|
<button
|
||||||
|
v-if="authStore.canManageOrders"
|
||||||
|
@click="openAssignModal"
|
||||||
|
class="btn btn-primary mt-4"
|
||||||
|
>
|
||||||
|
+ {{ t('users.employee') }} {{ t('app.add') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="assignment in assignments"
|
v-for="assignment in assignments"
|
||||||
:key="assignment.id"
|
:key="assignment.id"
|
||||||
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
|
class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
|
||||||
>
|
>
|
||||||
<div>
|
<div class="flex items-center gap-4">
|
||||||
<p class="font-medium text-gray-900 dark:text-white">{{ assignment.user_name }}</p>
|
<div class="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
|
||||||
<p class="text-sm text-gray-500">{{ assignment.user_phone }}</p>
|
<span class="text-primary-600 dark:text-primary-300 font-medium">
|
||||||
|
{{ assignment.user_name?.split(' ').map((n: string) => n[0]).join('') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white">{{ assignment.user_name }}</p>
|
||||||
|
<p class="text-sm text-gray-500">{{ assignment.user_phone || assignment.user_email }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span :class="['badge', assignment.status === 'confirmed' ? 'badge-success' : assignment.status === 'declined' ? 'badge-danger' : 'badge-warning']">
|
<span :class="['badge', assignment.status === 'confirmed' ? 'badge-success' : assignment.status === 'declined' ? 'badge-danger' : 'badge-warning']">
|
||||||
@@ -146,6 +263,16 @@ function getStatusLabel(status: string) {
|
|||||||
<button class="btn btn-success text-sm" @click="confirmAssignment(true)">✓</button>
|
<button class="btn btn-success text-sm" @click="confirmAssignment(true)">✓</button>
|
||||||
<button class="btn btn-danger text-sm" @click="confirmAssignment(false)">✗</button>
|
<button class="btn btn-danger text-sm" @click="confirmAssignment(false)">✗</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Remove button for management -->
|
||||||
|
<button
|
||||||
|
v-if="authStore.canManageOrders"
|
||||||
|
@click="removeAssignment(assignment.user_id)"
|
||||||
|
class="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||||
|
:title="t('app.delete')"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,9 +281,77 @@ function getStatusLabel(status: string) {
|
|||||||
<!-- Special Instructions -->
|
<!-- Special Instructions -->
|
||||||
<div v-if="order.special_instructions" class="card">
|
<div v-if="order.special_instructions" class="card">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
📝 Besondere Hinweise
|
📝 {{ t('app.notes') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-gray-600 dark:text-gray-400 whitespace-pre-wrap">{{ order.special_instructions }}</p>
|
<p class="text-gray-600 dark:text-gray-400 whitespace-pre-wrap">{{ order.special_instructions }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Assign Users Modal -->
|
||||||
|
<div v-if="showAssignModal" 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-lg mx-4 max-h-[80vh] flex flex-col">
|
||||||
|
<div class="p-4 border-b dark:border-gray-700">
|
||||||
|
<h2 class="text-xl font-semibold">👥 {{ t('users.employee') }} {{ t('app.add') }}</h2>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
{{ t('users.employees') }}: {{ selectedUsers.length }} {{ t('app.add') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
|
<div v-if="loadingUsers" class="text-center py-8 text-gray-500">
|
||||||
|
{{ t('app.loading') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="availableUsers.length === 0" class="text-center py-8 text-gray-500">
|
||||||
|
<p class="text-4xl mb-2">😕</p>
|
||||||
|
<p>{{ t('messages.noData') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<button
|
||||||
|
v-for="user in availableUsers"
|
||||||
|
:key="user.id"
|
||||||
|
@click="toggleUserSelection(user.id)"
|
||||||
|
:class="[
|
||||||
|
'w-full flex items-center gap-4 p-3 rounded-lg transition-colors text-left',
|
||||||
|
selectedUsers.includes(user.id)
|
||||||
|
? 'bg-primary-100 dark:bg-primary-900/50 border-2 border-primary-500'
|
||||||
|
: 'bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 border-2 border-transparent'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span class="font-medium text-gray-600 dark:text-gray-300">
|
||||||
|
{{ user.first_name?.[0] }}{{ user.last_name?.[0] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ user.first_name }} {{ user.last_name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 truncate">{{ user.email }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span :class="['px-2 py-0.5 rounded text-xs', getRoleBadge(user.role).class]">
|
||||||
|
{{ getRoleBadge(user.role).label }}
|
||||||
|
</span>
|
||||||
|
<span v-if="selectedUsers.includes(user.id)" class="text-primary-600 text-xl">✓</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 border-t dark:border-gray-700 flex gap-3">
|
||||||
|
<button @click="showAssignModal = false" class="btn flex-1">
|
||||||
|
{{ t('app.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="assignSelectedUsers"
|
||||||
|
:disabled="selectedUsers.length === 0 || assigning"
|
||||||
|
class="btn btn-primary flex-1"
|
||||||
|
>
|
||||||
|
{{ assigning ? t('app.loading') : `${selectedUsers.length} ${t('app.add')}` }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user