422 lines
13 KiB
Vue
422 lines
13 KiB
Vue
<script setup>
|
|
import { computed } from 'vue';
|
|
import {
|
|
mdiArrowDown,
|
|
mdiArrowUp,
|
|
mdiDeleteOutline,
|
|
mdiPlus,
|
|
} from '@mdi/js';
|
|
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
members: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
errors: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
disabled: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
labels: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits(['update:modelValue']);
|
|
|
|
const roleOptions = [
|
|
'administrator',
|
|
'manager',
|
|
'workspace-member',
|
|
'client',
|
|
'provider',
|
|
];
|
|
const membershipOptions = ['Team', 'Client'];
|
|
const targetTypes = ['Role', 'Membership', 'Member'];
|
|
const roleItems = computed(() => roleOptions.map(role => ({
|
|
title: props.labels.roles[role],
|
|
value: role,
|
|
})));
|
|
const membershipItems = computed(() => membershipOptions.map(membership => ({
|
|
title: props.labels.memberships[membership],
|
|
value: membership,
|
|
})));
|
|
const targetTypeItems = computed(() => targetTypes.map(targetType => ({
|
|
title: props.labels.targetTypes[targetType],
|
|
value: targetType,
|
|
})));
|
|
const memberItems = computed(() => props.members.map(member => ({
|
|
title: `${member.displayName} - ${member.email}`,
|
|
value: member.id,
|
|
})));
|
|
|
|
function emitSteps(steps) {
|
|
emit('update:modelValue', steps.map((step, index) => ({
|
|
...step,
|
|
sortOrder: index,
|
|
})));
|
|
}
|
|
|
|
function createStep() {
|
|
emitSteps([
|
|
...props.modelValue,
|
|
{
|
|
name: props.labels.defaultStepName(props.modelValue.length + 1),
|
|
sortOrder: props.modelValue.length,
|
|
targetType: 'Role',
|
|
targetValue: 'manager',
|
|
requiredApproverCount: 1,
|
|
},
|
|
]);
|
|
}
|
|
|
|
function updateStep(index, updates) {
|
|
const steps = props.modelValue.map((step, stepIndex) => {
|
|
if (stepIndex !== index) {
|
|
return step;
|
|
}
|
|
|
|
const nextStep = {
|
|
...step,
|
|
...updates,
|
|
};
|
|
|
|
if (updates.targetType) {
|
|
nextStep.targetValue = defaultTargetValue(updates.targetType);
|
|
}
|
|
|
|
return nextStep;
|
|
});
|
|
|
|
emitSteps(steps);
|
|
}
|
|
|
|
function defaultTargetValue(targetType) {
|
|
if (targetType === 'Membership') {
|
|
return membershipOptions[0];
|
|
}
|
|
|
|
if (targetType === 'Member') {
|
|
return props.members[0]?.id ?? '';
|
|
}
|
|
|
|
return roleOptions[1];
|
|
}
|
|
|
|
function getSelectedMemberIds(step) {
|
|
return (step.targetValue ?? '')
|
|
.split(',')
|
|
.map(value => value.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function updateMemberTargets(index, selectedMemberIds) {
|
|
updateStep(index, { targetValue: selectedMemberIds.filter(Boolean).join(',') });
|
|
}
|
|
|
|
function moveStep(index, offset) {
|
|
const nextIndex = index + offset;
|
|
|
|
if (nextIndex < 0 || nextIndex >= props.modelValue.length) {
|
|
return;
|
|
}
|
|
|
|
const steps = [...props.modelValue];
|
|
const [step] = steps.splice(index, 1);
|
|
steps.splice(nextIndex, 0, step);
|
|
emitSteps(steps);
|
|
}
|
|
|
|
function removeStep(index) {
|
|
emitSteps(props.modelValue.filter((_, stepIndex) => stepIndex !== index));
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="approval-workflow-editor">
|
|
<div class="approval-editor-header">
|
|
<div>
|
|
<strong>{{ labels.title }}</strong>
|
|
<span>{{ labels.description }}</span>
|
|
</div>
|
|
|
|
<v-btn variant="text" :ripple="false"
|
|
type="button"
|
|
class="secondary-button"
|
|
:disabled="disabled"
|
|
@click="createStep"
|
|
>
|
|
<v-icon :icon="mdiPlus" />
|
|
<span>{{ labels.addStep }}</span>
|
|
</v-btn>
|
|
</div>
|
|
|
|
<div
|
|
v-if="!modelValue.length"
|
|
class="approval-empty"
|
|
>
|
|
{{ labels.empty }}
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
class="approval-step-list"
|
|
>
|
|
<section
|
|
v-for="(step, index) in modelValue"
|
|
:key="step.id ?? `${index}-${step.sortOrder}`"
|
|
class="approval-step-card"
|
|
>
|
|
<div class="approval-step-heading">
|
|
<div>
|
|
<small>{{ labels.stepNumber(index + 1) }}</small>
|
|
<strong>{{ step.name || labels.unnamedStep }}</strong>
|
|
</div>
|
|
|
|
<div class="approval-step-actions">
|
|
<v-btn variant="text" :ripple="false"
|
|
type="button"
|
|
:aria-label="labels.moveUp"
|
|
:disabled="disabled || index === 0"
|
|
@click="moveStep(index, -1)"
|
|
>
|
|
<v-icon :icon="mdiArrowUp" />
|
|
</v-btn>
|
|
<v-btn variant="text" :ripple="false"
|
|
type="button"
|
|
:aria-label="labels.moveDown"
|
|
:disabled="disabled || index === modelValue.length - 1"
|
|
@click="moveStep(index, 1)"
|
|
>
|
|
<v-icon :icon="mdiArrowDown" />
|
|
</v-btn>
|
|
<v-btn variant="text" :ripple="false"
|
|
type="button"
|
|
:aria-label="labels.removeStep"
|
|
:disabled="disabled"
|
|
@click="removeStep(index)"
|
|
>
|
|
<v-icon :icon="mdiDeleteOutline" />
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="approval-step-fields">
|
|
<div class="field">
|
|
<v-text-field
|
|
:model-value="step.name"
|
|
:label="labels.fields.name"
|
|
:disabled="disabled"
|
|
variant="outlined"
|
|
hide-details
|
|
@update:model-value="updateStep(index, { name: $event })"
|
|
/>
|
|
<small
|
|
v-if="errors[index]?.name"
|
|
class="field-error"
|
|
>
|
|
{{ errors[index].name }}
|
|
</small>
|
|
</div>
|
|
|
|
<v-select
|
|
:model-value="step.targetType"
|
|
:items="targetTypeItems"
|
|
:label="labels.fields.targetType"
|
|
:disabled="disabled"
|
|
variant="outlined"
|
|
hide-details
|
|
@update:model-value="updateStep(index, { targetType: $event })"
|
|
/>
|
|
|
|
<div class="field">
|
|
<v-select
|
|
v-if="step.targetType === 'Role'"
|
|
:model-value="step.targetValue"
|
|
:items="roleItems"
|
|
:label="labels.fields.targetValue"
|
|
:disabled="disabled"
|
|
variant="outlined"
|
|
hide-details
|
|
@update:model-value="updateStep(index, { targetValue: $event })"
|
|
/>
|
|
|
|
<v-select
|
|
v-else-if="step.targetType === 'Membership'"
|
|
:model-value="step.targetValue"
|
|
:items="membershipItems"
|
|
:label="labels.fields.targetValue"
|
|
:disabled="disabled"
|
|
variant="outlined"
|
|
hide-details
|
|
@update:model-value="updateStep(index, { targetValue: $event })"
|
|
/>
|
|
|
|
<v-select
|
|
v-else
|
|
:model-value="getSelectedMemberIds(step)"
|
|
:items="memberItems"
|
|
:label="labels.fields.targetValue"
|
|
:disabled="disabled"
|
|
multiple
|
|
chips
|
|
closable-chips
|
|
variant="outlined"
|
|
hide-details
|
|
@update:model-value="updateMemberTargets(index, $event)"
|
|
/>
|
|
<small
|
|
v-if="step.targetType === 'Member'"
|
|
class="field-help"
|
|
>
|
|
{{ labels.selectMembers }}
|
|
</small>
|
|
|
|
<small
|
|
v-if="errors[index]?.targetValue"
|
|
class="field-error"
|
|
>
|
|
{{ errors[index].targetValue }}
|
|
</small>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<v-text-field
|
|
:model-value="step.requiredApproverCount"
|
|
:label="labels.fields.requiredApproverCount"
|
|
type="number"
|
|
min="1"
|
|
step="1"
|
|
:disabled="disabled"
|
|
variant="outlined"
|
|
hide-details
|
|
@update:model-value="updateStep(index, { requiredApproverCount: Number($event) })"
|
|
/>
|
|
<small
|
|
v-if="errors[index]?.requiredApproverCount"
|
|
class="field-error"
|
|
>
|
|
{{ errors[index].requiredApproverCount }}
|
|
</small>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
@reference "@/assets/main.css";
|
|
.approval-workflow-editor {
|
|
@apply flex flex-col gap-3;
|
|
}
|
|
|
|
.approval-editor-header {
|
|
@apply flex flex-col gap-3 rounded-[1rem] border px-4 py-4 sm:flex-row sm:items-center sm:justify-between;
|
|
background: var(--app-color-on-primary);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.approval-editor-header div,
|
|
.approval-step-heading div:first-child {
|
|
@apply flex min-w-0 flex-col gap-1;
|
|
}
|
|
|
|
.approval-editor-header strong,
|
|
.approval-step-heading strong {
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.approval-editor-header span,
|
|
.approval-empty,
|
|
.approval-step-heading small {
|
|
@apply text-sm leading-6;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.approval-step-list {
|
|
@apply flex flex-col gap-3;
|
|
}
|
|
|
|
.approval-empty,
|
|
.approval-step-card {
|
|
@apply rounded-[1rem] border px-4 py-4;
|
|
background: var(--app-color-on-primary);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.approval-step-card {
|
|
@apply flex flex-col gap-4;
|
|
}
|
|
|
|
.approval-step-heading {
|
|
@apply flex items-start justify-between gap-3;
|
|
}
|
|
|
|
.approval-step-actions {
|
|
@apply flex flex-shrink-0 gap-2;
|
|
}
|
|
|
|
.approval-step-actions button {
|
|
@apply inline-flex h-9 w-9 items-center justify-center rounded-full;
|
|
background: var(--app-border-subtle);
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.approval-step-actions button:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.42;
|
|
}
|
|
|
|
.approval-step-fields {
|
|
@apply grid gap-3 md:grid-cols-2;
|
|
}
|
|
|
|
.secondary-button {
|
|
@apply inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm font-semibold;
|
|
background: var(--app-border-subtle);
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.secondary-button:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.56;
|
|
}
|
|
|
|
.field {
|
|
@apply flex flex-col gap-2;
|
|
}
|
|
|
|
.field span {
|
|
@apply text-sm font-semibold;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.field input,
|
|
.field select {
|
|
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
|
background: var(--app-color-surface);
|
|
border-color: var(--app-control-active);
|
|
color: var(--app-color-on-surface);
|
|
outline: none;
|
|
}
|
|
|
|
.field-error {
|
|
@apply text-sm leading-6;
|
|
color: var(--app-danger-muted);
|
|
}
|
|
|
|
.field-help {
|
|
@apply text-sm leading-6;
|
|
color: var(--app-text-muted);
|
|
}
|
|
</style>
|