unit/#116-repairs #119

Merged
jkeffects merged 4 commits from unit/#116-repairs into milestone/ff-admin-unit 2025-07-23 07:30:04 +00:00
43 changed files with 1836 additions and 56 deletions

View file

@ -5,6 +5,7 @@
>
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p>
{{ damageReport.title }} -
{{ damageReport?.related?.name ?? "Ohne Zuordnung" }}
<small v-if="damageReport?.related">({{ damageReport.related.code }})</small>
</p>
@ -18,7 +19,7 @@
<div v-if="damageReport.reportedBy" class="cursor-pointer">
<UserIcon class="w-5 h-5" />
</div>
<div v-if="damageReport.maintenance" class="cursor-pointer">
<div v-if="damageReport.repair" class="cursor-pointer">
<WrenchScrewdriverIcon class="w-5 h-5" />
</div>
</div>

View file

@ -0,0 +1,39 @@
<template>
<RouterLink
:to="{ name: 'admin-unit-repair-overview', params: { repairId: repair.id } }"
class="flex flex-col h-fit w-full border border-primary rounded-md"
>
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p>
{{ repair.title }} -
{{ repair?.related?.name ?? "Ohne Zuordnung" }}
<small v-if="repair?.related">({{ repair.related.code }})</small>
</p>
</div>
<div class="p-2">
<p>begonnen: {{ new Date(repair.createdAt).toLocaleString("de") }}</p>
<p>Status: {{ repair.status }}</p>
<p v-if="repair.responsible">Verantwortlich: {{ repair.responsible }}</p>
<p v-if="repair.description">Beschreibung: {{ repair.description }}</p>
</div>
</RouterLink>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import { mapState, mapActions } from "pinia";
import { useAbilityStore } from "@/stores/ability";
import type { RepairViewModel } from "@/viewmodels/admin/unit/repair.models";
import { MapPinIcon, PhotoIcon, UserIcon, WrenchScrewdriverIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">
export default defineComponent({
props: {
repair: { type: Object as PropType<RepairViewModel>, default: {} },
},
computed: {
...mapState(useAbilityStore, ["can"]),
},
});
</script>

View file

@ -9,6 +9,10 @@
</p>
<p>Typ: {{ check.gear.type }}</p>
</div>
<div>
<label for="title">Kurzbeschreibung (Titel)</label>
<input id="title" type="text" readonly :value="check.title" />
</div>
<div>
<label for="description">Beschreibung des Schadens</label>
<textarea id="description" readonly :value="check.description"></textarea>
@ -59,6 +63,7 @@ export default defineComponent({
check: {
type: Object as PropType<{
gear: undefined | MinifiedEquipmentViewModel | MinifiedVehicleViewModel | MinifiedWearableViewModel;
title: string;
description: string;
location: string;
note: string;
@ -98,6 +103,7 @@ export default defineComponent({
this.message = "";
const formData = new FormData();
if (this.check.gear) formData.append("related", JSON.stringify(this.check.gear));
formData.append("title", this.check.title);
formData.append("description", this.check.description);
formData.append("location", this.check.location);
formData.append("note", this.check.note);

View file

@ -2,6 +2,10 @@
<form class="flex flex-col gap-2" @submit.prevent="setup">
<p class="text-primary cursor-pointer" @click="$emit('stepBack')">zurück</p>
<div class="flex flex-col gap-2">
<div>
<label for="title">Kurzbeschreibung (Titel)</label>
<input id="title" type="text" required :value="data.title" />
</div>
<div>
<label for="description">Beschreibung des Schadens</label>
<textarea id="description" required :value="data.description"></textarea>
@ -45,6 +49,7 @@ export default defineComponent({
data: {
type: Object as PropType<{
gear: undefined | MinifiedEquipmentViewModel | MinifiedVehicleViewModel | MinifiedWearableViewModel;
title: string;
description: string;
location: string;
note: string;
@ -57,7 +62,14 @@ export default defineComponent({
emits: {
nextStep: (s: string) => true,
stepBack: () => true,
data: (d: { description: string; location: string; note: string; reportedBy: string; image?: File }) => true,
data: (d: {
title: string;
description: string;
location: string;
note: string;
reportedBy: string;
image?: File;
}) => true,
},
mounted() {
if (this.data.image) {
@ -68,6 +80,7 @@ export default defineComponent({
setup(e: any) {
let formData = e.target.elements;
this.$emit("data", {
title: formData.title.value,
description: formData.description.value,
location: formData.location.value,
note: formData.note.value,

View file

@ -0,0 +1,233 @@
<template>
<div class="w-full">
<Combobox v-model="selected" :disabled="disabled" multiple>
<ComboboxLabel>{{ title }}</ComboboxLabel>
<div class="relative mt-1">
<ComboboxInput
class="rounded-md shadow-xs relative block w-full px-3 py-2 border border-gray-300 focus:border-primary placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
:displayValue="(e) => chosen.map((c) => c.title).join(', ')"
@input="query = $event.target.value"
/>
<ComboboxButton class="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<TransitionRoot
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
@after-leave="query = ''"
>
<ComboboxOptions
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-md ring-1 ring-black/5 focus:outline-hidden sm:text-sm z-20"
>
<ComboboxOption v-if="loading || deferingSearch" as="template" disabled>
<li class="flex flex-row gap-2 text-text relative cursor-default select-none py-2 pl-3 pr-4">
<Spinner />
<span class="font-normal block truncate">suche</span>
</li>
</ComboboxOption>
<ComboboxOption v-else-if="available.length === 0" as="template" disabled>
<li class="text-text relative cursor-default select-none py-2 pl-3 pr-4">
<span class="font-normal block truncate">tippe, um zu suchen...</span>
</li>
</ComboboxOption>
<ComboboxOption v-else-if="available.length === 0" as="template" disabled>
<li class="text-text relative cursor-default select-none py-2 pl-3 pr-4">
<span class="font-normal block truncate">Keine Auswahl gefunden.</span>
</li>
</ComboboxOption>
<ComboboxOption
v-if="!(loading || deferingSearch)"
v-for="damageReport in available"
as="template"
:key="damageReport.id"
:value="damageReport.id"
v-slot="{ selected, active }"
>
<li
class="relative cursor-default select-none py-2 pl-10 pr-4"
:class="{
'bg-primary text-white': active,
'text-gray-900': !active,
}"
>
<span class="block truncate" :class="{ 'font-medium': selected, 'font-normal': !selected }">
{{ damageReport.title }} <span v-if="damageReport.reportedBy">von {{ damageReport.reportedBy }}</span>
</span>
<span
v-if="selected"
class="absolute inset-y-0 left-0 flex items-center pl-3"
:class="{ 'text-white': active, 'text-primary': !active }"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</TransitionRoot>
</div>
</Combobox>
</div>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import { mapState, mapActions } from "pinia";
import {
Combobox,
ComboboxLabel,
ComboboxInput,
ComboboxButton,
ComboboxOptions,
ComboboxOption,
TransitionRoot,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { useDamageReportStore } from "@/stores/admin/unit/damageReport";
import type { DamageReportViewModel } from "@/viewmodels/admin/unit/damageReport.models";
import difference from "lodash.difference";
import Spinner from "@/components/Spinner.vue";
</script>
<script lang="ts">
export default defineComponent({
props: {
modelValue: {
type: Array as PropType<Array<string>>,
default: [],
},
title: String,
disabled: {
type: Boolean,
default: false,
},
related: {
type: String as PropType<"vehicle" | "equipment" | "wearable">,
default: "equipment",
},
relatedId: {
type: String,
required: true,
},
},
emits: ["update:model-value", "add:difference", "remove:difference", "add:damageReport"],
watch: {
modelValue() {
// if (this.initialLoaded) return;
this.initialLoaded = true;
this.loadDamageReportsInitial();
},
related() {
this.reload();
},
relatedId() {
this.reload();
},
query() {
this.deferingSearch = true;
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.deferingSearch = false;
this.search();
}, 600);
},
},
data() {
return {
initialLoaded: false as boolean,
loading: false as boolean,
deferingSearch: false as boolean,
timer: undefined as any,
query: "" as string,
all: [] as Array<DamageReportViewModel>,
filtered: [] as Array<DamageReportViewModel>,
chosen: [] as Array<DamageReportViewModel>,
};
},
computed: {
available() {
return this.query == "" ? this.all : this.filtered;
},
selected: {
get() {
return this.modelValue;
},
set(val: Array<string>) {
this.$emit("update:model-value", val);
if (this.modelValue.length < val.length) {
let diff = difference(val, this.modelValue);
if (diff.length != 1) return;
let diffObj = this.getDamageReportFromSearch(diff[0]);
if (!diffObj) return;
this.$emit("add:difference", diff[0]);
this.$emit("add:damageReport", diffObj);
} else {
let diff = difference(this.modelValue, val);
if (diff.length != 1) return;
this.$emit("remove:difference", diff[0]);
}
},
},
},
mounted() {
this.reload();
},
methods: {
...mapActions(useDamageReportStore, [
"searchDamageReports",
"getDamageReportsByIds",
"getAllDamageReportsWithRelated",
"searchDamageReportsWithRelated",
]),
reload() {
this.chosen = [];
this.filtered = [];
this.preloadAll();
this.loadDamageReportsInitial();
},
preloadAll() {
this.all = [];
if (this.relatedId == "") return;
this.loading = true;
this.getAllDamageReportsWithRelated(this.related, this.relatedId)
.then((res) => {
this.all = res.data;
})
.catch((err) => {})
.finally(() => {
this.loading = false;
});
},
search() {
this.filtered = [];
if (this.query == "") return;
this.loading = true;
this.searchDamageReportsWithRelated(this.related, this.relatedId, this.query)
.then((res) => {
this.filtered = res.data;
})
.catch((err) => {})
.finally(() => {
this.loading = false;
});
},
getDamageReportFromSearch(id: string) {
return this.available.find((f) => f.id == id);
},
loadDamageReportsInitial() {
if (this.modelValue.length == 0) {
this.chosen = [];
} else {
this.getDamageReportsByIds(this.modelValue)
.then((res) => {
this.chosen = res.data;
})
.catch(() => {});
}
},
},
});
</script>

View file

@ -0,0 +1,216 @@
<template>
<div class="w-full">
<Combobox v-model="selected" :disabled="disabled">
<ComboboxLabel>{{ title }}</ComboboxLabel>
<div class="relative mt-1">
<ComboboxInput
class="rounded-md shadow-xs relative block w-full px-3 py-2 border border-gray-300 focus:border-primary placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-hidden focus:ring-0 focus:z-10 sm:text-sm resize-none"
:display-value="() => chosen?.title ?? ''"
@input="query = $event.target.value"
/>
<ComboboxButton class="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<TransitionRoot
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
@after-leave="query = ''"
>
<ComboboxOptions
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-md ring-1 ring-black/5 focus:outline-hidden sm:text-sm z-20"
>
<ComboboxOption v-if="loading || deferingSearch" as="template" disabled>
<li class="flex flex-row gap-2 text-text relative cursor-default select-none py-2 pl-3 pr-4">
<Spinner />
<span class="font-normal block truncate">suche</span>
</li>
</ComboboxOption>
<ComboboxOption v-else-if="available.length === 0 && query == ''" as="template" disabled>
<li class="text-text relative cursor-default select-none py-2 pl-3 pr-4">
<span class="font-normal block truncate">tippe, um zu suchen...</span>
</li>
</ComboboxOption>
<ComboboxOption v-else-if="available.length === 0" as="template" disabled>
<li class="text-text relative cursor-default select-none py-2 pl-3 pr-4">
<span class="font-normal block truncate">Keine Auswahl gefunden.</span>
</li>
</ComboboxOption>
<ComboboxOption
v-if="!(loading || deferingSearch)"
v-for="damageReport in available"
as="template"
:key="damageReport.id"
:value="damageReport.id"
v-slot="{ selected, active }"
>
<li
class="relative cursor-default select-none py-2 pl-10 pr-4"
:class="{
'bg-primary text-white': active,
'text-gray-900': !active,
}"
>
<span class="block truncate" :class="{ 'font-medium': selected, 'font-normal': !selected }">
{{ damageReport.title }} <span v-if="damageReport.reportedBy">von {{ damageReport.reportedBy }}</span>
</span>
<span
v-if="selected"
class="absolute inset-y-0 left-0 flex items-center pl-3"
:class="{ 'text-white': active, 'text-primary': !active }"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</TransitionRoot>
</div>
</Combobox>
</div>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import { mapState, mapActions } from "pinia";
import {
Combobox,
ComboboxLabel,
ComboboxInput,
ComboboxButton,
ComboboxOptions,
ComboboxOption,
TransitionRoot,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { useDamageReportStore } from "@/stores/admin/unit/damageReport";
import type { DamageReportViewModel } from "@/viewmodels/admin/unit/damageReport.models";
import Spinner from "../Spinner.vue";
</script>
<script lang="ts">
export default defineComponent({
props: {
modelValue: {
type: String,
default: "",
},
title: String,
disabled: {
type: Boolean,
default: false,
},
related: {
type: String as PropType<"vehicle" | "equipment" | "wearable">,
default: "equipment",
},
relatedId: {
type: String,
required: true,
},
},
emits: ["update:model-value"],
watch: {
modelValue() {
//if (this.initialLoaded) return;
this.initialLoaded = true;
this.loadDamageReportInitial();
},
related() {
this.reload();
},
relatedId() {
this.reload();
},
query() {
this.deferingSearch = true;
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.deferingSearch = false;
this.search();
}, 600);
},
},
data() {
return {
initialLoaded: false as boolean,
loading: false as boolean,
deferingSearch: false as boolean,
timer: undefined as any,
query: "" as string,
all: [] as Array<DamageReportViewModel>,
filtered: [] as Array<DamageReportViewModel>,
chosen: undefined as undefined | DamageReportViewModel,
};
},
computed: {
available() {
return this.query == "" ? this.all : this.filtered;
},
selected: {
get() {
return this.modelValue;
},
set(val: string) {
this.chosen = this.getDamageReportFromSearch(val);
this.$emit("update:model-value", val);
},
},
},
mounted() {
this.reload();
},
methods: {
...mapActions(useDamageReportStore, [
"searchDamageReports",
"fetchDamageReportById",
"getAllDamageReportsWithRelated",
"searchDamageReportsWithRelated",
]),
reload() {
this.chosen = undefined;
this.filtered = [];
this.preloadAll();
this.loadDamageReportInitial();
},
preloadAll() {
this.all = [];
if (this.relatedId == "") return;
this.loading = true;
this.getAllDamageReportsWithRelated(this.related, this.relatedId)
.then((res) => {
this.all = res.data;
})
.catch((err) => {})
.finally(() => {
this.loading = false;
});
},
search() {
this.filtered = [];
if (this.query == "") return;
this.loading = true;
this.searchDamageReportsWithRelated(this.related, this.relatedId, this.query)
.then((res) => {
this.filtered = res.data;
})
.catch((err) => {})
.finally(() => {
this.loading = false;
});
},
getDamageReportFromSearch(id: string) {
return this.available.find((f) => f.id == id);
},
loadDamageReportInitial() {
if (this.modelValue == "" || this.modelValue == null) return;
this.fetchDamageReportById(this.modelValue)
.then((res) => {
this.chosen = res.data;
})
.catch(() => {});
},
},
});
</script>

View file

@ -21,6 +21,7 @@ import { setVehicleTypeId } from "./unit/vehicleType";
import { resetInspectionStores, setInspectionId } from "./unit/inspection";
import { setWearableTypeId } from "./unit/wearableType";
import { resetDamageReportStores, setDamageReportId } from "./unit/damageReport";
import { resetRepairStores, setRepairId } from "./unit/repair";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -374,6 +375,12 @@ const router = createRouter({
component: () => import("@/views/admin/ViewSelect.vue"),
props: true,
},
{
path: "repair",
name: "admin-unit-equipment-repair",
component: () => import("@/views/admin/unit/equipment/Repair.vue"),
props: true,
},
{
path: "inspection",
name: "admin-unit-equipment-inspection",
@ -437,6 +444,12 @@ const router = createRouter({
component: () => import("@/views/admin/ViewSelect.vue"),
props: true,
},
{
path: "repair",
name: "admin-unit-vehicle-repair",
component: () => import("@/views/admin/unit/vehicle/Repair.vue"),
props: true,
},
{
path: "inspection",
name: "admin-unit-vehicle-inspection",
@ -500,6 +513,12 @@ const router = createRouter({
component: () => import("@/views/admin/ViewSelect.vue"),
props: true,
},
{
path: "repair",
name: "admin-unit-wearable-repair",
component: () => import("@/views/admin/unit/wearable/Repair.vue"),
props: true,
},
{
path: "inspection",
name: "admin-unit-wearable-inspection",
@ -713,6 +732,11 @@ const router = createRouter({
component: () => import("@/views/admin/unit/damageReport/DamageReportStatusRouting.vue"),
beforeEnter: [resetDamageReportStores],
children: [
{
path: "",
name: "admin-unit-damage_report-status",
redirect: { name: "admin-unit-damage_report-open" },
},
{
path: "open",
name: "admin-unit-damage_report-open",
@ -742,6 +766,71 @@ const router = createRouter({
},
],
},
{
path: "repair",
name: "admin-unit-repair-route",
component: () => import("@/views/RouterView.vue"),
meta: { type: "read", section: "unit", module: "repair" },
beforeEnter: [abilityAndNavUpdate],
children: [
{
path: "",
name: "admin-unit-repair",
redirect: { name: "admin-unit-repair-open" },
},
{
path: "status",
name: "admin-unit-repair-statusrouting",
component: () => import("@/views/admin/unit/repair/RepairStatusRouting.vue"),
beforeEnter: [resetRepairStores],
children: [
{
path: "",
name: "admin-unit-repair-status",
redirect: { name: "admin-unit-repair-open" },
},
{
path: "open",
name: "admin-unit-repair-open",
component: () => import("@/views/admin/unit/repair/RepairOpen.vue"),
},
{
path: "done",
name: "admin-unit-repair-done",
component: () => import("@/views/admin/unit/repair/RepairClosed.vue"),
},
],
},
{
path: "create/:type?/:relatedId?",
name: "admin-unit-repair-create",
component: () => import("@/views/admin/unit/repair/RepairCreate.vue"),
beforeEnter: [],
props: true,
},
{
path: "execute/:repairId",
name: "admin-unit-repair-routing",
component: () => import("@/views/admin/unit/repair/RepairRouting.vue"),
beforeEnter: [setRepairId],
props: true,
children: [
{
path: "",
name: "admin-unit-repair-overview",
component: () => import("@/views/admin/unit/repair/Overview.vue"),
props: true,
},
{
path: "reports",
name: "admin-unit-repair-reports",
component: () => import("@/views/admin/unit/repair/DamageReports.vue"),
props: true,
},
],
},
],
},
{
path: "maintenance",
name: "admin-unit-maintenance-route",

View file

@ -1,4 +1,4 @@
import { useDamageReportStore } from "@/stores/admin/unit/damageReport/damageReport";
import { useDamageReportStore } from "@/stores/admin/unit/damageReport";
export async function setDamageReportId(to: any, from: any, next: any) {
const damageReportStore = useDamageReportStore();

20
src/router/unit/repair.ts Normal file
View file

@ -0,0 +1,20 @@
import { useRepairStore } from "@/stores/admin/unit/repair";
export async function setRepairId(to: any, from: any, next: any) {
const repairStore = useRepairStore();
repairStore.activeRepair = to.params?.repairId ?? null;
//xystore().$reset();
next();
}
export async function resetRepairStores(to: any, from: any, next: any) {
const repairStore = useRepairStore();
repairStore.activeRepair = null;
repairStore.activeRepairObj = null;
//xystore().$reset();
next();
}

View file

@ -120,12 +120,11 @@ export const useNavigationStore = defineStore("navigation", {
? [{ key: "respiratory_mission", title: "Atemschutz-Einsätze" }]
: []),
...(abilityStore.can("create", "unit", "inspection") ? [{ key: "inspection", title: "Prüfungen" }] : []),
...(abilityStore.can("read", "unit", "maintenance") ? [{ key: "maintenance", title: "Wartungen" }] : []),
...(abilityStore.can("read", "unit", "damage_report")
? [{ key: "damage_report", title: "Schadensmeldungen" }]
: []),
...(abilityStore.can("read", "unit", "maintenance")
? [{ key: "maintenance", title: "Wartungen / Reparaturen" }]
: []),
...(abilityStore.can("read", "unit", "repair") ? [{ key: "repair", title: "Reparaturen" }] : []),
{ key: "divider1", title: "Basisdaten" },
...(abilityStore.can("read", "unit", "equipment_type")
? [{ key: "equipment_type", title: "Geräte-Typen" }]

View file

@ -74,6 +74,23 @@ export const useDamageReportStore = defineStore("damageReport", {
return { ...res, data: res.data.damageReports };
});
},
async getAllDamageReportsWithRelated(
related: "vehicle" | "equipment" | "wearable",
relatedId: string
): Promise<AxiosResponse<any, any>> {
return await http.get(`/admin/damageReport/${related}/${relatedId}?noLimit=true`).then((res) => {
return { ...res, data: res.data.damageReports };
});
},
async searchDamageReportsWithRelated(
related: "vehicle" | "equipment" | "wearable",
relatedId: string,
search: string
): Promise<AxiosResponse<any, any>> {
return await http.get(`/admin/damageReport/${related}/${relatedId}?search=${search}&noLimit=true`).then((res) => {
return { ...res, data: res.data.damageReports };
});
},
fetchDamageReportByActiveId() {
this.loadingActive = "loading";
http

View file

@ -0,0 +1,43 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import { useEquipmentStore } from "./equipment";
import type { RepairViewModel } from "@/viewmodels/admin/unit/repair.models";
export const useEquipmentRepairStore = defineStore("equipmentRepair", {
state: () => {
return {
repairs: [] as Array<RepairViewModel & { tab_pos: number }>,
totalCount: 0 as number,
loading: "loading" as "loading" | "fetched" | "failed",
};
},
actions: {
fetchRepairForEquipment(offset = 0, count = 25, search = "", clear = false) {
const equipmentId = useEquipmentStore().activeEquipment;
if (clear) this.repairs = [];
this.loading = "loading";
http
.get(
`/admin/repair/equipment/${equipmentId}?offset=${offset}&count=${count}${search != "" ? "&search=" + search : ""}`
)
.then((result) => {
this.totalCount = result.data.total;
result.data.repairs
.filter((elem: RepairViewModel) => this.repairs.findIndex((m) => m.id == elem.id) == -1)
.map((elem: RepairViewModel, index: number): RepairViewModel & { tab_pos: number } => {
return {
...elem,
tab_pos: index + offset,
};
})
.forEach((elem: RepairViewModel & { tab_pos: number }) => {
this.repairs.push(elem);
});
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
},
});

View file

@ -0,0 +1,135 @@
import { defineStore } from "pinia";
import type {
CreateRepairViewModel,
RepairViewModel,
UpdateRepairStatusViewModel,
UpdateRepairViewModel,
} from "@/viewmodels/admin/unit/repair.models";
import { http } from "@/serverCom";
import type { AxiosResponse } from "axios";
export const useRepairStore = defineStore("repair", {
state: () => {
return {
repairs: [] as Array<RepairViewModel & { tab_pos: number }>,
totalCount: 0 as number,
loading: "loading" as "loading" | "fetched" | "failed",
activeRepair: null as string | null,
activeRepairObj: null as RepairViewModel | null,
loadingActive: "loading" as "loading" | "fetched" | "failed",
};
},
actions: {
formatQueryReturnToPagination(result: AxiosResponse<any, any>, offset: number) {
this.totalCount = result.data.total;
result.data.repairs
.filter((elem: RepairViewModel) => this.repairs.findIndex((m) => m.id == elem.id) == -1)
.map((elem: RepairViewModel, index: number): RepairViewModel & { tab_pos: number } => {
return {
...elem,
tab_pos: index + offset,
};
})
.forEach((elem: RepairViewModel & { tab_pos: number }) => {
this.repairs.push(elem);
});
},
fetchOpenRepairs(offset = 0, count = 25, search = "", clear = false) {
if (clear) this.repairs = [];
this.loading = "loading";
http
.get(`/admin/repair?done=false&offset=${offset}&count=${count}${search != "" ? "&search=" + search : ""}`)
.then((result) => {
this.formatQueryReturnToPagination(result, offset);
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
fetchDoneRepairs(offset = 0, count = 25, search = "", clear = false) {
if (clear) this.repairs = [];
this.loading = "loading";
http
.get(`/admin/repair?done=true&offset=${offset}&count=${count}${search != "" ? "&search=" + search : ""}`)
.then((result) => {
this.formatQueryReturnToPagination(result, offset);
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
async getAllRepairs(): Promise<AxiosResponse<any, any>> {
return await http.get(`/admin/repair?noLimit=true`).then((res) => {
return { ...res, data: res.data.repairs };
});
},
async getRepairsByIds(ids: Array<string>): Promise<AxiosResponse<any, any>> {
return await http
.post(`/admin/repair/ids`, {
ids,
})
.then((res) => {
return { ...res, data: res.data.repairs };
});
},
async searchRepairs(search: string): Promise<AxiosResponse<any, any>> {
return await http.get(`/admin/repair?search=${search}&noLimit=true`).then((res) => {
return { ...res, data: res.data.repairs };
});
},
fetchRepairByActiveId() {
this.loadingActive = "loading";
http
.get(`/admin/repair/${this.activeRepair}`)
.then((res) => {
this.activeRepairObj = res.data;
this.loadingActive = "fetched";
})
.catch((err) => {
this.loadingActive = "failed";
});
},
fetchRepairById(id: string) {
return http.get(`/admin/repair/${id}`);
},
loadRepairImage(url: string) {
return http.get(`/admin/repair/${this.activeRepairObj?.id}/${url}`, {
responseType: "blob",
});
},
async createRepair(repair: CreateRepairViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.post(`/admin/repair`, {
affected: repair.affected,
affectedId: repair.affectedId,
title: repair.title,
description: repair.description,
responsible: repair.responsible,
reports: repair.reports,
});
return result;
},
async updateRepair(repair: UpdateRepairViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.patch(`/admin/repair/${this.activeRepairObj?.id}`, {
title: repair.title,
description: repair.description,
responsible: repair.responsible,
});
return result;
},
async updateRepairReports(reports: Array<string>): Promise<AxiosResponse<any, any>> {
const result = await http.patch(`/admin/repair/${this.activeRepairObj?.id}/reports`, {
reports,
});
return result;
},
async updateRepairStatus(repair: UpdateRepairStatusViewModel): Promise<AxiosResponse<any, any>> {
const result = await http.patch(`/admin/repair/${this.activeRepairObj?.id}/status`, {
status: repair.status,
done: repair.done,
});
return result;
},
},
});

View file

@ -0,0 +1,43 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import { useVehicleStore } from "./vehicle";
import type { RepairViewModel } from "@/viewmodels/admin/unit/repair.models";
export const useVehicleRepairStore = defineStore("vehicleRepair", {
state: () => {
return {
repairs: [] as Array<RepairViewModel & { tab_pos: number }>,
totalCount: 0 as number,
loading: "loading" as "loading" | "fetched" | "failed",
};
},
actions: {
fetchRepairForVehicle(offset = 0, count = 25, search = "", clear = false) {
const vehicleId = useVehicleStore().activeVehicle;
if (clear) this.repairs = [];
this.loading = "loading";
http
.get(
`/admin/repair/vehicle/${vehicleId}?offset=${offset}&count=${count}${search != "" ? "&search=" + search : ""}`
)
.then((result) => {
this.totalCount = result.data.total;
result.data.repairs
.filter((elem: RepairViewModel) => this.repairs.findIndex((m) => m.id == elem.id) == -1)
.map((elem: RepairViewModel, index: number): RepairViewModel & { tab_pos: number } => {
return {
...elem,
tab_pos: index + offset,
};
})
.forEach((elem: RepairViewModel & { tab_pos: number }) => {
this.repairs.push(elem);
});
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
},
});

View file

@ -0,0 +1,43 @@
import { defineStore } from "pinia";
import { http } from "@/serverCom";
import { useWearableStore } from "./wearable";
import type { RepairViewModel } from "@/viewmodels/admin/unit/repair.models";
export const useWearableRepairStore = defineStore("wearableRepair", {
state: () => {
return {
repairs: [] as Array<RepairViewModel & { tab_pos: number }>,
totalCount: 0 as number,
loading: "loading" as "loading" | "fetched" | "failed",
};
},
actions: {
fetchRepairForWearable(offset = 0, count = 25, search = "", clear = false) {
const wearableId = useWearableStore().activeWearable;
if (clear) this.repairs = [];
this.loading = "loading";
http
.get(
`/admin/repair/wearable/${wearableId}?offset=${offset}&count=${count}${search != "" ? "&search=" + search : ""}`
)
.then((result) => {
this.totalCount = result.data.total;
result.data.repairs
.filter((elem: RepairViewModel) => this.repairs.findIndex((m) => m.id == elem.id) == -1)
.map((elem: RepairViewModel, index: number): RepairViewModel & { tab_pos: number } => {
return {
...elem,
tab_pos: index + offset,
};
})
.forEach((elem: RepairViewModel & { tab_pos: number }) => {
this.repairs.push(elem);
});
this.loading = "fetched";
})
.catch((err) => {
this.loading = "failed";
});
},
},
});

View file

@ -22,6 +22,7 @@ export type PermissionModule =
| "respiratory_mission"
| "damage_report"
| "maintenance"
| "repair"
// configuration
| "qualification"
| "award"
@ -97,6 +98,7 @@ export const permissionModules: Array<PermissionModule> = [
"respiratory_mission",
"damage_report",
"maintenance",
"repair",
// configuration
"qualification",
"award",
@ -134,6 +136,7 @@ export const sectionsAndModules: SectionsAndModulesObject = {
"respiratory_mission",
"damage_report",
"maintenance",
"repair",
],
configuration: [
"qualification",

View file

@ -1,5 +1,5 @@
import type { EquipmentViewModel } from "./equipment/equipment.models";
import type { MaintenanceViewModel } from "./maintenance.models";
import type { RepairViewModel } from "./repair.models";
import type { VehicleViewModel } from "./vehicle/vehicle.models";
import type { WearableViewModel } from "./wearable/wearable.models";
@ -22,6 +22,7 @@ export type DamageReportAssigned = {
export type DamageReportViewModel = {
id: string;
title: string;
reportedAt: Date;
status: string;
done: boolean;
@ -31,10 +32,11 @@ export type DamageReportViewModel = {
noteByWorker: string;
images: string[];
reportedBy: string;
maintenance?: MaintenanceViewModel;
repair?: RepairViewModel;
} & Optional<DamageReportAssigned>;
export interface CreateDamageReportViewModel {
title: string;
description: string;
reportedBy: string;
affectedId: string;

View file

@ -1,4 +1,3 @@
import type { DamageReportViewModel } from "./damageReport.models";
import type { EquipmentViewModel } from "./equipment/equipment.models";
import type { VehicleViewModel } from "./vehicle/vehicle.models";
import type { WearableViewModel } from "./wearable/wearable.models";
@ -24,9 +23,7 @@ export type MaintenanceViewModel = {
id: string;
createdAt: Date;
status: string;
done: boolean;
description: string;
reports: DamageReportViewModel[];
} & MaintenanceAssigned;
export interface CreateMaintenanceViewModel {

View file

@ -0,0 +1,57 @@
import type { DamageReportViewModel } from "./damageReport.models";
import type { EquipmentViewModel } from "./equipment/equipment.models";
import type { MaintenanceViewModel } from "./maintenance.models";
import type { VehicleViewModel } from "./vehicle/vehicle.models";
import type { WearableViewModel } from "./wearable/wearable.models";
export type RepairAssigned = {
relatedId: string;
} & (
| {
assigned: "equipment";
related: EquipmentViewModel;
}
| {
assigned: "vehicle";
related: VehicleViewModel;
}
| {
assigned: "wearable";
related: WearableViewModel;
}
);
export type RepairViewModel = {
id: string;
createdAt: Date;
finishedAt?: Date;
status: string;
responsible: string;
title: string;
description: string;
images: string[];
reportDocument: string;
reports: DamageReportViewModel[];
} & RepairAssigned;
export interface CreateRepairViewModel {
affected: "equipment" | "vehicle" | "wearable";
affectedId: string;
title: string;
description: string;
responsible: string;
reports: string[];
}
export interface UpdateRepairStatusViewModel {
id: string;
status: string;
done: boolean;
}
export interface UpdateRepairViewModel {
id: string;
title: string;
description: string;
responsible: string;
}

View file

@ -19,7 +19,7 @@ import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { useAbilityStore } from "@/stores/ability";
import { useDamageReportStore } from "@/stores/admin/unit/damageReport/damageReport";
import { useDamageReportStore } from "@/stores/admin/unit/damageReport";
import type { DamageReportViewModel } from "@/viewmodels/admin/unit/damageReport.models";
import Pagination from "@/components/Pagination.vue";
import DamageReportListItem from "@/components/admin/unit/damageReport/DamageReportListItem.vue";

View file

@ -19,7 +19,7 @@ import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { useAbilityStore } from "@/stores/ability";
import { useDamageReportStore } from "@/stores/admin/unit/damageReport/damageReport";
import { useDamageReportStore } from "@/stores/admin/unit/damageReport";
import type { DamageReportViewModel } from "@/viewmodels/admin/unit/damageReport.models";
import Pagination from "@/components/Pagination.vue";
import DamageReportListItem from "@/components/admin/unit/damageReport/DamageReportListItem.vue";

View file

@ -5,20 +5,32 @@
</template>
<template #topBar>
<h1 class="font-bold text-xl h-8 min-h-fit">
Schadensmeldung:
{{ activeDamageReportObj?.title }} -
{{ activeDamageReportObj?.related?.name ?? "Ohne Zuordnung" }}
<small v-if="activeDamageReportObj?.related">({{ activeDamageReportObj.related.code }})</small>
</h1>
<RouterLink
v-if="activeDamageReportObj?.related && can('read', 'unit', 'equipment')"
:to="{
name: `admin-unit-${activeDamageReportObj.assigned}-overview`,
params: { [`${activeDamageReportObj.assigned}Id`]: activeDamageReportObj.related.id ?? '_' },
}"
>
<ArrowTopRightOnSquareIcon class="w-5 h-5" />
</RouterLink>
<div class="flex flex-row gap-2">
<RouterLink
v-if="activeDamageReportObj?.repair && can('read', 'unit', 'repair')"
:to="{
name: `admin-unit-repair-overview`,
params: { repairId: activeDamageReportObj.repair.id },
}"
title="Zur verbundenen Reparatur"
>
<WrenchScrewdriverIcon class="w-5 h-5" />
</RouterLink>
<RouterLink
v-if="activeDamageReportObj?.related && can('read', 'unit', activeDamageReportObj.assigned)"
:to="{
name: `admin-unit-${activeDamageReportObj.assigned}-overview`,
params: { [`${activeDamageReportObj.assigned}Id`]: activeDamageReportObj.related.id ?? '_' },
}"
>
<ArrowTopRightOnSquareIcon class="w-5 h-5" />
</RouterLink>
</div>
</template>
<template #diffMain>
<div class="flex flex-col gap-2 grow px-7 overflow-hidden">
@ -54,8 +66,8 @@ import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { RouterLink, RouterView } from "vue-router";
import { useAbilityStore } from "@/stores/ability";
import { useDamageReportStore } from "@/stores/admin/unit/damageReport/damageReport";
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
import { useDamageReportStore } from "@/stores/admin/unit/damageReport";
import { ArrowTopRightOnSquareIcon, WrenchScrewdriverIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">

View file

@ -2,27 +2,25 @@
<MainTemplate title="Schadensmeldungen">
<template #diffMain>
<div class="flex flex-col gap-2 grow px-7 overflow-hidden">
<div class="flex flex-col grow gap-2 overflow-hidden">
<div class="w-full flex flex-row max-lg:flex-wrap justify-center">
<RouterLink
v-for="tab in tabs"
:key="tab.route"
v-slot="{ isExactActive }"
:to="{ name: tab.route }"
class="w-1/2 md:w-1/3 lg:w-full p-0.5 first:pl-0 last:pr-0"
<div class="w-full flex flex-row max-lg:flex-wrap justify-center">
<RouterLink
v-for="tab in tabs"
:key="tab.route"
v-slot="{ isExactActive }"
:to="{ name: tab.route }"
class="w-1/2 md:w-1/3 lg:w-full p-0.5 first:pl-0 last:pr-0"
>
<p
:class="[
'w-full rounded-lg py-2.5 text-sm text-center font-medium leading-5 focus:ring-0 outline-hidden',
isExactActive ? 'bg-red-200 shadow-sm border-b-2 border-primary rounded-b-none' : ' hover:bg-red-200',
]"
>
<p
:class="[
'w-full rounded-lg py-2.5 text-sm text-center font-medium leading-5 focus:ring-0 outline-hidden',
isExactActive ? 'bg-red-200 shadow-sm border-b-2 border-primary rounded-b-none' : ' hover:bg-red-200',
]"
>
{{ tab.title }}
</p>
</RouterLink>
</div>
<RouterView />
{{ tab.title }}
</p>
</RouterLink>
</div>
<RouterView />
</div>
</template>
</MainTemplate>

View file

@ -63,7 +63,7 @@
import { defineComponent } from "vue";
import { mapActions, mapState, mapWritableState } from "pinia";
import { useAbilityStore } from "@/stores/ability";
import { useDamageReportStore } from "@/stores/admin/unit/damageReport/damageReport";
import { useDamageReportStore } from "@/stores/admin/unit/damageReport";
import type { DamageReportViewModel, UpdateDamageReportViewModel } from "@/viewmodels/admin/unit/damageReport.models";
</script>

View file

@ -14,7 +14,7 @@
>
<div class="bg-primary p-2 text-white flex flex-row gap-2 items-center">
<PencilSquareIcon v-if="!row.done" class="w-5 h-5" />
<p class="grow">{{ new Date(row.reportedAt).toLocaleString("de") }} - {{ row.status }}</p>
<p class="grow">{{ row.title }} - {{ new Date(row.reportedAt).toLocaleString("de") }} - {{ row.status }}</p>
<div class="flex flex-row gap-2">
<div v-if="row.images.length != 0" class="cursor-pointer">
<PhotoIcon class="w-5 h-5" />
@ -25,7 +25,7 @@
<div v-if="row.reportedBy" class="cursor-pointer">
<UserIcon class="w-5 h-5" />
</div>
<div v-if="row.maintenance" class="cursor-pointer">
<div v-if="row.repair" class="cursor-pointer">
<WrenchScrewdriverIcon class="w-5 h-5" />
</div>
</div>

View file

@ -55,9 +55,10 @@ export default defineComponent({
return {
tabs: [
{ route: "admin-unit-equipment-overview", title: "Übersicht" },
{ route: "admin-unit-equipment-maintenance", title: "Wartungen/Reparaturen" },
{ route: "admin-unit-equipment-inspection", title: "Prüfungen" },
{ route: "admin-unit-equipment-maintenance", title: "Wartungen" },
{ route: "admin-unit-equipment-damage_report", title: "Schadensmeldungen" },
{ route: "admin-unit-equipment-repair", title: "Reparaturen" },
],
};
},

View file

@ -0,0 +1,62 @@
<template>
<div class="flex flex-col gap-2 h-full w-full">
<Pagination
:items="repairs"
:totalCount="totalCount"
:indicateLoading="loading == 'loading'"
@load-data="(offset, count, search) => fetchRepairForEquipment(offset, count, search)"
@search="(search) => fetchRepairForEquipment(0, 25, search, true)"
>
<template #pageRow="{ row }: { row: RepairViewModel }">
<RouterLink
:to="{ name: 'admin-unit-repair-overview', params: { repairId: row.id } }"
class="flex flex-col h-fit w-full border border-primary rounded-md"
>
<div class="bg-primary p-2 text-white flex flex-row gap-2 items-center">
<PencilSquareIcon v-if="row.finishedAt == null" class="w-5 h-5" />
<p class="grow">{{ new Date(row.createdAt).toLocaleString("de") }} - {{ row.status }}</p>
</div>
<div class="p-2">
<p>Beschreibung: {{ row.description }}</p>
</div>
</RouterLink>
</template>
</Pagination>
<RouterLink
:to="{ name: 'admin-unit-repair-create', params: { type: 'equipment', relatedId: equipmentId } }"
button
primary
class="w-fit!"
>
Reparatur erstellen
</RouterLink>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import { useAbilityStore } from "@/stores/ability";
import { useEquipmentRepairStore } from "@/stores/admin/unit/equipment/repair";
import Pagination from "@/components/Pagination.vue";
import type { RepairViewModel } from "@/viewmodels/admin/unit/repair.models";
import { PhotoIcon, PencilSquareIcon, MapPinIcon, WrenchScrewdriverIcon, UserIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">
export default defineComponent({
props: {
equipmentId: String,
},
computed: {
...mapState(useAbilityStore, ["can"]),
...mapState(useEquipmentRepairStore, ["repairs", "loading", "totalCount"]),
},
mounted() {
this.fetchRepairForEquipment(0, 25, "", true);
},
methods: {
...mapActions(useEquipmentRepairStore, ["fetchRepairForEquipment"]),
},
});
</script>

View file

@ -19,7 +19,7 @@ import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { useAbilityStore } from "@/stores/ability";
import { useMaintenanceStore } from "@/stores/admin/unit/maintenance/maintenance";
import { useMaintenanceStore } from "@/stores/admin/unit/maintenance";
import type { MaintenanceViewModel } from "@/viewmodels/admin/unit/maintenance.models";
import Pagination from "@/components/Pagination.vue";
import MaintenanceListItem from "@/components/admin/unit/maintenance/MaintenanceListItem.vue";

View file

@ -0,0 +1,92 @@
<template>
<div v-if="activeRepairObj != null" class="flex flex-col gap-2 h-full w-full">
<DamageReportSearchSelectMultipleWithRelated
title="verbundene Schadensmeldungen"
:related="activeRepairObj.assigned"
:relatedId="activeRepairObj.relatedId"
:model-value="activeRepairObj.reports.map((r) => r.id)"
:disabled="!!activeRepairObj.finishedAt"
@add:damage-report="handleReportAdd"
@remove:difference="handleReportRemove"
/>
<div class="flex flex-col gap-2 overflow-y-scroll h-full">
<div
v-for="damageReport in activeRepairObj.reports"
:key="damageReport.id"
class="flex flex-col h-fit w-full border border-primary rounded-md"
>
<div class="bg-primary p-2 text-white flex flex-row justify-between items-center">
<p>{{ damageReport.title }}</p>
<TrashIcon
v-if="!activeRepairObj.finishedAt"
class="w-5 h-5 cursor-pointer"
@click="handleReportRemove(damageReport.id)"
/>
</div>
<div class="p-2">
<p v-if="damageReport.description">Beschreibung: {{ damageReport.description }}</p>
</div>
</div>
</div>
<button primary class="w-fit! self-end" :disabled="!!activeRepairObj.finishedAt" @click="saveReports">
Änderungen speichern
</button>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState, mapWritableState } from "pinia";
import { useAbilityStore } from "@/stores/ability";
import { useRepairStore } from "@/stores/admin/unit/repair";
import type { RepairViewModel, UpdateRepairViewModel } from "@/viewmodels/admin/unit/repair.models";
import DamageReportSearchSelectMultipleWithRelated from "@/components/search/DamageReportSearchSelectMultipleWithRelated.vue";
import { aC } from "node_modules/@fullcalendar/core/internal-common";
import type { DamageReportViewModel } from "@/viewmodels/admin/unit/damageReport.models";
import { TrashIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">
export default defineComponent({
props: {
repairId: String,
},
watch: {},
data() {
return {
loading: undefined as undefined | "loading" | "success" | "failed",
};
},
computed: {
...mapWritableState(useRepairStore, ["activeRepairObj"]),
...mapState(useAbilityStore, ["can"]),
},
methods: {
...mapActions(useRepairStore, ["loadRepairImage", "updateRepairReports"]),
handleReportAdd(report: DamageReportViewModel) {
if (!this.activeRepairObj) return;
this.activeRepairObj.reports.push(report);
},
handleReportRemove(reportId: string) {
if (!this.activeRepairObj) return;
this.activeRepairObj.reports = this.activeRepairObj.reports.filter((r) => r.id != reportId);
},
saveReports() {
if (this.activeRepairObj == null) return;
this.loading = "loading";
this.updateRepairReports(this.activeRepairObj.reports.map((r) => r.id))
.then((res) => {
this.loading = "success";
})
.catch((err) => {
this.loading = "failed";
})
.finally(() => {
setTimeout(() => {
this.loading = undefined;
}, 2000);
});
},
},
});
</script>

View file

@ -0,0 +1,118 @@
<template>
<div v-if="activeRepairObj != null" class="flex flex-col gap-2 h-full w-full overflow-y-auto">
<div>
<label for="status">Status</label>
<input id="status" ref="status" type="text" :readonly="!editStatus" :value="activeRepairObj.status" />
</div>
<button
v-if="!editStatus && !activeRepairObj.finishedAt"
primary
class="w-fit! self-end"
@click="editStatus = true"
>
Status ändern
</button>
<div v-else-if="!activeRepairObj.finishedAt" class="flex flex-row gap-2 justify-end">
<button primary-outline class="w-fit!" @click="saveStatus(true)">speichern und abschließen</button>
<button primary class="w-fit!" @click="saveStatus(false)">Status speichern</button>
</div>
<br />
<form class="flex flex-col gap-2" @submit.prevent="saveData">
<div>
<label for="title">Kurzbeschreibung</label>
<input id="title" type="text" placeholder="---" :value="activeRepairObj.title" />
</div>
<div>
<label for="description">Beschreibung der Reparatur</label>
<textarea id="description" placeholder="---" :value="activeRepairObj.description"></textarea>
</div>
<div>
<label for="responsible">Verantwortlich</label>
<input id="responsible" type="text" placeholder="---" :value="activeRepairObj.responsible" />
</div>
<button primary type="submit" class="w-fit! self-end" :disabled="!!activeRepairObj.finishedAt">
Änderungen speichern
</button>
</form>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState, mapWritableState } from "pinia";
import { useAbilityStore } from "@/stores/ability";
import { useRepairStore } from "@/stores/admin/unit/repair";
import type { UpdateRepairStatusViewModel, UpdateRepairViewModel } from "@/viewmodels/admin/unit/repair.models";
</script>
<script lang="ts">
export default defineComponent({
props: {
repairId: String,
},
data() {
return {
editStatus: false as boolean,
loading: undefined as undefined | "loading" | "success" | "failed",
};
},
computed: {
...mapWritableState(useRepairStore, ["activeRepairObj"]),
...mapState(useAbilityStore, ["can"]),
},
methods: {
...mapActions(useRepairStore, ["loadRepairImage", "updateRepairStatus", "updateRepair"]),
saveStatus(finish: boolean) {
if (this.activeRepairObj == null) return;
this.loading = "loading";
let update: UpdateRepairStatusViewModel = {
id: this.activeRepairObj.id,
status: (this.$refs.status as HTMLInputElement).value,
done: finish,
};
this.updateRepairStatus(update)
.then((res) => {
this.activeRepairObj!.status = update.status;
this.activeRepairObj!.finishedAt = update.done ? new Date() : undefined;
this.loading = "success";
this.editStatus = false;
})
.catch((err) => {
this.loading = "failed";
})
.finally(() => {
setTimeout(() => {
this.loading = undefined;
}, 2000);
});
},
saveData(e: any) {
if (this.activeRepairObj == null) return;
this.loading = "loading";
const formData = e.target.elements;
let update: UpdateRepairViewModel = {
id: this.activeRepairObj.id,
title: formData.title.value,
description: formData.description.value,
responsible: formData.responsible.value,
};
this.updateRepair(update)
.then((res) => {
this.activeRepairObj!.title = update.title;
this.activeRepairObj!.description = update.description;
this.activeRepairObj!.responsible = update.responsible;
this.loading = "success";
this.editStatus = false;
})
.catch((err) => {
this.loading = "failed";
})
.finally(() => {
setTimeout(() => {
this.loading = undefined;
}, 2000);
});
},
},
});
</script>

View file

@ -0,0 +1,46 @@
<template>
<div class="flex flex-col w-full h-full gap-2 justify-center">
<Pagination
:items="repairs"
:totalCount="totalCount"
:indicateLoading="loading == 'loading'"
@load-data="(offset, count, search) => fetchDoneRepairs(offset, count, search)"
@search="(search) => fetchDoneRepairs(0, maxEntriesPerPage, search, true)"
>
<template #pageRow="{ row }: { row: RepairViewModel }">
<RepairListItem :repair="row" />
</template>
</Pagination>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { useAbilityStore } from "@/stores/ability";
import { useRepairStore } from "@/stores/admin/unit/repair";
import type { RepairViewModel } from "@/viewmodels/admin/unit/repair.models";
import Pagination from "@/components/Pagination.vue";
import RepairListItem from "@/components/admin/unit/repair/RepairListItem.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
maxEntriesPerPage: 25,
};
},
computed: {
...mapState(useRepairStore, ["repairs", "totalCount", "loading"]),
...mapState(useAbilityStore, ["can"]),
},
mounted() {
this.fetchDoneRepairs(0, this.maxEntriesPerPage, "", true);
},
methods: {
...mapActions(useRepairStore, ["fetchDoneRepairs"]),
},
});
</script>

View file

@ -0,0 +1,171 @@
<template>
<MainTemplate title="Reparatur erstellen">
<template #main>
<form class="flex flex-col gap-4 py-2 w-full max-w-xl mx-auto" @submit.prevent="createNewRepair">
<div class="flex flex-row">
<div
v-for="tab in tabs"
:key="tab.key"
class="w-1/2 p-0.5 first:pl-0 last:pr-0 cursor-pointer"
@click="
active = tab.key;
related = '';
reports = [];
"
>
<p
:class="[
'w-full rounded-lg py-2.5 text-sm text-center font-medium leading-5 focus:ring-0 outline-hidden',
tab.key == active
? 'bg-red-200 shadow-sm border-b-2 border-primary rounded-b-none'
: ' hover:bg-red-200',
]"
>
{{ tab.title }}
</p>
</div>
</div>
<EquipmentSearchSelect v-if="active == 'equipment'" title="Gerät" useScanner v-model="related" />
<VehicleSearchSelect v-else-if="active == 'vehicle'" title="Fahrzeug" useScanner v-model="related" />
<WearableSearchSelect v-else title="Kleidung" useScanner v-model="related" />
<div>
<label for="title"> Kurzbeschreibung </label>
<input id="title" type="text" required />
</div>
<div>
<label for="description"> Beschreibung (optional) </label>
<textarea id="description" class="h-24"></textarea>
</div>
<div>
<label for="responsible"> Verantwortlich (optional) </label>
<input id="responsible" type="text" />
</div>
<DamageReportSearchSelectMultipleWithRelated
title="verbundene Schadensmeldungen zu dieser Reparatur (optional)"
:related="active"
:relatedId="related"
v-model="reports"
/>
<div class="flex flex-row justify-end gap-2">
<RouterLink
:to="{ name: 'admin-unit-repair' }"
primary-outline
button
class="w-fit!"
:disabled="status == 'loading' || status?.status == 'success'"
>
abbrechen
</RouterLink>
<button primary type="submit" class="w-fit!" :disabled="status == 'loading'">starten</button>
<Spinner v-if="status == 'loading'" class="my-auto" />
<SuccessCheckmark v-else-if="status?.status == 'success'" />
<FailureXMark v-else-if="status?.status == 'failed'" />
</div>
</form>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent, type PropType } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import Spinner from "@/components/Spinner.vue";
import SuccessCheckmark from "@/components/SuccessCheckmark.vue";
import FailureXMark from "@/components/FailureXMark.vue";
import EquipmentSearchSelect from "@/components/search/EquipmentSearchSelect.vue";
import VehicleSearchSelect from "@/components/search/VehicleSearchSelect.vue";
import WearableSearchSelect from "@/components/search/WearableSearchSelect.vue";
import { useEquipmentStore } from "@/stores/admin/unit/equipment/equipment";
import { useVehicleStore } from "@/stores/admin/unit/vehicle/vehicle";
import { useWearableStore } from "@/stores/admin/unit/wearable/wearable";
import type { CreateRepairViewModel } from "@/viewmodels/admin/unit/repair.models";
import { useRepairStore } from "@/stores/admin/unit/repair";
import DamageReportSearchSelectMultipleWithRelated from "@/components/search/DamageReportSearchSelectMultipleWithRelated.vue";
</script>
<script lang="ts">
export default defineComponent({
props: {
type: {
type: String as PropType<"vehicle" | "equipment" | "wearable">,
default: "equipment",
},
relatedId: {
type: String,
default: "",
},
},
data() {
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: null as any,
related: "",
active: "equipment" as "equipment" | "vehicle" | "wearable",
tabs: [
{
key: "equipment",
title: "Gerät",
},
{
key: "vehicle",
title: "Fahrzeug",
},
{
key: "wearable",
title: "Kleidung",
},
] as Array<{ key: "equipment" | "vehicle" | "wearable"; title: string }>,
reports: [] as Array<string>,
};
},
mounted() {
if (["vehicle", "equipment", "wearable"].includes(this.type)) {
this.active = this.type;
this.related = this.relatedId ?? "";
}
},
methods: {
...mapActions(useRepairStore, ["createRepair"]),
...mapActions(useEquipmentStore, ["fetchEquipmentById"]),
...mapActions(useVehicleStore, ["fetchVehicleById"]),
...mapActions(useWearableStore, ["fetchWearableById"]),
createNewRepair(e: any) {
if (this.related == "") return;
let formData = e.target.elements;
let createRepair: CreateRepairViewModel = {
affected: this.active,
affectedId: this.related,
title: formData.responsible.value,
description: formData.description.value,
responsible: formData.responsible.value,
reports: this.reports,
};
this.status = "loading";
this.createRepair(createRepair)
.then((res) => {
this.status = { status: "success" };
this.timeout = setTimeout(() => {
this.$router.push({
name: "admin-unit-repair-overview",
params: {
repairId: res.data,
},
});
}, 1500);
})
.catch((err) => {
this.status = { status: "failed" };
});
},
},
});
</script>

View file

@ -0,0 +1,46 @@
<template>
<div class="flex flex-col w-full h-full gap-2 justify-center">
<Pagination
:items="repairs"
:totalCount="totalCount"
:indicateLoading="loading == 'loading'"
@load-data="(offset, count, search) => fetchOpenRepairs(offset, count, search)"
@search="(search) => fetchOpenRepairs(0, maxEntriesPerPage, search, true)"
>
<template #pageRow="{ row }: { row: RepairViewModel }">
<RepairListItem :repair="row" />
</template>
</Pagination>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { useAbilityStore } from "@/stores/ability";
import { useRepairStore } from "@/stores/admin/unit/repair";
import type { RepairViewModel } from "@/viewmodels/admin/unit/repair.models";
import Pagination from "@/components/Pagination.vue";
import RepairListItem from "@/components/admin/unit/repair/RepairListItem.vue";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
maxEntriesPerPage: 25,
};
},
computed: {
...mapState(useRepairStore, ["repairs", "totalCount", "loading"]),
...mapState(useAbilityStore, ["can"]),
},
mounted() {
this.fetchOpenRepairs(0, this.maxEntriesPerPage, "", true);
},
methods: {
...mapActions(useRepairStore, ["fetchOpenRepairs"]),
},
});
</script>

View file

@ -0,0 +1,86 @@
<template>
<MainTemplate>
<template #headerInsert>
<RouterLink :to="{ name: 'admin-unit-repair-open' }" class="text-primary">zurück zur Liste</RouterLink>
</template>
<template #topBar>
<h1 class="font-bold text-xl h-8 min-h-fit">
{{ activeRepairObj?.title }} -
{{ activeRepairObj?.related?.name ?? "Ohne Zuordnung" }}
<small v-if="activeRepairObj?.related">({{ activeRepairObj.related.code }})</small>
</h1>
<RouterLink
v-if="activeRepairObj?.related && can('read', 'unit', activeRepairObj.assigned)"
:to="{
name: `admin-unit-${activeRepairObj.assigned}-overview`,
params: { [`${activeRepairObj.assigned}Id`]: activeRepairObj.related.id ?? '_' },
}"
>
<ArrowTopRightOnSquareIcon class="w-5 h-5" />
</RouterLink>
</template>
<template #diffMain>
<div class="flex flex-col gap-2 grow px-7 overflow-hidden">
<div class="flex flex-col grow gap-2 overflow-hidden">
<div class="w-full flex flex-row max-lg:flex-wrap justify-center">
<RouterLink
v-for="tab in tabs"
:key="tab.route"
v-slot="{ isExactActive }"
:to="{ name: tab.route }"
class="w-1/2 md:w-1/3 lg:w-full p-0.5 first:pl-0 last:pr-0"
>
<p
:class="[
'w-full rounded-lg py-2.5 text-sm text-center font-medium leading-5 focus:ring-0 outline-hidden',
isExactActive ? 'bg-red-200 shadow-sm border-b-2 border-primary rounded-b-none' : ' hover:bg-red-200',
]"
>
{{ tab.title }}
</p>
</RouterLink>
</div>
<RouterView />
</div>
</div>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { RouterLink, RouterView } from "vue-router";
import { useAbilityStore } from "@/stores/ability";
import { useRepairStore } from "@/stores/admin/unit/repair";
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">
export default defineComponent({
props: {
repairId: String,
},
data() {
return {
tabs: [
{ route: "admin-unit-repair-overview", title: "Übersicht" },
// { route: "admin-unit-repair-overview", title: "Bilder & Bericht" },
{ route: "admin-unit-repair-reports", title: "Schadensmeldungen" },
],
};
},
computed: {
...mapState(useRepairStore, ["activeRepairObj"]),
...mapState(useAbilityStore, ["can"]),
},
mounted() {
this.fetchRepairByActiveId();
},
methods: {
...mapActions(useRepairStore, ["fetchRepairByActiveId"]),
},
});
</script>

View file

@ -0,0 +1,55 @@
<template>
<MainTemplate title="Reparaturen">
<template #diffMain>
<div class="flex flex-col gap-2 grow px-7 overflow-hidden">
<div class="w-full flex flex-row max-lg:flex-wrap justify-center">
<RouterLink
v-for="tab in tabs"
:key="tab.route"
v-slot="{ isExactActive }"
:to="{ name: tab.route }"
class="w-1/2 md:w-1/3 lg:w-full p-0.5 first:pl-0 last:pr-0"
>
<p
:class="[
'w-full rounded-lg py-2.5 text-sm text-center font-medium leading-5 focus:ring-0 outline-hidden',
isExactActive ? 'bg-red-200 shadow-sm border-b-2 border-primary rounded-b-none' : ' hover:bg-red-200',
]"
>
{{ tab.title }}
</p>
</RouterLink>
</div>
<RouterView />
<RouterLink :to="{ name: 'admin-unit-repair-create' }" button primary class="w-fit!">
Reparatur erstellen
</RouterLink>
</div>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import MainTemplate from "@/templates/Main.vue";
import { RouterLink, RouterView } from "vue-router";
import { useAbilityStore } from "@/stores/ability";
import { useEquipmentStore } from "@/stores/admin/unit/equipment/equipment";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
tabs: [
{ route: "admin-unit-repair-open", title: "offen" },
{ route: "admin-unit-repair-done", title: "bearbeitet" },
],
};
},
computed: {
...mapState(useAbilityStore, ["can"]),
},
});
</script>

View file

@ -14,7 +14,7 @@
>
<div class="bg-primary p-2 text-white flex flex-row gap-2 items-center">
<PencilSquareIcon v-if="!row.done" class="w-5 h-5" />
<p class="grow">{{ new Date(row.reportedAt).toLocaleString("de") }} - {{ row.status }}</p>
<p class="grow">{{ row.title }} - {{ new Date(row.reportedAt).toLocaleString("de") }} - {{ row.status }}</p>
<div class="flex flex-row gap-2">
<div v-if="row.images.length != 0" class="cursor-pointer">
<PhotoIcon class="w-5 h-5" />
@ -25,7 +25,7 @@
<div v-if="row.reportedBy" class="cursor-pointer">
<UserIcon class="w-5 h-5" />
</div>
<div v-if="row.maintenance" class="cursor-pointer">
<div v-if="row.repair" class="cursor-pointer">
<WrenchScrewdriverIcon class="w-5 h-5" />
</div>
</div>

View file

@ -0,0 +1,62 @@
<template>
<div class="flex flex-col gap-2 h-full w-full">
<Pagination
:items="repairs"
:totalCount="totalCount"
:indicateLoading="loading == 'loading'"
@load-data="(offset, count, search) => fetchRepairForVehicle(offset, count, search)"
@search="(search) => fetchRepairForVehicle(0, 25, search, true)"
>
<template #pageRow="{ row }: { row: RepairViewModel }">
<RouterLink
:to="{ name: 'admin-unit-repair-overview', params: { repairId: row.id } }"
class="flex flex-col h-fit w-full border border-primary rounded-md"
>
<div class="bg-primary p-2 text-white flex flex-row gap-2 items-center">
<PencilSquareIcon v-if="row.finishedAt == null" class="w-5 h-5" />
<p class="grow">{{ new Date(row.createdAt).toLocaleString("de") }} - {{ row.status }}</p>
</div>
<div class="p-2">
<p>Beschreibung: {{ row.description }}</p>
</div>
</RouterLink>
</template>
</Pagination>
<RouterLink
:to="{ name: 'admin-unit-repair-create', params: { type: 'vehicle', relatedId: vehicleId } }"
button
primary
class="w-fit!"
>
Reparatur erstellen
</RouterLink>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import { useAbilityStore } from "@/stores/ability";
import { useVehicleRepairStore } from "@/stores/admin/unit/vehicle/repair";
import Pagination from "@/components/Pagination.vue";
import type { RepairViewModel } from "@/viewmodels/admin/unit/repair.models";
import { PhotoIcon, PencilSquareIcon, MapPinIcon, WrenchScrewdriverIcon, UserIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">
export default defineComponent({
props: {
vehicleId: String,
},
computed: {
...mapState(useAbilityStore, ["can"]),
...mapState(useVehicleRepairStore, ["repairs", "loading", "totalCount"]),
},
mounted() {
this.fetchRepairForVehicle(0, 25, "", true);
},
methods: {
...mapActions(useVehicleRepairStore, ["fetchRepairForVehicle"]),
},
});
</script>

View file

@ -55,9 +55,10 @@ export default defineComponent({
return {
tabs: [
{ route: "admin-unit-vehicle-overview", title: "Übersicht" },
{ route: "admin-unit-vehicle-maintenance", title: "Wartungen/Reparaturen" },
{ route: "admin-unit-vehicle-inspection", title: "Prüfungen" },
{ route: "admin-unit-vehicle-maintenance", title: "Wartungen" },
{ route: "admin-unit-vehicle-damage_report", title: "Schadensmeldungen" },
{ route: "admin-unit-vehicle-repair", title: "Reparaturen" },
],
};
},

View file

@ -14,7 +14,7 @@
>
<div class="bg-primary p-2 text-white flex flex-row gap-2 items-center">
<PencilSquareIcon v-if="!row.done" class="w-5 h-5" />
<p class="grow">{{ new Date(row.reportedAt).toLocaleString("de") }} - {{ row.status }}</p>
<p class="grow">{{ row.title }} - {{ new Date(row.reportedAt).toLocaleString("de") }} - {{ row.status }}</p>
<div class="flex flex-row gap-2">
<div v-if="row.images.length != 0" class="cursor-pointer">
<PhotoIcon class="w-5 h-5" />
@ -25,7 +25,7 @@
<div v-if="row.reportedBy" class="cursor-pointer">
<UserIcon class="w-5 h-5" />
</div>
<div v-if="row.maintenance" class="cursor-pointer">
<div v-if="row.repair" class="cursor-pointer">
<WrenchScrewdriverIcon class="w-5 h-5" />
</div>
</div>

View file

@ -0,0 +1,62 @@
<template>
<div class="flex flex-col gap-2 h-full w-full">
<Pagination
:items="repairs"
:totalCount="totalCount"
:indicateLoading="loading == 'loading'"
@load-data="(offset, count, search) => fetchRepairForWearable(offset, count, search)"
@search="(search) => fetchRepairForWearable(0, 25, search, true)"
>
<template #pageRow="{ row }: { row: RepairViewModel }">
<RouterLink
:to="{ name: 'admin-unit-repair-overview', params: { repairId: row.id } }"
class="flex flex-col h-fit w-full border border-primary rounded-md"
>
<div class="bg-primary p-2 text-white flex flex-row gap-2 items-center">
<PencilSquareIcon v-if="row.finishedAt == null" class="w-5 h-5" />
<p class="grow">{{ new Date(row.createdAt).toLocaleString("de") }} - {{ row.status }}</p>
</div>
<div class="p-2">
<p>Beschreibung: {{ row.description }}</p>
</div>
</RouterLink>
</template>
</Pagination>
<RouterLink
:to="{ name: 'admin-unit-repair-create', params: { type: 'wearable', relatedId: wearableId } }"
button
primary
class="w-fit!"
>
Reparatur erstellen
</RouterLink>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapActions, mapState } from "pinia";
import { useAbilityStore } from "@/stores/ability";
import { useWearableRepairStore } from "@/stores/admin/unit/wearable/repair";
import Pagination from "@/components/Pagination.vue";
import type { RepairViewModel } from "@/viewmodels/admin/unit/repair.models";
import { PhotoIcon, PencilSquareIcon, MapPinIcon, WrenchScrewdriverIcon, UserIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">
export default defineComponent({
props: {
wearableId: String,
},
computed: {
...mapState(useAbilityStore, ["can"]),
...mapState(useWearableRepairStore, ["repairs", "loading", "totalCount"]),
},
mounted() {
this.fetchRepairForWearable(0, 25, "", true);
},
methods: {
...mapActions(useWearableRepairStore, ["fetchRepairForWearable"]),
},
});
</script>

View file

@ -55,9 +55,10 @@ export default defineComponent({
return {
tabs: [
{ route: "admin-unit-wearable-overview", title: "Übersicht" },
{ route: "admin-unit-wearable-maintenance", title: "Wartungen/Reparaturen" },
{ route: "admin-unit-wearable-inspection", title: "Prüfungen" },
{ route: "admin-unit-wearable-maintenance", title: "Wartungen" },
{ route: "admin-unit-wearable-damage_report", title: "Schadensmeldungen" },
{ route: "admin-unit-wearable-repair", title: "Reparaturen" },
],
};
},

View file

@ -56,6 +56,7 @@ export default defineComponent({
usingBarcode: false,
content: {
gear: undefined,
title: "",
description: "",
location: "",
note: "",
@ -63,6 +64,7 @@ export default defineComponent({
image: undefined,
} as {
gear: undefined | MinifiedEquipmentViewModel | MinifiedVehicleViewModel | MinifiedWearableViewModel;
title: string;
description: string;
location: string;
note: string;
@ -83,7 +85,15 @@ export default defineComponent({
this.step = index;
this.successfull = index - 1;
},
updateContent(d: { description: string; location: string; note: string; reportedBy: string; image?: File }) {
updateContent(d: {
title: string;
description: string;
location: string;
note: string;
reportedBy: string;
image?: File;
}) {
this.content.title = d.title;
this.content.description = d.description;
this.content.location = d.location;
this.content.note = d.note;
@ -102,6 +112,7 @@ export default defineComponent({
this.usingBarcode = false;
this.content = {
gear: undefined,
title: "",
description: "",
location: "",
note: "",