This commit is contained in:
2026-05-01 14:23:37 -04:00
parent 5077f557f4
commit df0409d7f6
47 changed files with 7800 additions and 194 deletions

View File

@@ -0,0 +1,424 @@
<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>