2026-10-06 19:54:38 +03:30
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { computed } from 'vue';
|
|
|
|
|
import type { RoutineCalendarDayDto } from '@mento/shared';
|
|
|
|
|
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
|
|
|
|
|
import { dateToJalali, toFaDigits } from '@/lib/jalali';
|
|
|
|
|
import { DAY_STATE_LABELS, addDaysKey, keyToDate, todayKey, weekdayIndex } from '@/lib/routines';
|
2026-11-03 16:51:51 +03:30
|
|
|
import {
|
|
|
|
|
Button as AButton,
|
|
|
|
|
Flex as AFlex,
|
|
|
|
|
Typography,
|
|
|
|
|
theme,
|
|
|
|
|
} from 'ant-design-vue';
|
|
|
|
|
|
|
|
|
|
const { Text: ATypographyText } = Typography;
|
|
|
|
|
const { token } = theme.useToken();
|
2026-10-06 19:54:38 +03:30
|
|
|
|
|
|
|
|
const props = withDefaults(
|
|
|
|
|
defineProps<{
|
|
|
|
|
from: string;
|
|
|
|
|
to: string;
|
|
|
|
|
days: RoutineCalendarDayDto[];
|
|
|
|
|
selected?: string | null;
|
|
|
|
|
/** heat = رنگ بر اساس درصد (گزارش ماهانه) */
|
|
|
|
|
mode?: 'state' | 'heat';
|
|
|
|
|
compact?: boolean;
|
|
|
|
|
}>(),
|
|
|
|
|
{ selected: null, mode: 'state', compact: false },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{ select: [day: string] }>();
|
|
|
|
|
|
|
|
|
|
const byDate = computed(() => new Map(props.days.map((d) => [d.date, d])));
|
|
|
|
|
const today = todayKey();
|
|
|
|
|
|
|
|
|
|
const cells = computed(() => {
|
|
|
|
|
const out: Array<{ key: string; blank: boolean; day?: RoutineCalendarDayDto; jd: number; future: boolean }> = [];
|
|
|
|
|
const lead = weekdayIndex(props.from);
|
|
|
|
|
for (let i = 0; i < lead; i++) out.push({ key: `b${i}`, blank: true, jd: 0, future: false });
|
|
|
|
|
for (let k = props.from; k <= props.to; k = addDaysKey(k, 1)) {
|
|
|
|
|
out.push({
|
|
|
|
|
key: k,
|
|
|
|
|
blank: false,
|
|
|
|
|
day: byDate.value.get(k),
|
|
|
|
|
jd: dateToJalali(keyToDate(k)).jd,
|
|
|
|
|
future: k > today,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function heatLevel(d?: RoutineCalendarDayDto): number {
|
|
|
|
|
if (!d || !d.total || d.percent == null) return 0;
|
|
|
|
|
if (d.percent >= 90) return 4;
|
|
|
|
|
if (d.percent >= 70) return 3;
|
|
|
|
|
if (d.percent >= 40) return 2;
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
const STATE_BG: Record<string, string> = {
|
|
|
|
|
complete: 'var(--ant-color-success-bg)',
|
|
|
|
|
partial: 'var(--ant-color-warning-bg)',
|
|
|
|
|
missed: 'var(--ant-color-error-bg)',
|
|
|
|
|
empty: 'var(--ant-color-bg-container)',
|
|
|
|
|
future: 'var(--ant-color-fill-quaternary)',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const STATE_DOT: Record<string, string> = {
|
|
|
|
|
complete: 'var(--ant-color-success)',
|
|
|
|
|
partial: 'var(--ant-color-warning)',
|
|
|
|
|
missed: 'var(--ant-color-error)',
|
|
|
|
|
empty: 'var(--ant-color-text-quaternary)',
|
|
|
|
|
future: 'var(--ant-color-text-quaternary)',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const HEAT_BG: Record<number, string> = {
|
|
|
|
|
0: 'var(--ant-color-fill-quaternary)',
|
|
|
|
|
1: 'var(--ant-color-info-bg)',
|
|
|
|
|
2: 'var(--ant-color-primary-bg)',
|
|
|
|
|
3: 'var(--ant-color-success-bg)',
|
|
|
|
|
4: 'var(--ant-color-success)',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function cellKey(c: (typeof cells.value)[number]) {
|
|
|
|
|
return c.future && !c.day?.total ? 'future' : (c.day?.state ?? 'empty');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cellBackground(c: (typeof cells.value)[number]) {
|
|
|
|
|
if (props.mode === 'heat') return HEAT_BG[heatLevel(c.day)];
|
|
|
|
|
return STATE_BG[cellKey(c)] ?? STATE_BG.empty;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 19:54:38 +03:30
|
|
|
function title(c: (typeof cells.value)[number]) {
|
|
|
|
|
const d = c.day;
|
|
|
|
|
if (!d || !d.total) return 'بدون کار';
|
|
|
|
|
return `${DAY_STATE_LABELS[d.state]} — ${toFaDigits(d.completed)} از ${toFaDigits(d.total)} انجام شده`;
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-11-03 16:51:51 +03:30
|
|
|
<a-flex vertical :gap="6">
|
|
|
|
|
<a-flex :gap="6">
|
|
|
|
|
<a-typography-text
|
|
|
|
|
v-for="w in ROUTINE_WEEKDAY_LABELS"
|
|
|
|
|
:key="w"
|
|
|
|
|
type="secondary"
|
|
|
|
|
strong
|
|
|
|
|
:style="{ flex: '1 1 0', minWidth: 0, textAlign: 'center' }"
|
2026-10-06 19:54:38 +03:30
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
{{ compact ? w.charAt(0) : w }}
|
|
|
|
|
</a-typography-text>
|
|
|
|
|
</a-flex>
|
|
|
|
|
<a-flex wrap="wrap" :gap="6">
|
|
|
|
|
<template v-for="c in cells" :key="c.key">
|
|
|
|
|
<a-flex
|
|
|
|
|
v-if="c.blank"
|
|
|
|
|
:style="{ flex: '0 0 calc((100% - 36px) / 7)', minWidth: 0 }"
|
|
|
|
|
/>
|
|
|
|
|
<a-button
|
|
|
|
|
v-else
|
|
|
|
|
:title="title(c)"
|
|
|
|
|
:style="{
|
|
|
|
|
flex: '0 0 calc((100% - 36px) / 7)',
|
|
|
|
|
minWidth: 0,
|
|
|
|
|
height: compact ? '44px' : '74px',
|
|
|
|
|
padding: '8px',
|
|
|
|
|
background: cellBackground(c),
|
|
|
|
|
borderColor:
|
|
|
|
|
selected === c.key
|
|
|
|
|
? token.colorPrimary
|
|
|
|
|
: c.key === today
|
|
|
|
|
? token.colorPrimaryBorder
|
|
|
|
|
: undefined,
|
|
|
|
|
boxShadow: selected === c.key ? `0 0 0 1px ${token.colorPrimary}` : undefined,
|
|
|
|
|
}"
|
|
|
|
|
@click="emit('select', c.key)"
|
|
|
|
|
>
|
|
|
|
|
<a-flex
|
|
|
|
|
vertical
|
|
|
|
|
align="start"
|
|
|
|
|
justify="space-between"
|
|
|
|
|
:style="{ width: '100%', height: '100%' }"
|
|
|
|
|
>
|
|
|
|
|
<a-typography-text
|
|
|
|
|
strong
|
|
|
|
|
:style="c.key === today ? { color: 'var(--ant-color-primary)' } : undefined"
|
|
|
|
|
>
|
|
|
|
|
{{ toFaDigits(c.jd) }}
|
|
|
|
|
</a-typography-text>
|
|
|
|
|
<a-flex
|
|
|
|
|
v-if="!compact && c.day?.total"
|
|
|
|
|
justify="space-between"
|
|
|
|
|
align="center"
|
|
|
|
|
:style="{ width: '100%' }"
|
|
|
|
|
>
|
|
|
|
|
<a-typography-text type="secondary">
|
|
|
|
|
{{ toFaDigits(c.day.completed) }}/{{ toFaDigits(c.day.total) }}
|
|
|
|
|
</a-typography-text>
|
|
|
|
|
<a-flex
|
|
|
|
|
v-if="mode === 'state'"
|
|
|
|
|
:style="{
|
|
|
|
|
width: '8px',
|
|
|
|
|
height: '8px',
|
|
|
|
|
borderRadius: token.borderRadiusLG,
|
|
|
|
|
background: STATE_DOT[cellKey(c)],
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
}"
|
|
|
|
|
/>
|
|
|
|
|
</a-flex>
|
|
|
|
|
</a-flex>
|
|
|
|
|
</a-button>
|
|
|
|
|
</template>
|
|
|
|
|
</a-flex>
|
|
|
|
|
</a-flex>
|
2026-10-06 19:54:38 +03:30
|
|
|
</template>
|