feat: task system
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m55s
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m55s
This commit is contained in:
207
frontend/src/components/TaskDetailModal.vue
Normal file
207
frontend/src/components/TaskDetailModal.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ localTask.id ? "Task Detail" : "Create Task" }}</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-7">
|
||||
<label class="form-label">Title</label>
|
||||
<input v-model="localTask.title" class="form-control" type="text" maxlength="255" />
|
||||
|
||||
<label class="form-label mt-3">Description</label>
|
||||
<textarea v-model="localTask.description" class="form-control" rows="5" maxlength="2000"></textarea>
|
||||
|
||||
<label class="form-label mt-3">Category</label>
|
||||
<select v-model="localTask.category_id" class="form-select">
|
||||
<option value="">Uncategorized</option>
|
||||
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">{{ category.label }}</option>
|
||||
</select>
|
||||
|
||||
<label class="form-label mt-3">Parent Task</label>
|
||||
<select v-model="localTask.parent_task_id" class="form-select">
|
||||
<option value="">No parent (top level)</option>
|
||||
<option v-for="option in parentTaskOptions" :key="option.id" :value="option.id">{{ option.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-5">
|
||||
<label class="form-label">Status</label>
|
||||
<select v-model="localTask.status_id" class="form-select">
|
||||
<option v-for="status in statuses" :key="status.id" :value="status.id">{{ status.name }}</option>
|
||||
</select>
|
||||
|
||||
<div class="status-progress mt-3">
|
||||
<div v-for="status in statuses" :key="status.id" class="progress-step" :class="stepClass(status)">
|
||||
<span class="dot" :style="{ borderColor: status.color || '#7c8596', backgroundColor: isReached(status) ? status.color || '#7c8596' : 'transparent' }"></span>
|
||||
<span>{{ status.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary" :disabled="!localTask.id" @click="emit('transition', { taskId: localTask.id, direction: 'backward' })">Revert</button>
|
||||
<button class="btn btn-outline-primary" :disabled="!localTask.id" @click="emit('transition', { taskId: localTask.id, direction: 'forward' })">Advance</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h6>Subtasks</h6>
|
||||
<div v-if="!subtasks.length" class="text-muted small">No subtasks yet.</div>
|
||||
<button v-for="subtask in subtasks" :key="subtask.id" class="subtask-row" @click="emit('open-task', subtask)">
|
||||
<span>{{ subtask.title }}</span>
|
||||
<small>L{{ subtask.depth + 1 }}</small>
|
||||
</button>
|
||||
<button v-if="canAddSubtask" class="btn btn-sm btn-outline-primary mt-2" @click="emit('create-subtask', localTask)">Add Subtask</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Close</button>
|
||||
<button v-if="localTask.id" type="button" class="btn btn-danger" @click="emit('delete-task', localTask)">Delete</button>
|
||||
<button type="button" class="btn btn-primary" @click="saveTask">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
task: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
statuses: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
categoryOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
parentTaskOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
subtasks: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "save-task", "delete-task", "transition", "create-subtask", "open-task"]);
|
||||
|
||||
const localTask = ref({});
|
||||
|
||||
watch(
|
||||
() => props.task,
|
||||
(value) => {
|
||||
localTask.value = {
|
||||
title: "",
|
||||
description: "",
|
||||
category_id: "",
|
||||
status_id: props.statuses[0]?.id || "",
|
||||
parent_task_id: "",
|
||||
note_links: [],
|
||||
...value,
|
||||
category_id: value?.category_id || "",
|
||||
parent_task_id: value?.parent_task_id || "",
|
||||
note_links: value?.note_links || [],
|
||||
};
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const canAddSubtask = computed(() => !!localTask.value.id && (localTask.value.depth ?? 0) < 2);
|
||||
|
||||
const isReached = (status) => {
|
||||
const current = props.statuses.find((item) => item.id === localTask.value.status_id)?.order ?? 0;
|
||||
return status.order <= current;
|
||||
};
|
||||
|
||||
const stepClass = (status) => {
|
||||
const current = props.statuses.find((item) => item.id === localTask.value.status_id)?.order ?? 0;
|
||||
return {
|
||||
current: status.order === current,
|
||||
done: status.order < current,
|
||||
};
|
||||
};
|
||||
|
||||
const saveTask = () => {
|
||||
emit("save-task", {
|
||||
...localTask.value,
|
||||
category_id: localTask.value.category_id || null,
|
||||
parent_task_id: localTask.value.parent_task_id || null,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.progress-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: #627086;
|
||||
}
|
||||
|
||||
.progress-step.current {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.progress-step.done {
|
||||
color: #1f7a4d;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid;
|
||||
}
|
||||
|
||||
.subtask-row {
|
||||
width: 100%;
|
||||
margin-top: 0.35rem;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fbff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Dark mode ── */
|
||||
:root[data-bs-theme="dark"] .progress-step {
|
||||
color: #7a8fa8;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .progress-step.current {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .progress-step.done {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .subtask-row {
|
||||
background: #252b38;
|
||||
border-color: #3a3f4b;
|
||||
color: #c8d3e6;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user