425 lines
13 KiB
Vue
425 lines
13 KiB
Vue
<script setup>
|
|
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'];
|
|
|
|
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, selectedOptions) {
|
|
const targetValue = Array.from(selectedOptions)
|
|
.map(option => option.value)
|
|
.filter(Boolean)
|
|
.join(',');
|
|
|
|
updateStep(index, { targetValue });
|
|
}
|
|
|
|
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>
|
|
|
|
<button
|
|
type="button"
|
|
class="secondary-button"
|
|
:disabled="disabled"
|
|
@click="createStep"
|
|
>
|
|
<v-icon :icon="mdiPlus" />
|
|
<span>{{ labels.addStep }}</span>
|
|
</button>
|
|
</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">
|
|
<button
|
|
type="button"
|
|
:aria-label="labels.moveUp"
|
|
:disabled="disabled || index === 0"
|
|
@click="moveStep(index, -1)"
|
|
>
|
|
<v-icon :icon="mdiArrowUp" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
:aria-label="labels.moveDown"
|
|
:disabled="disabled || index === modelValue.length - 1"
|
|
@click="moveStep(index, 1)"
|
|
>
|
|
<v-icon :icon="mdiArrowDown" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
:aria-label="labels.removeStep"
|
|
:disabled="disabled"
|
|
@click="removeStep(index)"
|
|
>
|
|
<v-icon :icon="mdiDeleteOutline" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="approval-step-fields">
|
|
<label class="field">
|
|
<span>{{ labels.fields.name }}</span>
|
|
<input
|
|
:value="step.name"
|
|
type="text"
|
|
:disabled="disabled"
|
|
@input="updateStep(index, { name: $event.target.value })"
|
|
/>
|
|
<small
|
|
v-if="errors[index]?.name"
|
|
class="field-error"
|
|
>
|
|
{{ errors[index].name }}
|
|
</small>
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>{{ labels.fields.targetType }}</span>
|
|
<select
|
|
:value="step.targetType"
|
|
:disabled="disabled"
|
|
@change="updateStep(index, { targetType: $event.target.value })"
|
|
>
|
|
<option
|
|
v-for="targetType in targetTypes"
|
|
:key="targetType"
|
|
:value="targetType"
|
|
>
|
|
{{ labels.targetTypes[targetType] }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>{{ labels.fields.targetValue }}</span>
|
|
<select
|
|
v-if="step.targetType === 'Role'"
|
|
:value="step.targetValue"
|
|
:disabled="disabled"
|
|
@change="updateStep(index, { targetValue: $event.target.value })"
|
|
>
|
|
<option
|
|
v-for="role in roleOptions"
|
|
:key="role"
|
|
:value="role"
|
|
>
|
|
{{ labels.roles[role] }}
|
|
</option>
|
|
</select>
|
|
|
|
<select
|
|
v-else-if="step.targetType === 'Membership'"
|
|
:value="step.targetValue"
|
|
:disabled="disabled"
|
|
@change="updateStep(index, { targetValue: $event.target.value })"
|
|
>
|
|
<option
|
|
v-for="membership in membershipOptions"
|
|
:key="membership"
|
|
:value="membership"
|
|
>
|
|
{{ labels.memberships[membership] }}
|
|
</option>
|
|
</select>
|
|
|
|
<select
|
|
v-else
|
|
:value="getSelectedMemberIds(step)"
|
|
:disabled="disabled"
|
|
multiple
|
|
size="5"
|
|
@change="updateMemberTargets(index, $event.target.selectedOptions)"
|
|
>
|
|
<option
|
|
v-for="member in members"
|
|
:key="member.id"
|
|
:value="member.id"
|
|
>
|
|
{{ member.displayName }} - {{ member.email }}
|
|
</option>
|
|
</select>
|
|
<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>
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>{{ labels.fields.requiredApproverCount }}</span>
|
|
<input
|
|
:value="step.requiredApproverCount"
|
|
type="number"
|
|
min="1"
|
|
step="1"
|
|
:disabled="disabled"
|
|
@input="updateStep(index, { requiredApproverCount: Number($event.target.value) })"
|
|
/>
|
|
<small
|
|
v-if="errors[index]?.requiredApproverCount"
|
|
class="field-error"
|
|
>
|
|
{{ errors[index].requiredApproverCount }}
|
|
</small>
|
|
</label>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.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: #fffaf2;
|
|
border-color: rgba(23, 32, 51, 0.08);
|
|
}
|
|
|
|
.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: #172033;
|
|
}
|
|
|
|
.approval-editor-header span,
|
|
.approval-empty,
|
|
.approval-step-heading small {
|
|
@apply text-sm leading-6;
|
|
color: #526178;
|
|
}
|
|
|
|
.approval-step-list {
|
|
@apply flex flex-col gap-3;
|
|
}
|
|
|
|
.approval-empty,
|
|
.approval-step-card {
|
|
@apply rounded-[1rem] border px-4 py-4;
|
|
background: #fffaf2;
|
|
border-color: rgba(23, 32, 51, 0.08);
|
|
}
|
|
|
|
.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: rgba(23, 32, 51, 0.08);
|
|
color: #172033;
|
|
}
|
|
|
|
.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: rgba(23, 32, 51, 0.08);
|
|
color: #172033;
|
|
}
|
|
|
|
.secondary-button:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.56;
|
|
}
|
|
|
|
.field {
|
|
@apply flex flex-col gap-2;
|
|
}
|
|
|
|
.field span {
|
|
@apply text-sm font-semibold;
|
|
color: #172033;
|
|
}
|
|
|
|
.field input,
|
|
.field select {
|
|
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
|
background: #fffdf8;
|
|
border-color: rgba(23, 32, 51, 0.1);
|
|
color: #172033;
|
|
outline: none;
|
|
}
|
|
|
|
.field-error {
|
|
@apply text-sm leading-6;
|
|
color: #b91c1c;
|
|
}
|
|
|
|
.field-help {
|
|
@apply text-sm leading-6;
|
|
color: #526178;
|
|
}
|
|
</style>
|