Files
CRM_Montzar/apps/web/src/components/routines/RoutineMonthGrid.vue
T

266 lines
5.5 KiB
Vue

<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';
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;
}
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>
<div class="mgrid" :class="[mode, { compact }]">
<div v-for="w in ROUTINE_WEEKDAY_LABELS" :key="w" class="wd">{{ compact ? w.charAt(0) : w }}</div>
<template v-for="c in cells" :key="c.key">
<span v-if="c.blank" class="cell blank" />
<button
v-else
type="button"
class="cell"
:class="[
mode === 'state' ? `s-${c.future && !c.day?.total ? 'future' : (c.day?.state ?? 'empty')}` : `h-${heatLevel(c.day)}`,
{ sel: selected === c.key, today: c.key === today },
]"
:title="title(c)"
@click="emit('select', c.key)"
>
<span class="num">{{ toFaDigits(c.jd) }}</span>
<template v-if="!compact && c.day?.total">
<span class="frac">{{ toFaDigits(c.day.completed) }}/{{ toFaDigits(c.day.total) }}</span>
<i v-if="mode === 'state'" class="dot" />
</template>
</button>
</template>
</div>
</template>
<style scoped>
.mgrid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
}
.wd {
text-align: center;
font-size: var(--fs-xs);
color: var(--muted);
font-weight: var(--fw-semibold);
padding-bottom: 2px;
}
.cell {
position: relative;
min-height: 74px;
padding: 8px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
gap: 4px;
cursor: pointer;
font: inherit;
text-align: right;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.cell.blank {
border: 0;
background: transparent;
cursor: default;
}
.cell:not(.blank):hover {
border-color: var(--line-strong);
}
.cell.sel {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.cell.today .num {
background: var(--accent);
color: #fff;
}
.num {
min-width: 24px;
height: 24px;
padding: 0 4px;
border-radius: 99px;
display: inline-grid;
place-items: center;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.frac {
font-size: var(--fs-xs);
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.dot {
position: absolute;
top: 10px;
inset-inline-end: 10px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--line-strong);
}
/* وضعیت روز */
.s-complete {
background: color-mix(in srgb, var(--success-soft) 45%, var(--surface));
}
.s-complete .dot {
background: var(--success);
}
.s-partial {
background: color-mix(in srgb, var(--warning-soft) 45%, var(--surface));
}
.s-partial .dot {
background: var(--warning);
}
.s-missed {
background: color-mix(in srgb, var(--danger-soft) 45%, var(--surface));
}
.s-missed .dot {
background: var(--danger);
}
.s-future {
background: var(--surface);
}
.s-empty {
background: var(--surface);
}
/* Heatmap */
.h-0 {
background: var(--surface-subtle);
}
.h-1 {
background: color-mix(in srgb, var(--accent) 14%, white);
}
.h-2 {
background: color-mix(in srgb, var(--accent) 32%, white);
}
.h-3 {
background: color-mix(in srgb, var(--accent) 55%, white);
}
.h-4 {
background: color-mix(in srgb, var(--accent) 80%, white);
}
.h-3 .num,
.h-4 .num,
.h-3 .frac,
.h-4 .frac {
color: #fff;
}
.compact {
gap: 4px;
}
.compact .cell {
min-height: 0;
aspect-ratio: 1;
padding: 2px;
align-items: center;
justify-content: center;
border-color: transparent;
}
.compact .num {
font-size: 0.7rem;
min-width: 0;
height: auto;
}
@media (max-width: 700px) {
.mgrid {
gap: 3px;
}
.cell {
min-height: 54px;
padding: 4px;
align-items: center;
}
.frac {
font-size: 0.62rem;
}
.dot {
position: static;
width: 7px;
height: 7px;
}
}
</style>