Files
CRM_Montzar/apps/web/src/components/dashboard/HomeBirthdayStrip.vue
T
MahdiandCursor 13ee70764b feat: show this month's birthdays and send a greeting SMS
Each owner sees their customers born this Jalali month and can text a custom greeting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 19:55:11 +03:30

301 lines
6.8 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import { UiButton, UiModal, UiTextarea } from '@/components/ui';
type BirthdayItem = {
id: string;
firstName: string;
lastName: string;
fullName: string;
mobile: string | null;
day: number;
};
type BirthdayMonth = {
monthName: string;
today: number;
total: number;
preview: BirthdayItem[];
items: BirthdayItem[];
};
const data = ref<BirthdayMonth | null>(null);
const listOpen = ref(false);
const smsTarget = ref<BirthdayItem | null>(null);
const smsText = ref('');
const smsBusy = ref(false);
const smsError = ref('');
const smsDone = ref('');
const groups = computed(() => {
const map = new Map<number, BirthdayItem[]>();
for (const item of data.value?.items ?? []) {
const list = map.get(item.day) ?? [];
list.push(item);
map.set(item.day, list);
}
return [...map.entries()].sort((a, b) => a[0] - b[0]);
});
function apiMessage(e: unknown, fallback: string) {
if (!axios.isAxiosError(e)) return fallback;
const msg = (e.response?.data as { message?: string | string[] })?.message;
if (Array.isArray(msg)) return msg.join('، ') || fallback;
if (typeof msg === 'string' && msg.trim()) return msg;
return fallback;
}
function openSms(person: BirthdayItem) {
smsTarget.value = person;
smsText.value = `${person.fullName} عزیز، تولدتان مبارک.`;
smsError.value = '';
smsDone.value = '';
}
async function sendSms() {
const person = smsTarget.value;
if (!person || smsBusy.value) return;
smsBusy.value = true;
smsError.value = '';
try {
await api.post(`/people/${person.id}/birthday-sms`, { text: smsText.value });
smsDone.value = `پیامک برای ${person.fullName} ارسال شد.`;
} catch (e) {
smsError.value = apiMessage(e, 'ارسال پیامک ناموفق بود.');
} finally {
smsBusy.value = false;
}
}
onMounted(async () => {
try {
const { data: body } = await api.get<BirthdayMonth>('/people/birthdays');
data.value = body.total > 0 ? body : null;
} catch {
data.value = null;
}
});
</script>
<template>
<section v-if="data" class="birthdays">
<div class="head">
<span class="ico"><UiIcon name="gift" :size="18" /></span>
<div>
<strong>تولدهای {{ data.monthName }}</strong>
<p>{{ toFaDigits(data.total) }} مشتری این ماه تولد دارند</p>
</div>
</div>
<div class="chips">
<RouterLink
v-for="person in data.preview"
:key="person.id"
class="chip"
:to="{ name: 'person-detail', params: { id: person.id } }"
>
<b>{{ toFaDigits(person.day) }} {{ data.monthName }}</b>
<span>{{ person.fullName }}</span>
</RouterLink>
<button v-if="data.total > data.preview.length" type="button" class="more" @click="listOpen = true">
همه
</button>
<button v-else type="button" class="more" @click="listOpen = true">فهرست و پیامک</button>
</div>
<UiModal :open="listOpen" :title="`تولدهای ${data.monthName}`" @close="listOpen = false">
<div class="days">
<section v-for="[day, people] in groups" :key="day" class="day">
<h3>
{{ toFaDigits(day) }} {{ data.monthName }}
<em v-if="day < data.today">گذشته</em>
<em v-else-if="day === data.today">امروز</em>
</h3>
<ul>
<li v-for="person in people" :key="person.id">
<RouterLink :to="{ name: 'person-detail', params: { id: person.id } }" @click="listOpen = false">
{{ person.fullName }}
</RouterLink>
<UiButton
size="sm"
variant="outline"
:disabled="!person.mobile"
@click="openSms(person)"
>
{{ person.mobile ? 'پیامک تبریک' : 'بدون شماره' }}
</UiButton>
</li>
</ul>
</section>
</div>
</UiModal>
<UiModal
:open="!!smsTarget"
nest
title="پیامک تبریک تولد"
@close="smsTarget = null"
>
<p v-if="smsTarget" class="lead">برای {{ smsTarget.fullName }}</p>
<UiTextarea v-model="smsText" :rows="4" />
<p class="hint">می‌توانید متن را عوض کنید. عبارت {نام} با نام مشتری جایگزین می‌شود.</p>
<p v-if="smsError" class="err">{{ smsError }}</p>
<p v-if="smsDone" class="ok">{{ smsDone }}</p>
<template #footer>
<UiButton variant="ghost" :disabled="smsBusy" @click="smsTarget = null">بستن</UiButton>
<UiButton variant="primary" :loading="smsBusy" :disabled="smsBusy || !smsText.trim() || !!smsDone" @click="sendSms">
ارسال پیامک
</UiButton>
</template>
</UiModal>
</section>
</template>
<style scoped>
.birthdays {
display: flex;
align-items: center;
gap: 1rem;
background: #fff;
border: 1px solid #eef2f7;
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
padding: 0.85rem 1rem;
}
.head {
display: flex;
align-items: center;
gap: 0.7rem;
flex-shrink: 0;
}
.ico {
width: 40px;
height: 40px;
border-radius: 12px;
display: grid;
place-items: center;
background: #fff7ed;
color: #ea580c;
}
.head strong {
display: block;
color: #0f172a;
}
.head p {
margin: 0.15rem 0 0;
color: #94a3b8;
font-size: 0.82rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
min-width: 0;
}
.chip,
.more {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border-radius: 999px;
border: 1px solid #fed7aa;
background: #fff7ed;
color: #9a3412;
padding: 0.35rem 0.7rem;
text-decoration: none;
font: inherit;
cursor: pointer;
}
.chip b {
font-weight: 700;
}
.more {
background: #0f172a;
border-color: #0f172a;
color: #fff;
}
.days {
display: grid;
gap: 0.9rem;
max-height: 60vh;
overflow: auto;
}
.day h3 {
margin: 0 0 0.4rem;
font-size: 0.92rem;
color: #0f172a;
}
.day em {
margin-inline-start: 0.4rem;
font-style: normal;
font-size: 0.75rem;
color: #94a3b8;
}
.day ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.day li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
}
.day a {
color: #0f172a;
text-decoration: none;
}
.lead {
margin: 0 0 0.6rem;
}
.hint,
.err,
.ok {
margin: 0.55rem 0 0;
font-size: 0.82rem;
}
.hint {
color: #94a3b8;
}
.err {
color: #dc2626;
}
.ok {
color: #16a34a;
}
@media (max-width: 720px) {
.birthdays {
align-items: flex-start;
flex-direction: column;
}
}
</style>