feat: routines module; super admin subscription badge, 7-day trial and lifetime plan
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
import type {
|
||||
RoutineCalendarDayDto,
|
||||
RoutineCategoryStatDto,
|
||||
RoutineMemberStatDto,
|
||||
RoutineStatusBreakdownDto,
|
||||
RoutineTrendPointDto,
|
||||
} from '@mento/shared';
|
||||
import type { UiChartOption } from '@/components/ui';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { formatDayNumeric } from '@/lib/routines';
|
||||
|
||||
const FONT = 'IRANSans, Tahoma, sans-serif';
|
||||
const AXIS_LABEL = { color: '#64748b', fontFamily: FONT, fontSize: 11 };
|
||||
const SPLIT = { lineStyle: { color: '#f1f5f9', type: 'dashed' as const } };
|
||||
const LEGEND = { bottom: 0, left: 'center', textStyle: { fontFamily: FONT, fontSize: 11, color: '#475569' } };
|
||||
|
||||
export const STATUS_COLORS = {
|
||||
completed: '#10b981',
|
||||
notDone: '#94a3b8',
|
||||
overdue: '#ef4444',
|
||||
skipped: '#f59e0b',
|
||||
cancelled: '#cbd5e1',
|
||||
};
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/** روند درصد انجام روزانه */
|
||||
export function trendOption(trend: RoutineTrendPointDto[]): UiChartOption {
|
||||
return {
|
||||
textStyle: { fontFamily: FONT },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: any) => {
|
||||
const item = Array.isArray(params) ? params[0] : params;
|
||||
const p = trend[item?.dataIndex ?? -1];
|
||||
if (!p) return '';
|
||||
return `${formatDayNumeric(p.date)}<br/>درصد انجام: <strong>${p.percent == null ? '—' : `${toFaDigits(p.percent)}٪`}</strong><br/>${toFaDigits(p.completed)} از ${toFaDigits(p.total)}`;
|
||||
},
|
||||
},
|
||||
grid: { left: 8, right: 16, top: 16, bottom: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: trend.map((p) => formatDayNumeric(p.date)),
|
||||
axisLine: { lineStyle: { color: '#cbd5e1' } },
|
||||
axisLabel: { ...AXIS_LABEL, hideOverlap: true },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
splitLine: SPLIT,
|
||||
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => `${toFaDigits(v)}٪` },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'درصد انجام',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
connectNulls: true,
|
||||
showSymbol: false,
|
||||
itemStyle: { color: '#14b8a6' },
|
||||
lineStyle: { width: 3, color: '#0d9488' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(20, 184, 166, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(20, 184, 166, 0.01)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
data: trend.map((p) => p.percent),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** دونات وضعیت کارها */
|
||||
export function statusDonut(s: RoutineStatusBreakdownDto): UiChartOption {
|
||||
const rows = [
|
||||
{ name: 'انجام شده', value: s.completed, color: STATUS_COLORS.completed },
|
||||
{ name: 'انجام نشده', value: s.notDone, color: STATUS_COLORS.notDone },
|
||||
{ name: 'عقبافتاده', value: s.overdue, color: STATUS_COLORS.overdue },
|
||||
{ name: 'رد شده', value: s.skipped, color: STATUS_COLORS.skipped },
|
||||
].filter((r) => r.value > 0);
|
||||
return {
|
||||
textStyle: { fontFamily: FONT },
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (p: any) => `${p.name}: <strong>${toFaDigits(p.value)}</strong> (${toFaDigits(p.percent)}٪)`,
|
||||
},
|
||||
legend: LEGEND,
|
||||
series: [
|
||||
{
|
||||
name: 'وضعیت',
|
||||
type: 'pie',
|
||||
radius: ['48%', '72%'],
|
||||
center: ['50%', '44%'],
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: false },
|
||||
data: rows.map((r) => ({ name: r.name, value: r.value, itemStyle: { color: r.color } })),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** میلهای انجام به تفکیک دسته */
|
||||
export function categoryBar(rows: RoutineCategoryStatDto[]): UiChartOption {
|
||||
return {
|
||||
textStyle: { fontFamily: FONT },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: any) => {
|
||||
const item = Array.isArray(params) ? params[0] : params;
|
||||
const r = rows[item?.dataIndex ?? -1];
|
||||
if (!r) return '';
|
||||
return `${r.name}<br/>${toFaDigits(r.completed)} از ${toFaDigits(r.total)} · <strong>${r.percent == null ? '—' : `${toFaDigits(r.percent)}٪`}</strong>`;
|
||||
},
|
||||
},
|
||||
grid: { left: 8, right: 16, top: 16, bottom: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rows.map((r) => r.name),
|
||||
axisLine: { lineStyle: { color: '#cbd5e1' } },
|
||||
axisLabel: { ...AXIS_LABEL, interval: 0 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
splitLine: SPLIT,
|
||||
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => `${toFaDigits(v)}٪` },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'درصد انجام',
|
||||
type: 'bar',
|
||||
barMaxWidth: 36,
|
||||
data: rows.map((r) => ({
|
||||
value: r.percent ?? 0,
|
||||
itemStyle: { color: r.color, borderRadius: [6, 6, 0, 0] },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** میلهای انجامشده/کل به تفکیک روز */
|
||||
export function daysBar(days: RoutineCalendarDayDto[], labels?: string[]): UiChartOption {
|
||||
return {
|
||||
textStyle: { fontFamily: FONT },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: any) => {
|
||||
const list = Array.isArray(params) ? params : [params];
|
||||
const d = days[list[0]?.dataIndex ?? -1];
|
||||
if (!d) return '';
|
||||
return `${formatDayNumeric(d.date)}<br/>انجام شده: <strong>${toFaDigits(d.completed)}</strong> از ${toFaDigits(d.total)}`;
|
||||
},
|
||||
},
|
||||
legend: LEGEND,
|
||||
grid: { left: 8, right: 16, top: 16, bottom: 32, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels ?? days.map((d) => formatDayNumeric(d.date)),
|
||||
axisLine: { lineStyle: { color: '#cbd5e1' } },
|
||||
axisLabel: { ...AXIS_LABEL, hideOverlap: true },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
splitLine: SPLIT,
|
||||
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => toFaDigits(v) },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'انجام شده',
|
||||
type: 'bar',
|
||||
stack: 'all',
|
||||
barMaxWidth: 28,
|
||||
itemStyle: { color: STATUS_COLORS.completed },
|
||||
data: days.map((d) => d.completed),
|
||||
},
|
||||
{
|
||||
name: 'باقیمانده',
|
||||
type: 'bar',
|
||||
stack: 'all',
|
||||
barMaxWidth: 28,
|
||||
itemStyle: { color: '#e2e8f0', borderRadius: [6, 6, 0, 0] },
|
||||
data: days.map((d) => Math.max(0, d.total - d.completed)),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** میلهای افقی مقایسه اعضا */
|
||||
export function membersBar(members: RoutineMemberStatDto[]): UiChartOption {
|
||||
const rows = [...members].filter((m) => m.total > 0).sort((a, b) => (a.percent ?? 0) - (b.percent ?? 0));
|
||||
return {
|
||||
textStyle: { fontFamily: FONT },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: any) => {
|
||||
const item = Array.isArray(params) ? params[0] : params;
|
||||
const m = rows[item?.dataIndex ?? -1];
|
||||
if (!m) return '';
|
||||
return `${m.user.fullName}<br/>${toFaDigits(m.completed)} از ${toFaDigits(m.total)} · <strong>${m.percent == null ? '—' : `${toFaDigits(m.percent)}٪`}</strong>`;
|
||||
},
|
||||
},
|
||||
grid: { left: 8, right: 24, top: 8, bottom: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
splitLine: SPLIT,
|
||||
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => `${toFaDigits(v)}٪` },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: rows.map((m) => m.user.fullName),
|
||||
axisLabel: AXIS_LABEL,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'درصد انجام',
|
||||
type: 'bar',
|
||||
barMaxWidth: 18,
|
||||
data: rows.map((m) => ({
|
||||
value: m.percent ?? 0,
|
||||
itemStyle: {
|
||||
borderRadius: [0, 6, 6, 0],
|
||||
color: (m.percent ?? 0) >= 85 ? '#10b981' : (m.percent ?? 0) >= 60 ? '#f59e0b' : '#ef4444',
|
||||
},
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import axios from 'axios';
|
||||
import type { InjectionKey, Ref } from 'vue';
|
||||
import type {
|
||||
ActivityPriority,
|
||||
RoutineContextDto,
|
||||
RoutineDayState,
|
||||
RoutineEntityRefDto,
|
||||
RoutineTaskDto,
|
||||
RoutineTaskStatus,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
addJalaliMonths,
|
||||
dateToJalali,
|
||||
formatJalaliLong,
|
||||
formatJalaliShort,
|
||||
jalaliMonthLength,
|
||||
jalaliMonthName,
|
||||
jalaliToDate,
|
||||
toFaDigits,
|
||||
} from '@/lib/jalali';
|
||||
|
||||
export {
|
||||
ROUTINE_ENTITY_LINK_LABELS,
|
||||
ROUTINE_RECURRENCE_LABELS,
|
||||
ROUTINE_TASK_SOURCE_LABELS,
|
||||
ROUTINE_TASK_STATUS_LABELS,
|
||||
ROUTINE_WEEKDAY_LABELS,
|
||||
} from '@mento/shared';
|
||||
|
||||
type BadgeVariant = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
|
||||
|
||||
export const PRIORITY_LABELS: Record<ActivityPriority, string> = {
|
||||
very_low: 'خیلی کم',
|
||||
low: 'کم',
|
||||
medium: 'متوسط',
|
||||
high: 'زیاد',
|
||||
very_high: 'خیلی زیاد',
|
||||
};
|
||||
|
||||
export const PRIORITY_VARIANTS: Record<ActivityPriority, BadgeVariant> = {
|
||||
very_low: 'neutral',
|
||||
low: 'neutral',
|
||||
medium: 'info',
|
||||
high: 'warning',
|
||||
very_high: 'danger',
|
||||
};
|
||||
|
||||
export const STATUS_VARIANTS: Record<RoutineTaskStatus, BadgeVariant> = {
|
||||
todo: 'neutral',
|
||||
in_progress: 'info',
|
||||
completed: 'success',
|
||||
overdue: 'danger',
|
||||
skipped: 'warning',
|
||||
cancelled: 'neutral',
|
||||
};
|
||||
|
||||
export const DAY_STATE_LABELS: Record<RoutineDayState, string> = {
|
||||
complete: 'روز کامل',
|
||||
partial: 'روز ناقص',
|
||||
missed: 'روز از دست رفته',
|
||||
empty: 'بدون کار',
|
||||
};
|
||||
|
||||
export const DAY_STATE_SYMBOLS: Record<RoutineDayState, string> = {
|
||||
complete: '✓',
|
||||
partial: '△',
|
||||
missed: '×',
|
||||
empty: '·',
|
||||
};
|
||||
|
||||
/** زمینه مشترک بخش کارهای روتین (کاربران، دستهها، نقش مدیر، رفرش) */
|
||||
export type RoutinesShellContext = {
|
||||
context: Ref<RoutineContextDto | null>;
|
||||
refreshTick: Ref<number>;
|
||||
notifyChanged: () => void;
|
||||
openTask: (id: string) => void;
|
||||
};
|
||||
|
||||
export const ROUTINES_CTX: InjectionKey<RoutinesShellContext> = Symbol('routines-ctx');
|
||||
|
||||
/* ───────── تاریخها (کلید روز YYYY-MM-DD به وقت تهران) ───────── */
|
||||
|
||||
const TEHRAN_DAY = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
const TEHRAN_TIME = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
|
||||
export function todayKey(): string {
|
||||
return TEHRAN_DAY.format(new Date());
|
||||
}
|
||||
|
||||
export function dayKeyOf(iso: string | Date): string {
|
||||
return TEHRAN_DAY.format(typeof iso === 'string' ? new Date(iso) : iso);
|
||||
}
|
||||
|
||||
/** Date محلی (نیمهشب) برای نمایش شمسی */
|
||||
export function keyToDate(key: string): Date {
|
||||
const [y, m, d] = key.split('-').map(Number);
|
||||
return new Date(y!, m! - 1, d!);
|
||||
}
|
||||
|
||||
export function dateToKey(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
export function addDaysKey(key: string, n: number): string {
|
||||
const d = keyToDate(key);
|
||||
d.setDate(d.getDate() + n);
|
||||
return dateToKey(d);
|
||||
}
|
||||
|
||||
/** ۰ = شنبه */
|
||||
export function weekdayIndex(key: string): number {
|
||||
return (keyToDate(key).getDay() + 1) % 7;
|
||||
}
|
||||
|
||||
export function weekStart(key: string): string {
|
||||
return addDaysKey(key, -weekdayIndex(key));
|
||||
}
|
||||
|
||||
export function jalaliMonthRange(key: string): { from: string; to: string; label: string } {
|
||||
const { jy, jm } = dateToJalali(keyToDate(key));
|
||||
const from = dateToKey(jalaliToDate(jy, jm, 1));
|
||||
const to = dateToKey(jalaliToDate(jy, jm, jalaliMonthLength(jy, jm)));
|
||||
return { from, to, label: `${jalaliMonthName(jm)} ${toFaDigits(jy)}` };
|
||||
}
|
||||
|
||||
export function shiftJalaliMonth(key: string, delta: number): string {
|
||||
const { jy, jm } = dateToJalali(keyToDate(key));
|
||||
const next = addJalaliMonths(jy, jm, delta);
|
||||
return dateToKey(jalaliToDate(next.jy, next.jm, 1));
|
||||
}
|
||||
|
||||
export function formatDayLong(key: string): string {
|
||||
return formatJalaliLong(keyToDate(key));
|
||||
}
|
||||
|
||||
export function formatDayShort(key: string): string {
|
||||
return formatJalaliShort(keyToDate(key));
|
||||
}
|
||||
|
||||
export function formatDayNumeric(key: string): string {
|
||||
const { jm, jd } = dateToJalali(keyToDate(key));
|
||||
return `${toFaDigits(jd)} ${jalaliMonthName(jm)}`;
|
||||
}
|
||||
|
||||
export function formatTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return toFaDigits(TEHRAN_TIME.format(new Date(iso)));
|
||||
}
|
||||
|
||||
export function timeOf(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
return TEHRAN_TIME.format(new Date(iso));
|
||||
}
|
||||
|
||||
/** زمان پایان روز بدون ساعت مشخص (۲۳:۵۹) در UI نمایش داده نمیشود */
|
||||
export function hasExplicitTime(task: Pick<RoutineTaskDto, 'dueAt'>): boolean {
|
||||
return !!task.dueAt && timeOf(task.dueAt) !== '23:59';
|
||||
}
|
||||
|
||||
export function formatDelay(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
if (diff <= 0) return '—';
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 60) return `${toFaDigits(minutes)} دقیقه`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${toFaDigits(hours)} ساعت`;
|
||||
return `${toFaDigits(Math.floor(hours / 24))} روز`;
|
||||
}
|
||||
|
||||
export function formatMinutes(min: number | null | undefined): string {
|
||||
if (min == null) return '—';
|
||||
if (min < 60) return `${toFaDigits(min)} دقیقه`;
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
return m ? `${toFaDigits(h)} ساعت و ${toFaDigits(m)} دقیقه` : `${toFaDigits(h)} ساعت`;
|
||||
}
|
||||
|
||||
export function formatPercent(p: number | null | undefined): string {
|
||||
return p == null ? '—' : `${toFaDigits(p)}٪`;
|
||||
}
|
||||
|
||||
export function percentTone(p: number | null | undefined): 'good' | 'mid' | 'low' | 'none' {
|
||||
if (p == null) return 'none';
|
||||
if (p >= 85) return 'good';
|
||||
if (p >= 60) return 'mid';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
export function isoToLocalInput(iso: string | null | undefined): { date: string; time: string } {
|
||||
if (!iso) return { date: '', time: '' };
|
||||
return { date: dayKeyOf(iso), time: timeOf(iso) };
|
||||
}
|
||||
|
||||
/** ورودی تاریخ/ساعت همیشه به وقت تهران تفسیر میشود */
|
||||
export function localInputToIso(date: string, time: string): string | null {
|
||||
if (!date) return null;
|
||||
const d = new Date(`${date}T${time || '23:59'}:00+03:30`);
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||
}
|
||||
|
||||
/* ───────── پیوند پروندهها ───────── */
|
||||
|
||||
export const ENTITY_TYPE_LABELS: Record<RoutineEntityRefDto['type'], string> = {
|
||||
lead: 'لید',
|
||||
person: 'مشتری',
|
||||
company: 'شرکت',
|
||||
deal: 'معامله',
|
||||
invoice: 'فاکتور',
|
||||
activity: 'پیگیری',
|
||||
};
|
||||
|
||||
export function entityRoute(ref: RoutineEntityRefDto): string {
|
||||
switch (ref.type) {
|
||||
case 'lead':
|
||||
return `/leads/${ref.id}`;
|
||||
case 'person':
|
||||
return `/contacts/people/${ref.id}`;
|
||||
case 'company':
|
||||
return `/contacts/companies/${ref.id}`;
|
||||
case 'deal':
|
||||
return `/deals/${ref.id}`;
|
||||
case 'invoice':
|
||||
return `/invoices/${ref.id}`;
|
||||
case 'activity':
|
||||
return `/activities/${ref.id}`;
|
||||
default:
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
|
||||
export function apiError(e: unknown, fallback: string): string {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
if (Array.isArray(msg)) return msg.join('، ');
|
||||
if (msg) return msg;
|
||||
if (!e.response) return 'اتصال به سرور برقرار نشد.';
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function isOpenStatus(status: RoutineTaskStatus): boolean {
|
||||
return status === 'todo' || status === 'in_progress' || status === 'overdue';
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { reactive, ref } from 'vue';
|
||||
import type { RoutineTaskDto } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { apiError, isoToLocalInput, localInputToIso, todayKey } from '@/lib/routines';
|
||||
|
||||
export type RoutineTaskModalKind = 'complete' | 'skip' | 'reschedule' | 'cancel';
|
||||
|
||||
/**
|
||||
* اکشنهای مشترک روی یک Task (تیک، رد، جابهجایی، لغو) + وضعیت مودالها.
|
||||
* `onChanged` بعد از هر تغییر موفق با Task بهروزشده صدا زده میشود.
|
||||
*/
|
||||
export function useRoutineTaskActions(onChanged: (task: RoutineTaskDto) => void) {
|
||||
const busyId = ref<string | null>(null);
|
||||
const error = ref('');
|
||||
|
||||
const modal = reactive({
|
||||
kind: null as RoutineTaskModalKind | null,
|
||||
task: null as RoutineTaskDto | null,
|
||||
note: '',
|
||||
date: '',
|
||||
time: '',
|
||||
saving: false,
|
||||
error: '',
|
||||
});
|
||||
|
||||
async function run(task: RoutineTaskDto, path: string, body: object = {}) {
|
||||
busyId.value = task.id;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.post<RoutineTaskDto>(`/routine-tasks/${task.id}/${path}`, body);
|
||||
onChanged(data);
|
||||
return data;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'انجام عملیات ممکن نشد.');
|
||||
return null;
|
||||
} finally {
|
||||
busyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function open(kind: RoutineTaskModalKind, task: RoutineTaskDto) {
|
||||
const due = isoToLocalInput(task.dueAt);
|
||||
Object.assign(modal, {
|
||||
kind,
|
||||
task,
|
||||
note: kind === 'complete' ? (task.resultNote ?? '') : '',
|
||||
date: kind === 'reschedule' ? due.date || todayKey() : '',
|
||||
time: kind === 'reschedule' ? (due.time === '23:59' ? '' : due.time) : '',
|
||||
saving: false,
|
||||
error: '',
|
||||
});
|
||||
}
|
||||
|
||||
function close() {
|
||||
modal.kind = null;
|
||||
modal.task = null;
|
||||
}
|
||||
|
||||
/** تیک یککلیکی؛ اگر گزارش اجباری باشد مودال نتیجه باز میشود */
|
||||
async function toggle(task: RoutineTaskDto) {
|
||||
if (busyId.value) return;
|
||||
if (task.status === 'completed') return run(task, 'uncomplete');
|
||||
if (task.status === 'cancelled' || task.status === 'skipped') return null;
|
||||
if (task.rules.reportRequired) {
|
||||
open('complete', task);
|
||||
return null;
|
||||
}
|
||||
return run(task, 'complete');
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const task = modal.task;
|
||||
if (!task || !modal.kind) return;
|
||||
modal.error = '';
|
||||
let path = '';
|
||||
let body: object = {};
|
||||
if (modal.kind === 'complete') {
|
||||
if (task.rules.reportRequired && !modal.note.trim()) {
|
||||
modal.error = 'ثبت گزارش برای این کار الزامی است.';
|
||||
return;
|
||||
}
|
||||
path = 'complete';
|
||||
body = { note: modal.note.trim() || null };
|
||||
} else if (modal.kind === 'skip') {
|
||||
if (task.rules.skipReasonRequired && !modal.note.trim()) {
|
||||
modal.error = 'ثبت دلیل رد کردن الزامی است.';
|
||||
return;
|
||||
}
|
||||
path = 'skip';
|
||||
body = { reason: modal.note.trim() || null };
|
||||
} else if (modal.kind === 'cancel') {
|
||||
path = 'cancel';
|
||||
body = { reason: modal.note.trim() || null };
|
||||
} else {
|
||||
const dueAt = localInputToIso(modal.date, modal.time);
|
||||
if (!dueAt) {
|
||||
modal.error = 'تاریخ جدید را انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
path = 'reschedule';
|
||||
body = { dueAt };
|
||||
}
|
||||
modal.saving = true;
|
||||
try {
|
||||
const { data } = await api.post<RoutineTaskDto>(`/routine-tasks/${task.id}/${path}`, body);
|
||||
onChanged(data);
|
||||
close();
|
||||
} catch (e) {
|
||||
modal.error = apiError(e, 'ذخیره ممکن نشد.');
|
||||
} finally {
|
||||
modal.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
busyId,
|
||||
error,
|
||||
modal,
|
||||
toggle,
|
||||
start: (task: RoutineTaskDto) => run(task, 'start'),
|
||||
moveToday: (task: RoutineTaskDto) => run(task, 'move-today'),
|
||||
openComplete: (task: RoutineTaskDto) => open('complete', task),
|
||||
openSkip: (task: RoutineTaskDto) => open('skip', task),
|
||||
openReschedule: (task: RoutineTaskDto) => open('reschedule', task),
|
||||
openCancel: (task: RoutineTaskDto) => open('cancel', task),
|
||||
close,
|
||||
submit,
|
||||
};
|
||||
}
|
||||
|
||||
export type RoutineTaskActions = ReturnType<typeof useRoutineTaskActions>;
|
||||
Reference in New Issue
Block a user