diff --git a/apps/api/src/common/jalali.ts b/apps/api/src/common/jalali.ts new file mode 100644 index 0000000..5da7973 --- /dev/null +++ b/apps/api/src/common/jalali.ts @@ -0,0 +1,114 @@ +const MONTH_NAMES = [ + 'فروردین', + 'اردیبهشت', + 'خرداد', + 'تیر', + 'مرداد', + 'شهریور', + 'مهر', + 'آبان', + 'آذر', + 'دی', + 'بهمن', + 'اسفند', +] as const; + +function div(a: number, b: number) { + return Math.trunc(a / b); +} + +function mod(a: number, b: number) { + return a - Math.trunc(a / b) * b; +} + +export function toJalali(gy: number, gm: number, gd: number) { + const gdm = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; + const gy2 = gm > 2 ? gy + 1 : gy; + let days = + 355666 + + 365 * gy + + div(gy2 + 3, 4) - + div(gy2 + 99, 100) + + div(gy2 + 399, 400) + + gd + + gdm[gm - 1]; + let jy = -1595 + 33 * div(days, 12053); + days %= 12053; + jy += 4 * div(days, 1461); + days %= 1461; + if (days > 365) { + jy += div(days - 1, 365); + days = (days - 1) % 365; + } + const jm = days < 186 ? 1 + div(days, 31) : 7 + div(days - 186, 30); + const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30); + return { jy, jm, jd }; +} + +export function toGregorian(jy: number, jm: number, jd: number) { + const jy2 = jy + 1595; + let days = + -355668 + + 365 * jy2 + + div(jy2, 33) * 8 + + div(mod(jy2, 33) + 3, 4) + + jd + + (jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186); + let gy = 400 * div(days, 146097); + days %= 146097; + if (days > 36524) { + gy += 100 * div(--days, 36524); + days %= 36524; + if (days >= 365) days += 1; + } + gy += 4 * div(days, 1461); + days %= 1461; + if (days > 365) { + gy += div(days - 1, 365); + days = (days - 1) % 365; + } + const gd = days + 1; + const salA = [ + 0, 31, (gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0 ? 29 : 28, + 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, + ]; + let gm = 0; + let dayAcc = gd; + for (gm = 1; gm <= 12 && dayAcc > salA[gm]; gm += 1) { + dayAcc -= salA[gm]; + } + return { gy, gm, gd: dayAcc }; +} + +export function dateToJalali(d: Date) { + return toJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()); +} + +export function jalaliToDate(jy: number, jm: number, jd: number): Date { + const { gy, gm, gd } = toGregorian(jy, jm, jd); + return new Date(gy, gm - 1, gd); +} + +export function addJalaliMonths(jy: number, jm: number, delta: number) { + const idx = jy * 12 + (jm - 1) + delta; + return { jy: Math.floor(idx / 12), jm: (idx % 12) + 1 }; +} + +export function jalaliMonthName(jm: number): string { + return MONTH_NAMES[jm - 1] ?? ''; +} + +export function jalaliMonthKey(d: Date): string { + const { jy, jm } = dateToJalali(d); + return `${jy}-${String(jm).padStart(2, '0')}`; +} + +export function startOfJalaliMonth(jy: number, jm: number): Date { + return jalaliToDate(jy, jm, 1); +} + +export function endOfJalaliMonth(jy: number, jm: number): Date { + const next = addJalaliMonths(jy, jm, 1); + const startNext = jalaliToDate(next.jy, next.jm, 1); + return new Date(startNext.getTime() - 1); +} diff --git a/apps/api/src/reports/reports.controller.ts b/apps/api/src/reports/reports.controller.ts index 60b13f8..105040a 100644 --- a/apps/api/src/reports/reports.controller.ts +++ b/apps/api/src/reports/reports.controller.ts @@ -24,4 +24,16 @@ export class ReportsController { { from: query.from, to: query.to }, ); } + + @Get('home') + home( + @CurrentUser() + user: { userId: string; organizationId: string; role: string }, + ) { + return this.reportsService.home({ + userId: user.userId, + organizationId: user.organizationId, + role: user.role, + }); + } } diff --git a/apps/api/src/reports/reports.service.ts b/apps/api/src/reports/reports.service.ts index b801fb0..2b1b83e 100644 --- a/apps/api/src/reports/reports.service.ts +++ b/apps/api/src/reports/reports.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import type { ReportsDashboardDto } from '@mento/shared'; +import type { HomeDashboardDto, ReportsDashboardDto } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { @@ -10,6 +10,13 @@ import { ticketAccessWhere, } from '../common/record-scope'; import { decimalToBase, loadOrgRates } from '../common/currency'; +import { + addJalaliMonths, + dateToJalali, + endOfJalaliMonth, + jalaliMonthName, + startOfJalaliMonth, +} from '../common/jalali'; @Injectable() export class ReportsService { @@ -246,6 +253,303 @@ export class ReportsService { peopleCreated, }; } + + async home(actor: Actor): Promise { + const orgId = actor.organizationId; + const dealScope = ownerWhere(actor); + const activityScope = assigneeWhere(actor); + const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId }; + const rates = await loadOrgRates(this.prisma, orgId); + const now = new Date(); + const { jy, jm } = dateToJalali(now); + const curMonthStart = startOfJalaliMonth(jy, jm); + const curMonthEnd = endOfJalaliMonth(jy, jm); + const prev = addJalaliMonths(jy, jm, -1); + const prevMonthStart = startOfJalaliMonth(prev.jy, prev.jm); + const prevMonthEnd = endOfJalaliMonth(prev.jy, prev.jm); + + const todayStart = startOfDay(now); + const todayEnd = endOfDay(now); + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayStart = startOfDay(yesterday); + const yesterdayEnd = endOfDay(yesterday); + const weekAgo = new Date(now); + weekAgo.setDate(weekAgo.getDate() - 7); + + const [ + wonDeals, + openDeals, + staleDeals, + peopleCur, + peoplePrev, + openActivitiesToday, + openActivitiesYesterday, + openDealsWeekAgo, + goals, + overdueCount, + ] = await Promise.all([ + this.prisma.deal.findMany({ + where: { + organizationId: orgId, + status: 'won', + ...dealScope, + updatedAt: { gte: addMonthsStart(now, -5) }, + }, + select: { + amount: true, + currency: true, + updatedAt: true, + source: true, + companyId: true, + personId: true, + company: { select: { id: true, name: true } }, + person: { select: { id: true, firstName: true, lastName: true } }, + }, + }), + this.prisma.deal.count({ + where: { organizationId: orgId, status: 'open', ...dealScope }, + }), + this.prisma.deal.findMany({ + where: { + organizationId: orgId, + status: 'open', + ...dealScope, + OR: [ + { isStale: true }, + { isRotten: true }, + { updatedAt: { lt: daysAgo(5) } }, + ], + }, + select: { + id: true, + title: true, + company: { select: { name: true } }, + person: { select: { firstName: true, lastName: true } }, + }, + orderBy: { updatedAt: 'asc' }, + take: 5, + }), + this.prisma.person.count({ + where: { + organizationId: orgId, + ...personScope, + createdAt: { gte: curMonthStart, lte: curMonthEnd }, + }, + }), + this.prisma.person.count({ + where: { + organizationId: orgId, + ...personScope, + createdAt: { gte: prevMonthStart, lte: prevMonthEnd }, + }, + }), + this.prisma.activity.count({ + where: { + organizationId: orgId, + ...activityScope, + status: 'open', + dueAt: { gte: todayStart, lte: todayEnd }, + }, + }), + this.prisma.activity.count({ + where: { + organizationId: orgId, + ...activityScope, + dueAt: { gte: yesterdayStart, lte: yesterdayEnd }, + }, + }), + this.prisma.deal.count({ + where: { + organizationId: orgId, + status: 'open', + ...dealScope, + updatedAt: { lte: weekAgo }, + }, + }), + this.prisma.salesGoal.findMany({ + where: { + organizationId: orgId, + periodStart: { lte: curMonthEnd }, + periodEnd: { gte: curMonthStart }, + }, + select: { targetAmount: true }, + }), + this.prisma.activity.count({ + where: { + organizationId: orgId, + ...activityScope, + status: 'open', + dueAt: { lt: now }, + }, + }), + ]); + + const sumWonInRange = (start: Date, end: Date) => + wonDeals + .filter((d) => d.updatedAt >= start && d.updatedAt <= end) + .reduce( + (acc, d) => acc + decimalToBase(d.amount, d.currency, rates), + 0, + ); + + const salesCur = sumWonInRange(curMonthStart, curMonthEnd); + const salesPrev = sumWonInRange(prevMonthStart, prevMonthEnd); + const targetAmount = goals.reduce( + (acc, g) => acc + Number(g.targetAmount), + 0, + ); + + const trendMonths: Array<{ key: string; label: string; start: Date; end: Date }> = + []; + for (let i = 4; i >= 0; i -= 1) { + const m = addJalaliMonths(jy, jm, -i); + trendMonths.push({ + key: `${m.jy}-${String(m.jm).padStart(2, '0')}`, + label: jalaliMonthName(m.jm), + start: startOfJalaliMonth(m.jy, m.jm), + end: endOfJalaliMonth(m.jy, m.jm), + }); + } + + const salesTrend = trendMonths.map((m) => ({ + monthKey: m.key, + monthLabel: m.label, + amount: Math.round(sumWonInRange(m.start, m.end)), + })); + + const sourceMap = new Map(); + for (const d of wonDeals) { + const label = normalizeSource(d.source); + sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1); + } + const sourceTotal = [...sourceMap.values()].reduce((a, b) => a + b, 0) || 1; + const sources = [...sourceMap.entries()] + .map(([label, count]) => ({ + label, + count, + percent: Math.round((count / sourceTotal) * 1000) / 10, + })) + .sort((a, b) => b.count - a.count) + .slice(0, 6); + + const customerMap = new Map< + string, + { name: string; dealCount: number } + >(); + for (const d of wonDeals) { + if (d.company) { + const cur = customerMap.get(`c:${d.company.id}`) ?? { + name: d.company.name, + dealCount: 0, + }; + cur.dealCount += 1; + customerMap.set(`c:${d.company.id}`, cur); + } else if (d.person) { + const name = `${d.person.firstName} ${d.person.lastName}`.trim(); + const cur = customerMap.get(`p:${d.person.id}`) ?? { + name, + dealCount: 0, + }; + cur.dealCount += 1; + customerMap.set(`p:${d.person.id}`, cur); + } + } + const topCustomers = [...customerMap.entries()] + .map(([id, row]) => ({ + id: id.slice(2), + name: row.name, + dealCount: row.dealCount, + rank: 0, + })) + .sort((a, b) => b.dealCount - a.dealCount) + .slice(0, 5) + .map((row, index) => ({ ...row, rank: index + 1 })); + + const alerts: HomeDashboardDto['alerts'] = []; + if (staleDeals.length) { + const first = staleDeals[0]!; + const who = + first.company?.name ?? + (first.person + ? `${first.person.firstName} ${first.person.lastName}`.trim() + : first.title); + alerts.push({ + key: 'stale-deal', + tone: 'urgent', + text: `${who} در مرحله تصمیم‌گیری است — امروز تماس بگیرید`, + link: '/pipeline', + }); + } + if (staleDeals.length > 1) { + alerts.push({ + key: 'stale-count', + tone: 'warning', + text: `${staleDeals.length} معامله بیش از ۵ روز پیگیری نشده`, + link: '/pipeline', + }); + } else if (overdueCount > 0) { + alerts.push({ + key: 'overdue', + tone: 'warning', + text: `${overdueCount} پیگیری عقب‌افتاده دارید`, + link: '/activities', + }); + } + if (targetAmount > 0 && salesCur >= targetAmount) { + alerts.push({ + key: 'target-ahead', + tone: 'success', + text: 'این ماه از تارگت جلوتر هستید', + link: '/sales/forecast', + }); + } else if (targetAmount > 0 && salesCur >= targetAmount * 0.8) { + alerts.push({ + key: 'target-close', + tone: 'info', + text: 'به تارگت ماه نزدیک شده‌اید', + link: '/sales/forecast', + }); + } + + return { + currency: rates.baseCurrency, + kpis: [ + { + key: 'sales', + label: 'فروش این ماه', + value: Math.round(salesCur), + changePercent: pctChange(salesCur, salesPrev), + changeLabel: 'نسبت به ماه قبل', + }, + { + key: 'customers', + label: 'مشتریان جدید', + value: peopleCur, + changePercent: pctChange(peopleCur, peoplePrev), + changeLabel: 'نسبت به ماه قبل', + }, + { + key: 'open-deals', + label: 'معاملات باز', + value: openDeals, + changePercent: pctChange(openDeals, openDealsWeekAgo || openDeals), + changeLabel: 'نسبت به هفته قبل', + }, + { + key: 'followups', + label: 'پیگیری امروز', + value: openActivitiesToday, + changePercent: pctChange(openActivitiesToday, openActivitiesYesterday), + changeLabel: 'نسبت به دیروز', + }, + ], + alerts, + salesTrend, + sources, + topCustomers, + }; + } } function resolveRange(from?: string, to?: string): { start: Date; end: Date } { @@ -269,3 +573,34 @@ function startOfDay(d: Date): Date { function endOfDay(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999); } + +function daysAgo(n: number): Date { + const d = new Date(); + d.setDate(d.getDate() - n); + return d; +} + +function addMonthsStart(from: Date, deltaMonths: number): Date { + const { jy, jm } = dateToJalali(from); + const m = addJalaliMonths(jy, jm, deltaMonths); + return startOfJalaliMonth(m.jy, m.jm); +} + +function pctChange(current: number, previous: number): number | null { + if (previous <= 0) return current > 0 ? 100 : null; + return Math.round(((current - previous) / previous) * 1000) / 10; +} + +function normalizeSource(raw: string | null | undefined): string { + const value = raw?.trim(); + if (!value) return 'سایر'; + const map: Record = { + referral: 'معرفی دوستان', + website: 'وب‌سایت', + social: 'شبکه‌های اجتماعی', + ads: 'تبلیغات', + instagram: 'شبکه‌های اجتماعی', + google: 'تبلیغات', + }; + return map[value.toLowerCase()] ?? value; +} diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 08956f7..abbeb81 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -3,7 +3,12 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'; import { useAuthStore } from '@/stores/auth'; import { api } from '@/lib/api'; -import type { NotificationDto, NotificationsListDto } from '@mento/shared'; +import { formatJalaliLong } from '@/lib/jalali'; +import type { + ActivitySummaryDto, + NotificationDto, + NotificationsListDto, +} from '@mento/shared'; import { USER_ROLE_LABELS, canManageSettings, canViewAllRecords } from '@/lib/roles'; const auth = useAuthStore(); @@ -17,23 +22,24 @@ const showOwnRecordsBanner = computed( const roleLabel = computed(() => auth.user?.role ? USER_ROLE_LABELS[auth.user.role] : '', ); +const userInitials = computed(() => { + const parts = auth.user?.fullName?.trim().split(/\s+/) ?? []; + if (!parts.length) return 'م'; + return parts + .slice(0, 2) + .map((p) => p[0]) + .join(''); +}); +const todayLabel = computed(() => formatJalaliLong(new Date())); const salesOpen = ref(false); const moreOpen = ref(false); const settingsOpen = ref(false); +const globalSearch = ref(''); +const activityBadge = ref(0); -const salesPaths = [ - '/quotes', - '/invoices', - '/sales/forecast', - '/reports', -]; -const morePaths = [ - '/activities/calendar', - '/projects', - '/project-cards', - '/loyalty', -]; +const salesPaths = ['/quotes', '/invoices', '/sales/forecast', '/reports']; +const morePaths = ['/activities/calendar', '/projects', '/project-cards', '/loyalty']; const settingsPaths = [ '/settings/team', '/settings/automations', @@ -64,10 +70,7 @@ watch( { immediate: true }, ); -function toggleGroup( - key: 'sales' | 'more' | 'settings', - event: Event, -) { +function toggleGroup(key: 'sales' | 'more' | 'settings', event: Event) { event.preventDefault(); event.stopPropagation(); if (key === 'sales') salesOpen.value = !salesOpen.value; @@ -99,7 +102,7 @@ function onLogout() { router.push({ name: 'login' }); } -async function refresh() { +async function refreshNotifs() { if (!auth.isAuthenticated) return; loading.value = true; try { @@ -115,6 +118,15 @@ async function refresh() { } } +async function loadActivityBadge() { + try { + const { data } = await api.get('/activities/summary'); + activityBadge.value = data.todayCount + data.overdueCount; + } catch { + activityBadge.value = 0; + } +} + async function markRead(item: NotificationDto) { if (!item.readAt) { await api.patch(`/notifications/${item.id}/read`); @@ -145,7 +157,7 @@ function toggleMenu() { function toggleNotif() { menuOpen.value = false; notifOpen.value = !notifOpen.value; - if (notifOpen.value) void refresh(); + if (notifOpen.value) void refreshNotifs(); } function formatTime(iso: string) { @@ -159,6 +171,12 @@ function formatTime(iso: string) { } } +function submitSearch() { + const q = globalSearch.value.trim(); + if (!q) return; + void router.push({ name: 'contacts', query: { q } }); +} + watch(menuOpen, (open) => { document.body.style.overflow = open || notifOpen.value ? 'hidden' : ''; }); @@ -176,9 +194,10 @@ watch( ); onMounted(() => { - void refresh(); + void refreshNotifs(); + void loadActivityBadge(); pollTimer = setInterval(() => { - void refresh(); + void refreshNotifs(); }, 45000); }); @@ -190,29 +209,6 @@ onUnmounted(() => { @@ -400,6 +451,13 @@ onUnmounted(() => { grid-template-columns: 260px minmax(0, 1fr); min-height: 100vh; min-height: 100dvh; + background: var(--bg); +} + +.workspace { + min-width: 0; + display: flex; + flex-direction: column; } .topbar { @@ -418,152 +476,111 @@ onUnmounted(() => { position: sticky; top: 0; align-self: start; - background: #13201b; - color: #edf5f1; - padding: 1.4rem; + background: #fff; + color: var(--ink); + padding: 1.25rem 1rem; display: flex; flex-direction: column; - gap: 1.1rem; + gap: 1rem; height: 100vh; height: 100dvh; overflow: hidden; box-sizing: border-box; + border-inline-start: 1px solid var(--line); + box-shadow: -4px 0 24px rgba(15, 23, 42, 0.04); } -.brand-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; +.brand-row, +.drawer-head { + flex-shrink: 0; } .brand { display: flex; align-items: center; - gap: 0.8rem; + gap: 0.75rem; min-width: 0; } .mark { width: 42px; height: 42px; - border-radius: 12px; + border-radius: 14px; display: grid; place-items: center; - background: var(--accent); - font-weight: 700; + background: linear-gradient(135deg, #2eb29b, #0d9488); + color: #fff; + font-weight: 800; flex-shrink: 0; } .brand small { display: block; - color: #9fb3aa; + color: var(--muted); + font-size: 0.78rem; } -.notif-icon-btn { - position: relative; - flex-shrink: 0; - width: 40px; - height: 40px; - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.06); - border-radius: 10px; - cursor: pointer; - display: grid; - place-items: center; - color: #edf5f1; - padding: 0; -} - -.notif-icon-btn:hover { - background: rgba(255, 255, 255, 0.1); -} - -.bell { - width: 16px; - height: 16px; - border: 1.7px solid currentColor; - border-radius: 50% 50% 45% 45%; - border-bottom: 0; - position: relative; - box-sizing: border-box; -} - -.bell::before { - content: ''; - position: absolute; - top: -3px; - left: 50%; - width: 4px; - height: 4px; - border: 1.5px solid currentColor; - border-radius: 50%; - transform: translateX(-50%); - box-sizing: border-box; -} - -.bell::after { - content: ''; - position: absolute; - bottom: -3px; - left: 50%; - width: 10px; - height: 2px; - background: currentColor; - border-radius: 2px; - transform: translateX(-50%); -} - -.notif-icon-btn .badge { - position: absolute; - top: -5px; - left: -5px; - min-width: 1.15rem; - height: 1.15rem; - font-size: 0.68rem; -} - -nav, .side-nav { display: grid; gap: 0.35rem; overflow-y: auto; min-height: 0; flex: 1; - padding-left: 0.15rem; - margin-left: -0.15rem; scrollbar-width: none; - -ms-overflow-style: none; } .side-nav::-webkit-scrollbar { - width: 0; - height: 0; display: none; } .nav-primary, .nav-sub { display: grid; - gap: 0.28rem; + gap: 0.25rem; } +.nav-item, .side-nav a { - padding: 0.65rem 0.85rem; - border-radius: 10px; - color: #d7e4de; + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.72rem 0.85rem; + border-radius: 14px; + color: #475569; text-decoration: none; + font-weight: 500; } +.nav-icon { + width: 1.25rem; + text-align: center; + opacity: 0.85; +} + +.nav-item.router-link-active, .side-nav a.router-link-active { - background: rgba(255, 255, 255, 0.08); + background: var(--accent-soft); + color: var(--accent); + font-weight: 700; +} + +.nav-badge { + margin-inline-start: auto; + min-width: 1.35rem; + height: 1.35rem; + border-radius: 999px; + background: var(--accent); color: #fff; + font-size: 0.72rem; + display: grid; + place-items: center; + padding: 0 0.35rem; } .nav-group { display: grid; - gap: 0.2rem; - margin-top: 0.25rem; + gap: 0.15rem; + margin-top: 0.35rem; } .nav-group-btn { @@ -574,24 +591,19 @@ nav, gap: 0.5rem; border: 0; background: transparent; - color: #9fb3aa; + color: var(--muted); font: inherit; font-size: 0.82rem; font-weight: 600; - letter-spacing: 0.01em; padding: 0.55rem 0.85rem; - border-radius: 10px; + border-radius: 12px; cursor: pointer; text-align: right; } .nav-group-btn:hover { - background: rgba(255, 255, 255, 0.05); - color: #d7e4de; -} - -.nav-group.active > .nav-group-btn { - color: #c5ddd3; + background: #f8fafc; + color: var(--ink); } .chev { @@ -601,8 +613,6 @@ nav, border-bottom: 1.5px solid currentColor; transform: rotate(45deg); transition: transform 0.15s ease; - flex-shrink: 0; - opacity: 0.85; } .nav-group.open .chev { @@ -611,8 +621,8 @@ nav, .nav-sub { padding: 0.1rem 0 0.25rem 0.35rem; - border-right: 1px solid rgba(255, 255, 255, 0.08); - margin-right: 0.85rem; + border-inline-end: 1px solid var(--line); + margin-inline-end: 0.85rem; } .nav-sub a { @@ -620,83 +630,174 @@ nav, font-size: 0.92rem; } -nav a { - padding: 0.7rem 0.85rem; - border-radius: 10px; - color: #d7e4de; -} - -nav a.router-link-active { - background: rgba(255, 255, 255, 0.08); - color: #fff; -} - .scope-banner { margin: 0; padding: 0.55rem 0.75rem; - border-radius: 8px; - background: rgba(255, 255, 255, 0.06); - color: #9fb3aa; + border-radius: 10px; + background: #f8fafc; + color: var(--muted); font-size: 0.78rem; - line-height: 1.45; } .sidebar-foot { margin-top: auto; display: grid; - gap: 0.65rem; + gap: 0.75rem; flex-shrink: 0; } -.brand-row, -.drawer-head { - flex-shrink: 0; -} - -.user-line { - margin: 0; +.promo-card { display: flex; align-items: center; - gap: 0.45rem; - flex-wrap: wrap; - min-width: 0; + gap: 0.65rem; + padding: 0.85rem 1rem; + border-radius: 16px; + background: linear-gradient(135deg, #0f766e, #134e4a); + color: #ecfdf5; + font-size: 0.88rem; + font-weight: 600; + line-height: 1.45; } -.user-name { - color: #d7e4de; - font-size: 0.92rem; - font-weight: 600; +.promo-card p { + margin: 0; +} + +.profile-card { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.65rem 0.75rem; + border-radius: 14px; + background: #f8fafc; + border: 1px solid var(--line); +} + +.avatar { + width: 40px; + height: 40px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + display: grid; + place-items: center; + font-weight: 700; + flex-shrink: 0; +} + +.profile-meta { + min-width: 0; + display: grid; + gap: 0.1rem; +} + +.profile-meta strong { + font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - min-width: 0; } -.role-pill { - flex-shrink: 0; - font-size: 0.72rem; - color: #9fb3aa; - border: 1px solid rgba(255, 255, 255, 0.14); +.profile-meta span { + color: var(--muted); + font-size: 0.78rem; +} + +.online-dot { + margin-inline-start: auto; + width: 10px; + height: 10px; border-radius: 999px; - padding: 0.12rem 0.45rem; - line-height: 1.3; + background: #22c55e; + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); + flex-shrink: 0; } .logout { - color: #edf5f1; - border-color: rgba(255, 255, 255, 0.18); + width: 100%; +} + +.desktop-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.5rem 0.75rem; + position: sticky; + top: 0; + z-index: 20; + background: rgba(244, 247, 248, 0.92); + backdrop-filter: blur(8px); +} + +.global-search { + flex: 1; + max-width: 520px; + display: flex; + align-items: center; + gap: 0.65rem; + background: #fff; + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.55rem 1rem; + box-shadow: var(--shadow); +} + +.global-search input { + border: 0; + background: transparent; + width: 100%; + outline: none; +} + +.search-icon { + opacity: 0.55; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 0.55rem; +} + +.top-icon { + position: relative; + width: 42px; + height: 42px; + border-radius: 12px; + border: 1px solid var(--line); + background: #fff; + display: grid; + place-items: center; + cursor: pointer; + text-decoration: none; + color: inherit; + font-size: 1rem; +} + +.date-chip { + padding: 0.55rem 0.85rem; + border-radius: 12px; + background: #fff; + border: 1px solid var(--line); + color: var(--muted); + font-size: 0.85rem; + white-space: nowrap; } .badge { - min-width: 1.4rem; - height: 1.4rem; + position: absolute; + top: -4px; + inset-inline-start: -4px; + min-width: 1.15rem; + height: 1.15rem; border-radius: 999px; - background: #e11d48; + background: #ef4444; color: #fff; - font-size: 0.75rem; + font-size: 0.68rem; display: grid; place-items: center; - padding: 0 0.35rem; + padding: 0 0.3rem; } .notif-layer { @@ -705,37 +806,35 @@ nav a.router-link-active { z-index: 40; display: grid; place-items: start end; - padding: 4.5rem 0.75rem 1rem; + padding: 5rem 1rem 1rem; pointer-events: none; } .notif-backdrop { position: absolute; inset: 0; - background: rgba(10, 18, 15, 0.35); + background: rgba(15, 23, 42, 0.25); pointer-events: auto; } .notif-panel { position: relative; - width: min(360px, calc(100vw - 1.5rem)); + width: min(360px, calc(100vw - 2rem)); max-height: min(70vh, 480px); overflow: auto; - background: #1b2a24; - color: #edf5f1; - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 14px; + background: #fff; + color: var(--ink); + border: 1px solid var(--line); + border-radius: 18px; padding: 0.85rem; - box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35); + box-shadow: 0 20px 50px rgba(15, 23, 42, 0.12); pointer-events: auto; - z-index: 1; } .notif-head { display: flex; justify-content: space-between; align-items: center; - gap: 0.5rem; margin-bottom: 0.55rem; } @@ -748,7 +847,7 @@ nav a.router-link-active { .linkish { border: 0; background: transparent; - color: #9fd4c0; + color: var(--accent); cursor: pointer; font: inherit; font-size: 0.82rem; @@ -766,9 +865,8 @@ nav a.router-link-active { width: 100%; text-align: right; border: 0; - background: rgba(255, 255, 255, 0.04); - color: inherit; - border-radius: 10px; + background: #f8fafc; + border-radius: 12px; padding: 0.65rem 0.7rem; cursor: pointer; display: grid; @@ -777,7 +875,7 @@ nav a.router-link-active { } .notif-list li.unread button { - background: rgba(15, 92, 99, 0.35); + background: var(--accent-soft); } .title { @@ -788,35 +886,30 @@ nav a.router-link-active { .body, .time, .muted { - color: #9fb3aa; + color: var(--muted); font-size: 0.8rem; } .content { - padding: 1.5rem; + padding: 0 1.5rem 1.5rem; min-width: 0; overflow-x: clip; } .icon-btn { - border: 1px solid transparent; - background: rgba(255, 255, 255, 0.08); - color: inherit; - border-radius: 10px; + border: 1px solid var(--line); + background: #fff; + border-radius: 12px; min-width: 42px; height: 42px; - padding: 0 0.65rem; cursor: pointer; - font: inherit; display: inline-flex; align-items: center; justify-content: center; - gap: 0.35rem; } .icon-btn.ghost { background: transparent; - border-color: rgba(255, 255, 255, 0.14); } .burger { @@ -825,7 +918,6 @@ nav a.router-link-active { background: currentColor; position: relative; display: block; - transition: background 0.15s ease; } .burger::before, @@ -835,7 +927,6 @@ nav a.router-link-active { inset-inline: 0; height: 2px; background: currentColor; - transition: transform 0.15s ease; } .burger::before { @@ -861,7 +952,6 @@ nav a.router-link-active { @media (max-width: 900px) { .shell { grid-template-columns: minmax(0, 1fr); - grid-template-rows: auto 1fr; } .topbar { @@ -869,32 +959,18 @@ nav a.router-link-active { align-items: center; justify-content: space-between; gap: 0.75rem; + padding: 0.7rem 0.85rem; + background: #fff; + border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 30; - padding: 0.7rem 0.85rem; - padding-top: max(0.7rem, env(safe-area-inset-top)); - background: #13201b; - color: #edf5f1; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); } .top-brand { display: flex; align-items: center; gap: 0.55rem; - min-width: 0; - } - - .top-brand .mark { - width: 34px; - height: 34px; - border-radius: 10px; - font-size: 0.9rem; - } - - .notif-top { - font-size: 0.85rem; } .desktop-only { @@ -906,16 +982,16 @@ nav a.router-link-active { } .drawer-head { + display: flex; align-items: center; justify-content: space-between; - gap: 0.75rem; } .backdrop { display: block; position: fixed; inset: 0; - background: rgba(10, 18, 15, 0.45); + background: rgba(15, 23, 42, 0.35); z-index: 34; } @@ -925,25 +1001,18 @@ nav a.router-link-active { bottom: 0; inset-inline-start: 0; width: min(300px, 86vw); - height: 100vh; - height: 100dvh; z-index: 35; - /* پنل RTL است؛ منو از سمت راست می‌آید */ transform: translateX(110%); transition: transform 0.2s ease; - box-shadow: none; - padding-top: max(1rem, env(safe-area-inset-top)); - overflow: hidden; } .sidebar.open { transform: translateX(0); - box-shadow: 0 0 40px rgba(0, 0, 0, 0.35); + box-shadow: 0 0 40px rgba(0, 0, 0, 0.15); } .content { padding: 1rem 0.85rem 1.4rem; - padding-bottom: max(1.4rem, env(safe-area-inset-bottom)); } .notif-layer { @@ -953,8 +1022,6 @@ nav a.router-link-active { .notif-panel { width: 100%; - max-height: min(75vh, 560px); - margin-inline: auto; } } diff --git a/apps/web/src/styles/main.css b/apps/web/src/styles/main.css index 45855ab..1219427 100644 --- a/apps/web/src/styles/main.css +++ b/apps/web/src/styles/main.css @@ -1,14 +1,14 @@ :root { - --bg: #eef2f0; + --bg: #f4f7f8; --surface: #ffffff; - --ink: #17211c; - --muted: #5b6b63; - --line: #d5ddd8; - --accent: #0f6b5c; - --accent-soft: #d8efe9; + --ink: #0f172a; + --muted: #64748b; + --line: #e2e8f0; + --accent: #0d9488; + --accent-soft: #ccfbf1; --danger: #b42318; - --shadow: 0 10px 30px rgba(23, 33, 28, 0.06); - --radius: 14px; + --shadow: 0 8px 24px rgba(15, 23, 42, 0.06); + --radius: 18px; --font: "Vazirmatn", Tahoma, sans-serif; } @@ -31,9 +31,7 @@ body { margin: 0; font-family: var(--font); color: var(--ink); - background: - radial-gradient(circle at top left, rgba(15, 107, 92, 0.08), transparent 28%), - linear-gradient(180deg, #f7faf8 0%, var(--bg) 100%); + background: var(--bg); overflow-x: clip; } diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue index dbfec15..6f984dd 100644 --- a/apps/web/src/views/HomeView.vue +++ b/apps/web/src/views/HomeView.vue @@ -1,336 +1,916 @@