283 lines
12 KiB
Vue
283 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { useForm } from '@inertiajs/vue3';
|
|
import { ArrowRightLeft } from 'lucide-vue-next';
|
|
import { computed, ref, watch } from 'vue';
|
|
import { updateStatusTransitions } from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
|
import FormFeedback from '@/components/FormFeedback.vue';
|
|
import InputError from '@/components/InputError.vue';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useFormProtection } from '@/composables/useFormProtection';
|
|
import { useLocalization } from '@/composables/useLocalization';
|
|
import { ticketStatusLabel } from '@/lib/tickets';
|
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
|
|
|
const props = defineProps<{
|
|
projectId: number;
|
|
statuses: TicketStatusModel[];
|
|
}>();
|
|
const { t } = useLocalization();
|
|
const sourceId = ref(props.statuses[0]?.id ?? null);
|
|
const source = computed(() =>
|
|
props.statuses.find((status) => status.id === sourceId.value),
|
|
);
|
|
const targets = computed(() =>
|
|
props.statuses.filter((status) => status.id !== sourceId.value),
|
|
);
|
|
const savedTargets = computed(
|
|
() =>
|
|
source.value?.allowed_target_statuses
|
|
?.map((status) => status.id)
|
|
.sort((a, b) => a - b) ?? [],
|
|
);
|
|
const form = useForm({ allowed_ticket_status_ids: [...savedTargets.value] });
|
|
const protection = useFormProtection(form);
|
|
const saved = ref(false);
|
|
const hasErrors = computed(() => Object.keys(form.errors).length > 0);
|
|
|
|
function reset(): void {
|
|
saved.value = false;
|
|
form.defaults({ allowed_ticket_status_ids: [...savedTargets.value] });
|
|
form.resetAndClearErrors();
|
|
protection.clearFeedback();
|
|
}
|
|
|
|
watch(
|
|
() => props.statuses,
|
|
() => {
|
|
if (!source.value) {
|
|
sourceId.value = props.statuses[0]?.id ?? null;
|
|
reset();
|
|
} else if (!form.isDirty && !form.processing) {
|
|
reset();
|
|
}
|
|
},
|
|
);
|
|
|
|
function selectSource(event: Event): void {
|
|
const select = event.target as HTMLSelectElement;
|
|
if (form.processing || !protection.confirmLeave()) {
|
|
select.value = String(sourceId.value);
|
|
return;
|
|
}
|
|
sourceId.value = Number(select.value);
|
|
reset();
|
|
}
|
|
|
|
function toggleTarget(id: number, event: Event): void {
|
|
saved.value = false;
|
|
const checked = (event.target as HTMLInputElement).checked;
|
|
form.allowed_ticket_status_ids = checked
|
|
? [...form.allowed_ticket_status_ids, id].sort((a, b) => a - b)
|
|
: form.allowed_ticket_status_ids.filter((target) => target !== id);
|
|
form.clearErrors();
|
|
protection.clearFeedback();
|
|
}
|
|
|
|
function submit(): void {
|
|
if (!source.value || !form.isDirty || form.processing) {
|
|
return;
|
|
}
|
|
protection.submit(
|
|
updateStatusTransitions([props.projectId, source.value.id]),
|
|
{
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
saved.value = true;
|
|
form.defaults({
|
|
allowed_ticket_status_ids: [...savedTargets.value],
|
|
});
|
|
form.resetAndClearErrors();
|
|
},
|
|
},
|
|
);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section
|
|
class="bg-card min-w-0 overflow-hidden rounded-xl border"
|
|
aria-labelledby="workflow-transitions"
|
|
>
|
|
<header class="grid gap-3 border-b p-5 sm:p-6">
|
|
<div class="flex items-center gap-2">
|
|
<ArrowRightLeft
|
|
class="text-primary size-5 shrink-0"
|
|
aria-hidden="true"
|
|
/>
|
|
<h3 id="workflow-transitions" class="font-semibold">
|
|
{{ t('workflow.transitions') }}
|
|
</h3>
|
|
</div>
|
|
<p class="text-muted-foreground text-sm">
|
|
{{ t('workflow.transitionsDescription') }}
|
|
</p>
|
|
</header>
|
|
<div class="grid min-w-0 gap-5 p-5 sm:p-6">
|
|
<p v-if="!source" class="text-muted-foreground text-sm">
|
|
{{ t('workflow.noStatuses') }}
|
|
</p>
|
|
<template v-else>
|
|
<div class="grid min-w-0 gap-2">
|
|
<Label for="workflow-source">{{
|
|
t('workflow.sourceStatus')
|
|
}}</Label>
|
|
<select
|
|
id="workflow-source"
|
|
:value="sourceId"
|
|
:disabled="form.processing"
|
|
class="border-input bg-background focus-visible:ring-ring/40 min-h-11 w-full min-w-0 rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-50"
|
|
@change="selectSource"
|
|
>
|
|
<option
|
|
v-for="status in statuses"
|
|
:key="status.id"
|
|
:value="status.id"
|
|
>
|
|
{{ ticketStatusLabel(status, '') }}
|
|
</option>
|
|
</select>
|
|
<p class="text-muted-foreground text-sm">
|
|
{{ t('workflow.directionHelp') }}
|
|
</p>
|
|
</div>
|
|
<form
|
|
class="grid min-w-0 gap-4"
|
|
:aria-busy="form.processing"
|
|
@submit.prevent="submit"
|
|
>
|
|
<fieldset
|
|
:disabled="form.processing"
|
|
class="grid min-w-0 gap-3"
|
|
aria-describedby="workflow-target-help workflow-transition-errors"
|
|
:aria-invalid="hasErrors"
|
|
>
|
|
<legend class="mb-3 break-words font-medium">
|
|
{{
|
|
t('workflow.targetsFrom', { name: source.name })
|
|
}}
|
|
</legend>
|
|
<p
|
|
id="workflow-target-help"
|
|
class="text-muted-foreground text-sm"
|
|
>
|
|
{{ t('workflow.targetHelp') }}
|
|
</p>
|
|
<p
|
|
v-if="targets.length === 0"
|
|
class="text-muted-foreground text-sm"
|
|
>
|
|
{{ t('workflow.noTargets') }}
|
|
</p>
|
|
<div v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
|
|
<label
|
|
v-for="target in targets"
|
|
:key="target.id"
|
|
class="has-[:focus-visible]:ring-ring/40 flex min-h-14 min-w-0 cursor-pointer items-center gap-3 rounded-lg border p-3 has-[:focus-visible]:ring-2"
|
|
:class="
|
|
form.allowed_ticket_status_ids.includes(
|
|
target.id,
|
|
)
|
|
? 'border-primary/40 bg-accent/40'
|
|
: 'border-input'
|
|
"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
name="allowed_ticket_status_ids[]"
|
|
:value="target.id"
|
|
:checked="
|
|
form.allowed_ticket_status_ids.includes(
|
|
target.id,
|
|
)
|
|
"
|
|
class="accent-primary size-4 shrink-0"
|
|
@change="toggleTarget(target.id, $event)"
|
|
/>
|
|
<span class="grid min-w-0 gap-1">
|
|
<span
|
|
class="break-words text-sm font-medium"
|
|
>{{
|
|
ticketStatusLabel(target, '')
|
|
}}</span
|
|
>
|
|
<span
|
|
class="text-muted-foreground text-xs"
|
|
>{{
|
|
t(
|
|
form.allowed_ticket_status_ids.includes(
|
|
target.id,
|
|
)
|
|
? 'workflow.allowed'
|
|
: 'workflow.notAllowed',
|
|
)
|
|
}}</span
|
|
>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
<p
|
|
v-if="
|
|
targets.length > 0 &&
|
|
form.allowed_ticket_status_ids.length === 0
|
|
"
|
|
class="text-muted-foreground text-sm"
|
|
>
|
|
{{ t('workflow.noMovesHelp') }}
|
|
</p>
|
|
<div id="workflow-transition-errors" role="alert">
|
|
<InputError
|
|
v-for="(message, key) in form.errors"
|
|
:key="key"
|
|
:message="message"
|
|
/>
|
|
</div>
|
|
<FormFeedback
|
|
:state="
|
|
!saved && protection.state.value === 'success'
|
|
? 'idle'
|
|
: protection.state.value
|
|
"
|
|
@retry="submit"
|
|
/>
|
|
<div
|
|
class="flex flex-wrap items-center gap-3 border-t pt-4"
|
|
>
|
|
<Badge
|
|
:variant="form.isDirty ? 'warning' : 'secondary'"
|
|
role="status"
|
|
>{{
|
|
t(
|
|
form.isDirty
|
|
? 'workflow.unsavedMoves'
|
|
: 'workflow.savedMoves',
|
|
{ count: savedTargets.length },
|
|
)
|
|
}}</Badge
|
|
>
|
|
<div
|
|
class="flex w-full flex-wrap gap-2 sm:ml-auto sm:w-auto"
|
|
>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
:disabled="!form.isDirty || form.processing"
|
|
@click="reset"
|
|
>{{ t('workflow.resetMoves') }}</Button
|
|
>
|
|
<Button
|
|
type="submit"
|
|
:disabled="!form.isDirty"
|
|
:loading="form.processing"
|
|
>{{ t('workflow.saveMoves') }}</Button
|
|
>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
</template>
|