701 lines
31 KiB
Vue
701 lines
31 KiB
Vue
<script setup lang="ts">
|
|
import { useLocalization } from '@/composables/useLocalization';
|
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
|
|
|
import { useForm, useRemember } from '@inertiajs/vue3';
|
|
import { computed, ref, toRef, unref } from 'vue';
|
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
|
import InputError from '@/components/InputError.vue';
|
|
import FormFeedback from '@/components/FormFeedback.vue';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useFormProtection } from '@/composables/useFormProtection';
|
|
import {
|
|
acceptanceCriteriaToEditorState,
|
|
buildAcceptanceCriteria,
|
|
formatChecklistLines,
|
|
normalizeAcceptanceCriteria,
|
|
syncAcceptanceCriteriaDescendantCheckedState,
|
|
} from '@/lib/acceptance-criteria';
|
|
import type { TicketDetailContentProps } from '@/types/ticket-detail';
|
|
const props = defineProps<TicketDetailContentProps>();
|
|
const { t } = useLocalization();
|
|
const canEditAuthor = computed(
|
|
() => props.canEditTicket && props.canManageProject,
|
|
);
|
|
const canViewAuthor = computed(
|
|
() => props.project.show_author || props.canManageProject,
|
|
);
|
|
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
|
|
props.ticket.acceptance_criteria,
|
|
);
|
|
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
|
const acceptanceCriteriaDraft = useRemember(
|
|
{ checklist: acceptanceCriteriaEditorState.checklist },
|
|
`${draftKey}:checklist`,
|
|
);
|
|
const acceptanceCriteriaChecklist = toRef(
|
|
unref(acceptanceCriteriaDraft),
|
|
'checklist',
|
|
);
|
|
const savedAcceptanceCriteria = ref(
|
|
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
|
|
);
|
|
const isPersistingAcceptanceCriteriaToggle = ref(false);
|
|
const acceptanceCriteriaToggleError = ref('');
|
|
|
|
const form = useForm(draftKey, {
|
|
acceptance_criteria: buildAcceptanceCriteria(
|
|
acceptanceCriteriaChecklist.value,
|
|
),
|
|
assignee_user_id: props.ticket.assignee_user_id ?? '',
|
|
author_user_id: props.ticket.author_user_id ?? '',
|
|
blocked_comment: props.ticket.blocked_comment ?? '',
|
|
description: props.ticket.description ?? '',
|
|
field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => {
|
|
const currentFieldValue = props.ticket.field_values?.find(
|
|
(fieldValue) =>
|
|
fieldValue.ticket_field_definition_id ===
|
|
ticketFieldDefinition.id,
|
|
);
|
|
|
|
return {
|
|
ticket_field_definition_id: ticketFieldDefinition.id,
|
|
value_number: currentFieldValue?.value_number ?? '',
|
|
value_user_id: currentFieldValue?.value_user_id ?? '',
|
|
};
|
|
}),
|
|
is_blocked: props.ticket.is_blocked,
|
|
parent_ticket_id: props.ticket.parent_ticket_id ?? '',
|
|
ticket_priority_id: props.ticket.ticket_priority_id,
|
|
ticket_type_id: props.ticket.ticket_type_id,
|
|
title: props.ticket.title,
|
|
story_points: props.ticket.story_points,
|
|
});
|
|
|
|
const acceptanceCriteriaPreviewItems = computed(
|
|
() => savedAcceptanceCriteria.value,
|
|
);
|
|
|
|
const hasUnsavedAcceptanceCriteriaDraft = computed(
|
|
() =>
|
|
acceptanceCriteriaChecklist.value !==
|
|
formatChecklistLines(savedAcceptanceCriteria.value),
|
|
);
|
|
|
|
const protection = useFormProtection(form, {
|
|
isDirty: () =>
|
|
props.canEditTicket &&
|
|
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
|
|
});
|
|
|
|
const acceptanceCriteriaError = computed(() => {
|
|
return (
|
|
form.errors.acceptance_criteria ??
|
|
Object.entries(form.errors).find(([key]) =>
|
|
key.startsWith('acceptance_criteria.'),
|
|
)?.[1] ??
|
|
''
|
|
);
|
|
});
|
|
|
|
const buildAcceptanceCriteriaForSubmit = () => {
|
|
const draftItems = buildAcceptanceCriteria(
|
|
acceptanceCriteriaChecklist.value,
|
|
);
|
|
|
|
return draftItems.map((item, index) => ({
|
|
...item,
|
|
...(savedAcceptanceCriteria.value[index]?.id !== undefined
|
|
? { id: savedAcceptanceCriteria.value[index].id }
|
|
: {}),
|
|
}));
|
|
};
|
|
|
|
const buildSavedTicketPayload = (
|
|
acceptanceCriteria = savedAcceptanceCriteria.value,
|
|
) => ({
|
|
acceptance_criteria: acceptanceCriteria,
|
|
assignee_user_id: props.ticket.assignee_user_id,
|
|
author_user_id: props.ticket.author_user_id,
|
|
blocked_comment: props.ticket.blocked_comment,
|
|
description: props.ticket.description,
|
|
field_values: (props.ticket.field_values ?? []).map((fieldValue) => ({
|
|
ticket_field_definition_id: fieldValue.ticket_field_definition_id,
|
|
value_number: fieldValue.value_number,
|
|
value_user_id: fieldValue.value_user_id,
|
|
})),
|
|
is_blocked: props.ticket.is_blocked,
|
|
parent_ticket_id: props.ticket.parent_ticket_id,
|
|
story_points: props.ticket.story_points,
|
|
ticket_priority_id: props.ticket.ticket_priority_id,
|
|
ticket_type_id: props.ticket.ticket_type_id,
|
|
title: props.ticket.title,
|
|
});
|
|
|
|
const persistAcceptanceCriteriaToggle = async (
|
|
index: number,
|
|
checked: boolean,
|
|
): Promise<void> => {
|
|
if (
|
|
!props.canEditTicket ||
|
|
isPersistingAcceptanceCriteriaToggle.value ||
|
|
hasUnsavedAcceptanceCriteriaDraft.value
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const nextAcceptanceCriteria = syncAcceptanceCriteriaDescendantCheckedState(
|
|
savedAcceptanceCriteria.value,
|
|
index,
|
|
checked,
|
|
);
|
|
const previousChecklist = formatChecklistLines(
|
|
savedAcceptanceCriteria.value,
|
|
);
|
|
const nextChecklist = formatChecklistLines(nextAcceptanceCriteria);
|
|
const csrfToken = document
|
|
.querySelector('meta[name="csrf-token"]')
|
|
?.getAttribute('content');
|
|
|
|
isPersistingAcceptanceCriteriaToggle.value = true;
|
|
acceptanceCriteriaToggleError.value = '';
|
|
|
|
try {
|
|
const response = await fetch(
|
|
ProjectTicketController.update.url({
|
|
project: props.project.id,
|
|
ticket: props.ticket.id,
|
|
}),
|
|
{
|
|
method: 'PATCH',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken ?? '',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: JSON.stringify(
|
|
buildSavedTicketPayload(nextAcceptanceCriteria),
|
|
),
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Checklist toggle update failed.');
|
|
}
|
|
|
|
savedAcceptanceCriteria.value = nextAcceptanceCriteria;
|
|
|
|
if (acceptanceCriteriaChecklist.value === previousChecklist) {
|
|
acceptanceCriteriaChecklist.value = nextChecklist;
|
|
}
|
|
} catch (_error) {
|
|
acceptanceCriteriaToggleError.value = t(
|
|
'ticketDetail.checklistSaveFailed',
|
|
);
|
|
} finally {
|
|
isPersistingAcceptanceCriteriaToggle.value = false;
|
|
}
|
|
};
|
|
|
|
const onAcceptanceCriteriaCheckboxChange = async (
|
|
index: number,
|
|
event: Event,
|
|
): Promise<void> => {
|
|
const target = event.target;
|
|
|
|
if (!(target instanceof HTMLInputElement)) {
|
|
return;
|
|
}
|
|
|
|
await persistAcceptanceCriteriaToggle(index, target.checked);
|
|
target.checked = savedAcceptanceCriteria.value[index]?.checked ?? false;
|
|
};
|
|
|
|
const submit = (): void => {
|
|
if (!props.canEditTicket) {
|
|
return;
|
|
}
|
|
|
|
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
|
|
|
|
protection.submit(
|
|
ProjectTicketController.update({
|
|
project: props.project.id,
|
|
ticket: props.ticket.id,
|
|
}),
|
|
{
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
|
|
props.ticket.acceptance_criteria,
|
|
);
|
|
acceptanceCriteriaChecklist.value = formatChecklistLines(
|
|
savedAcceptanceCriteria.value,
|
|
);
|
|
},
|
|
},
|
|
);
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex min-w-0 flex-col gap-6">
|
|
<section
|
|
id="ticket-description"
|
|
aria-labelledby="ticket-description-title"
|
|
class="scroll-mt-6"
|
|
>
|
|
<Card>
|
|
<CardHeader
|
|
><h2
|
|
id="ticket-description-title"
|
|
class="text-lg font-semibold"
|
|
>
|
|
{{ t('ticketDetail.description') }}
|
|
</h2></CardHeader
|
|
>
|
|
<CardContent>
|
|
<p
|
|
class="wrap-anywhere whitespace-pre-wrap text-sm leading-7"
|
|
:class="{
|
|
'text-muted-foreground': !ticket.description,
|
|
}"
|
|
>
|
|
{{
|
|
ticket.description ||
|
|
t('ticketDetail.noDescription')
|
|
}}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</section>
|
|
<section
|
|
id="ticket-checklist"
|
|
aria-labelledby="ticket-checklist-title"
|
|
class="scroll-mt-6"
|
|
>
|
|
<Card>
|
|
<CardHeader
|
|
><h2
|
|
id="ticket-checklist-title"
|
|
class="text-lg font-semibold"
|
|
>
|
|
{{ t('ticketDetail.checklist') }}
|
|
</h2></CardHeader
|
|
>
|
|
<CardContent class="space-y-3">
|
|
<div
|
|
v-if="acceptanceCriteriaPreviewItems.length > 0"
|
|
class="border-border rounded-xl border p-4 text-sm"
|
|
>
|
|
<ul class="space-y-2">
|
|
<li
|
|
v-for="(
|
|
item, index
|
|
) in acceptanceCriteriaPreviewItems"
|
|
:key="item.id ?? index"
|
|
:style="{
|
|
marginInlineStart: `${item.indent * 0.75}rem`,
|
|
}"
|
|
class="flex min-w-0 items-start gap-2"
|
|
>
|
|
<input
|
|
:id="`acceptance-criteria-${item.id ?? index}`"
|
|
:checked="item.checked"
|
|
type="checkbox"
|
|
:disabled="
|
|
!canEditTicket ||
|
|
form.processing ||
|
|
isPersistingAcceptanceCriteriaToggle ||
|
|
hasUnsavedAcceptanceCriteriaDraft
|
|
"
|
|
class="border-input text-primary focus:ring-ring mt-3.5 size-4 shrink-0 rounded"
|
|
@change="
|
|
onAcceptanceCriteriaCheckboxChange(
|
|
index,
|
|
$event,
|
|
)
|
|
"
|
|
/>
|
|
<label
|
|
:for="`acceptance-criteria-${item.id ?? index}`"
|
|
class="min-h-control wrap-anywhere flex min-w-0 flex-1 items-center py-2"
|
|
>
|
|
{{ item.text }}
|
|
</label>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<p v-else class="text-muted-foreground text-sm">
|
|
{{ t('ticketDetail.noChecklist') }}
|
|
</p>
|
|
<p
|
|
v-if="hasUnsavedAcceptanceCriteriaDraft"
|
|
class="text-muted-foreground text-sm"
|
|
>
|
|
{{ t('ticketDetail.saveChecklistFirst') }}
|
|
</p>
|
|
<p
|
|
v-if="acceptanceCriteriaToggleError"
|
|
role="alert"
|
|
class="text-destructive text-sm"
|
|
>
|
|
{{ acceptanceCriteriaToggleError }}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</section>
|
|
<details
|
|
v-if="canEditTicket"
|
|
id="ticket-edit"
|
|
class="bg-card group rounded-xl border shadow-sm"
|
|
>
|
|
<summary
|
|
class="min-h-control focus-visible:ring-ring cursor-pointer rounded-xl px-6 py-4 font-semibold focus-visible:outline-none focus-visible:ring-2"
|
|
>
|
|
{{ t('ticketDetail.edit') }}
|
|
</summary>
|
|
<div class="border-t p-6">
|
|
<p class="text-muted-foreground mb-6 text-sm">
|
|
{{ t('ticketDetail.editDescription') }}
|
|
</p>
|
|
<form class="grid gap-6" @submit.prevent="submit">
|
|
<FormFeedback
|
|
:state="protection.state.value"
|
|
@retry="submit"
|
|
/>
|
|
<div class="grid min-w-0 gap-2">
|
|
<Label for="title">{{ t('ticketDetail.title') }}</Label>
|
|
<Input
|
|
id="title"
|
|
v-model="form.title"
|
|
:invalid="Boolean(form.errors.title)"
|
|
aria-describedby="title-error"
|
|
:disabled="!canEditTicket || form.processing"
|
|
/>
|
|
<InputError
|
|
id="title-error"
|
|
:message="form.errors.title"
|
|
/>
|
|
</div>
|
|
|
|
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
|
|
<div class="grid min-w-0 gap-2">
|
|
<Label for="ticket_type_id">{{
|
|
t('ticketDetail.type')
|
|
}}</Label>
|
|
<select
|
|
id="ticket_type_id"
|
|
v-model="form.ticket_type_id"
|
|
:disabled="!canEditTicket || form.processing"
|
|
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
<option
|
|
v-for="ticketType in ticketTypes"
|
|
:key="ticketType.id"
|
|
:value="ticketType.id"
|
|
>
|
|
{{ ticketType.name }}
|
|
</option>
|
|
</select>
|
|
<InputError :message="form.errors.ticket_type_id" />
|
|
</div>
|
|
|
|
<div class="grid min-w-0 gap-2">
|
|
<Label for="ticket_priority_id">{{
|
|
t('ticketDetail.priority')
|
|
}}</Label>
|
|
<select
|
|
id="ticket_priority_id"
|
|
v-model="form.ticket_priority_id"
|
|
:disabled="!canEditTicket || form.processing"
|
|
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
<option
|
|
v-for="ticketPriority in ticketPriorities"
|
|
:key="ticketPriority.id"
|
|
:value="ticketPriority.id"
|
|
>
|
|
{{ ticketPriority.name }}
|
|
</option>
|
|
</select>
|
|
<InputError
|
|
:message="form.errors.ticket_priority_id"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
|
|
<div class="grid min-w-0 gap-2">
|
|
<Label for="assignee_user_id">{{
|
|
t('ticketDetail.assignee')
|
|
}}</Label>
|
|
<select
|
|
id="assignee_user_id"
|
|
v-model="form.assignee_user_id"
|
|
:disabled="!canEditTicket || form.processing"
|
|
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
<option value="">
|
|
{{ t('tickets.unassigned') }}
|
|
</option>
|
|
<option
|
|
v-for="user in users"
|
|
:key="user.id"
|
|
:value="user.id"
|
|
>
|
|
{{ user.name }}
|
|
</option>
|
|
</select>
|
|
<InputError
|
|
:message="form.errors.assignee_user_id"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="canViewAuthor" class="grid min-w-0 gap-2">
|
|
<Label for="author_user_id">{{
|
|
t('ticketDetail.author')
|
|
}}</Label>
|
|
<select
|
|
id="author_user_id"
|
|
v-model="form.author_user_id"
|
|
:disabled="!canEditAuthor || form.processing"
|
|
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
<option value="">
|
|
{{ t('ticketDetail.noAuthor') }}
|
|
</option>
|
|
<option
|
|
v-for="user in users"
|
|
:key="user.id"
|
|
:value="user.id"
|
|
>
|
|
{{ user.name }}
|
|
</option>
|
|
</select>
|
|
<InputError :message="form.errors.author_user_id" />
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="grid min-w-0 gap-2"
|
|
v-if="project.show_story_points"
|
|
>
|
|
<Label for="story_points">{{
|
|
t('ticketDetail.storyPoints')
|
|
}}</Label>
|
|
<Input
|
|
id="story_points"
|
|
name="story_points"
|
|
:disabled="!canEditTicket || form.processing"
|
|
v-model="form.story_points"
|
|
type="number"
|
|
step="1"
|
|
/>
|
|
<InputError :message="form.errors.story_points" />
|
|
</div>
|
|
|
|
<div class="grid min-w-0 gap-2">
|
|
<Label for="parent_ticket_id">{{
|
|
t('ticketDetail.parent')
|
|
}}</Label>
|
|
<select
|
|
id="parent_ticket_id"
|
|
v-model="form.parent_ticket_id"
|
|
:disabled="!canEditTicket || form.processing"
|
|
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
<option value="">
|
|
{{ t('ticketDetail.noParent') }}
|
|
</option>
|
|
<option
|
|
v-for="parentTicketOption in parentTicketOptions"
|
|
:key="parentTicketOption.id"
|
|
:value="parentTicketOption.id"
|
|
>
|
|
{{
|
|
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
|
|
}}
|
|
</option>
|
|
</select>
|
|
<InputError :message="form.errors.parent_ticket_id" />
|
|
</div>
|
|
|
|
<div class="grid min-w-0 gap-2">
|
|
<Label for="description">{{
|
|
t('ticketDetail.description')
|
|
}}</Label>
|
|
<textarea
|
|
id="description"
|
|
v-model="form.description"
|
|
rows="5"
|
|
:disabled="!canEditTicket || form.processing"
|
|
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
/>
|
|
<InputError :message="form.errors.description" />
|
|
</div>
|
|
|
|
<div class="grid gap-4">
|
|
<div class="grid min-w-0 gap-2">
|
|
<Label for="acceptance_criteria_checklist">{{
|
|
t('ticketDetail.checklistEditor')
|
|
}}</Label>
|
|
<textarea
|
|
id="acceptance_criteria_checklist"
|
|
v-model="acceptanceCriteriaChecklist"
|
|
rows="5"
|
|
:disabled="!canEditTicket || form.processing"
|
|
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
:placeholder="
|
|
t('ticketDetail.checklistPlaceholder')
|
|
"
|
|
/>
|
|
<p class="text-muted-foreground text-xs">
|
|
{{ t('ticketDetail.checklistHelp') }}
|
|
</p>
|
|
<p
|
|
v-if="hasUnsavedAcceptanceCriteriaDraft"
|
|
class="text-muted-foreground text-xs"
|
|
>
|
|
{{ t('ticketDetail.saveChecklistFirst') }}
|
|
</p>
|
|
<InputError :message="acceptanceCriteriaError" />
|
|
<InputError
|
|
:message="acceptanceCriteriaToggleError"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border-border grid gap-3 rounded-xl border p-4">
|
|
<div class="flex items-center gap-3">
|
|
<input
|
|
id="is_blocked"
|
|
v-model="form.is_blocked"
|
|
type="checkbox"
|
|
:disabled="!canEditTicket || form.processing"
|
|
class="border-input text-primary focus:ring-ring size-4 rounded"
|
|
/>
|
|
<Label for="is_blocked">{{
|
|
t('ticketDetail.blocked')
|
|
}}</Label>
|
|
</div>
|
|
|
|
<div class="grid min-w-0 gap-2">
|
|
<Label for="blocked_comment">{{
|
|
t('ticketDetail.blockerComment')
|
|
}}</Label>
|
|
<textarea
|
|
id="blocked_comment"
|
|
v-model="form.blocked_comment"
|
|
rows="3"
|
|
:disabled="!canEditTicket || form.processing"
|
|
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
:placeholder="
|
|
t('ticketDetail.blockerPlaceholder')
|
|
"
|
|
/>
|
|
<InputError
|
|
:message="form.errors.blocked_comment"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid gap-4">
|
|
<div class="space-y-1">
|
|
<h3 class="text-sm font-semibold">
|
|
{{ t('ticketDetail.customFields') }}
|
|
</h3>
|
|
<p class="text-muted-foreground text-sm">
|
|
{{ t('ticketDetail.customFieldsDescription') }}
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
v-if="ticketFieldDefinitions.length === 0"
|
|
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
|
>
|
|
{{ t('ticketDetail.noCustomFields') }}
|
|
</div>
|
|
|
|
<div v-else class="grid gap-4 md:grid-cols-2">
|
|
<div
|
|
v-for="(
|
|
ticketFieldDefinition, index
|
|
) in ticketFieldDefinitions"
|
|
:key="ticketFieldDefinition.id"
|
|
class="grid min-w-0 gap-2"
|
|
>
|
|
<Label
|
|
:for="`field-${ticketFieldDefinition.id}`"
|
|
>
|
|
{{ ticketFieldDefinition.label }}
|
|
</Label>
|
|
|
|
<select
|
|
v-if="
|
|
ticketFieldDefinition.field_type ===
|
|
'user'
|
|
"
|
|
:id="`field-${ticketFieldDefinition.id}`"
|
|
v-model="
|
|
form.field_values[index].value_user_id
|
|
"
|
|
:disabled="
|
|
!canEditTicket || form.processing
|
|
"
|
|
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
<option value="">
|
|
{{ t('tickets.unassigned') }}
|
|
</option>
|
|
<option
|
|
v-for="user in users"
|
|
:key="user.id"
|
|
:value="user.id"
|
|
>
|
|
{{ user.name }}
|
|
</option>
|
|
</select>
|
|
|
|
<Input
|
|
v-else
|
|
:id="`field-${ticketFieldDefinition.id}`"
|
|
v-model="
|
|
form.field_values[index].value_number
|
|
"
|
|
type="number"
|
|
step="0.1"
|
|
:disabled="
|
|
!canEditTicket || form.processing
|
|
"
|
|
/>
|
|
|
|
<InputError
|
|
:message="
|
|
form.errors[
|
|
`field_values.${index}.value_user_id`
|
|
] ??
|
|
form.errors[
|
|
`field_values.${index}.value_number`
|
|
]
|
|
"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="canEditTicket" class="flex items-center gap-3">
|
|
<Button type="submit" :disabled="form.processing">{{
|
|
t('ticketDetail.saveChanges')
|
|
}}</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</template>
|