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(() => {
-
-
-
-
- M
- منتو
-
-
-
-
-
{
M
منتو
- Mento CRM
+ by Mentech
-
@@ -245,7 +232,7 @@ onUnmounted(() => {
M
منتو
- Mento CRM
+ by Mentech
-
-
-
-
-
در حال بارگذاری…
-
- -
-
-
-
-
اعلانی نیست.
-
-
+
+
+
+
+ M
+ منتو
+
+
+
-
-
-
+
+
+
+
+
+
+
در حال بارگذاری…
+
+ -
+
+
+
+
اعلانی نیست.
+
+
+
+
+
+
+
@@ -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 @@
-