feat(routines): let each person create a routine for themselves
Open the new-routine action to every member, while keeping assignment to other people and teams limited to managers. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -185,6 +185,7 @@ export function toRoutineDto(
|
||||
entityLink: normalizeEntityLink(row.entityLink),
|
||||
reminderMinutesBefore: row.reminderMinutesBefore,
|
||||
isActive: row.isActive,
|
||||
createdById: row.createdById,
|
||||
recurrenceLabel: describeRecurrence(rule),
|
||||
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
|
||||
executionTotal: execution.total,
|
||||
|
||||
@@ -200,8 +200,7 @@ export class RoutinesService {
|
||||
|
||||
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const data = await this.buildData(actor, scope, dto);
|
||||
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
|
||||
const row = await this.prisma.routine.create({
|
||||
data: {
|
||||
...data,
|
||||
@@ -216,10 +215,9 @@ export class RoutinesService {
|
||||
|
||||
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
const data = await this.buildData(actor, scope, dto);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
|
||||
const row = await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: data as Prisma.RoutineUncheckedUpdateInput,
|
||||
@@ -232,9 +230,8 @@ export class RoutinesService {
|
||||
|
||||
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
const row = await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
@@ -251,9 +248,8 @@ export class RoutinesService {
|
||||
dto: ChangeRoutineAssigneeDto,
|
||||
): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
|
||||
await this.cancelTodayPending(id, 'تغییر مسئول روتین');
|
||||
const row = await this.prisma.routine.update({
|
||||
@@ -268,9 +264,8 @@ export class RoutinesService {
|
||||
|
||||
async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const src = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, src);
|
||||
this.assertCanWriteRoutine(scope, src);
|
||||
const {
|
||||
id: _id,
|
||||
createdAt: _c,
|
||||
@@ -295,9 +290,8 @@ export class RoutinesService {
|
||||
|
||||
async remove(actor: Actor, id: string) {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date(), isActive: false },
|
||||
@@ -345,6 +339,27 @@ export class RoutinesService {
|
||||
return row;
|
||||
}
|
||||
|
||||
/** عضو عادی فقط روتینی را مینویسد که خودش برای خودش ساخته است. */
|
||||
private ownRoutineInput(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto): SaveRoutineDto {
|
||||
if (scope.isManager) return dto;
|
||||
if (dto.teamId || (dto.assigneeId && dto.assigneeId !== actor.userId)) {
|
||||
throw new ForbiddenException('فقط میتوانید برای خودتان روتین بسازید');
|
||||
}
|
||||
return { ...dto, assigneeId: actor.userId, teamId: null };
|
||||
}
|
||||
|
||||
private assertCanWriteRoutine(
|
||||
scope: RoutineScope,
|
||||
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
|
||||
) {
|
||||
if (scope.isManager) {
|
||||
this.assertCanManageRoutine(scope, row);
|
||||
return;
|
||||
}
|
||||
if (row.createdById === scope.userId && row.assigneeId === scope.userId && !row.teamId) return;
|
||||
throw new ForbiddenException('فقط روتینی که برای خودتان ساختهاید را میتوانید تغییر دهید');
|
||||
}
|
||||
|
||||
private assertCanManageRoutine(
|
||||
scope: RoutineScope,
|
||||
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, inject, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import type { ActivityPriority, RoutineDto, RoutineEntityLink, SaveRoutineRequest } from '@mento/shared';
|
||||
import { ROUTINE_ENTITY_LINK_LABELS, ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
|
||||
@@ -18,10 +18,15 @@ import {
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { PRIORITY_LABELS, ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const isManager = computed(() => !!shell.context.value?.isManager);
|
||||
const selfId = computed(() => auth.user?.id ?? '');
|
||||
const backTo = computed(() => (isManager.value ? '/routines/manage' : '/routines/list'));
|
||||
|
||||
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : null));
|
||||
const isEdit = computed(() => !!id.value);
|
||||
@@ -60,6 +65,8 @@ const recurrence = ref<RecurrenceValue>({
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const loaded = ref<RoutineDto | null>(null);
|
||||
const foreign = ref(false);
|
||||
|
||||
const targetOptions = [
|
||||
{ value: 'user' as const, label: 'یک کاربر' },
|
||||
@@ -111,10 +118,14 @@ function fill(r: RoutineDto) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!id.value) return;
|
||||
if (!id.value) {
|
||||
if (selfId.value) form.assigneeId = selfId.value;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get<RoutineDto>(`/routines/${id.value}`);
|
||||
loaded.value = data;
|
||||
fill(data);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'روتین پیدا نشد.');
|
||||
@@ -123,6 +134,23 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => shell.context.value, loaded, selfId],
|
||||
() => {
|
||||
const ctx = shell.context.value;
|
||||
const data = loaded.value;
|
||||
if (!ctx || !data || ctx.isManager) {
|
||||
foreign.value = false;
|
||||
return;
|
||||
}
|
||||
foreign.value = data.createdById !== selfId.value;
|
||||
if (foreign.value) {
|
||||
error.value = 'این روتین را مدیر تعریف کرده و فقط خودش میتواند تغییرش دهد.';
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const users = computed(() => shell.context.value?.users ?? []);
|
||||
const teams = computed(() => shell.context.value?.teams ?? []);
|
||||
const categories = computed(() => shell.context.value?.categories ?? []);
|
||||
@@ -169,6 +197,11 @@ const summary = computed(() => {
|
||||
});
|
||||
|
||||
function validate(): string | null {
|
||||
if (!isManager.value) {
|
||||
form.target = 'user';
|
||||
form.assigneeId = selfId.value;
|
||||
form.teamId = '';
|
||||
}
|
||||
if (form.title.trim().length < 2) return 'عنوان روتین را وارد کنید.';
|
||||
if (form.target === 'user' && !form.assigneeId) return 'مسئول روتین را انتخاب کنید.';
|
||||
if (form.target === 'team' && !form.teamId) return 'تیم را انتخاب کنید.';
|
||||
@@ -220,7 +253,7 @@ async function save() {
|
||||
if (isEdit.value) await api.patch(`/routines/${id.value}`, body);
|
||||
else await api.post('/routines', body);
|
||||
shell.notifyChanged();
|
||||
void router.push('/routines/manage');
|
||||
void router.push(backTo.value);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ذخیره روتین ممکن نشد.');
|
||||
} finally {
|
||||
@@ -234,10 +267,11 @@ async function save() {
|
||||
<UiPageHeader
|
||||
:title="isEdit ? 'ویرایش روتین' : 'روتین جدید'"
|
||||
lead="کاری که باید بهصورت منظم انجام شود؛ هر روزِ تکرار، یک کار برای مسئول ساخته میشود."
|
||||
back-to="/routines/manage"
|
||||
:back-to="backTo"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="state">در حال بارگذاری…</div>
|
||||
<p v-else-if="foreign" class="error">{{ error }}</p>
|
||||
|
||||
<form v-else class="grid" @submit.prevent="save">
|
||||
<div class="col">
|
||||
@@ -268,7 +302,8 @@ async function save() {
|
||||
|
||||
<UiCard>
|
||||
<h3 class="sec-title">مسئول</h3>
|
||||
<div class="fields">
|
||||
<p v-if="!isManager" class="self-note">این روتین فقط برای خودتان ساخته میشود.</p>
|
||||
<div v-else class="fields">
|
||||
<UiSegmented v-model="form.target" :options="targetOptions" />
|
||||
<UiField v-if="form.target === 'user'" label="کاربر" required>
|
||||
<UiSelect v-model="form.assigneeId">
|
||||
@@ -508,6 +543,13 @@ async function save() {
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.self-note {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import type { RoutineDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiBadge, UiEmptyState, UiSearch } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
PRIORITY_LABELS,
|
||||
@@ -16,6 +18,8 @@ import {
|
||||
} from '@/lib/routines';
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
const auth = useAuthStore();
|
||||
const meId = computed(() => auth.user?.id ?? '');
|
||||
|
||||
const items = ref<RoutineDto[]>([]);
|
||||
const loading = ref(true);
|
||||
@@ -48,7 +52,7 @@ const filtered = computed(() => {
|
||||
<template>
|
||||
<div class="rlist">
|
||||
<div class="toolbar">
|
||||
<p class="lead">روتینهایی که به شما یا تیم شما سپرده شدهاند. کارهای هر روز از روی اینها ساخته میشوند.</p>
|
||||
<p class="lead">روتینهای شما. کارهای هر روز از روی اینها ساخته میشوند. با «روتین جدید» میتوانید برای خودتان برنامه تکراری بسازید.</p>
|
||||
<UiSearch v-model="q" placeholder="جستجوی روتین…" class="search" />
|
||||
</div>
|
||||
|
||||
@@ -58,8 +62,8 @@ const filtered = computed(() => {
|
||||
<UiEmptyState
|
||||
v-else-if="!filtered.length"
|
||||
icon="refresh"
|
||||
:title="q ? 'روتینی با این عنوان پیدا نشد' : 'هنوز روتینی برای شما تعریف نشده'"
|
||||
description="روتینها توسط مدیر تیم تعریف میشوند و هر روز بهصورت خودکار در «کارهای امروز» ظاهر میشوند."
|
||||
:title="q ? 'روتینی با این عنوان پیدا نشد' : 'هنوز روتینی برای شما نیست'"
|
||||
description="با دکمه «روتین جدید» یک برنامه تکراری برای خودتان بسازید. روتینی که مدیر برای شما تعریف کرده هم اینجا دیده میشود."
|
||||
/>
|
||||
|
||||
<div v-else class="cards">
|
||||
@@ -90,6 +94,9 @@ const filtered = computed(() => {
|
||||
<small class="since">
|
||||
از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }}
|
||||
</small>
|
||||
<RouterLink v-if="r.createdById === meId" :to="`/routines/manage/${r.id}/edit`" class="edit">
|
||||
ویرایش
|
||||
</RouterLink>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
@@ -239,6 +246,13 @@ const filtered = computed(() => {
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.edit {
|
||||
justify-self: start;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ const tabs: Tab[] = [
|
||||
|
||||
const visibleTabs = computed(() => tabs.filter((t) => !t.manager || isManager.value));
|
||||
|
||||
const managerOnlyRoutes = new Set(['routines-team', 'routines-manage', 'routine-new', 'routine-edit']);
|
||||
const managerOnlyRoutes = new Set(['routines-team', 'routines-manage']);
|
||||
|
||||
watch(
|
||||
[() => route.name, context],
|
||||
@@ -106,7 +106,7 @@ const hideHeader = computed(() => route.name === 'routine-new' || route.name ===
|
||||
<RouterLink to="/routines/settings" class="icon-link" title="تنظیمات یادآوری و اعلانها">
|
||||
<UiIcon name="bell" :size="17" />
|
||||
</RouterLink>
|
||||
<UiButton v-if="isManager" variant="outline" @click="router.push('/routines/manage/new')">
|
||||
<UiButton variant="outline" @click="router.push('/routines/manage/new')">
|
||||
<template #icon><UiIcon name="refresh" :size="16" /></template>
|
||||
روتین جدید
|
||||
</UiButton>
|
||||
|
||||
@@ -6261,6 +6261,8 @@ export interface RoutineDto {
|
||||
entityLink: RoutineEntityLink;
|
||||
reminderMinutesBefore: number | null;
|
||||
isActive: boolean;
|
||||
/** سازندهٔ روتین؛ عضو عادی فقط روتین خودش را ویرایش میکند */
|
||||
createdById: string | null;
|
||||
recurrenceLabel: string;
|
||||
/** درصد اجرا در ۳۰ روز اخیر */
|
||||
executionRate: number | null;
|
||||
|
||||
Reference in New Issue
Block a user