Author SHA1 Message Date
MahdiandCursor ebf13cfde5 Fix web build by removing unused axios import.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 15:59:31 +03:30
Mahdi 0e580b19c3 Editing Super Admin permissions and visual changes 2026-09-20 12:07:05 +03:30
Mahdi 4a9fe0b07c Configuring access rights in the CRM Super Admin 2026-09-20 11:24:14 +03:30
MahdiandCursor 04d37a9d12 Add design spec for BillingAccount, per-org modules, and /adm polish.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 10:54:29 +03:30
Mahdi 605e1d3340 Edit login suoerAdmin 2026-09-19 22:42:51 +03:30
Mahdi b016b4754f Creating a CRM Super Admin 2026-09-19 21:54:21 +03:30
Mahdi ff5ad21eb9 Edit form costumer 2026-09-19 16:23:11 +03:30
Mahdi 4776d1a3c3 Edit import excel 2026-09-19 15:30:31 +03:30
Mahdi 9c7dad3677 Edit login and registration details 2026-09-19 13:57:15 +03:30
MahdiandCursor a428c6f135 Fix modal body scroll so tall forms like new-person stay usable.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 11:10:16 +03:30
MahdiandCursor 3b643d7bb9 Lift mobile docks and FABs above the bottom tab bar.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 17:36:21 +03:30
MahdiandCursor dfd35c45ef Merge design_ui_v2 into design_ui so the new design system can be previewed without dropping later product work.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 16:46:00 +03:30
s.esmaeili 1ae87c97b7 feat(ui): standardize design system, modals, sidebar, and form inputs
- Implement unified design system tokens, 10px radius, and IRANSans typography
- Modernize sidebar with Gemini-style collapsed expander, square 1:1 items, and elevated profile flyout
- Standardize all modal dialogs with 16px padding, fixed header/footer, 16px medium title, and sticky bottom actions
- Refactor DealFormView and CustomFieldsSection with standard UiField, UiInput, and UiSelect components
- Add draggable bottom-left QuickCreate floating action button
2026-09-12 01:33:07 +03:30
150 changed files with 13279 additions and 5602 deletions
+8
View File
@@ -24,4 +24,12 @@ SMS_SENDER=
# مثال ساخت: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
INTEGRATIONS_ENCRYPTION_KEY=
# سوپرادمین پلتفرم — ورود از /adm با موبایل + رمز
# در لوکال اگر خالی بماند: 09120000000 / demo1234
PLATFORM_ADMIN_MOBILE=
PLATFORM_ADMIN_PASSWORD=
PLATFORM_ADMIN_NAME=سوپرادمین
# اختیاری
# PLATFORM_ADMIN_EMAIL=
# Production: see deploy/.env.production.example and deploy/DEPLOY.md
@@ -0,0 +1,18 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN IF NOT EXISTS "isActive" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE IF NOT EXISTS "PlatformAdmin" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PlatformAdmin_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "PlatformAdmin_email_key" ON "PlatformAdmin"("email");
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "PlatformAdmin" ADD COLUMN IF NOT EXISTS "mobile" TEXT;
-- Make email optional (existing rows keep their email)
ALTER TABLE "PlatformAdmin" ALTER COLUMN "email" DROP NOT NULL;
-- Unique mobile
CREATE UNIQUE INDEX IF NOT EXISTS "PlatformAdmin_mobile_key" ON "PlatformAdmin"("mobile");
@@ -0,0 +1,64 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN IF NOT EXISTS "enabledModules" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- CreateTable
CREATE TABLE IF NOT EXISTS "BillingAccount" (
"id" TEXT NOT NULL,
"ownerUserId" TEXT NOT NULL,
"planCode" TEXT NOT NULL DEFAULT 'trial',
"status" TEXT NOT NULL DEFAULT 'active',
"startsAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"maxOrganizations" INTEGER NOT NULL DEFAULT 1,
"externalPaymentRef" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BillingAccount_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "BillingAccount_ownerUserId_key" ON "BillingAccount"("ownerUserId");
CREATE INDEX IF NOT EXISTS "BillingAccount_status_idx" ON "BillingAccount"("status");
CREATE INDEX IF NOT EXISTS "BillingAccount_expiresAt_idx" ON "BillingAccount"("expiresAt");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'BillingAccount_ownerUserId_fkey'
) THEN
ALTER TABLE "BillingAccount"
ADD CONSTRAINT "BillingAccount_ownerUserId_fkey"
FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- Backfill: full module catalog for existing orgs (empty → all)
UPDATE "Organization"
SET "enabledModules" = ARRAY[
'customers','pipeline','products','invoices','quotes','commissions','sales_forecast',
'activities','tickets','workflow_automations','message_automations','lead_forms',
'project_boards','inventory','cheques','attachments','loyalty','reports','notes',
'tahesab','custom_fields','currencies','team_settings','faq'
]::TEXT[]
WHERE cardinality("enabledModules") = 0;
-- Backfill BillingAccount for every active owner membership (15-day trial)
INSERT INTO "BillingAccount" (
"id", "ownerUserId", "planCode", "status", "startsAt", "expiresAt",
"maxOrganizations", "createdAt", "updatedAt"
)
SELECT
md5(random()::text || clock_timestamp()::text || m."userId"),
m."userId",
'trial',
'active',
NOW(),
NOW() + INTERVAL '15 days',
GREATEST(1, COUNT(*)::int),
NOW(),
NOW()
FROM "Membership" m
WHERE m."role" = 'owner' AND m."isActive" = true
GROUP BY m."userId"
ON CONFLICT ("ownerUserId") DO NOTHING;
+40
View File
@@ -147,6 +147,9 @@ model Organization {
businessField String?
baseCurrency String @default("IRR")
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
/// ماژول‌های فعال منو برای این محیط
enabledModules String[] @default([])
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
@@ -229,6 +232,7 @@ model User {
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy")
memberships Membership[]
billingAccount BillingAccount?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -278,6 +282,42 @@ model Membership {
@@index([orgRoleId])
}
/// حساب سوپرادمین پلتفرم — جدا از User سازمانی
model PlatformAdmin {
id String @id @default(cuid())
/// ورود اصلی با موبایل نرمال‌شده (مثلاً 0912…)
mobile String? @unique
email String? @unique
passwordHash String
fullName String
isActive Boolean @default(true)
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
/// اشتراک SaaS روی مالک حساب (نه روی هر عضویت)
model BillingAccount {
id String @id @default(cuid())
ownerUserId String @unique
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
/// trial | monthly | quarterly | semiannual | yearly | custom
planCode String @default("trial")
/// active | expired | suspended
status String @default("active")
startsAt DateTime @default(now())
expiresAt DateTime
maxOrganizations Int @default(1)
/// رزرو برای درگاه/لندینگ بعدی
externalPaymentRef String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([expiresAt])
}
model Company {
id String @id @default(cuid())
organizationId String
+96
View File
@@ -13,7 +13,103 @@ const DEFAULT_STAGES = [
{ name: 'از دست‌رفته', isWon: false, isLost: true },
] as const;
async function seedPlatformAdmin() {
const envMobile = (process.env.PLATFORM_ADMIN_MOBILE ?? '').trim();
const envEmail = (process.env.PLATFORM_ADMIN_EMAIL ?? '').trim().toLowerCase();
const envPassword = process.env.PLATFORM_ADMIN_PASSWORD ?? '';
const envName = (process.env.PLATFORM_ADMIN_NAME ?? 'سوپرادمین').trim() || 'سوپرادمین';
const isProd = process.env.NODE_ENV === 'production';
const mobileRaw = envMobile || (isProd ? '' : '09120000000');
const password = envPassword || (isProd ? '' : 'demo1234');
const email = envEmail || null;
if (!mobileRaw || !password) {
console.log(
'Platform admin seed skipped (set PLATFORM_ADMIN_MOBILE and PLATFORM_ADMIN_PASSWORD)',
);
return;
}
if (password.length < 8) {
console.log('Platform admin seed skipped (password must be at least 8 chars)');
return;
}
// normalize: 09xxxxxxxxx
let mobile = mobileRaw.replace(/[\s\-()]/g, '');
if (mobile.startsWith('+98')) mobile = `0${mobile.slice(3)}`;
if (mobile.startsWith('98') && mobile.length === 12) mobile = `0${mobile.slice(2)}`;
if (/^9\d{9}$/.test(mobile)) mobile = `0${mobile}`;
if (!/^09\d{9}$/.test(mobile)) {
console.log('Platform admin seed skipped (PLATFORM_ADMIN_MOBILE invalid)');
return;
}
const passwordHash = await bcrypt.hash(password, 10);
const existingByMobile = await prisma.platformAdmin.findUnique({ where: { mobile } });
if (existingByMobile) {
await prisma.platformAdmin.update({
where: { id: existingByMobile.id },
data: {
passwordHash,
fullName: envName,
isActive: true,
...(email ? { email } : {}),
},
});
console.log('Platform admin password refreshed:', mobile);
return;
}
if (email) {
const existingByEmail = await prisma.platformAdmin.findUnique({ where: { email } });
if (existingByEmail) {
await prisma.platformAdmin.update({
where: { id: existingByEmail.id },
data: {
mobile,
passwordHash,
fullName: envName,
isActive: true,
},
});
console.log('Platform admin linked mobile + password refreshed:', mobile, '→', email);
return;
}
}
const orphans = await prisma.platformAdmin.findMany({
where: { mobile: null },
take: 2,
});
if (orphans.length === 1) {
const orphan = orphans[0]!;
await prisma.platformAdmin.update({
where: { id: orphan.id },
data: {
mobile,
passwordHash,
fullName: envName,
isActive: true,
...(email ? { email } : {}),
},
});
console.log('Platform admin orphan linked mobile:', mobile);
return;
}
await prisma.platformAdmin.create({
data: {
mobile,
email,
passwordHash,
fullName: envName,
},
});
console.log('Seeded platform admin:', mobile);
}
async function main() {
await seedPlatformAdmin();
const email = 'owner@demo.local';
let org = await prisma.organization.findUnique({ where: { slug: 'demo-mento' } });
if (!org) {
@@ -5,12 +5,16 @@ import type { Actor } from '../common/record-scope';
import { assigneeWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatClock,
formatJalaliDate,
parseExcelMapping,
parseJalaliDateTime,
previewExcel,
readMappedRows,
stripHtml,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
const ACTIVITY_COLUMNS = [
@@ -143,8 +147,16 @@ export class ActivitiesExcelService {
);
}
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, ACTIVITY_ALIASES);
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, ACTIVITY_ALIASES, excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']));
}
async import(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, ACTIVITY_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
for (let i = 0; i < rows.length; i++) {
@@ -79,6 +79,17 @@ export class ActivitiesController {
return new StreamableFile(buffer);
}
@Post('import/preview')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
}),
)
importPreview(@UploadedFile() file: Express.Multer.File) {
return this.excelService.preview(file);
}
@Post('import')
@UseInterceptors(
FileInterceptor('file', {
@@ -89,8 +100,9 @@ export class ActivitiesController {
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.import(user, file);
return this.excelService.import(user, file, mapping);
}
@Get(':id')
+4
View File
@@ -32,6 +32,8 @@ import { MemoryModule } from './memory/memory.module';
import { TahesabModule } from './integrations/tahesab/tahesab.module';
import { InventoryModule } from './inventory/inventory.module';
import { FinanceModule } from './finance/finance.module';
import { PlatformModule } from './platform/platform.module';
import { BillingModule } from './billing/billing.module';
@Module({
imports: [
@@ -44,7 +46,9 @@ import { FinanceModule } from './finance/finance.module';
PrismaModule,
StorageModule,
HealthModule,
BillingModule,
AuthModule,
PlatformModule,
UsersModule,
OrgRolesModule,
OrganizationsModule,
+2
View File
@@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MembershipsModule } from '../memberships/memberships.module';
import { BillingModule } from '../billing/billing.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
@@ -13,6 +14,7 @@ import { SmsService } from '../common/sms.service';
imports: [
ConfigModule,
MembershipsModule,
BillingModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
+36 -2
View File
@@ -10,12 +10,19 @@ import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import * as path from 'path';
import type { AuthUserDto, LoginResponse, LoginResult } from '@mento/shared';
import type {
AuthUserDto,
LoginResponse,
LoginResult,
OrgModuleKey,
SubscriptionSummaryDto,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { MembershipsService } from '../memberships/memberships.service';
import { BillingService } from '../billing/billing.service';
import {
LoginDto,
LoginOtpRequestDto,
@@ -97,6 +104,7 @@ export class AuthService {
private readonly sms: SmsService,
private readonly files: LocalFileStorage,
private readonly memberships: MembershipsService,
private readonly billing: BillingService,
) {}
/**
@@ -260,6 +268,11 @@ export class AuthService {
organizationId: organization.id,
role: 'owner',
});
await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 });
await this.prisma.organization.update({
where: { id: organization.id },
data: { enabledModules: this.billing.defaultModulesForNewOrg() },
});
await this.prisma.authChallenge.update({
where: { id: challenge.id },
@@ -578,6 +591,9 @@ export class AuthService {
if (!user || user.isActive === false) {
throw new UnauthorizedException('حساب کاربری غیرفعال است');
}
const subscription = await this.billing.requireActiveForOrganization(
organizationId,
);
await this.memberships.syncUserLastOrg(
userId,
organizationId,
@@ -597,6 +613,7 @@ export class AuthService {
user: await this.toAuthUserDto(
{ ...user, role: membership.role, organizationId },
organizationId,
subscription,
),
};
}
@@ -611,19 +628,30 @@ export class AuthService {
organizationId: string;
},
organizationId: string,
subscription?: SubscriptionSummaryDto | null,
): Promise<AuthUserDto> {
const membership = await this.prisma.membership.findFirst({
where: { userId: user.id, organizationId, isActive: true },
include: { organization: { select: { name: true } } },
include: {
organization: { select: { name: true, enabledModules: true } },
},
});
const role = membership?.role ?? user.role;
const orgName = membership?.organization.name;
const sub =
subscription ??
(await this.billing.requireActiveForOrganization(organizationId));
return this.toAuthUser({
...user,
role,
organizationId,
organizationName: orgName,
avatarStorageKey: await this.readAvatarKey(user.id),
enabledModules: this.billing.normalizeModules(
membership?.organization.enabledModules,
),
subscription: sub,
isOrgOwner: role === 'owner',
});
}
@@ -708,6 +736,9 @@ export class AuthService {
organizationId: string;
organizationName?: string;
avatarStorageKey?: string | null;
enabledModules?: OrgModuleKey[];
subscription?: SubscriptionSummaryDto | null;
isOrgOwner?: boolean;
}): AuthUserDto {
return {
id: user.id,
@@ -718,6 +749,9 @@ export class AuthService {
organizationName: user.organizationName,
hasAvatar: Boolean(user.avatarStorageKey),
...(user.mobile ? { mobile: user.mobile } : {}),
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
...(user.subscription ? { subscription: user.subscription } : {}),
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
};
}
}
+9 -1
View File
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { BillingService } from '../billing/billing.service';
import { PrismaService } from '../prisma/prisma.service';
export type JwtPayload = {
@@ -15,6 +16,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
config: ConfigService,
private readonly prisma: PrismaService,
private readonly billing: BillingService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
@@ -24,7 +26,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
async validate(payload: JwtPayload & { typ?: string; mobile?: string }) {
if (payload.typ === 'portal' || payload.typ === 'org-select') {
if (
payload.typ === 'portal' ||
payload.typ === 'org-select' ||
payload.typ === 'platform-admin'
) {
throw new UnauthorizedException();
}
if (!payload.sub || !payload.organizationId) {
@@ -42,12 +48,14 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
userId: user.id,
organizationId: payload.organizationId,
isActive: true,
organization: { isActive: true },
},
select: { organizationId: true, role: true },
});
if (!membership) {
throw new UnauthorizedException();
}
await this.billing.requireActiveForOrganization(membership.organizationId);
return {
userId: user.id,
organizationId: membership.organizationId,
+10
View File
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { BillingService } from './billing.service';
import { OrgModuleGuard } from './org-module.guard';
@Global()
@Module({
providers: [BillingService, OrgModuleGuard],
exports: [BillingService, OrgModuleGuard],
})
export class BillingModule {}
+302
View File
@@ -0,0 +1,302 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import type {
ApplyBillingPlanRequest,
BillingAccountDto,
BillingAccountStatus,
BillingPlanCode,
OrgModuleKey,
SubscriptionSummaryDto,
} from '@mento/shared';
import {
BILLING_PLAN_DAYS,
BILLING_PLAN_DEFAULT_MAX_ORGS,
defaultEnabledModules,
isOrgModuleKey,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
function daysRemaining(expiresAt: Date, now = new Date()): number {
const ms = expiresAt.getTime() - now.getTime();
return Math.max(0, Math.ceil(ms / (24 * 60 * 60 * 1000)));
}
function toSummary(row: {
planCode: string;
status: string;
startsAt: Date;
expiresAt: Date;
maxOrganizations: number;
}): SubscriptionSummaryDto {
const remaining = daysRemaining(row.expiresAt);
const status = (row.status as BillingAccountStatus) ?? 'active';
return {
planCode: row.planCode as BillingPlanCode,
status,
startsAt: row.startsAt.toISOString(),
expiresAt: row.expiresAt.toISOString(),
maxOrganizations: row.maxOrganizations,
daysRemaining: remaining,
warn: remaining > 0 && remaining <= 3,
};
}
@Injectable()
export class BillingService {
constructor(private readonly prisma: PrismaService) {}
toDto(row: {
id: string;
ownerUserId: string;
planCode: string;
status: string;
startsAt: Date;
expiresAt: Date;
maxOrganizations: number;
externalPaymentRef: string | null;
notes: string | null;
}): BillingAccountDto {
const summary = toSummary(row);
return {
id: row.id,
ownerUserId: row.ownerUserId,
...summary,
externalPaymentRef: row.externalPaymentRef,
notes: row.notes,
};
}
async getByOwnerUserId(ownerUserId: string) {
return this.prisma.billingAccount.findUnique({ where: { ownerUserId } });
}
async ensureTrialForOwner(
ownerUserId: string,
opts?: { maxOrganizations?: number },
) {
const existing = await this.getByOwnerUserId(ownerUserId);
if (existing) return existing;
const now = new Date();
const expiresAt = new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000);
return this.prisma.billingAccount.create({
data: {
ownerUserId,
planCode: 'trial',
status: 'active',
startsAt: now,
expiresAt,
maxOrganizations: Math.max(1, opts?.maxOrganizations ?? 1),
},
});
}
/** اشتراک محیط = BillingAccount اولین مالک فعال — برای عضو زیرمجموعه حساب جدید ساخته نمی‌شود */
async resolveForOrganization(organizationId: string) {
const owner = await this.prisma.membership.findFirst({
where: {
organizationId,
role: 'owner',
isActive: true,
},
orderBy: { createdAt: 'asc' },
select: { userId: true },
});
if (!owner) return null;
return this.getByOwnerUserId(owner.userId);
}
async requireActiveForOrganization(organizationId: string): Promise<SubscriptionSummaryDto> {
const account = await this.resolveForOrganization(organizationId);
if (!account) {
throw new UnauthorizedException(
'اشتراک این محیط یافت نشد؛ مالک باید از پنل سوپرادمین پلن داشته باشد',
);
}
return this.assertActive(account);
}
async requireActiveForOwner(ownerUserId: string): Promise<SubscriptionSummaryDto> {
const account = await this.getByOwnerUserId(ownerUserId);
if (!account) {
throw new UnauthorizedException(
'اشتراک یافت نشد؛ ابتدا پلن را از سوپرادمین اعمال کنید',
);
}
return this.assertActive(account);
}
private async assertActive(account: {
id: string;
planCode: string;
status: string;
startsAt: Date;
expiresAt: Date;
maxOrganizations: number;
}): Promise<SubscriptionSummaryDto> {
const now = new Date();
if (account.status === 'suspended') {
throw new UnauthorizedException('اشتراک این حساب معلق شده است');
}
if (account.status === 'expired' || account.expiresAt.getTime() < now.getTime()) {
if (account.status !== 'expired') {
await this.prisma.billingAccount.update({
where: { id: account.id },
data: { status: 'expired' },
});
}
throw new UnauthorizedException('اشتراک منقضی شده است');
}
return toSummary(account);
}
async assertCanCreateOrganization(ownerUserId: string) {
let account = await this.getByOwnerUserId(ownerUserId);
if (!account) {
// اولین محیط مالک: trial خودکار
account = await this.ensureTrialForOwner(ownerUserId, { maxOrganizations: 1 });
}
const summary = await this.assertActive(account);
const ownedCount = await this.prisma.membership.count({
where: { userId: ownerUserId, role: 'owner', isActive: true },
});
if (ownedCount >= summary.maxOrganizations) {
throw new ForbiddenException(
`سقف تعداد محیط برای این اشتراک ${summary.maxOrganizations} است`,
);
}
}
async applyPlan(
ownerUserId: string,
dto: ApplyBillingPlanRequest,
): Promise<BillingAccountDto> {
const ownerMembership = await this.prisma.membership.findFirst({
where: { userId: ownerUserId, role: 'owner', isActive: true },
select: { id: true },
});
if (!ownerMembership) {
throw new BadRequestException(
'اشتراک فقط برای مالک محیط تعریف می‌شود؛ این کاربر مالک هیچ محیطی نیست',
);
}
const planCode = dto.planCode;
const now = new Date();
let expiresAt: Date;
let maxOrganizations: number;
if (planCode === 'custom') {
if (!dto.expiresAt) {
throw new BadRequestException('برای پلن سفارشی تاریخ انقضا لازم است');
}
expiresAt = new Date(dto.expiresAt);
if (Number.isNaN(expiresAt.getTime())) {
throw new BadRequestException('تاریخ انقضا معتبر نیست');
}
maxOrganizations = Math.max(1, dto.maxOrganizations ?? 1);
} else {
const days = BILLING_PLAN_DAYS[planCode];
expiresAt = dto.expiresAt
? new Date(dto.expiresAt)
: new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
if (Number.isNaN(expiresAt.getTime())) {
throw new BadRequestException('تاریخ انقضا معتبر نیست');
}
maxOrganizations = Math.max(
1,
dto.maxOrganizations ?? BILLING_PLAN_DEFAULT_MAX_ORGS[planCode],
);
}
const status: BillingAccountStatus =
dto.status ??
(expiresAt.getTime() < now.getTime() ? 'expired' : 'active');
const existing = await this.getByOwnerUserId(ownerUserId);
const row = existing
? await this.prisma.billingAccount.update({
where: { id: existing.id },
data: {
planCode,
status,
startsAt: existing.startsAt ?? now,
expiresAt,
maxOrganizations,
...(dto.notes !== undefined ? { notes: dto.notes } : {}),
},
})
: await this.prisma.billingAccount.create({
data: {
ownerUserId,
planCode,
status,
startsAt: now,
expiresAt,
maxOrganizations,
notes: dto.notes ?? null,
},
});
return this.toDto(row);
}
async setOrgModules(organizationId: string, modules: string[]) {
const cleaned = [
...new Set(
modules.filter(isOrgModuleKey),
),
] as OrgModuleKey[];
await this.prisma.organization.update({
where: { id: organizationId },
data: { enabledModules: cleaned },
});
return cleaned;
}
normalizeModules(raw: string[] | null | undefined): OrgModuleKey[] {
if (!raw?.length) return defaultEnabledModules();
const cleaned = raw.filter(isOrgModuleKey) as OrgModuleKey[];
return cleaned.length ? cleaned : defaultEnabledModules();
}
async requireModule(organizationId: string, module: OrgModuleKey) {
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { enabledModules: true },
});
if (!org) throw new ForbiddenException('محیط پیدا نشد');
const enabled = this.normalizeModules(org.enabledModules);
if (!enabled.includes(module)) {
throw new ForbiddenException('این ماژول برای این محیط فعال نیست');
}
}
defaultModulesForNewOrg(): OrgModuleKey[] {
return defaultEnabledModules();
}
async countExpiringSoon() {
const now = new Date();
const in3 = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000);
return this.prisma.billingAccount.count({
where: {
status: 'active',
expiresAt: { gt: now, lte: in3 },
},
});
}
async countExpired() {
return this.prisma.billingAccount.count({
where: {
OR: [
{ status: 'expired' },
{ status: 'active', expiresAt: { lt: new Date() } },
],
},
});
}
}
@@ -0,0 +1,7 @@
import { SetMetadata } from '@nestjs/common';
import type { OrgModuleKey } from '@mento/shared';
export const ORG_MODULE_KEY = 'org_module';
export const RequireOrgModule = (module: OrgModuleKey) =>
SetMetadata(ORG_MODULE_KEY, module);
+32
View File
@@ -0,0 +1,32 @@
import {
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { OrgModuleKey } from '@mento/shared';
import { BillingService } from './billing.service';
import { ORG_MODULE_KEY } from './org-module.decorator';
@Injectable()
export class OrgModuleGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly billing: BillingService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const module = this.reflector.getAllAndOverride<OrgModuleKey | undefined>(
ORG_MODULE_KEY,
[context.getHandler(), context.getClass()],
);
if (!module) return true;
const req = context.switchToHttp().getRequest<{
user?: { organizationId?: string };
}>();
const organizationId = req.user?.organizationId;
if (!organizationId) return false;
await this.billing.requireModule(organizationId, module);
return true;
}
}
+114 -3
View File
@@ -188,9 +188,121 @@ export async function workbookFromUpload(file: Express.Multer.File | undefined)
return sheet;
}
export type ExcelFieldOption = {
key: string;
label: string;
required?: boolean;
};
export type ExcelColumnPreview = {
header: string;
index: number;
samples: string[];
};
export type ExcelPreviewResult = {
columns: ExcelColumnPreview[];
fields: ExcelFieldOption[];
suggested: Record<string, string>;
needsMapping: boolean;
};
export function excelFieldsFromColumns(
columns: Array<{ header: string; key: string }>,
requiredKeys: string[] = [],
): ExcelFieldOption[] {
const seen = new Set<string>();
const fields: ExcelFieldOption[] = [];
for (const col of columns) {
if (!col.key || col.key === '_type' || seen.has(col.key)) continue;
seen.add(col.key);
fields.push({
key: col.key,
label: col.header,
required: requiredKeys.includes(col.key),
});
}
return fields;
}
export function parseExcelMapping(raw?: string): Record<string, string> | undefined {
if (raw == null || !String(raw).trim()) return undefined;
try {
const parsed = JSON.parse(String(raw)) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('invalid');
}
const mapping: Record<string, string> = {};
for (const [header, field] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof field === 'string' && field.trim()) mapping[header] = field.trim();
}
return mapping;
} catch {
throw new BadRequestException('نگاشت ستون‌ها نامعتبر است');
}
}
function resolveFieldKey(
raw: string,
aliases: Record<string, string>,
mapping?: Record<string, string>,
) {
if (mapping) {
return mapping[raw] || mapping[normalizeHeader(raw)] || '';
}
const compact = normalizeHeader(raw);
return aliases[compact] ?? aliases[raw.trim()] ?? aliases[normalizeHeader(raw)] ?? '';
}
export async function previewExcel(
file: Express.Multer.File,
aliases: Record<string, string>,
fields: ExcelFieldOption[],
): Promise<ExcelPreviewResult> {
const sheet = await workbookFromUpload(file);
const headerRow = sheet.getRow(1);
const columns: ExcelColumnPreview[] = [];
const suggested: Record<string, string> = {};
const used = new Set<string>();
headerRow.eachCell((cell, col) => {
const header = cellStr(cell.value);
if (!header) return;
const samples: string[] = [];
const last = Math.min(sheet.rowCount, 6);
for (let r = 2; r <= last; r += 1) {
const value = cellStr(sheet.getRow(r).getCell(col).value);
if (value) samples.push(value);
if (samples.length >= 3) break;
}
columns.push({ header, index: col, samples });
const key = resolveFieldKey(header, aliases);
if (key && !used.has(key)) {
suggested[header] = key;
used.add(key);
}
});
if (!columns.length) {
throw new BadRequestException('ستون عنوان در ردیف اول فایل پیدا نشد');
}
const required = fields.filter((f) => f.required).map((f) => f.key);
const mapped = new Set(Object.values(suggested));
const missingRequired = required.some((key) => !mapped.has(key));
const unrecognized = columns.some((col) => !suggested[col.header]);
return {
columns,
fields,
suggested,
needsMapping: unrecognized || missingRequired || !mapped.size,
};
}
export async function readMappedRows(
file: Express.Multer.File,
aliases: Record<string, string>,
mapping?: Record<string, string>,
): Promise<Record<string, string>[]> {
const sheet = await workbookFromUpload(file);
const headerRow = sheet.getRow(1);
@@ -198,13 +310,12 @@ export async function readMappedRows(
headerRow.eachCell((cell, col) => {
const raw = cellStr(cell.value);
if (!raw) return;
const compact = normalizeHeader(raw);
const key = aliases[compact] ?? aliases[raw.trim()] ?? aliases[normalizeHeader(raw)];
const key = resolveFieldKey(raw, aliases, mapping);
if (key) map.set(col, key);
});
if (!map.size) {
throw new BadRequestException(
'ستون‌های فایل شناسایی نشد. از قالب خروجی همین سیستم یا خروجی دیدار استفاده کنید.',
'ستون‌های فایل شناسایی نشد. ستون‌های فایل را با فیلدهای سیستم تطبیق دهید.',
);
}
@@ -56,13 +56,20 @@ export class CompaniesController {
return new StreamableFile(buffer);
}
@Post('import/preview')
@UseInterceptors(FileInterceptor('file'))
previewImport(@UploadedFile() file: Express.Multer.File) {
return this.excelService.previewCompanies(file);
}
@Post('import')
@UseInterceptors(FileInterceptor('file'))
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.importCompanies(user, file);
return this.excelService.importCompanies(user, file, mapping);
}
@Get(':id')
@@ -6,8 +6,11 @@ import type { Actor } from '../common/record-scope';
import { ownerWhere } from '../common/record-scope';
import {
formatJalaliDate,
parseExcelMapping,
previewExcel,
readMappedRows,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
import { CompaniesService } from './companies.service';
import { PeopleService } from './people.service';
@@ -107,6 +110,19 @@ const PERSON_ALIASES: Record<string, string> = {
ستاره: 'star',
};
const PERSON_FIELDS = [
{ key: 'firstName', label: 'نام' },
{ key: 'lastName', label: 'نام خانوادگی' },
{ key: 'title', label: 'نام کامل / عنوان' },
{ key: 'mobile', label: 'موبایل' },
{ key: 'phone', label: 'تلفن' },
{ key: 'email', label: 'ایمیل' },
{ key: 'position', label: 'سمت' },
{ key: 'companyName', label: 'شرکت' },
{ key: 'notes', label: 'یادداشت' },
{ key: 'mood', label: 'وضعیت مشتری' },
];
const COMPANY_ALIASES: Record<string, string> = {
name: 'name',
نام: 'name',
@@ -126,6 +142,16 @@ const COMPANY_ALIASES: Record<string, string> = {
یادداشت: 'notes',
};
const COMPANY_FIELDS = [
{ key: 'name', label: 'نام شرکت', required: true },
{ key: 'nationalId', label: 'شناسه ملی' },
{ key: 'phone', label: 'تلفن' },
{ key: 'email', label: 'ایمیل' },
{ key: 'website', label: 'وب‌سایت' },
{ key: 'address', label: 'آدرس' },
{ key: 'notes', label: 'یادداشت' },
];
const CONTACT_COLUMNS = [
{ header: '_type', key: '_type', width: 12 },
{ header: 'کد دیدار مشتری', key: 'didarId', width: 16 },
@@ -294,11 +320,20 @@ export class ContactsExcelService {
);
}
previewPeople(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, PERSON_ALIASES, PERSON_FIELDS);
}
previewCompanies(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, COMPANY_ALIASES, COMPANY_FIELDS);
}
async importPeople(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, PERSON_ALIASES);
const rows = await readMappedRows(file, PERSON_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
for (let i = 0; i < rows.length; i++) {
@@ -307,9 +342,18 @@ export class ContactsExcelService {
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'contact') {
continue;
}
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title);
if (!names) {
result.errors.push({ row: rowNum, message: 'نام مشتری الزامی است' });
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title) ?? {
firstName: (row.firstName ?? '').trim().slice(0, 80),
lastName: (row.lastName ?? '').trim().slice(0, 80),
};
if (
!names.firstName &&
!names.lastName &&
!row.mobile?.trim() &&
!row.email?.trim() &&
!row.phone?.trim()
) {
result.errors.push({ row: rowNum, message: 'حداقل نام، موبایل، تلفن یا ایمیل لازم است' });
continue;
}
@@ -357,8 +401,9 @@ export class ContactsExcelService {
async importCompanies(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, COMPANY_ALIASES);
const rows = await readMappedRows(file, COMPANY_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
for (let i = 0; i < rows.length; i++) {
+4 -6
View File
@@ -135,15 +135,15 @@ export class UpdateCompanyDto {
}
export class CreatePersonDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
firstName!: string;
firstName?: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
lastName!: string;
lastName?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@@ -192,13 +192,11 @@ export class CreatePersonDto {
export class UpdatePersonDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
firstName?: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
lastName?: string;
+19 -3
View File
@@ -18,13 +18,16 @@ import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { CreatePersonDto, ListQueryDto, UpdatePersonDto } from './dto';
import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service';
import { brand } from '@mento/shared';
@Controller('people')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('customers')
export class PeopleController {
constructor(
private readonly peopleService: PeopleService,
@@ -57,13 +60,22 @@ export class PeopleController {
return new StreamableFile(buffer);
}
@Post('import/preview')
@UseInterceptors(FileInterceptor('file'))
previewImport(
@UploadedFile() file: Express.Multer.File,
) {
return this.excelService.previewPeople(file);
}
@Post('import')
@UseInterceptors(FileInterceptor('file'))
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.importPeople(user, file);
return this.excelService.importPeople(user, file, mapping);
}
@Get(':id')
@@ -73,7 +85,11 @@ export class PeopleController {
@Post()
create(@CurrentUser() user: Actor, @Body() dto: CreatePersonDto) {
return this.peopleService.create(user, dto);
return this.peopleService.create(user, {
...dto,
firstName: dto.firstName ?? '',
lastName: dto.lastName ?? '',
});
}
@Patch(':id')
+2 -2
View File
@@ -134,8 +134,8 @@ export class PeopleService {
const data: Prisma.PersonUncheckedCreateInput = {
organizationId: actor.organizationId,
firstName: dto.firstName.trim(),
lastName: dto.lastName.trim(),
firstName: (dto.firstName ?? '').trim(),
lastName: (dto.lastName ?? '').trim(),
email: normalizeEmail(dto.email),
phone: normalizePhone(dto.phone),
mobile: normalizePhone(dto.mobile),
@@ -20,7 +20,7 @@ export class MembershipsService {
async listActive(userId: string) {
return this.prisma.membership.findMany({
where: { userId, isActive: true },
where: { userId, isActive: true, organization: { isActive: true } },
include: {
organization: { select: { id: true, name: true } },
orgRole: { select: { name: true } },
@@ -35,12 +35,24 @@ export class MembershipsService {
): Promise<MembershipActor> {
const row = await this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
select: { userId: true, organizationId: true, role: true },
select: {
userId: true,
organizationId: true,
role: true,
organization: { select: { isActive: true } },
},
});
if (!row) {
throw new ForbiddenException('به این محیط دسترسی ندارید');
}
return row;
if (row.organization.isActive === false) {
throw new UnauthorizedException('این محیط غیرفعال است');
}
return {
userId: row.userId,
organizationId: row.organizationId,
role: row.role,
};
}
toChoices(
@@ -12,6 +12,8 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import {
CreateMessageAutomationRuleDto,
UpdateMessageAutomationRuleDto,
@@ -19,7 +21,8 @@ import {
import { MessageAutomationsService } from './message-automations.service';
@Controller('message-automations')
@UseGuards(JwtAuthGuard, RolesGuard)
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('message_automations')
@Roles('owner', 'admin')
export class MessageAutomationsController {
constructor(private readonly messages: MessageAutomationsService) {}
@@ -3,11 +3,18 @@ import { AuthModule } from '../auth/auth.module';
import { MembershipsModule } from '../memberships/memberships.module';
import { OrgRolesModule } from '../org-roles/org-roles.module';
import { PipelinesModule } from '../pipelines/pipelines.module';
import { BillingModule } from '../billing/billing.module';
import { OrganizationsController } from './organizations.controller';
import { OrganizationsService } from './organizations.service';
@Module({
imports: [AuthModule, MembershipsModule, OrgRolesModule, PipelinesModule],
imports: [
AuthModule,
MembershipsModule,
OrgRolesModule,
PipelinesModule,
BillingModule,
],
controllers: [OrganizationsController],
providers: [OrganizationsService],
})
@@ -15,6 +15,7 @@ import { isKnownCurrency } from '../common/currency';
import { MembershipsService } from '../memberships/memberships.service';
import { OrgRolesService } from '../org-roles/org-roles.service';
import { PipelinesService } from '../pipelines/pipelines.service';
import { BillingService } from '../billing/billing.service';
function slugify(input: string): string {
return (
@@ -34,6 +35,7 @@ export class OrganizationsService {
private readonly memberships: MembershipsService,
private readonly orgRoles: OrgRolesService,
private readonly pipelines: PipelinesService,
private readonly billing: BillingService,
) {}
async createForUser(
@@ -57,6 +59,8 @@ export class OrganizationsService {
throw new UnauthorizedException();
}
await this.billing.assertCanCreateOrganization(userId);
const baseSlug = slugify(name);
let slug = baseSlug;
let i = 1;
@@ -69,6 +73,7 @@ export class OrganizationsService {
name,
slug,
businessField,
enabledModules: this.billing.defaultModulesForNewOrg(),
},
});
await this.memberships.createMembership({
@@ -76,6 +81,7 @@ export class OrganizationsService {
organizationId: organization.id,
role: 'owner',
});
await this.billing.ensureTrialForOwner(userId);
await this.orgRoles.ensureDefaultRoles(organization.id);
await this.pipelines.ensureDefaultPipeline(organization.id);
return { id: organization.id };
@@ -219,6 +225,7 @@ export class OrganizationsService {
businessField: string | null;
baseCurrency: string;
enabledCurrencies: string[];
enabledModules?: string[];
exchangeRates: Array<{
currency: string;
rateToBase: Prisma.Decimal;
@@ -235,6 +242,7 @@ export class OrganizationsService {
: null,
baseCurrency: org.baseCurrency,
enabledCurrencies: org.enabledCurrencies,
enabledModules: this.billing.normalizeModules(org.enabledModules),
exchangeRates: org.exchangeRates.map((r) => ({
currency: r.currency,
rateToBase: Number(r.rateToBase),
+14 -2
View File
@@ -7,11 +7,15 @@ import { ownerWhere, resolveOwnerId } from '../common/record-scope';
import { toJsonCustomFields } from '../common/custom-fields';
import { normalizePhone } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatJalaliDate,
parseAmount,
parseExcelMapping,
parseJalaliDateTime,
previewExcel,
readMappedRows,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
import { PipelinesService } from './pipelines.service';
@@ -144,8 +148,16 @@ export class DealsExcelService {
);
}
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, DEAL_ALIASES);
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, DEAL_ALIASES, excelFieldsFromColumns(DEAL_COLUMNS, ['title']));
}
async import(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, DEAL_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
const affected = new Map<string, string>();
+19 -2
View File
@@ -18,13 +18,16 @@ import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { CreateDealDto, MoveDealDto, UpdateDealDto } from './dto';
import { DealsService } from './deals.service';
import { DealsExcelService } from './deals-excel.service';
import { brand } from '@mento/shared';
@Controller('deals')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('pipeline')
export class DealsController {
constructor(
private readonly dealsService: DealsService,
@@ -45,6 +48,19 @@ export class DealsController {
return new StreamableFile(buffer);
}
@Post('import/preview')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
}),
)
importPreview(
@UploadedFile() file: Express.Multer.File,
) {
return this.excelService.preview(file);
}
@Post('import')
@UseInterceptors(
FileInterceptor('file', {
@@ -55,8 +71,9 @@ export class DealsController {
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.import(user, file);
return this.excelService.import(user, file, mapping);
}
@Get(':id')
+153
View File
@@ -0,0 +1,153 @@
import {
IsArray,
IsBoolean,
IsEmail,
IsIn,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
import { USER_ROLES, type UserRole } from '@mento/shared';
export class PlatformLoginDto {
@IsString()
@MinLength(8)
@MaxLength(40)
mobile!: string;
@IsString()
@MinLength(6)
password!: string;
}
export class CreatePlatformAdminDto {
@IsString()
@MinLength(2)
@MaxLength(120)
fullName!: string;
@IsString()
@MinLength(8)
@MaxLength(40)
mobile!: string;
@IsString()
@MinLength(8)
@MaxLength(120)
password!: string;
@IsOptional()
@IsEmail()
email?: string;
}
export class ResetPlatformAdminPasswordDto {
@IsString()
@MinLength(8)
@MaxLength(120)
password!: string;
}
export class UpdatePlatformStatusDto {
@IsBoolean()
isActive!: boolean;
}
export class CreatePlatformOrganizationDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(64)
businessField?: string;
@IsString()
@MinLength(2)
@MaxLength(120)
ownerFullName!: string;
@IsEmail()
ownerEmail!: string;
@IsString()
@MinLength(8)
@MaxLength(120)
ownerPassword!: string;
@IsOptional()
@IsString()
@MaxLength(40)
ownerMobile?: string;
}
export class UpdatePlatformOrganizationDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(120)
name?: string;
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(64)
slug?: string;
@IsOptional()
@ValidateIf((_, v) => v !== null && v !== undefined)
@IsString()
@MaxLength(64)
businessField?: string | null;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdatePlatformMembershipRoleDto {
@IsIn(USER_ROLES)
role!: UserRole;
}
export class ResetPlatformUserPasswordDto {
@IsString()
@MinLength(8)
@MaxLength(120)
password!: string;
}
export class ApplyBillingPlanDto {
@IsIn(['trial', 'monthly', 'quarterly', 'semiannual', 'yearly', 'custom'])
planCode!: 'trial' | 'monthly' | 'quarterly' | 'semiannual' | 'yearly' | 'custom';
@IsOptional()
@IsInt()
@Min(1)
maxOrganizations?: number;
@IsOptional()
@IsString()
expiresAt?: string;
@IsOptional()
@IsString()
@MaxLength(500)
notes?: string | null;
@IsOptional()
@IsIn(['active', 'expired', 'suspended'])
status?: 'active' | 'expired' | 'suspended';
}
export class UpdateOrgModulesDto {
@IsArray()
@IsString({ each: true })
enabledModules!: string[];
}
@@ -0,0 +1,22 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import { PlatformLoginDto } from './dto';
import { PlatformAuthGuard } from './platform-auth.guard';
import { PlatformAuthService } from './platform-auth.service';
import type { PlatformActor } from './platform-jwt.strategy';
@Controller('platform/auth')
export class PlatformAuthController {
constructor(private readonly auth: PlatformAuthService) {}
@Post('login')
login(@Body() dto: PlatformLoginDto) {
return this.auth.login(dto);
}
@Get('me')
@UseGuards(PlatformAuthGuard)
me(@CurrentUser() actor: PlatformActor) {
return this.auth.me(actor.platformAdminId);
}
}
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class PlatformAuthGuard extends AuthGuard('platform-jwt') {}
@@ -0,0 +1,80 @@
import {
BadRequestException,
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import type { PlatformAdminDto, PlatformLoginResponse } from '@mento/shared';
import { normalizeMobile } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { PlatformLoginDto } from './dto';
@Injectable()
export class PlatformAuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {}
async login(dto: PlatformLoginDto): Promise<PlatformLoginResponse> {
const mobile = normalizeMobile(dto.mobile);
if (!mobile) {
throw new UnauthorizedException('اطلاعات ورود نادرست است');
}
const admin = await this.prisma.platformAdmin.findUnique({
where: { mobile },
});
if (!admin || admin.isActive === false) {
throw new UnauthorizedException('اطلاعات ورود نادرست است');
}
const ok = await bcrypt.compare(dto.password, admin.passwordHash);
if (!ok) {
throw new UnauthorizedException('اطلاعات ورود نادرست است');
}
await this.prisma.platformAdmin.update({
where: { id: admin.id },
data: { lastLoginAt: new Date() },
});
return {
accessToken: this.jwt.sign(
{ sub: admin.id, typ: 'platform-admin' },
{ expiresIn: '12h' },
),
admin: this.toDto(admin),
};
}
async me(platformAdminId: string): Promise<PlatformAdminDto> {
const admin = await this.prisma.platformAdmin.findUnique({
where: { id: platformAdminId },
});
if (!admin || admin.isActive === false) {
throw new UnauthorizedException();
}
return this.toDto(admin);
}
toDto(admin: {
id: string;
mobile: string | null;
email: string | null;
fullName: string;
isActive?: boolean;
lastLoginAt?: Date | null;
createdAt?: Date;
}): PlatformAdminDto {
return {
id: admin.id,
mobile: admin.mobile,
email: admin.email,
fullName: admin.fullName,
...(admin.isActive !== undefined ? { isActive: admin.isActive } : {}),
...(admin.lastLoginAt !== undefined
? { lastLoginAt: admin.lastLoginAt?.toISOString() ?? null }
: {}),
...(admin.createdAt ? { createdAt: admin.createdAt.toISOString() } : {}),
};
}
}
@@ -0,0 +1,59 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../prisma/prisma.service';
export type PlatformJwtPayload = {
sub: string;
typ?: string;
};
export type PlatformActor = {
platformAdminId: string;
mobile: string | null;
email: string | null;
fullName: string;
};
@Injectable()
export class PlatformJwtStrategy extends PassportStrategy(
Strategy,
'platform-jwt',
) {
constructor(
config: ConfigService,
private readonly prisma: PrismaService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>('JWT_SECRET') ?? 'dev-secret',
});
}
async validate(payload: PlatformJwtPayload): Promise<PlatformActor> {
if (payload.typ !== 'platform-admin' || !payload.sub) {
throw new UnauthorizedException();
}
const admin = await this.prisma.platformAdmin.findUnique({
where: { id: payload.sub },
select: {
id: true,
mobile: true,
email: true,
fullName: true,
isActive: true,
},
});
if (!admin || admin.isActive === false) {
throw new UnauthorizedException();
}
return {
platformAdminId: admin.id,
mobile: admin.mobile,
email: admin.email,
fullName: admin.fullName,
};
}
}
@@ -0,0 +1,138 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import {
ApplyBillingPlanDto,
CreatePlatformAdminDto,
CreatePlatformOrganizationDto,
ResetPlatformAdminPasswordDto,
ResetPlatformUserPasswordDto,
UpdateOrgModulesDto,
UpdatePlatformMembershipRoleDto,
UpdatePlatformOrganizationDto,
UpdatePlatformStatusDto,
} from './dto';
import { PlatformAuthGuard } from './platform-auth.guard';
import type { PlatformActor } from './platform-jwt.strategy';
import { PlatformService } from './platform.service';
@Controller('platform')
@UseGuards(PlatformAuthGuard)
export class PlatformController {
constructor(private readonly platform: PlatformService) {}
@Get('stats')
stats() {
return this.platform.stats();
}
@Get('admins')
listAdmins() {
return this.platform.listAdmins();
}
@Post('admins')
createAdmin(@Body() dto: CreatePlatformAdminDto) {
return this.platform.createAdmin(dto);
}
@Patch('admins/:id')
setAdminActive(
@Param('id') id: string,
@Body() dto: UpdatePlatformStatusDto,
@CurrentUser() actor: PlatformActor,
) {
return this.platform.setAdminActive(id, dto.isActive, actor.platformAdminId);
}
@Patch('admins/:id/password')
resetAdminPassword(
@Param('id') id: string,
@Body() dto: ResetPlatformAdminPasswordDto,
) {
return this.platform.resetAdminPassword(id, dto);
}
@Get('organizations')
listOrganizations(@Query('q') q?: string) {
return this.platform.listOrganizations(q);
}
@Post('organizations')
createOrganization(@Body() dto: CreatePlatformOrganizationDto) {
return this.platform.createOrganization(dto);
}
@Get('organizations/:id')
getOrganization(@Param('id') id: string) {
return this.platform.getOrganization(id);
}
@Patch('organizations/:id')
updateOrganization(
@Param('id') id: string,
@Body() dto: UpdatePlatformOrganizationDto,
) {
return this.platform.updateOrganization(id, dto);
}
@Get('users')
listUsers(@Query('q') q?: string) {
return this.platform.listUsers(q);
}
@Get('users/:id')
getUser(@Param('id') id: string) {
return this.platform.getUser(id);
}
@Patch('users/:id')
setUserActive(@Param('id') id: string, @Body() dto: UpdatePlatformStatusDto) {
return this.platform.setUserActive(id, dto.isActive);
}
@Patch('users/:id/password')
resetUserPassword(
@Param('id') id: string,
@Body() dto: ResetPlatformUserPasswordDto,
) {
return this.platform.resetUserPassword(id, dto);
}
@Post('users/:id/billing/apply-plan')
applyBillingPlan(@Param('id') id: string, @Body() dto: ApplyBillingPlanDto) {
return this.platform.applyBillingPlan(id, dto);
}
@Patch('organizations/:id/modules')
updateOrganizationModules(
@Param('id') id: string,
@Body() dto: UpdateOrgModulesDto,
) {
return this.platform.updateOrganizationModules(id, dto);
}
@Patch('memberships/:id')
setMembershipActive(
@Param('id') id: string,
@Body() dto: UpdatePlatformStatusDto,
) {
return this.platform.setMembershipActive(id, dto.isActive);
}
@Patch('memberships/:id/role')
updateMembershipRole(
@Param('id') id: string,
@Body() dto: UpdatePlatformMembershipRoleDto,
) {
return this.platform.updateMembershipRole(id, dto.role);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { AuthModule } from '../auth/auth.module';
import { MembershipsModule } from '../memberships/memberships.module';
import { OrgRolesModule } from '../org-roles/org-roles.module';
import { PipelinesModule } from '../pipelines/pipelines.module';
import { BillingModule } from '../billing/billing.module';
import { PlatformAuthController } from './platform-auth.controller';
import { PlatformAuthGuard } from './platform-auth.guard';
import { PlatformAuthService } from './platform-auth.service';
import { PlatformJwtStrategy } from './platform-jwt.strategy';
import { PlatformController } from './platform.controller';
import { PlatformService } from './platform.service';
@Module({
imports: [
AuthModule,
MembershipsModule,
OrgRolesModule,
PipelinesModule,
BillingModule,
PassportModule,
],
controllers: [PlatformAuthController, PlatformController],
providers: [
PlatformJwtStrategy,
PlatformAuthGuard,
PlatformAuthService,
PlatformService,
],
})
export class PlatformModule {}
+717
View File
@@ -0,0 +1,717 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
PlatformAdminDto,
PlatformOrganizationDto,
PlatformStatsDto,
PlatformUserDto,
UserRole,
} from '@mento/shared';
import { isBusinessField } from '@mento/shared';
import * as bcrypt from 'bcryptjs';
import { Prisma } from '@prisma/client';
import { normalizeMobile } from '../auth/auth.service';
import { MembershipsService } from '../memberships/memberships.service';
import { OrgRolesService } from '../org-roles/org-roles.service';
import { PipelinesService } from '../pipelines/pipelines.service';
import { BillingService } from '../billing/billing.service';
import { PrismaService } from '../prisma/prisma.service';
import type {
ApplyBillingPlanDto,
CreatePlatformAdminDto,
CreatePlatformOrganizationDto,
ResetPlatformAdminPasswordDto,
ResetPlatformUserPasswordDto,
UpdateOrgModulesDto,
UpdatePlatformOrganizationDto,
} from './dto';
function slugify(input: string): string {
return (
input
.trim()
.toLowerCase()
.replace(/[^\w\u0600-\u06FF]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || `org-${Date.now()}`
);
}
@Injectable()
export class PlatformService {
constructor(
private readonly prisma: PrismaService,
private readonly memberships: MembershipsService,
private readonly orgRoles: OrgRolesService,
private readonly pipelines: PipelinesService,
private readonly billing: BillingService,
) {}
async stats(): Promise<PlatformStatsDto> {
const [
organizationCount,
activeOrganizationCount,
userCount,
activeUserCount,
membershipCount,
] = await this.prisma.$transaction([
this.prisma.organization.count(),
this.prisma.organization.count({ where: { isActive: true } }),
this.prisma.user.count(),
this.prisma.user.count({ where: { isActive: true } }),
this.prisma.membership.count(),
]);
const [expiringSoonCount, expiredBillingCount] = await Promise.all([
this.billing.countExpiringSoon(),
this.billing.countExpired(),
]);
return {
organizationCount,
activeOrganizationCount,
userCount,
activeUserCount,
membershipCount,
expiringSoonCount,
expiredBillingCount,
};
}
async listOrganizations(query?: string): Promise<PlatformOrganizationDto[]> {
const q = query?.trim();
const rows = await this.prisma.organization.findMany({
where: q
? {
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ slug: { contains: q, mode: 'insensitive' } },
],
}
: undefined,
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
slug: true,
businessField: true,
isActive: true,
createdAt: true,
enabledModules: true,
_count: {
select: {
memberships: true,
people: true,
deals: true,
},
},
},
});
return rows.map((row) => this.orgToDto(row));
}
async getOrganization(id: string): Promise<PlatformOrganizationDto> {
const row = await this.prisma.organization.findUnique({
where: { id },
select: {
id: true,
name: true,
slug: true,
businessField: true,
isActive: true,
createdAt: true,
enabledModules: true,
_count: {
select: {
memberships: true,
people: true,
deals: true,
},
},
memberships: {
include: {
user: {
select: {
id: true,
fullName: true,
email: true,
mobile: true,
isActive: true,
},
},
},
orderBy: { createdAt: 'asc' },
},
},
});
if (!row) throw new NotFoundException('محیط پیدا نشد');
return {
...this.orgToDto(row),
members: row.memberships.map((m) => ({
membershipId: m.id,
userId: m.user.id,
fullName: m.user.fullName,
email: m.user.email,
mobile: m.user.mobile,
role: m.role,
isActive: m.isActive,
userIsActive: m.user.isActive,
})),
};
}
async createOrganization(
dto: CreatePlatformOrganizationDto,
): Promise<PlatformOrganizationDto> {
const name = dto.name.trim();
if (name.length < 2) {
throw new BadRequestException('نام محیط معتبر نیست');
}
const ownerFullName = dto.ownerFullName.trim();
if (ownerFullName.length < 2) {
throw new BadRequestException('نام مالک معتبر نیست');
}
const ownerEmail = dto.ownerEmail.trim().toLowerCase();
const ownerPassword = dto.ownerPassword.trim();
if (ownerPassword.length < 8) {
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
}
const businessField =
dto.businessField && isBusinessField(dto.businessField)
? dto.businessField
: 'personal';
let ownerMobile: string | null = null;
if (dto.ownerMobile?.trim()) {
ownerMobile = normalizeMobile(dto.ownerMobile);
if (!ownerMobile) {
throw new BadRequestException('شماره موبایل مالک معتبر نیست');
}
}
const existingEmail = await this.prisma.user.findFirst({
where: { email: ownerEmail },
select: { id: true },
});
if (existingEmail) {
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
}
if (ownerMobile) {
const existingMobile = await this.prisma.user.findFirst({
where: { mobile: ownerMobile },
select: { id: true },
});
if (existingMobile) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
}
const baseSlug = slugify(name);
let slug = baseSlug;
let i = 1;
while (await this.prisma.organization.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${i++}`;
}
const passwordHash = await bcrypt.hash(ownerPassword, 10);
const now = new Date();
try {
const organization = await this.prisma.organization.create({
data: {
name,
slug,
businessField,
enabledModules: this.billing.defaultModulesForNewOrg(),
users: {
create: {
email: ownerEmail,
mobile: ownerMobile,
fullName: ownerFullName,
passwordHash,
role: 'owner',
emailVerifiedAt: now,
mobileVerifiedAt: ownerMobile ? now : null,
},
},
},
include: { users: true },
});
const owner = organization.users[0];
if (!owner) {
throw new BadRequestException('ساخت حساب مالک ناموفق بود');
}
await this.memberships.createMembership({
userId: owner.id,
organizationId: organization.id,
role: 'owner',
});
await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 });
await this.orgRoles.ensureDefaultRoles(organization.id);
await this.pipelines.ensureDefaultPipeline(organization.id);
return this.getOrganization(organization.id);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('ایمیل یا موبایل تکراری است');
}
throw e;
}
}
async updateOrganization(
id: string,
dto: UpdatePlatformOrganizationDto,
): Promise<PlatformOrganizationDto> {
const existing = await this.prisma.organization.findUnique({
where: { id },
select: { id: true, slug: true },
});
if (!existing) throw new NotFoundException('محیط پیدا نشد');
const data: Prisma.OrganizationUpdateInput = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (name.length < 2) {
throw new BadRequestException('نام محیط معتبر نیست');
}
data.name = name;
}
if (dto.slug !== undefined) {
const slug = slugify(dto.slug);
if (slug.length < 2) {
throw new BadRequestException('اسلاگ معتبر نیست');
}
if (slug !== existing.slug) {
const clash = await this.prisma.organization.findUnique({
where: { slug },
select: { id: true },
});
if (clash) {
throw new ConflictException('این اسلاگ قبلاً استفاده شده است');
}
data.slug = slug;
}
}
if (dto.businessField !== undefined) {
if (dto.businessField === null || dto.businessField === '') {
data.businessField = null;
} else if (!isBusinessField(dto.businessField)) {
throw new BadRequestException('زمینه فعالیت معتبر نیست');
} else {
data.businessField = dto.businessField;
}
}
if (dto.isActive !== undefined) {
data.isActive = dto.isActive;
}
if (Object.keys(data).length === 0) {
throw new BadRequestException('هیچ فیلدی برای به‌روزرسانی ارسال نشده');
}
await this.prisma.organization.update({ where: { id }, data });
return this.getOrganization(id);
}
async setOrganizationActive(
id: string,
isActive: boolean,
): Promise<PlatformOrganizationDto> {
return this.updateOrganization(id, { isActive });
}
async listUsers(query?: string): Promise<PlatformUserDto[]> {
const q = query?.trim();
const rows = await this.prisma.user.findMany({
where: q
? {
OR: [
{ fullName: { contains: q, mode: 'insensitive' } },
{ email: { contains: q, mode: 'insensitive' } },
{ mobile: { contains: q } },
],
}
: undefined,
orderBy: { createdAt: 'desc' },
take: 300,
include: {
billingAccount: true,
memberships: {
include: {
organization: {
select: { id: true, name: true, isActive: true },
},
},
orderBy: { createdAt: 'asc' },
},
},
});
return rows.map((row) => this.userToDto(row));
}
async getUser(id: string): Promise<PlatformUserDto> {
const row = await this.prisma.user.findUnique({
where: { id },
include: {
memberships: {
include: {
organization: {
select: { id: true, name: true, isActive: true },
},
},
orderBy: { createdAt: 'asc' },
},
billingAccount: true,
},
});
if (!row) throw new NotFoundException('کاربر پیدا نشد');
return this.userToDto(row);
}
async applyBillingPlan(userId: string, dto: ApplyBillingPlanDto) {
const existing = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (!existing) throw new NotFoundException('کاربر پیدا نشد');
await this.billing.applyPlan(userId, dto);
return this.getUser(userId);
}
async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) {
const existing = await this.prisma.organization.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('محیط پیدا نشد');
await this.billing.setOrgModules(id, dto.enabledModules ?? []);
return this.getOrganization(id);
}
async setUserActive(id: string, isActive: boolean): Promise<PlatformUserDto> {
const existing = await this.prisma.user.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('کاربر پیدا نشد');
await this.prisma.user.update({
where: { id },
data: { isActive },
});
return this.getUser(id);
}
async resetUserPassword(
id: string,
dto: ResetPlatformUserPasswordDto,
): Promise<{ ok: true }> {
const existing = await this.prisma.user.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('کاربر پیدا نشد');
const password = dto.password.trim();
if (password.length < 8) {
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
}
const passwordHash = await bcrypt.hash(password, 10);
await this.prisma.user.update({
where: { id },
data: { passwordHash },
});
return { ok: true };
}
async setMembershipActive(
id: string,
isActive: boolean,
): Promise<PlatformUserDto> {
const row = await this.prisma.membership.findUnique({
where: { id },
select: {
id: true,
userId: true,
organizationId: true,
role: true,
},
});
if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (!isActive && row.role === 'owner') {
const activeOwners = await this.memberships.countActiveOwners(
row.organizationId,
row.userId,
);
if (activeOwners < 1) {
throw new BadRequestException('حداقل یک مالک فعال برای این محیط لازم است');
}
}
await this.prisma.membership.update({
where: { id },
data: { isActive },
});
return this.getUser(row.userId);
}
async updateMembershipRole(
id: string,
role: UserRole,
): Promise<PlatformUserDto> {
const row = await this.prisma.membership.findUnique({
where: { id },
include: {
user: { select: { id: true, organizationId: true } },
},
});
if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (row.role === 'owner' && role !== 'owner') {
const owners = await this.memberships.countActiveOwners(
row.organizationId,
row.userId,
);
if (owners < 1) {
throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
);
}
}
await this.orgRoles.ensureDefaultRoles(row.organizationId);
const orgRole = await this.prisma.orgRole.findFirst({
where: { organizationId: row.organizationId, systemKey: role },
});
await this.prisma.membership.update({
where: { id },
data: {
role,
orgRoleId: orgRole?.id ?? row.orgRoleId,
},
});
if (row.user.organizationId === row.organizationId) {
await this.memberships.syncUserLastOrg(
row.userId,
row.organizationId,
role,
orgRole?.id ?? row.orgRoleId,
);
}
return this.getUser(row.userId);
}
async listAdmins(): Promise<PlatformAdminDto[]> {
const rows = await this.prisma.platformAdmin.findMany({
orderBy: { createdAt: 'asc' },
});
return rows.map((row) => this.adminToDto(row));
}
async createAdmin(dto: CreatePlatformAdminDto): Promise<PlatformAdminDto> {
const fullName = dto.fullName.trim();
if (fullName.length < 2) {
throw new BadRequestException('نام معتبر نیست');
}
const mobile = normalizeMobile(dto.mobile);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const password = dto.password.trim();
if (password.length < 8) {
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
}
const email = dto.email?.trim().toLowerCase() || null;
const existingMobile = await this.prisma.platformAdmin.findUnique({
where: { mobile },
select: { id: true },
});
if (existingMobile) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
if (email) {
const existingEmail = await this.prisma.platformAdmin.findUnique({
where: { email },
select: { id: true },
});
if (existingEmail) {
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
}
}
const passwordHash = await bcrypt.hash(password, 10);
try {
const created = await this.prisma.platformAdmin.create({
data: {
fullName,
mobile,
email,
passwordHash,
},
});
return this.adminToDto(created);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('موبایل یا ایمیل تکراری است');
}
throw e;
}
}
async setAdminActive(
id: string,
isActive: boolean,
actorId: string,
): Promise<PlatformAdminDto> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
if (!isActive && id === actorId) {
throw new BadRequestException('نمی‌توانید حساب خودتان را غیرفعال کنید');
}
if (!isActive) {
const activeCount = await this.prisma.platformAdmin.count({
where: { isActive: true, id: { not: id } },
});
if (activeCount < 1) {
throw new BadRequestException('حداقل یک سوپرادمین فعال لازم است');
}
}
const updated = await this.prisma.platformAdmin.update({
where: { id },
data: { isActive },
});
return this.adminToDto(updated);
}
async resetAdminPassword(
id: string,
dto: ResetPlatformAdminPasswordDto,
): Promise<{ ok: true }> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
const password = dto.password.trim();
if (password.length < 8) {
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
}
const passwordHash = await bcrypt.hash(password, 10);
await this.prisma.platformAdmin.update({
where: { id },
data: { passwordHash },
});
return { ok: true };
}
private adminToDto(row: {
id: string;
mobile: string | null;
email: string | null;
fullName: string;
isActive: boolean;
lastLoginAt: Date | null;
createdAt: Date;
}): PlatformAdminDto {
return {
id: row.id,
mobile: row.mobile,
email: row.email,
fullName: row.fullName,
isActive: row.isActive,
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
private orgToDto(row: {
id: string;
name: string;
slug: string;
businessField: string | null;
isActive: boolean;
createdAt: Date;
enabledModules?: string[];
_count: { memberships: number; people: number; deals: number };
}): PlatformOrganizationDto {
return {
id: row.id,
name: row.name,
slug: row.slug,
businessField: row.businessField,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
memberCount: row._count.memberships,
personCount: row._count.people,
dealCount: row._count.deals,
enabledModules: this.billing.normalizeModules(row.enabledModules),
};
}
private userToDto(row: {
id: string;
email: string;
mobile: string | null;
fullName: string;
isActive: boolean;
lastLoginAt: Date | null;
createdAt: Date;
billingAccount?: {
id: string;
ownerUserId: string;
planCode: string;
status: string;
startsAt: Date;
expiresAt: Date;
maxOrganizations: number;
externalPaymentRef: string | null;
notes: string | null;
} | null;
memberships: Array<{
id: string;
role: PlatformUserDto['memberships'][number]['role'];
isActive: boolean;
organization: { id: string; name: string; isActive: boolean };
}>;
}): PlatformUserDto {
const isBillingOwner = row.memberships.some(
(m) => m.role === 'owner' && m.isActive,
);
return {
id: row.id,
email: row.email,
mobile: row.mobile,
fullName: row.fullName,
isActive: row.isActive,
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
isBillingOwner,
billing:
isBillingOwner && row.billingAccount
? this.billing.toDto(row.billingAccount)
: null,
memberships: row.memberships.map((m) => ({
membershipId: m.id,
organizationId: m.organization.id,
organizationName: m.organization.name,
organizationIsActive: m.organization.isActive,
role: m.role,
isActive: m.isActive,
})),
};
}
}
@@ -2,9 +2,13 @@ import { Injectable } from '@nestjs/common';
import type { ImportContactsResult } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import {
excelFieldsFromColumns,
parseAmount,
parseExcelMapping,
previewExcel,
readMappedRows,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
import { ProductsService } from './products.service';
@@ -106,11 +110,16 @@ export class ProductsExcelService {
);
}
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, PRODUCT_ALIASES, excelFieldsFromColumns(PRODUCT_COLUMNS, ['name']));
}
async import(
organizationId: string,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, PRODUCT_ALIASES);
const rows = await readMappedRows(file, PRODUCT_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
for (let i = 0; i < rows.length; i++) {
+14 -1
View File
@@ -62,6 +62,18 @@ export class ProductsController {
return new StreamableFile(buffer);
}
@Post('import/preview')
@Roles('owner', 'admin')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
}),
)
importPreview(@UploadedFile() file: Express.Multer.File) {
return this.excelService.preview(file);
}
@Post('import')
@Roles('owner', 'admin')
@UseInterceptors(
@@ -73,8 +85,9 @@ export class ProductsController {
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.import(user.organizationId, file);
return this.excelService.import(user.organizationId, file, mapping);
}
@Get(':id')
@@ -128,6 +128,17 @@ export class ProjectBoardsController {
return new StreamableFile(buffer);
}
@Post('project-cards/import/preview')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
}),
)
previewCards(@UploadedFile() file: Express.Multer.File) {
return this.excelService.preview(file);
}
@Post('project-cards/import')
@UseInterceptors(
FileInterceptor('file', {
@@ -138,8 +149,9 @@ export class ProjectBoardsController {
importCards(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.import(user, file);
return this.excelService.import(user, file, mapping);
}
@Get('project-cards/:id')
@@ -5,11 +5,15 @@ import type { Actor } from '../common/record-scope';
import { ownerWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatJalaliDate,
parseExcelMapping,
parseJalaliDateTime,
previewExcel,
readMappedRows,
stripHtml,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
import { ProjectBoardsService } from './project-boards.service';
@@ -105,8 +109,16 @@ export class ProjectCardsExcelService {
);
}
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, CASE_ALIASES);
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, CASE_ALIASES, excelFieldsFromColumns(CASE_COLUMNS, ['title']));
}
async import(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, CASE_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
const affected = new Map<string, string>();
Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

+3 -3
View File
@@ -90,7 +90,7 @@ form {
gap: 0.45rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
border-radius: var(--radius);
padding: 0.35rem 0.4rem 0.35rem 0.7rem;
}
@@ -110,7 +110,7 @@ input {
button {
border: 0;
border-radius: 10px;
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
font: inherit;
@@ -126,7 +126,7 @@ button:disabled {
.answer {
background: #f0fdfa;
border-radius: 12px;
border-radius: var(--radius);
padding: 0.7rem 0.85rem;
}
+47 -58
View File
@@ -2,6 +2,7 @@
import { onMounted, ref, watch } from 'vue';
import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { UiField, UiInput, UiSelect } from '@/components/ui';
import type {
CustomFieldDefinitionDto,
CustomFieldEntity,
@@ -58,27 +59,28 @@ onMounted(load);
<template>
<div v-if="loading" class="muted">بارگذاری فیلدهای سفارشی…</div>
<div v-else-if="fields.length" class="custom-block">
<h3>فیلدهای سفارشی</h3>
<div class="grid">
<div v-for="field in fields" :key="field.id" class="field">
<label :for="`cf-${field.key}`">
{{ field.label }}
<span v-if="field.required" class="req">*</span>
</label>
<select
<h3 class="custom-title">فیلدهای سفارشی</h3>
<div class="crm-form-grid-2">
<UiField
v-for="field in fields"
:key="field.id"
:label="field.label"
:required="field.required"
:for-id="`cf-${field.key}`"
>
<UiSelect
v-if="field.fieldType === 'select'"
:id="`cf-${field.key}`"
:value="String(displayValue(field.key))"
@change="setValue(field.key, ($event.target as HTMLSelectElement).value)"
:model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event)"
>
<option value="">انتخاب کنید</option>
<option v-for="opt in field.options" :key="opt" :value="opt">
{{ opt }}
</option>
</select>
</UiSelect>
<label v-else-if="field.fieldType === 'boolean'" class="check">
<label v-else-if="field.fieldType === 'boolean'" class="cf-check">
<input
:id="`cf-${field.key}`"
type="checkbox"
@@ -87,20 +89,18 @@ onMounted(load);
setValue(field.key, ($event.target as HTMLInputElement).checked)
"
/>
فعال
<span>فعال</span>
</label>
<input
<UiInput
v-else-if="field.fieldType === 'number'"
:id="`cf-${field.key}`"
type="number"
:value="String(displayValue(field.key))"
@input="
:model-value="props.modelValue[field.key] != null ? Number(props.modelValue[field.key]) : ''"
@update:model-value="
setValue(
field.key,
($event.target as HTMLInputElement).value === ''
? null
: Number(($event.target as HTMLInputElement).value),
$event === '' || $event == null ? null : Number($event),
)
"
/>
@@ -112,66 +112,55 @@ onMounted(load);
@update:model-value="setValue(field.key, $event || null)"
/>
<input
<UiInput
v-else
:id="`cf-${field.key}`"
type="text"
:value="String(displayValue(field.key))"
@input="setValue(field.key, ($event.target as HTMLInputElement).value)"
:model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event)"
/>
</div>
</UiField>
</div>
</div>
</template>
<style scoped>
.custom-block {
display: grid;
gap: 0.75rem;
padding-top: 0.4rem;
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding-top: var(--sp-4);
border-top: 1px dashed var(--line);
width: 100%;
}
h3 {
.custom-title {
margin: 0;
font-size: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
}
.muted {
color: var(--muted);
font-size: var(--fs-xs);
}
.req {
color: #b42318;
}
.check {
display: flex;
.cf-check {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
gap: var(--sp-2);
min-height: var(--control-h-md);
font-size: var(--fs-base);
font-weight: var(--fw-medium);
color: var(--ink);
cursor: pointer;
}
select,
input[type='text'],
input[type='number'] {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.8rem 0.95rem;
background: #fff;
font: inherit;
width: 100%;
}
@media (max-width: 700px) {
.grid {
grid-template-columns: 1fr;
}
.cf-check input[type='checkbox'] {
width: 18px;
height: 18px;
accent-color: var(--accent);
cursor: pointer;
}
</style>
+2 -2
View File
@@ -289,7 +289,7 @@ onMounted(load);
.composer input {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
border-radius: var(--radius);
padding: 0.7rem 0.85rem;
background: #fff;
}
@@ -316,7 +316,7 @@ onMounted(load);
gap: 0.75rem;
align-items: flex-start;
border: 1px solid var(--line);
border-radius: 12px;
border-radius: var(--radius);
padding: 0.75rem 0.85rem;
background: #fbfcfb;
}
+224
View File
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type { ExcelPreviewResult, ImportContactsResult } from '@mento/shared';
import { UiButton, UiModal, UiSelect } from '@/components/ui';
const props = defineProps<{
previewPath: string;
importPath: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
start: [];
done: [ImportContactsResult];
fail: [string];
settled: [];
}>();
const input = ref<HTMLInputElement | null>(null);
const pending = ref<File | null>(null);
const preview = ref<ExcelPreviewResult | null>(null);
const mapping = reactive<Record<string, string>>({});
const submitting = ref(false);
const requiredKeys = computed(
() => preview.value?.fields.filter((f) => f.required).map((f) => f.key) ?? [],
);
const mappedFields = computed(() => new Set(Object.values(mapping).filter(Boolean)));
const canImport = computed(() => requiredKeys.value.every((key) => mappedFields.value.has(key)));
function pick() {
if (props.disabled || submitting.value) return;
input.value?.click();
}
function reset() {
pending.value = null;
preview.value = null;
submitting.value = false;
for (const key of Object.keys(mapping)) delete mapping[key];
}
function closeMapper() {
reset();
emit('settled');
}
function setColumnField(header: string, field: string) {
if (field) {
for (const [col, current] of Object.entries(mapping)) {
if (col !== header && current === field) mapping[col] = '';
}
}
mapping[header] = field;
}
async function postImport(file: File, map?: Record<string, string>) {
const form = new FormData();
form.append('file', file);
if (map && Object.values(map).some(Boolean)) {
form.append('mapping', JSON.stringify(map));
}
const { data } = await api.post<ImportContactsResult>(props.importPath, form, {
headers: { 'Content-Type': undefined },
});
return data;
}
function apiMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
async function onFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
emit('start');
submitting.value = true;
try {
const form = new FormData();
form.append('file', file);
const { data } = await api.post<ExcelPreviewResult>(props.previewPath, form, {
headers: { 'Content-Type': undefined },
});
if (!data.needsMapping) {
emit('done', await postImport(file));
reset();
emit('settled');
return;
}
pending.value = file;
preview.value = data;
for (const col of data.columns) {
mapping[col.header] = data.suggested[col.header] ?? '';
}
} catch (e) {
emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود'));
reset();
emit('settled');
} finally {
submitting.value = false;
}
}
async function confirmMapping() {
if (!pending.value || !canImport.value) return;
submitting.value = true;
try {
emit('done', await postImport(pending.value, { ...mapping }));
reset();
emit('settled');
} catch (e) {
emit('fail', apiMessage(e, 'ایمپورت ناموفق بود'));
submitting.value = false;
}
}
</script>
<template>
<span class="excel-import" @click.stop="pick">
<slot />
<input
ref="input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
@change="onFile"
@click.stop
/>
</span>
<UiModal :open="!!preview" title="تطبیق ستون‌های اکسل" @close="closeMapper">
<p class="lead">
ستون‌های این فایل با قالب سیستم یکی نیست. مشخص کنید هر ستون فایل در کدام فیلد ریخته شود.
</p>
<div class="rows">
<div v-for="col in preview?.columns ?? []" :key="col.header" class="row">
<div class="source">
<strong>{{ col.header }}</strong>
<small v-if="col.samples.length">نمونه: {{ col.samples.join('، ') }}</small>
</div>
<UiSelect
:model-value="mapping[col.header] ?? ''"
@update:model-value="setColumnField(col.header, $event)"
>
<option value="">نادیده بگیر</option>
<option v-for="field in preview?.fields ?? []" :key="field.key" :value="field.key">
{{ field.label }}{{ field.required ? ' *' : '' }}
</option>
</UiSelect>
</div>
</div>
<p v-if="!canImport" class="hint">فیلدهای ستاره‌دار را حتماً به یک ستون وصل کنید.</p>
<template #footer>
<UiButton variant="ghost" :disabled="submitting" @click="closeMapper">انصراف</UiButton>
<UiButton :disabled="submitting || !canImport" @click="confirmMapping">
{{ submitting ? 'در حال ورود…' : 'ورود فایل' }}
</UiButton>
</template>
</UiModal>
</template>
<style scoped>
.excel-import {
display: inline-flex;
}
.lead {
margin: 0 0 1rem;
color: var(--muted);
line-height: 1.7;
}
.rows {
display: grid;
gap: 0.75rem;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 0.75rem;
align-items: center;
}
.source {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.source strong {
font-size: 0.95rem;
}
.source small {
color: var(--muted);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint {
margin: 0.85rem 0 0;
color: var(--danger);
font-size: 0.85rem;
}
@media (max-width: 700px) {
.row {
grid-template-columns: 1fr;
}
}
</style>
+14 -14
View File
@@ -675,7 +675,7 @@ onBeforeUnmount(() => {
justify-content: flex-start;
gap: 0.8rem;
min-height: 52px;
border-radius: 16px;
border-radius: var(--radius);
padding: 0.45rem 0.85rem 0.45rem 0.45rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fffd 100%);
}
@@ -698,7 +698,7 @@ onBeforeUnmount(() => {
left: 50%;
bottom: 50%;
transform-origin: center bottom;
border-radius: 99px;
border-radius: var(--radius);
background: #0f766e;
}
@@ -726,7 +726,7 @@ onBeforeUnmount(() => {
}
.clock-text {
font-weight: 800;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
font-size: 1.05rem;
@@ -768,7 +768,7 @@ onBeforeUnmount(() => {
max-height: calc(100vh - 16px);
overflow: auto;
background: #fff;
border-radius: 16px;
border-radius: var(--radius);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
padding: 0.75rem 0.8rem 0.7rem;
font-family: IRANSans, Tahoma, sans-serif;
@@ -797,7 +797,7 @@ onBeforeUnmount(() => {
width: 32px;
height: 32px;
border: 0;
border-radius: 8px;
border-radius: var(--radius);
background: #f1f5f9;
color: #0f766e;
display: grid;
@@ -826,7 +826,7 @@ onBeforeUnmount(() => {
.day {
height: 34px;
border: 0;
border-radius: 9px;
border-radius: var(--radius);
background: none;
color: #0f172a;
font: inherit;
@@ -881,12 +881,12 @@ onBeforeUnmount(() => {
min-width: 5.1rem;
height: 4.1rem;
border: 0;
border-radius: 14px;
border-radius: var(--radius);
background: #f1f5f9;
color: #0f172a;
font: inherit;
font-size: 2.35rem;
font-weight: 800;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
cursor: pointer;
@@ -900,7 +900,7 @@ onBeforeUnmount(() => {
.md-colon {
font-size: 2.2rem;
font-weight: 800;
font-weight: 600;
color: #0f172a;
line-height: 1;
padding-bottom: 0.15rem;
@@ -1005,7 +1005,7 @@ onBeforeUnmount(() => {
margin: 0;
text-align: center;
font-size: 1.2rem;
font-weight: 800;
font-weight: 600;
color: #0f766e;
letter-spacing: 0.04em;
}
@@ -1033,7 +1033,7 @@ onBeforeUnmount(() => {
height: 220px;
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 12px;
border-radius: var(--radius);
background: #f8fafc;
padding: 0.25rem;
scrollbar-width: thin;
@@ -1044,7 +1044,7 @@ onBeforeUnmount(() => {
width: 100%;
height: 36px;
border: 0;
border-radius: 8px;
border-radius: var(--radius);
background: transparent;
color: #334155;
font: inherit;
@@ -1087,9 +1087,9 @@ onBeforeUnmount(() => {
margin-inline-start: auto;
background: #14b8a6;
color: #fff;
border-radius: 10px;
border-radius: var(--radius);
padding: 0.4rem 0.8rem;
font-weight: 800;
font-weight: 600;
font-size: 0.8rem;
}
+2 -2
View File
@@ -203,7 +203,7 @@ h2 {
textarea {
border: 1px solid var(--line);
border-radius: 12px;
border-radius: var(--radius);
padding: 0.75rem 0.9rem;
background: #fff;
font: inherit;
@@ -221,7 +221,7 @@ textarea {
.items li {
border: 1px solid var(--line);
border-radius: 12px;
border-radius: var(--radius);
padding: 0.75rem 0.85rem;
background: #fff;
}
@@ -231,7 +231,7 @@ function formatPayloadSummary(value: NoteAiPayloadDto | null) {
margin-top: 0.75rem;
padding: 0.75rem 0.85rem;
border: 1px dashed var(--accent);
border-radius: 12px;
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.65);
display: grid;
gap: 0.6rem;
@@ -281,7 +281,7 @@ header {
input[type='text'],
textarea {
border: 1px solid var(--line);
border-radius: 10px;
border-radius: var(--radius);
padding: 0.55rem 0.7rem;
font: inherit;
width: 100%;
+70 -49
View File
@@ -5,8 +5,9 @@ import type { RouteLocationRaw } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
const FAB_SIZE = 36;
const FAB_MARGIN = 20;
const POS_KEY = 'mento.quickCreateFab';
const FAB_MARGIN_BOTTOM = 24;
const FAB_MARGIN_LEFT = 24;
const POS_KEY = 'mento.quickCreateFab_v2';
type FabPos = { left: number; top: number };
@@ -67,24 +68,37 @@ const router = useRouter();
const hidden = computed(() => route.name === 'person-detail');
const isQuickCreateOpen = ref(false);
const ready = ref(false);
const fabPos = ref<FabPos>({ left: FAB_MARGIN, top: FAB_MARGIN });
const fabPos = ref<FabPos>({
left: FAB_MARGIN_LEFT,
top: typeof window !== 'undefined' ? window.innerHeight - FAB_MARGIN_BOTTOM - FAB_SIZE : 600,
});
const viewport = ref({ w: 1280, h: 800 });
const dragging = ref(false);
let dragMoved = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
function mobileTabbarPx(): number {
if (typeof window === 'undefined' || viewport.value.w > 900) return 0;
const bar = document.querySelector('.mobile-tabbar');
if (bar instanceof HTMLElement) {
const h = bar.getBoundingClientRect().height;
if (h > 0) return h;
}
return 64;
}
function defaultPos(): FabPos {
const bottomGap = viewport.value.w <= 900 ? FAB_MARGIN + 64 : FAB_MARGIN;
const extra = mobileTabbarPx();
return {
left: FAB_MARGIN,
top: Math.max(FAB_MARGIN, viewport.value.h - bottomGap - FAB_SIZE),
left: FAB_MARGIN_LEFT,
top: Math.max(8, viewport.value.h - FAB_MARGIN_BOTTOM - extra - FAB_SIZE),
};
}
function clampPos(pos: FabPos): FabPos {
const margin = 8;
const bottomGap = viewport.value.w <= 900 ? 64 : 0;
const bottomGap = mobileTabbarPx();
return {
left: Math.min(Math.max(margin, pos.left), viewport.value.w - FAB_SIZE - margin),
top: Math.min(
@@ -171,7 +185,7 @@ const fabStyle = computed(() => ({
const panelStyle = computed(() => {
const gap = 12;
const edge = 16;
const width = Math.min(390, viewport.value.w - 32);
const width = Math.min(300, viewport.value.w - 32);
let left = fabPos.value.left;
left = Math.min(left, viewport.value.w - width - edge);
left = Math.max(edge, left);
@@ -278,7 +292,7 @@ onBeforeUnmount(() => {
@click="go(item.to)"
>
<span class="item-ico" :class="item.tone">
<UiIcon :name="item.icon" :size="26" />
<UiIcon :name="item.icon" :size="18" />
</span>
<span class="copy">
<strong>{{ item.title }}</strong>
@@ -303,7 +317,7 @@ onBeforeUnmount(() => {
@pointercancel="onFabPointerUp"
>
<span class="fab-face" aria-hidden="true">
<UiIcon :name="isQuickCreateOpen ? 'x' : 'apps-plus'" :size="28" />
<UiIcon :name="isQuickCreateOpen ? 'x' : 'apps-plus'" :size="20" />
</span>
</button>
</div>
@@ -328,25 +342,26 @@ onBeforeUnmount(() => {
.panel {
position: fixed;
z-index: 92;
width: 390px;
max-height: min(540px, calc(100dvh - 80px));
width: 300px;
max-height: min(480px, calc(100dvh - 80px));
box-sizing: border-box;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 28px;
padding: 16px 16px 12px;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 12px 12px 10px;
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.panel h2 {
flex-shrink: 0;
margin: 0 0 12px;
margin: 0 0 10px;
text-align: right;
font-size: 20px;
font-weight: 800;
color: #111827;
font-size: var(--fs-md);
font-weight: 700;
color: var(--ink);
}
.items {
@@ -354,31 +369,37 @@ onBeforeUnmount(() => {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
padding-left: 2px;
overscroll-behavior: contain;
}
.item {
width: 100%;
min-height: 68px;
min-height: 46px;
display: flex;
align-items: center;
gap: 14px;
padding: 10px 14px;
border: 1px solid #eef1f2;
border-radius: 16px;
gap: 10px;
padding: 6px 10px;
border: 1px solid var(--line-light);
border-radius: var(--radius);
background: #fff;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04);
box-shadow: var(--shadow-xs);
font: inherit;
text-align: right;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.item:hover {
background: var(--surface-hover);
border-color: var(--line-strong);
}
.item-ico {
width: 52px;
height: 52px;
border-radius: 14px;
width: 34px;
height: 34px;
border-radius: var(--radius);
display: grid;
place-items: center;
color: #fff;
@@ -390,7 +411,7 @@ onBeforeUnmount(() => {
}
.item-ico.company {
background: #22d3ee;
background: #0891b2;
}
.item-ico.deal {
@@ -398,7 +419,7 @@ onBeforeUnmount(() => {
}
.item-ico.activity {
background: #f97316;
background: #ea580c;
}
.item-ico.note {
@@ -407,33 +428,34 @@ onBeforeUnmount(() => {
.copy {
display: grid;
gap: 4px;
gap: 2px;
min-width: 0;
text-align: right;
}
.copy strong {
font-size: 16px;
font-size: var(--fs-sm);
font-weight: 600;
color: #111827;
color: var(--ink);
line-height: 1.2;
}
.copy small {
font-size: 13px;
color: #94a3b8;
line-height: 1.5;
font-size: var(--fs-xs);
color: var(--muted);
line-height: 1.3;
}
.fab {
position: fixed;
z-index: 93;
box-sizing: border-box;
width: 36px;
height: 36px;
width: var(--control-h);
height: var(--control-h);
padding: 0;
margin: 0;
border: 0;
border-radius: 10px;
border-radius: var(--radius);
overflow: hidden;
background: linear-gradient(145deg, #0f766e 0%, #14b8a6 100%);
color: #fff;
@@ -442,7 +464,7 @@ onBeforeUnmount(() => {
line-height: 0;
direction: ltr;
box-shadow:
0 8px 22px rgba(13, 148, 136, 0.4),
0 4px 14px rgba(13, 148, 136, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
cursor: grab;
touch-action: none;
@@ -463,29 +485,28 @@ onBeforeUnmount(() => {
.fab :deep(.ico) {
display: block;
width: 28px;
height: 28px;
max-width: 28px;
max-height: 28px;
width: 20px;
height: 20px;
max-width: 20px;
max-height: 20px;
stroke-width: 1.7;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
}
.fab:hover {
filter: brightness(1.06);
box-shadow:
0 10px 26px rgba(13, 148, 136, 0.48),
0 6px 18px rgba(13, 148, 136, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.fab.open {
background: linear-gradient(145deg, #115e59 0%, #0d9488 100%);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.28);
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.25);
}
.fab.dragging {
cursor: grabbing;
transform: scale(1.05);
box-shadow: 0 12px 28px rgba(13, 148, 136, 0.5);
box-shadow: 0 8px 24px rgba(13, 148, 136, 0.45);
}
</style>
+34 -2
View File
@@ -55,6 +55,8 @@ defineProps<{
| 'grid'
| 'list'
| 'eye'
| 'eye-off'
| 'lock'
| 'truck'
| 'grip'
| 'apps-plus'
@@ -64,7 +66,13 @@ defineProps<{
| 'logout'
| 'pulse'
| 'sliders'
| 'camera';
| 'camera'
| 'arrow-right'
| 'arrow-left'
| 'chevron-right'
| 'chevron-left'
| 'chevrons-right'
| 'chevrons-left';
size?: number;
}>();
</script>
@@ -218,9 +226,24 @@ defineProps<{
<template v-else-if="name === 'plus'">
<path d="M12 6v12M6 12h12" />
</template>
<template v-else-if="name === 'chevron'">
<template v-else-if="name === 'chevron' || name === 'chevron-right'">
<path d="m9 6 6 6-6 6" />
</template>
<template v-else-if="name === 'chevron-left'">
<path d="m15 18-6-6 6-6" />
</template>
<template v-else-if="name === 'chevrons-right'">
<path d="m6 17 5-5-5-5M13 17l5-5-5-5" />
</template>
<template v-else-if="name === 'chevrons-left'">
<path d="m11 17-5-5 5-5M18 17l-5-5 5-5" />
</template>
<template v-else-if="name === 'arrow-right'">
<path d="M5 12h14M12 5l7 7-7 7" />
</template>
<template v-else-if="name === 'arrow-left'">
<path d="M19 12H5M12 19l-7-7 7-7" />
</template>
<template v-else-if="name === 'star'">
<path d="m12 3.4 2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 15.8 7.2 18.3l.9-5.4-3.9-3.8 5.4-.8L12 3.4Z" />
</template>
@@ -318,6 +341,15 @@ defineProps<{
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
</template>
<template v-else-if="name === 'eye-off'">
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
<path d="M4 20 20 4" />
</template>
<template v-else-if="name === 'lock'">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
</template>
<template v-else-if="name === 'truck'">
<path d="M3 7.5h11V16H3z" />
<path d="M14 10h4.2l2.3 3.2V16H14z" />
+1 -1
View File
@@ -52,7 +52,7 @@ img {
em {
font-style: normal;
color: #fff;
font-weight: 800;
font-weight: 600;
font-size: 0.62rem;
line-height: 1;
}
@@ -373,7 +373,7 @@ onBeforeUnmount(() => {
.meter i {
display: block;
width: 3px;
border-radius: 99px;
border-radius: var(--radius);
background: #5eead4;
box-shadow: 0 0 8px rgba(94, 234, 212, 0.45);
transition: height 70ms linear;
@@ -402,7 +402,7 @@ onBeforeUnmount(() => {
height: 38px;
min-height: 38px;
padding: 0;
border-radius: 12px;
border-radius: var(--radius);
border-color: transparent;
background: #f8fafc;
justify-content: center;
@@ -296,8 +296,8 @@ onBeforeUnmount(() => {
.head h2 {
margin: 0;
text-align: center;
font-size: 1.05rem;
font-weight: 800;
font-size: 16px;
font-weight: 500;
color: #0f172a;
}
@@ -332,7 +332,7 @@ onBeforeUnmount(() => {
align-items: flex-start;
padding: 0.9rem 0.95rem;
border: 1px solid #e2e8f0;
border-radius: 18px;
border-radius: var(--radius);
background: #fff;
}
@@ -370,7 +370,7 @@ onBeforeUnmount(() => {
.ctx-copy h3 {
margin: 0.1rem 0 0;
font-size: 0.98rem;
font-weight: 800;
font-weight: 600;
line-height: 1.55;
}
@@ -404,7 +404,7 @@ onBeforeUnmount(() => {
.editor {
border: 1px solid #e2e8f0;
border-radius: 16px;
border-radius: var(--radius);
background: #fff;
min-height: 11.5rem;
display: flex;
@@ -448,13 +448,13 @@ onBeforeUnmount(() => {
width: 36px;
height: 36px;
border: 1px solid #e2e8f0;
border-radius: 10px;
border-radius: var(--radius);
background: #fff;
color: #0f766e;
display: grid;
place-items: center;
font: inherit;
font-weight: 800;
font-weight: 600;
cursor: pointer;
}
@@ -473,7 +473,7 @@ onBeforeUnmount(() => {
font-size: 0.74rem;
font-weight: 700;
padding: 0.42rem 0.65rem;
border-radius: 10px;
border-radius: var(--radius);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18);
}
@@ -497,7 +497,7 @@ onBeforeUnmount(() => {
max-height: 12rem;
overflow: auto;
background: #fff;
border-radius: 12px;
border-radius: var(--radius);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.16);
z-index: 2;
}
@@ -510,7 +510,7 @@ onBeforeUnmount(() => {
text-align: start;
font: inherit;
padding: 0.55rem 0.65rem;
border-radius: 8px;
border-radius: var(--radius);
color: #0f172a;
cursor: pointer;
}
@@ -536,7 +536,7 @@ onBeforeUnmount(() => {
align-items: flex-start;
gap: 0.5rem;
padding: 0.72rem 0.8rem;
border-radius: 12px;
border-radius: var(--radius);
background: #ecfdf5;
color: #0f766e;
font-size: 0.8rem;
@@ -559,7 +559,7 @@ onBeforeUnmount(() => {
height: var(--control-h);
border-radius: var(--radius);
font: inherit;
font-weight: 800;
font-weight: 600;
cursor: pointer;
}
@@ -600,7 +600,7 @@ onBeforeUnmount(() => {
position: relative;
max-width: 480px;
height: min(92dvh, 860px);
border-radius: 22px;
border-radius: var(--radius, 10px);
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22);
}
}
@@ -274,8 +274,8 @@ onBeforeUnmount(() => {
}
.titles h2 {
font-size: 1.02rem;
font-weight: 800;
font-size: 16px;
font-weight: 500;
color: #0f172a;
}
@@ -319,7 +319,7 @@ onBeforeUnmount(() => {
min-height: 72px;
padding: 0.7rem 0.9rem;
border: 1px solid #e8eef3;
border-radius: 16px;
border-radius: var(--radius);
background: #fff;
font: inherit;
text-align: start;
@@ -340,7 +340,7 @@ onBeforeUnmount(() => {
.label,
.copy strong {
font-size: 0.95rem;
font-weight: 800;
font-weight: 600;
color: #0f172a;
}
@@ -365,7 +365,7 @@ onBeforeUnmount(() => {
.ico-box {
width: 40px;
height: 40px;
border-radius: 10px;
border-radius: var(--radius);
background: #e6f7f4;
color: #0d9488;
display: grid;
@@ -375,13 +375,13 @@ onBeforeUnmount(() => {
.when {
color: #0d9488;
font-size: 0.74rem;
font-weight: 800;
font-weight: 600;
white-space: nowrap;
}
.custom-box {
border: 1px solid #e8eef3;
border-radius: 16px;
border-radius: var(--radius);
padding: 0.85rem 0.9rem;
display: grid;
gap: 0.55rem;
@@ -419,7 +419,7 @@ onBeforeUnmount(() => {
align-items: flex-start;
gap: 0.55rem;
padding: 0.85rem 0.9rem;
border-radius: 14px;
border-radius: var(--radius);
background: #f8fafc;
color: #475569;
font-size: 0.8rem;
@@ -452,7 +452,7 @@ onBeforeUnmount(() => {
position: relative;
max-width: 480px;
height: min(92dvh, 860px);
border-radius: 22px;
border-radius: var(--radius, 10px);
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22);
}
}
+84 -165
View File
@@ -10,44 +10,37 @@ withDefaults(
}>(),
{
headline: 'همه ارتباطات، یکجا مدیریت می‌شوند',
description: `با ${brand.nameFa} فروش، پشتیبانی و ارتباط با مشتری را در یک پنل یکپارچه مدیریت کنید و رشد پایدار بسازید.`,
description: `${brand.nameFa} به شما کمک می کند فروش بیشتر داشته باشید،\nمشتریان را بهتر بشناسید و کسب و کارتان را رشد دهید.`,
},
);
</script>
<template>
<aside class="brand-panel" aria-hidden="false">
<div class="brand-head">
<MentoLogo :size="44" :show-text="false" />
<div class="brand-copy">
<BrandName />
<span>{{ brand.taglineFa }}</span>
<div class="brand-main">
<div class="brand-head">
<MentoLogo :size="70" :show-text="false" />
<div class="brand-copy">
<BrandName />
<span class="tagline">{{ brand.taglineFa }}</span>
</div>
</div>
<div class="brand-body">
<h2>{{ headline }}</h2>
<p>{{ description }}</p>
</div>
<div class="illustration" aria-hidden="true">
<img src="/brand/lagin_pic.png" alt="" />
</div>
</div>
<div class="brand-body">
<h2>{{ headline }}</h2>
<p>{{ description }}</p>
</div>
<div class="illustration" aria-hidden="true">
<div class="dash">
<div class="dash-top">
<span class="avatar" />
<span class="bar short" />
<span class="bar" />
</div>
<div class="chart-line" />
<div class="chart-bars">
<i /><i /><i /><i /><i />
</div>
</div>
<span class="float chat" />
<span class="float people" />
<span class="float check" />
<span class="float ring" />
<span class="plant" />
</div>
<nav class="brand-foot">
<a href="#">شرایط و قوانین</a>
<span>|</span>
<a href="#">تماس با ما</a>
</nav>
</aside>
</template>
@@ -56,184 +49,110 @@ withDefaults(
position: relative;
display: none;
flex-direction: column;
gap: 1.5rem;
padding: clamp(1.4rem, 3vw, 2rem);
background:
radial-gradient(circle at 20% 10%, rgba(45, 212, 191, 0.18), transparent 42%),
linear-gradient(165deg, #f0fdfa 0%, #e6fffa 48%, #f8fafc 100%);
align-items: center;
justify-content: center;
text-align: center;
gap: 0;
padding: 2.6rem clamp(1.4rem, 3vw, 2.4rem);
background-color: #f5fbfb;
background-image:
radial-gradient(ellipse 72% 48% at 50% 58%, rgba(45, 212, 191, 0.14), transparent 70%),
radial-gradient(circle at 12% 10%, rgba(20, 184, 166, 0.08), transparent 28%),
radial-gradient(rgba(13, 148, 136, 0.16) 1.05px, transparent 1.2px);
background-size: auto, auto, 20px 20px;
overflow: hidden;
min-width: 0;
min-height: 100%;
}
.brand-main {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.35rem;
}
.brand-head {
display: flex;
align-items: center;
gap: 0.75rem;
gap: 0.85rem;
}
.brand-copy {
display: grid;
gap: 0.1rem;
text-align: start;
}
.brand-copy strong {
font-size: 1.2rem;
color: #0f172a;
.brand-copy :deep(.brand-name) {
font-size: 2rem;
font-weight: 800;
color: #0f172a;
line-height: 1.1;
}
.brand-copy span {
font-size: 0.78rem;
.brand-copy .tagline {
font-size: 0.88rem;
color: #64748b;
}
.brand-body {
width: fit-content;
max-width: 100%;
margin-inline: auto;
}
.brand-body h2 {
margin: 0 0 0.55rem;
font-size: clamp(1.35rem, 2.2vw, 1.75rem);
margin: 0 0 0.6rem;
font-size: clamp(1.22rem, 1.95vw, 1.52rem);
line-height: 1.45;
color: #0f172a;
font-weight: 800;
white-space: nowrap;
}
.brand-body p {
margin: 0;
width: 100%;
color: #64748b;
line-height: 1.75;
font-size: 0.95rem;
max-width: 28ch;
line-height: 1.7;
font-size: 1.08rem;
white-space: pre-line;
}
.illustration {
position: relative;
margin-top: auto;
min-height: 220px;
flex: 1;
width: 100%;
display: grid;
place-items: center;
}
.dash {
.illustration img {
width: min(100%, 460px);
height: auto;
max-height: 360px;
object-fit: contain;
}
.brand-foot {
position: absolute;
inset-inline-start: 12%;
bottom: 18%;
width: min(78%, 280px);
aspect-ratio: 1.35;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 18px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.1);
padding: 1rem;
backdrop-filter: blur(6px);
}
.dash-top {
inset-inline: 0;
bottom: 1.15rem;
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.avatar {
width: 28px;
height: 28px;
border-radius: 999px;
background: linear-gradient(145deg, #5eead4, #0d9488);
}
.bar {
height: 8px;
border-radius: 999px;
background: #e2e8f0;
flex: 1;
}
.bar.short {
flex: 0.55;
}
.chart-line {
height: 54px;
border-radius: 12px;
background:
linear-gradient(180deg, transparent 55%, rgba(13, 148, 136, 0.12) 55%),
radial-gradient(circle at 20% 60%, #14b8a6 3px, transparent 4px),
radial-gradient(circle at 45% 35%, #14b8a6 3px, transparent 4px),
radial-gradient(circle at 70% 50%, #14b8a6 3px, transparent 4px),
radial-gradient(circle at 88% 28%, #14b8a6 3px, transparent 4px);
margin-bottom: 0.85rem;
}
.chart-bars {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 0.45rem;
height: 48px;
color: #94a3b8;
font-size: 0.8rem;
}
.chart-bars i {
flex: 1;
border-radius: 6px 6px 2px 2px;
background: linear-gradient(180deg, #5eead4, #0f766e);
opacity: 0.85;
.brand-foot a {
color: #94a3b8;
text-decoration: none;
}
.chart-bars i:nth-child(1) { height: 45%; }
.chart-bars i:nth-child(2) { height: 70%; }
.chart-bars i:nth-child(3) { height: 55%; }
.chart-bars i:nth-child(4) { height: 88%; }
.chart-bars i:nth-child(5) { height: 62%; }
.float {
position: absolute;
width: 42px;
height: 42px;
border-radius: 14px;
background: #fff;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
}
.float.chat {
top: 18%;
inset-inline-end: 8%;
background:
radial-gradient(circle at 35% 40%, #0d9488 0 28%, transparent 29%),
linear-gradient(145deg, #ecfdf5, #fff);
}
.float.people {
top: 42%;
inset-inline-start: 4%;
background:
radial-gradient(circle at 50% 38%, #14b8a6 0 22%, transparent 23%),
linear-gradient(145deg, #f0fdfa, #fff);
}
.float.check {
bottom: 28%;
inset-inline-end: 6%;
background:
linear-gradient(135deg, transparent 42%, #10b981 42% 58%, transparent 58%),
linear-gradient(45deg, transparent 48%, #10b981 48% 62%, transparent 62%),
#ecfdf5;
}
.float.ring {
top: 8%;
inset-inline-start: 18%;
border-radius: 999px;
background:
conic-gradient(#0d9488 0 70%, #e2e8f0 70% 100%);
mask: radial-gradient(farthest-side, transparent calc(100% - 7px), #000 calc(100% - 6px));
}
.plant {
position: absolute;
bottom: 8%;
inset-inline-start: 8%;
width: 28px;
height: 36px;
background:
radial-gradient(ellipse at 50% 20%, #34d399 0 35%, transparent 36%),
linear-gradient(180deg, #0d9488 0 55%, #134e4a 55%);
border-radius: 40% 40% 8px 8px;
opacity: 0.9;
.brand-foot a:hover {
color: #0f766e;
}
@media (min-width: 900px) {
@@ -224,7 +224,7 @@ function nodeClass(step: AutomationStep) {
width: 100%;
min-width: 0;
min-height: 22rem;
border-radius: 16px;
border-radius: var(--radius);
overflow: auto;
background-color: #f3f6f5;
background-image: radial-gradient(#c9d4cf 1.1px, transparent 1.2px);
@@ -254,7 +254,7 @@ function nodeClass(step: AutomationStep) {
gap: 0.7rem;
text-align: start;
padding: 0.72rem 0.8rem;
border-radius: 12px;
border-radius: var(--radius);
border: 1px solid #d5ddd8;
background: #fff;
box-shadow: 0 10px 24px rgba(23, 33, 28, 0.06);
@@ -273,7 +273,7 @@ function nodeClass(step: AutomationStep) {
}
.node.action:not(.condition) {
border-radius: 10px;
border-radius: var(--radius);
border-inline-start-width: 5px;
}
@@ -325,7 +325,7 @@ function nodeClass(step: AutomationStep) {
.icon {
width: 2.35rem;
height: 2.35rem;
border-radius: 12px;
border-radius: var(--radius);
display: grid;
place-items: center;
flex-shrink: 0;
@@ -350,7 +350,7 @@ function nodeClass(step: AutomationStep) {
}
.node.condition .icon {
border-radius: 8px;
border-radius: var(--radius);
color: #b45309;
background: #ffedd5;
}
@@ -369,7 +369,7 @@ function nodeClass(step: AutomationStep) {
strong {
font-size: 0.88rem;
font-weight: 800;
font-weight: 600;
color: #0f172a;
}
@@ -472,7 +472,7 @@ em {
margin-top: 1rem;
background: #fff;
border: 1px solid #d5ddd8;
border-radius: 14px;
border-radius: var(--radius);
padding: 0.85rem;
display: grid;
gap: 0.65rem;
@@ -278,14 +278,14 @@ watch(
padding: 1rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
border-radius: var(--radius);
min-width: 0;
}
h2 {
margin: 0;
font-size: 0.98rem;
font-weight: 800;
font-weight: 600;
}
.hint {
@@ -312,7 +312,7 @@ input,
select,
textarea {
border: 1px solid #e2e8f0;
border-radius: 12px;
border-radius: var(--radius);
padding: 0.55rem 0.7rem;
font: inherit;
color: #0f172a;
@@ -64,7 +64,7 @@ withDefaults(
width: max-content;
max-width: 118px;
padding: 0.48rem 0.65rem 0.55rem;
border-radius: 14px;
border-radius: var(--radius);
background: #fff;
color: #0f172a;
font-size: 0.68rem;
+1 -1
View File
@@ -56,7 +56,7 @@ withDefaults(
letter-spacing: -0.02em;
color: #0f172a;
line-height: 1.1;
font-weight: 800;
font-weight: 600;
}
.text small {
@@ -93,7 +93,7 @@ function pick(label: string) {
<style scoped>
.panel {
background: #fff;
border-radius: 20px;
border-radius: var(--radius);
padding: 1rem 1rem 0.95rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
@@ -144,7 +144,7 @@ h2 {
align-items: center;
gap: 0.55rem;
padding: 0.58rem 0.7rem;
border-radius: 12px;
border-radius: var(--radius);
background: #fff;
border: 1px solid #eef2f7;
color: #334155;
@@ -168,7 +168,7 @@ h2 {
.chip-ico {
width: 26px;
height: 26px;
border-radius: 8px;
border-radius: var(--radius);
background: #ecfdf8;
color: #14b8a6;
display: grid;
@@ -185,7 +185,7 @@ h2 {
.ai-input input {
flex: 1;
border: 1px solid #e2e8f0;
border-radius: 12px;
border-radius: var(--radius);
padding: 0.58rem 0.75rem;
background: #f8fafc;
font-size: 0.8rem;
@@ -194,7 +194,7 @@ h2 {
.ai-input button {
width: 40px;
border: 0;
border-radius: 12px;
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
cursor: pointer;
@@ -208,7 +208,7 @@ h2 {
.ai-answer {
background: #f0fdfa;
border-radius: 12px;
border-radius: var(--radius);
padding: 0.7rem 0.8rem;
margin-bottom: 0.75rem;
}
@@ -140,7 +140,7 @@ function alertIcon(tone: string) {
align-items: start;
min-height: 0;
padding: 0.8rem 1.5rem 0.9rem;
border-radius: 28px;
border-radius: var(--radius);
overflow: visible;
color: #fff;
background: transparent;
@@ -151,7 +151,7 @@ function alertIcon(tone: string) {
position: absolute;
inset: 0;
z-index: 0;
border-radius: 28px;
border-radius: var(--radius);
overflow: hidden;
background-color: #134e4a;
background-image:
@@ -251,7 +251,7 @@ function alertIcon(tone: string) {
.greet-text h1 {
margin: 0;
font-size: clamp(1.35rem, 2vw, 1.75rem);
font-weight: 800;
font-weight: 600;
letter-spacing: -0.01em;
color: #fff;
}
@@ -276,7 +276,7 @@ function alertIcon(tone: string) {
gap: 0.4rem;
min-width: 0;
padding: 0.4rem 0.5rem;
border-radius: 12px;
border-radius: var(--radius);
background: #fff;
color: #0f172a;
text-decoration: none;
@@ -311,7 +311,7 @@ function alertIcon(tone: string) {
.chip-ico {
width: 22px;
height: 22px;
border-radius: 8px;
border-radius: var(--radius);
display: grid;
place-items: center;
flex-shrink: 0;
@@ -369,7 +369,7 @@ function alertIcon(tone: string) {
color: #94a3b8;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 7px;
border-radius: var(--radius);
padding: 0.14rem 0.38rem;
flex-shrink: 0;
}
@@ -58,7 +58,7 @@ const iconTones: Record<string, string> = {
gap: 0.85rem;
align-items: flex-start;
background: #fff;
border-radius: 22px;
border-radius: var(--radius);
padding: 1.1rem 1.15rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
@@ -67,7 +67,7 @@ const iconTones: Record<string, string> = {
.kpi-icon {
width: 48px;
height: 48px;
border-radius: 16px;
border-radius: var(--radius);
background: #f8fafc;
display: grid;
place-items: center;
@@ -101,7 +101,7 @@ const cols = computed<Col[]>(() => {
<style scoped>
.panel {
background: #fff;
border-radius: 20px;
border-radius: var(--radius);
padding: 1rem 1.05rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
@@ -151,7 +151,7 @@ a {
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.6rem;
border-radius: 12px;
border-radius: var(--radius);
background: #f8fafc;
border: 1px solid #eef2f7;
color: inherit;
@@ -185,12 +185,12 @@ a {
.deal-mark {
width: 28px;
height: 28px;
border-radius: 8px;
border-radius: var(--radius);
background: #ecfdf8;
color: #0f766e;
display: grid;
place-items: center;
font-weight: 800;
font-weight: 600;
flex-shrink: 0;
}
@@ -108,7 +108,7 @@ const rows = computed<Row[]>(() => {
<style scoped>
.panel {
background: #fff;
border-radius: 20px;
border-radius: var(--radius);
padding: 1rem 1.05rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
@@ -151,7 +151,7 @@ a {
.act-icon {
width: 38px;
height: 38px;
border-radius: 12px;
border-radius: var(--radius);
background: #ecfdf8;
color: #14b8a6;
display: grid;
@@ -47,7 +47,7 @@ function trophy(rank: number) {
<style scoped>
.panel {
background: #fff;
border-radius: 20px;
border-radius: var(--radius);
padding: 1rem 1.05rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
@@ -89,12 +89,12 @@ a {
.cust-avatar {
width: 38px;
height: 38px;
border-radius: 12px;
border-radius: var(--radius);
background: linear-gradient(145deg, #ccfbf1, #99f6e4);
color: #0f766e;
display: grid;
place-items: center;
font-weight: 800;
font-weight: 600;
flex-shrink: 0;
}
@@ -775,7 +775,7 @@ onMounted(async () => {
gap: 0.28rem;
min-width: 0;
padding: 0.8rem 0.9rem;
border-radius: 12px;
border-radius: var(--radius);
border: 1px solid #e2e8f0;
background: #f8fafc;
}
@@ -789,7 +789,7 @@ onMounted(async () => {
align-items: baseline;
gap: 0.3rem;
font-size: 1.15rem;
font-weight: 800;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.balance-box em {
@@ -843,7 +843,7 @@ onMounted(async () => {
border: 1px solid var(--line);
background: var(--bg);
color: var(--text);
border-radius: 10px;
border-radius: var(--radius);
padding: 0.45rem 0.75rem;
cursor: pointer;
}
@@ -918,7 +918,7 @@ onMounted(async () => {
justify-content: space-between;
gap: 0.5rem;
padding: 0.3rem 0.4rem;
border-radius: 8px;
border-radius: var(--radius);
margin-top: 0.25rem;
font-size: 0.9rem;
}
@@ -942,21 +942,21 @@ onMounted(async () => {
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 8px;
border-radius: var(--radius);
padding: 0.55rem 0.7rem;
font-size: 0.88rem;
}
input,
select {
border: 1px solid var(--line);
border-radius: 10px;
border-radius: var(--radius);
padding: 0.45rem 0.6rem;
background: var(--bg);
color: var(--text);
}
.btn {
border: 0;
border-radius: 10px;
border-radius: var(--radius);
padding: 0.45rem 0.8rem;
background: var(--accent);
color: #fff;
@@ -119,7 +119,7 @@ withDefaults(
h3 {
margin: 0;
font-size: 1.08rem;
font-weight: 800;
font-weight: 600;
color: #1e2937;
}
@@ -145,7 +145,7 @@ function onSelect(key: string) {
height: 38px;
border: 0;
background: #fff;
border-radius: 12px;
border-radius: var(--radius);
display: grid;
place-items: center;
color: #334155;
@@ -175,7 +175,7 @@ function onSelect(key: string) {
list-style: none;
width: 220px;
background: #fff;
border-radius: 14px;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
display: flex;
flex-direction: column;
@@ -193,7 +193,7 @@ function onSelect(key: string) {
box-sizing: border-box;
margin: 0;
border: 0;
border-radius: 10px;
border-radius: var(--radius);
background: transparent;
color: #0f172a;
font: inherit;
@@ -146,7 +146,7 @@ const colors = computed(() => PIPELINE_ACCENT[props.accent]);
background: var(--badge-bg);
color: var(--badge-fg);
font-size: 0.65rem;
font-weight: 800;
font-weight: 600;
line-height: 1;
}
@@ -163,7 +163,7 @@ const colors = computed(() => PIPELINE_ACCENT[props.accent]);
.titles h3 {
margin: 0;
font-size: 0.82rem;
font-weight: 800;
font-weight: 600;
line-height: 1.4;
color: var(--ink);
cursor: pointer;
@@ -230,7 +230,7 @@ const colors = computed(() => PIPELINE_ACCENT[props.accent]);
.amount strong {
font-size: 0.88rem;
font-weight: 800;
font-weight: 600;
color: var(--ink);
letter-spacing: -0.01em;
max-width: 100%;
@@ -295,10 +295,10 @@ onBeforeUnmount(() => {
min-width: 0;
width: 100%;
font-size: 0.98rem;
font-weight: 800;
font-weight: 600;
color: var(--ink);
font: inherit;
font-weight: 800;
font-weight: 600;
}
.column-head h2 {
@@ -315,7 +315,7 @@ onBeforeUnmount(() => {
height: 1.7rem;
padding: 0 0.4rem;
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius);
background: #fff;
}
@@ -389,7 +389,7 @@ onBeforeUnmount(() => {
color: var(--ink);
text-align: right;
cursor: pointer;
border-radius: 8px;
border-radius: var(--radius);
}
.col-menu button:hover:not(:disabled) {
@@ -233,7 +233,7 @@ const hideAmount = computed(() => Boolean(props.copy.hideAmount));
display: grid;
place-items: center;
font-size: 0.7rem;
font-weight: 800;
font-weight: 600;
}
.titles {
@@ -244,7 +244,7 @@ const hideAmount = computed(() => Boolean(props.copy.hideAmount));
.titles strong {
font-size: 0.92rem;
font-weight: 800;
font-weight: 600;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
@@ -285,7 +285,7 @@ const hideAmount = computed(() => Boolean(props.copy.hideAmount));
}
.amount strong {
font-weight: 800;
font-weight: 600;
color: var(--ink);
}
@@ -314,7 +314,7 @@ const hideAmount = computed(() => Boolean(props.copy.hideAmount));
background: transparent;
color: var(--muted);
padding: 0.2rem;
border-radius: 8px;
border-radius: var(--radius);
cursor: pointer;
}
@@ -332,7 +332,7 @@ const hideAmount = computed(() => Boolean(props.copy.hideAmount));
.card {
background: #fff;
border: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
border-radius: 16px;
border-radius: var(--radius);
padding: 0.95rem 1rem;
display: grid;
gap: 0.7rem;
@@ -133,7 +133,7 @@ function onPick(value: string) {
h1 {
margin: 0;
font-size: 1.15rem;
font-weight: 800;
font-weight: 600;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
withDefaults(
defineProps<{
variant?: 'neutral' | 'accent' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
size?: 'sm' | 'md';
dot?: boolean;
}>(),
{
variant: 'neutral',
size: 'md',
dot: false,
},
);
</script>
<template>
<span class="ui-badge" :class="[variant, size]">
<span v-if="dot" class="badge-dot" aria-hidden="true" />
<slot />
</span>
</template>
<style scoped>
.ui-badge {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.2rem 0.6rem;
border-radius: var(--radius-full);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
line-height: 1.2;
white-space: nowrap;
border: 1px solid transparent;
user-select: none;
}
.ui-badge.sm {
padding: 0.1rem 0.45rem;
font-size: 0.7rem;
}
.badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* Variants */
.ui-badge.neutral {
background: var(--surface-subtle);
color: var(--ink-secondary);
border-color: var(--line);
}
.ui-badge.accent,
.ui-badge.primary {
background: var(--accent-soft);
color: var(--accent-deep);
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
}
.ui-badge.success {
background: var(--success-soft);
color: var(--success-deep);
border-color: color-mix(in srgb, var(--success) 30%, transparent);
}
.ui-badge.warning {
background: var(--warning-soft);
color: var(--warning-deep);
border-color: color-mix(in srgb, var(--warning) 30%, transparent);
}
.ui-badge.danger {
background: var(--danger-soft);
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 30%, transparent);
}
.ui-badge.info {
background: var(--info-soft);
color: var(--info-deep);
border-color: color-mix(in srgb, var(--info) 30%, transparent);
}
</style>
+158 -15
View File
@@ -1,12 +1,21 @@
<script setup lang="ts">
withDefaults(
defineProps<{
variant?: 'primary' | 'ghost' | 'dashed' | 'icon';
type?: 'button' | 'submit';
variant?: 'primary' | 'ghost' | 'dashed' | 'icon' | 'danger' | 'outline' | 'link' | 'secondary' | 'success';
size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
loading?: boolean;
block?: boolean;
}>(),
{ variant: 'primary', type: 'button' },
{
variant: 'primary',
size: 'md',
type: 'button',
disabled: false,
loading: false,
block: false,
},
);
</script>
@@ -14,9 +23,12 @@ withDefaults(
<button
:type="type"
class="ui-btn"
:class="[variant, { block }]"
:disabled="disabled"
:class="[variant, size, { block, loading }]"
:disabled="disabled || loading"
:aria-busy="loading"
>
<span v-if="loading" class="spinner" aria-hidden="true"></span>
<slot name="icon" />
<slot />
</button>
</template>
@@ -26,18 +38,52 @@ withDefaults(
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: var(--control-h);
padding: 0 0.95rem;
gap: var(--sp-2);
min-height: var(--control-h-md);
height: var(--control-h-md);
padding: 0 0.85rem;
border: 1px solid transparent;
border-radius: var(--radius);
background: var(--accent);
color: #fff;
color: var(--accent-contrast);
font: inherit;
font-weight: 700;
font-size: 0.88rem;
font-weight: var(--fw-semibold);
font-size: var(--fs-sm);
white-space: nowrap;
cursor: pointer;
box-sizing: border-box;
transition: background var(--transition-fast), border-color var(--transition-fast),
box-shadow var(--transition-fast), opacity var(--transition-fast);
user-select: none;
}
/* Sizes */
.ui-btn.sm {
min-height: var(--control-h-sm);
height: var(--control-h-sm);
padding: 0 0.65rem;
font-size: var(--fs-xs);
border-radius: var(--radius-sm);
}
.ui-btn.md {
min-height: var(--control-h-md);
height: var(--control-h-md);
padding: 0 0.85rem;
font-size: var(--fs-sm);
border-radius: var(--radius);
}
.ui-btn.lg {
min-height: var(--control-h-lg);
height: var(--control-h-lg);
padding: 0 1.15rem;
font-size: var(--fs-base);
border-radius: var(--radius-md);
}
.ui-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.ui-btn:disabled {
@@ -49,28 +95,125 @@ withDefaults(
width: 100%;
}
/* Variants */
.ui-btn.ghost {
background: #fff;
background: transparent;
color: var(--ink);
border-color: var(--line);
}
.ui-btn.ghost:hover:not(:disabled) {
background: var(--surface-subtle);
border-color: var(--line-strong);
}
.ui-btn.outline {
background: transparent;
color: var(--accent);
border-color: var(--accent);
}
.ui-btn.outline:hover:not(:disabled) {
background: var(--accent-subtle);
}
.ui-btn.secondary {
background: var(--surface-subtle);
color: var(--ink-secondary);
border-color: var(--line);
}
.ui-btn.secondary:hover:not(:disabled) {
background: var(--line);
color: var(--ink);
}
.ui-btn.danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.ui-btn.danger:hover:not(:disabled) {
background: var(--danger-hover);
}
.ui-btn.success {
background: var(--success);
color: #fff;
border-color: var(--success);
}
.ui-btn.success:hover:not(:disabled) {
background: var(--success-deep);
}
.ui-btn.dashed {
background: #fff;
border: 1.5px dashed currentColor;
font-weight: 700;
color: var(--muted);
border: 1.5px dashed var(--line-strong);
}
.ui-btn.dashed:hover:not(:disabled) {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-subtle);
}
.ui-btn.link {
background: transparent;
border-color: transparent;
color: var(--accent);
padding: 0 var(--sp-1);
min-height: auto;
font-weight: var(--fw-semibold);
}
.ui-btn.link:hover:not(:disabled) {
background: transparent;
text-decoration: underline;
}
.ui-btn.icon {
width: var(--control-h);
width: var(--control-h-md);
padding: 0;
background: #fff;
color: var(--accent-deep);
border-color: var(--accent);
}
.ui-btn.icon.sm {
width: var(--control-h-sm);
min-height: var(--control-h-sm);
}
.ui-btn.icon.lg {
width: var(--control-h-lg);
min-height: var(--control-h-lg);
}
.ui-btn.icon:hover:not(:disabled) {
background: var(--accent-subtle);
}
.ui-btn:focus-visible {
outline: 2px solid var(--accent-soft);
outline-offset: 1px;
}
/* Spinner */
.spinner {
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: ui-spin 0.6s linear infinite;
}
@keyframes ui-spin {
to {
transform: rotate(360deg);
}
}
</style>
+109 -4
View File
@@ -1,14 +1,119 @@
<script setup lang="ts">
withDefaults(
defineProps<{
padding?: 'none' | 'sm' | 'md' | 'lg';
shadow?: 'none' | 'xs' | 'sm' | 'md';
interactive?: boolean;
title?: string;
}>(),
{
padding: 'md',
shadow: 'xs',
interactive: false,
},
);
</script>
<template>
<div class="ui-card">
<slot />
<div
class="ui-card"
:class="[
`pad-${padding}`,
`shadow-${shadow}`,
{ interactive }
]"
>
<header v-if="title || $slots.header" class="card-head">
<slot name="header">
<h3 v-if="title" class="card-title">{{ title }}</h3>
</slot>
<div v-if="$slots.actions" class="card-actions">
<slot name="actions" />
</div>
</header>
<div class="card-body">
<slot />
</div>
<footer v-if="$slots.footer" class="card-foot">
<slot name="footer" />
</footer>
</div>
</template>
<style scoped>
.ui-card {
background: #fff;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--pad-modal);
box-sizing: border-box;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
/* Padding variants */
.ui-card.pad-none {
padding: 0;
}
.ui-card.pad-sm {
padding: var(--sp-3);
}
.ui-card.pad-md {
padding: var(--sp-5);
}
.ui-card.pad-lg {
padding: var(--sp-6);
}
/* Shadow variants */
.ui-card.shadow-none {
box-shadow: none;
}
.ui-card.shadow-xs {
box-shadow: var(--shadow-xs);
}
.ui-card.shadow-sm {
box-shadow: var(--shadow-sm);
}
.ui-card.shadow-md {
box-shadow: var(--shadow-md);
}
.ui-card.interactive:hover {
border-color: var(--line-strong);
box-shadow: var(--shadow-sm);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding-bottom: var(--sp-3);
margin-bottom: var(--sp-4);
border-bottom: 1px solid var(--line-light);
}
.card-title {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-bold);
color: var(--ink);
}
.card-actions {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.card-foot {
padding-top: var(--sp-3);
margin-top: var(--sp-4);
border-top: 1px solid var(--line-light);
}
</style>
+67 -7
View File
@@ -1,54 +1,114 @@
<script setup lang="ts">
<script lang="ts">
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { LineChart, PieChart } from 'echarts/charts';
import { LineChart, PieChart, BarChart } from 'echarts/charts';
import {
GridComponent,
LegendComponent,
TooltipComponent,
TitleComponent,
} from 'echarts/components';
import VChart from 'vue-echarts';
import type { ComposeOption } from 'echarts/core';
import type { LineSeriesOption, PieSeriesOption } from 'echarts/charts';
import type { LineSeriesOption, PieSeriesOption, BarSeriesOption } from 'echarts/charts';
import type {
GridComponentOption,
LegendComponentOption,
TooltipComponentOption,
TitleComponentOption,
} from 'echarts/components';
use([
CanvasRenderer,
LineChart,
PieChart,
BarChart,
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
]);
export type UiChartOption = ComposeOption<
| LineSeriesOption
| PieSeriesOption
| BarSeriesOption
| GridComponentOption
| TooltipComponentOption
| LegendComponentOption
| TitleComponentOption
>;
/**
* Shared palette matching Montact CRM design tokens
*/
export const CHART_PALETTE = [
'#14b8a6', // Teal (Accent)
'#3b82f6', // Blue
'#f59e0b', // Amber
'#10b981', // Emerald
'#8b5cf6', // Violet
'#f43f5e', // Rose
'#06b6d4', // Cyan
'#eab308', // Yellow
];
</script>
<script setup lang="ts">
import VChart from 'vue-echarts';
withDefaults(
defineProps<{
option: UiChartOption;
height?: string;
loading?: boolean;
}>(),
{ height: '220px' },
{ height: '260px', loading: false },
);
</script>
<template>
<VChart class="ui-chart" :option="option" :style="{ height }" autoresize />
<div class="ui-chart-container" :style="{ height }">
<div v-if="loading" class="chart-loading">
<div class="chart-spinner"></div>
</div>
<VChart v-else class="ui-chart" :option="option" autoresize />
</div>
</template>
<style scoped>
.ui-chart {
.ui-chart-container {
width: 100%;
min-width: 0;
position: relative;
}
.ui-chart {
width: 100%;
height: 100%;
min-width: 0;
}
.chart-loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.7);
z-index: 2;
}
.chart-spinner {
width: 28px;
height: 28px;
border: 3px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: chart-spin 0.8s linear infinite;
}
@keyframes chart-spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import UiIcon from '@/components/UiIcon.vue';
type IconName = InstanceType<typeof UiIcon>['$props']['name'];
withDefaults(
defineProps<{
title?: string;
description?: string;
icon?: IconName;
}>(),
{
title: 'موردی یافت نشد',
icon: 'search',
},
);
</script>
<template>
<div class="ui-empty-state">
<div class="icon-wrap">
<slot name="icon">
<UiIcon :name="icon" :size="32" />
</slot>
</div>
<h3 class="title">{{ title }}</h3>
<p v-if="description" class="description">{{ description }}</p>
<slot />
<div v-if="$slots.action" class="action-wrap">
<slot name="action" />
</div>
</div>
</template>
<style scoped>
.ui-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--sp-10) var(--sp-6);
gap: var(--sp-3);
color: var(--muted);
background: var(--surface);
border: 1px dashed var(--line-strong);
border-radius: var(--radius);
width: 100%;
box-sizing: border-box;
}
.icon-wrap {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--surface-subtle);
color: var(--muted);
margin-bottom: var(--sp-1);
}
.title {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-bold);
color: var(--ink);
}
.description {
margin: 0;
font-size: var(--fs-sm);
max-width: 360px;
line-height: var(--lh-relaxed);
color: var(--muted);
}
.action-wrap {
margin-top: var(--sp-2);
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label?: string;
forId?: string;
required?: boolean;
hint?: string;
error?: string | boolean;
horizontal?: boolean;
}>(),
{
required: false,
horizontal: false,
},
);
</script>
<template>
<div class="ui-field" :class="{ horizontal, hasError: !!error }">
<div v-if="label || $slots.label" class="label-row">
<label v-if="label" :for="forId" class="field-label">
{{ label }}
<span v-if="required" class="required-mark" aria-hidden="true">*</span>
</label>
<slot v-else name="label" />
<slot name="label-extra" />
</div>
<div class="control-wrap">
<slot />
<p v-if="typeof error === 'string' && error" class="field-error" role="alert">
{{ error }}
</p>
<p v-else-if="hint || $slots.hint" class="field-hint">
<slot name="hint">{{ hint }}</slot>
</p>
</div>
</div>
</template>
<style scoped>
.ui-field {
display: flex;
flex-direction: column;
gap: var(--sp-1);
min-width: 0;
width: 100%;
}
.ui-field.horizontal {
flex-direction: row;
align-items: center;
gap: var(--sp-3);
}
.ui-field.horizontal .label-row {
flex: 0 0 140px;
}
.ui-field.horizontal .control-wrap {
flex: 1;
}
.label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
.field-label {
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
line-height: var(--lh-tight);
}
.required-mark {
color: var(--danger);
margin-right: 2px;
}
.control-wrap {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
width: 100%;
}
.field-error {
margin: 2px 0 0;
font-size: var(--fs-xs);
color: var(--danger);
font-weight: var(--fw-medium);
}
.field-hint {
margin: 2px 0 0;
font-size: var(--fs-xs);
color: var(--muted);
line-height: var(--lh-normal);
}
</style>
+3 -2
View File
@@ -21,7 +21,7 @@ const open = ref(false);
<div class="row">
<slot name="before" />
<UiSearch v-model="search" :placeholder="placeholder" class="search" />
<UiButton v-if="showFilters" type="button" @click="open = !open">
<UiButton v-if="showFilters" variant="secondary" type="button" @click="open = !open">
<UiIcon name="filter" :size="16" />
فیلترها
</UiButton>
@@ -54,9 +54,10 @@ const open = ref(false);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
gap: 0.65rem;
padding: var(--pad-modal);
padding: var(--sp-4);
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
</style>
+95 -21
View File
@@ -4,48 +4,122 @@ withDefaults(
type?: string;
placeholder?: string;
disabled?: boolean;
readonly?: boolean;
required?: boolean;
maxlength?: number;
minlength?: number;
error?: boolean | string;
size?: 'sm' | 'md' | 'lg';
maxlength?: number | string;
minlength?: number | string;
id?: string;
autocomplete?: string;
}>(),
{ type: 'text' },
{
type: 'text',
size: 'md',
disabled: false,
readonly: false,
required: false,
},
);
const model = defineModel<string>({ default: '' });
const model = defineModel<string | number>({ default: '' });
</script>
<template>
<input
v-model="model"
class="ui-input"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:maxlength="maxlength"
:minlength="minlength"
/>
<div class="ui-input-wrapper" :class="[size, { disabled, hasError: !!error }]">
<slot name="prefix" />
<input
:id="id"
v-model="model"
class="ui-input"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:required="required"
:maxlength="maxlength"
:minlength="minlength"
:autocomplete="autocomplete"
:aria-invalid="!!error"
/>
<slot name="suffix" />
</div>
</template>
<style scoped>
.ui-input {
.ui-input-wrapper {
display: inline-flex;
align-items: center;
width: 100%;
min-width: 0;
min-height: var(--control-h);
padding: 0 0.85rem;
min-height: var(--control-h-md);
padding: 0 var(--sp-3);
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
background: var(--surface);
color: var(--ink);
font: inherit;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
box-sizing: border-box;
}
.ui-input:focus {
.ui-input-wrapper.sm {
min-height: var(--control-h-sm);
padding: 0 var(--sp-2);
border-radius: var(--radius-sm);
font-size: var(--fs-xs);
}
.ui-input-wrapper.md {
min-height: var(--control-h-md);
padding: 0 var(--sp-3);
border-radius: var(--radius);
font-size: var(--fs-base);
}
.ui-input-wrapper.lg {
min-height: var(--control-h-lg);
padding: 0 var(--sp-4);
border-radius: var(--radius-md);
font-size: var(--fs-md);
}
.ui-input {
flex: 1 1 auto;
width: 100%;
min-width: 0;
border: 0;
outline: none;
background: transparent;
color: inherit;
font: inherit;
padding: 0;
}
.ui-input::placeholder {
color: #c0c8d4;
}
.ui-input-wrapper:focus-within {
outline: 2px solid var(--accent-soft);
border-color: var(--accent);
}
.ui-input-wrapper.hasError {
border-color: var(--danger);
}
.ui-input-wrapper.hasError:focus-within {
outline: 2px solid var(--danger-soft);
border-color: var(--danger);
}
.ui-input-wrapper.disabled {
background: var(--surface-subtle);
opacity: 0.65;
cursor: not-allowed;
}
.ui-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
+90 -70
View File
@@ -61,66 +61,132 @@ function onBackdrop(event: MouseEvent) {
z-index: 80;
display: grid;
place-items: center;
padding: 1.25rem;
padding: 16px;
background: rgba(15, 23, 42, 0.35);
}
.dialog {
width: min(880px, 100%);
max-height: min(88vh, 880px);
max-height: min(88dvh, 88vh, 880px);
min-height: 0;
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
border-radius: var(--radius, 10px);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.16);
overflow: hidden;
}
.head,
.body,
.foot {
padding: var(--pad-modal);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding-bottom: 0;
padding: 16px;
border-bottom: 1px solid var(--line);
flex-shrink: 0;
background: #fff;
z-index: 10;
}
.head h2 {
margin: 0;
font-size: 1.05rem;
font-weight: 800;
font-size: 16px;
font-weight: 500;
color: var(--ink);
line-height: 1.4;
}
.close {
width: var(--control-h);
height: var(--control-h);
width: 36px;
height: 36px;
border: 1px solid var(--line);
border-radius: var(--radius);
border-radius: var(--radius, 10px);
background: #fff;
color: var(--muted);
display: grid;
place-items: center;
cursor: pointer;
transition: all var(--transition-fast);
}
.close:hover {
background: var(--surface-subtle);
color: var(--ink);
border-color: var(--line-strong);
}
.body {
overflow: auto;
flex: 1 1 auto;
min-height: 0;
padding-top: 1rem;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: 16px;
box-sizing: border-box;
}
.body :deep(.crm-page.embedded),
.body :deep(.page.embedded),
.body :deep(.product-form-page.embedded),
.body :deep(.company-form-page.embedded) {
min-height: 0;
max-width: 100%;
}
.body :deep(.page.embedded > .form),
.body :deep(.page.embedded > form),
.body :deep(.crm-page.embedded > .form),
.body :deep(.crm-page.embedded > form),
.body :deep(.crm-page.embedded > .crm-form) {
min-height: 0;
}
.body :deep(.embed-actions),
.body :deep(.embedded-actions),
.body :deep(.form-bottom-actions),
.body :deep(.form-footer-actions) {
order: 99;
flex-shrink: 0;
position: sticky;
bottom: -16px;
margin-top: auto;
margin-inline: -16px;
margin-bottom: -16px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 16px;
background: #fff;
border-top: 1px solid var(--line);
z-index: 10;
box-shadow: 0 -4px 14px rgba(15, 23, 42, 0.05);
}
.body :deep(.embed-actions .btn),
.body :deep(.embedded-actions .btn),
.body :deep(.embed-actions .ui-btn),
.body :deep(.embedded-actions .ui-btn),
.body :deep(.form-bottom-actions .btn),
.body :deep(.form-bottom-actions .ui-btn),
.body :deep(.form-footer-actions .btn),
.body :deep(.form-footer-actions .ui-btn) {
border-radius: var(--radius, 10px);
}
.foot {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
padding-top: 0;
border-top: 1px solid #f1f5f9;
gap: 12px;
padding: 16px;
border-top: 1px solid var(--line);
flex-shrink: 0;
background: #fff;
z-index: 10;
}
@media (max-width: 900px) {
@@ -138,62 +204,16 @@ function onBackdrop(event: MouseEvent) {
}
.head {
flex-shrink: 0;
padding: 0.75rem 1rem 0;
padding: 16px;
}
.body {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
padding: 0.75rem 1rem;
}
.body:has(.page.embedded) {
overflow: hidden;
padding-bottom: 0;
}
.body :deep(.page.embedded) {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
gap: 0.75rem;
}
.body :deep(.page.embedded > .form),
.body :deep(.page.embedded > form) {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
.body :deep(.embed-actions) {
order: 20;
flex: 0 0 auto;
position: sticky;
bottom: 0;
margin-top: auto;
margin-inline: -1rem;
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #f1f5f9;
z-index: 3;
}
.body :deep(.embed-actions .btn) {
flex: 1;
min-width: 0;
flex: 1 1 0%;
padding: 16px;
}
.foot {
flex-shrink: 0;
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
padding: 16px calc(16px + env(safe-area-inset-bottom));
}
}
</style>
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
withDefaults(
defineProps<{
title?: string;
lead?: string;
description?: string;
backTo?: string | object;
}>(),
{},
);
</script>
<template>
<header class="ui-page-header">
<div class="header-main">
<RouterLink v-if="backTo" :to="backTo" class="back-link" title="بازگشت">
<UiIcon name="arrow-right" :size="18" />
</RouterLink>
<div class="titles">
<h1 v-if="title" class="title">{{ title }}</h1>
<slot v-else name="title" />
<p v-if="lead || description" class="lead">{{ lead || description }}</p>
<slot v-else name="lead" />
</div>
</div>
<div v-if="$slots.actions" class="actions">
<slot name="actions" />
</div>
</header>
</template>
<style scoped>
.ui-page-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-4);
min-width: 0;
width: 100%;
}
.header-main {
display: flex;
align-items: center;
gap: var(--sp-3);
min-width: 0;
}
.back-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--control-h-md);
height: var(--control-h-md);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink-secondary);
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.back-link:hover {
background: var(--surface-subtle);
border-color: var(--line-strong);
color: var(--accent-deep);
}
.titles {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.title {
margin: 0;
font-size: var(--fs-2xl);
font-weight: var(--fw-extrabold);
color: var(--ink);
line-height: var(--lh-tight);
}
.lead {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: var(--lh-normal);
}
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
}
@media (max-width: 900px) {
.ui-page-header {
flex-direction: column;
align-items: stretch;
gap: var(--sp-3);
}
.title {
font-size: var(--fs-xl);
}
.actions {
justify-content: stretch;
}
.actions > * {
flex: 1 1 auto;
}
}
</style>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { computed } from 'vue';
import UiButton from './UiButton.vue';
import UiIcon from '@/components/UiIcon.vue';
const props = withDefaults(
defineProps<{
page: number;
totalPages?: number;
totalItems?: number;
pageSize?: number;
}>(),
{
page: 1,
},
);
const emit = defineEmits<{
'update:page': [page: number];
change: [page: number];
}>();
const effectiveTotalPages = computed(() => {
if (props.totalPages != null) return Math.max(1, props.totalPages);
if (props.totalItems != null && props.pageSize != null && props.pageSize > 0) {
return Math.max(1, Math.ceil(props.totalItems / props.pageSize));
}
return 1;
});
function goTo(p: number) {
if (p < 1 || p > effectiveTotalPages.value || p === props.page) return;
emit('update:page', p);
emit('change', p);
}
</script>
<template>
<nav v-if="effectiveTotalPages > 1 || totalItems !== undefined" class="ui-pagination" aria-label="صفحه‌بندی">
<div class="pagination-info">
<span v-if="totalItems !== undefined">
مجموع: <strong>{{ totalItems }}</strong> مورد
</span>
<span v-if="effectiveTotalPages > 1" class="page-count">
(صفحه {{ page }} از {{ effectiveTotalPages }})
</span>
</div>
<div v-if="effectiveTotalPages > 1" class="pagination-controls">
<UiButton
variant="ghost"
size="sm"
:disabled="page <= 1"
title="صفحه اول"
@click="goTo(1)"
>
<UiIcon name="chevrons-right" :size="14" />
</UiButton>
<UiButton
variant="ghost"
size="sm"
:disabled="page <= 1"
title="صفحه قبل"
@click="goTo(page - 1)"
>
<UiIcon name="chevron-right" :size="14" />
قبلی
</UiButton>
<span class="current-indicator">
{{ page }} / {{ effectiveTotalPages }}
</span>
<UiButton
variant="ghost"
size="sm"
:disabled="page >= effectiveTotalPages"
title="صفحه بعد"
@click="goTo(page + 1)"
>
بعدی
<UiIcon name="chevron-left" :size="14" />
</UiButton>
<UiButton
variant="ghost"
size="sm"
:disabled="page >= effectiveTotalPages"
title="صفحه آخر"
@click="goTo(effectiveTotalPages)"
>
<UiIcon name="chevrons-left" :size="14" />
</UiButton>
</div>
</nav>
</template>
<style scoped>
.ui-pagination {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-3);
padding-top: var(--sp-4);
width: 100%;
}
.pagination-info {
font-size: var(--fs-sm);
color: var(--muted);
display: flex;
align-items: center;
gap: var(--sp-2);
}
.page-count {
color: var(--muted-light);
}
.pagination-controls {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
}
.current-indicator {
padding: 0 var(--sp-2);
font-size: var(--fs-xs);
font-weight: var(--fw-bold);
color: var(--ink-secondary);
}
@media (max-width: 900px) {
.ui-pagination {
flex-direction: column;
align-items: center;
gap: var(--sp-3);
}
}
</style>
+8 -2
View File
@@ -29,12 +29,15 @@ const model = defineModel<string>({ default: '' });
align-items: center;
gap: 0.45rem;
min-width: 0;
height: var(--control-h);
min-height: var(--control-h);
padding: 0 0.75rem;
background: #fff;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--muted);
box-sizing: border-box;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.ui-search input {
@@ -45,11 +48,14 @@ const model = defineModel<string>({ default: '' });
background: transparent;
color: var(--ink);
font: inherit;
font-size: var(--fs-sm);
padding: 0;
}
.ui-search:focus-within {
outline: 2px solid var(--accent-soft);
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.sr {
+61 -10
View File
@@ -1,19 +1,30 @@
<script setup lang="ts">
import UiIcon from '@/components/UiIcon.vue';
defineProps<{
disabled?: boolean;
}>();
withDefaults(
defineProps<{
disabled?: boolean;
error?: boolean | string;
size?: 'sm' | 'md' | 'lg';
id?: string;
required?: boolean;
}>(),
{
disabled: false,
size: 'md',
required: false,
},
);
const model = defineModel<string>({ default: '' });
</script>
<template>
<label class="ui-select">
<select v-model="model" :disabled="disabled">
<label class="ui-select" :class="[size, { disabled, hasError: !!error }]">
<select :id="id" v-model="model" :disabled="disabled" :required="required" :aria-invalid="!!error">
<slot />
</select>
<UiIcon class="chev" name="chevron" :size="16" />
<UiIcon class="chev" name="chevron" :size="size === 'sm' ? 14 : 16" />
</label>
</template>
@@ -22,20 +33,45 @@ const model = defineModel<string>({ default: '' });
position: relative;
display: block;
min-width: 0;
width: 100%;
}
.ui-select select {
appearance: none;
width: 100%;
min-height: var(--control-h);
padding: 0 2rem 0 0.85rem;
min-height: var(--control-h-md);
padding: 0 2rem 0 var(--sp-3);
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
background: var(--surface);
color: var(--ink);
font: inherit;
font-weight: 600;
font-weight: var(--fw-medium);
font-size: var(--fs-base);
cursor: pointer;
box-sizing: border-box;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.ui-select.sm select {
min-height: var(--control-h-sm);
padding: 0 1.75rem 0 var(--sp-2);
border-radius: var(--radius-sm);
font-size: var(--fs-xs);
}
.ui-select.md select {
min-height: var(--control-h-md);
padding: 0 2rem 0 var(--sp-3);
border-radius: var(--radius);
font-size: var(--fs-base);
}
.ui-select.lg select {
min-height: var(--control-h-lg);
padding: 0 2.25rem 0 var(--sp-4);
border-radius: var(--radius-md);
font-size: var(--fs-md);
}
.ui-select select:focus {
@@ -43,6 +79,21 @@ const model = defineModel<string>({ default: '' });
border-color: var(--accent);
}
.ui-select.hasError select {
border-color: var(--danger);
}
.ui-select.hasError select:focus {
outline: 2px solid var(--danger-soft);
border-color: var(--danger);
}
.ui-select.disabled select {
background: var(--surface-subtle);
opacity: 0.65;
cursor: not-allowed;
}
.chev {
position: absolute;
left: 0.65rem;
+81
View File
@@ -0,0 +1,81 @@
<script setup lang="ts">
withDefaults(
defineProps<{
placeholder?: string;
disabled?: boolean;
readonly?: boolean;
required?: boolean;
error?: boolean | string;
rows?: number;
maxlength?: number;
id?: string;
}>(),
{
rows: 3,
disabled: false,
readonly: false,
required: false,
},
);
const model = defineModel<string>({ default: '' });
</script>
<template>
<textarea
:id="id"
v-model="model"
class="ui-textarea"
:class="{ disabled, hasError: !!error }"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:required="required"
:rows="rows"
:maxlength="maxlength"
:aria-invalid="!!error"
/>
</template>
<style scoped>
.ui-textarea {
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: var(--sp-3);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink);
font: inherit;
font-size: var(--fs-base);
line-height: var(--lh-normal);
resize: vertical;
min-height: 80px;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.ui-textarea::placeholder {
color: #c0c8d4;
}
.ui-textarea:focus {
outline: 2px solid var(--accent-soft);
border-color: var(--accent);
}
.ui-textarea.hasError {
border-color: var(--danger);
}
.ui-textarea.hasError:focus {
outline: 2px solid var(--danger-soft);
border-color: var(--danger);
}
.ui-textarea.disabled {
background: var(--surface-subtle);
opacity: 0.65;
cursor: not-allowed;
}
</style>
+15
View File
@@ -0,0 +1,15 @@
export { default as UiButton } from './UiButton.vue';
export { default as UiInput } from './UiInput.vue';
export { default as UiTextarea } from './UiTextarea.vue';
export { default as UiSelect } from './UiSelect.vue';
export { default as UiField } from './UiField.vue';
export { default as UiCard } from './UiCard.vue';
export { default as UiModal } from './UiModal.vue';
export { default as UiPageHeader } from './UiPageHeader.vue';
export { default as UiBadge } from './UiBadge.vue';
export { default as UiPagination } from './UiPagination.vue';
export { default as UiEmptyState } from './UiEmptyState.vue';
export { default as UiSearch } from './UiSearch.vue';
export { default as UiFilterBar } from './UiFilterBar.vue';
export { default as UiSegmented } from './UiSegmented.vue';
export { default as UiChart, CHART_PALETTE, type UiChartOption } from './UiChart.vue';
+424
View File
@@ -0,0 +1,424 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
import { usePlatformAuthStore } from '@/stores/platform-auth';
import UiIcon from '@/components/UiIcon.vue';
import MentoLogo from '@/components/brand/MentoLogo.vue';
import BrandName from '@/components/brand/BrandName.vue';
const platform = usePlatformAuthStore();
const router = useRouter();
const route = useRoute();
const displayName = computed(() => platform.admin?.fullName || 'سوپرادمین');
const pageTitle = computed(() => {
switch (route.name) {
case 'adm-dashboard':
return 'داشبورد';
case 'adm-organizations':
return 'محیط‌ها';
case 'adm-users':
return 'کاربران';
case 'adm-admins':
return 'ادمین‌ها';
default:
return 'سوپرادمین';
}
});
const tabs = [
{ name: 'adm-dashboard', label: 'خانه', icon: 'home' as const },
{ name: 'adm-organizations', label: 'محیط‌ها', icon: 'building' as const },
{ name: 'adm-users', label: 'کاربران', icon: 'users' as const },
{ name: 'adm-admins', label: 'ادمین‌ها', icon: 'lock' as const },
];
function onLogout() {
platform.logout();
void router.push({ name: 'adm-login' });
}
</script>
<template>
<div class="adm-shell">
<aside class="sidebar desktop-only">
<div class="brand">
<div class="brand-mark">
<MentoLogo :size="36" :show-text="false" />
</div>
<div class="brand-copy">
<BrandName />
<span>کنترل پلتفرم</span>
</div>
</div>
<nav class="nav" aria-label="منوی سوپرادمین">
<RouterLink
v-for="tab in tabs"
:key="tab.name"
:to="{ name: tab.name }"
class="nav-item"
>
<span class="nav-ico" aria-hidden="true">
<UiIcon :name="tab.icon" :size="18" />
</span>
<span>{{ tab.label }}</span>
</RouterLink>
</nav>
<div class="side-foot">
<div class="who-card">
<span class="who-avatar" aria-hidden="true">
{{ displayName.slice(0, 1) }}
</span>
<div class="who-meta">
<strong>{{ displayName }}</strong>
<small>سوپرادمین</small>
</div>
</div>
<button type="button" class="logout" @click="onLogout">
<UiIcon name="logout" :size="16" />
<span>خروج</span>
</button>
</div>
</aside>
<div class="stage">
<header class="topbar">
<div class="top-brand mobile-only">
<MentoLogo :size="28" :show-text="false" />
<div>
<strong>{{ pageTitle }}</strong>
<small>سوپرادمین</small>
</div>
</div>
<div class="top-desktop desktop-only">
<h1>{{ pageTitle }}</h1>
<p>مدیریت حساب‌ها، اشتراک و ماژول‌ها</p>
</div>
<button type="button" class="logout-top mobile-only" @click="onLogout">
<UiIcon name="logout" :size="18" />
</button>
</header>
<main class="main">
<RouterView />
</main>
</div>
<nav class="tabbar" aria-label="ناوبری پایین">
<RouterLink
v-for="tab in tabs"
:key="tab.name"
:to="{ name: tab.name }"
class="tab"
>
<UiIcon :name="tab.icon" :size="20" />
<span>{{ tab.label }}</span>
</RouterLink>
</nav>
</div>
</template>
<style scoped>
.adm-shell {
min-height: 100vh;
min-height: 100dvh;
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
background:
radial-gradient(ellipse 70% 45% at 100% -10%, rgba(20, 184, 166, 0.14), transparent 55%),
radial-gradient(ellipse 50% 35% at 0% 100%, rgba(14, 165, 233, 0.08), transparent 50%),
linear-gradient(180deg, #f4fbfa 0%, var(--bg) 40%);
}
.desktop-only {
display: flex;
}
.mobile-only {
display: none;
}
.sidebar {
flex-direction: column;
gap: 1.35rem;
padding: 1.25rem 1rem;
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(12px);
border-inline-end: 1px solid color-mix(in srgb, var(--line) 80%, transparent);
box-shadow: 10px 0 40px rgba(15, 23, 42, 0.04);
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.35rem 0.4rem;
}
.brand-mark {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 16px;
background: linear-gradient(145deg, var(--accent-subtle), #fff);
border: 1px solid color-mix(in srgb, var(--accent) 18%, white);
box-shadow: 0 8px 20px rgba(13, 148, 136, 0.08);
}
.brand-copy {
display: grid;
min-width: 0;
gap: 0.1rem;
}
.brand-copy span {
color: var(--muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.nav {
display: grid;
gap: 0.35rem;
flex: 1;
align-content: start;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.72rem 0.8rem;
border-radius: 14px;
color: var(--ink-secondary);
text-decoration: none;
font-weight: 750;
font-size: 0.92rem;
transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.9);
color: var(--ink);
}
.nav-ico {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 11px;
background: var(--surface-subtle);
color: var(--muted);
}
.nav-item.router-link-active {
background: linear-gradient(135deg, var(--accent-subtle), #fff);
color: var(--accent-deep);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, white);
}
.nav-item.router-link-active .nav-ico {
background: var(--accent);
color: #fff;
}
.side-foot {
display: grid;
gap: 0.65rem;
padding-top: 0.35rem;
border-top: 1px solid var(--line-light);
}
.who-card {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem;
border-radius: 14px;
background: #fff;
border: 1px solid var(--line-light);
}
.who-avatar {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--accent-soft);
color: var(--accent-deep);
font-weight: 900;
}
.who-meta {
display: grid;
min-width: 0;
}
.who-meta strong {
font-size: 0.86rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.who-meta small {
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
}
.logout {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
border: 1px solid var(--line);
background: #fff;
color: var(--muted);
font: inherit;
font-size: 0.82rem;
font-weight: 750;
cursor: pointer;
padding: 0.55rem 0.75rem;
border-radius: 12px;
}
.logout:hover {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 30%, white);
}
.stage {
min-width: 0;
display: flex;
flex-direction: column;
min-height: 100dvh;
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.5rem 0.85rem;
background: color-mix(in srgb, #f4fbfa 78%, transparent);
backdrop-filter: blur(14px);
border-bottom: 1px solid transparent;
}
.top-desktop h1 {
margin: 0;
font-size: 1.35rem;
letter-spacing: -0.02em;
}
.top-desktop p {
margin: 0.2rem 0 0;
color: var(--muted);
font-size: 0.84rem;
}
.top-brand {
display: flex;
align-items: center;
gap: 0.65rem;
}
.top-brand strong {
display: block;
font-size: 1rem;
}
.top-brand small {
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
}
.logout-top {
border: 0;
background: #fff;
width: 40px;
height: 40px;
border-radius: 12px;
display: grid;
place-items: center;
color: var(--muted);
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
cursor: pointer;
}
.main {
flex: 1;
min-width: 0;
padding: 0.4rem 1.5rem 2rem;
}
.tabbar {
display: none;
position: sticky;
bottom: 0;
z-index: 30;
grid-template-columns: repeat(4, 1fr);
gap: 0.15rem;
padding: 0.45rem 0.55rem calc(0.45rem + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(16px);
border-top: 1px solid var(--line-light);
box-shadow: 0 -10px 30px rgba(15, 23, 42, 0.06);
}
.tab {
display: grid;
justify-items: center;
gap: 0.15rem;
padding: 0.45rem 0.2rem;
border-radius: 12px;
text-decoration: none;
color: var(--muted);
font-size: 0.68rem;
font-weight: 800;
}
.tab.router-link-active {
color: var(--accent-deep);
background: var(--accent-subtle);
}
@media (max-width: 860px) {
.adm-shell {
grid-template-columns: 1fr;
}
.desktop-only {
display: none !important;
}
.mobile-only {
display: flex;
}
.topbar {
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--line-light);
}
.main {
padding: 0.75rem 0.9rem calc(1.2rem + env(safe-area-inset-bottom));
}
.tabbar {
display: grid;
}
}
</style>
+60 -5
View File
@@ -79,17 +79,43 @@ const moreSectionActive = computed(
settingsActive.value,
);
const enabledModules = computed(() => auth.user?.enabledModules ?? null);
const subscription = computed(() => auth.user?.subscription ?? null);
function moduleAllowed(module?: string) {
if (!module) return true;
if (!enabledModules.value) return true;
return enabledModules.value.includes(module as never);
}
const openHub = computed<NavHubDef | null>(() => {
if (!openHubKey.value) return null;
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
if (!hub) return null;
return {
...hub,
cards: hub.cards.filter((card) => !card.admin || canSettings.value),
shortcuts: hub.shortcuts.filter((item) => !item.admin || canSettings.value),
cards: hub.cards.filter(
(card) =>
(!card.admin || canSettings.value) && moduleAllowed(card.module),
),
shortcuts: hub.shortcuts.filter(
(item) =>
(!item.admin || canSettings.value) && moduleAllowed(item.module),
),
};
});
const hubVisible = computed(() => {
const map: Record<string, boolean> = {};
for (const hub of NAV_HUBS) {
map[hub.key] = hub.cards.some(
(card) =>
(!card.admin || canSettings.value) && moduleAllowed(card.module),
);
}
return map;
});
function givenName(fullName: string): string {
const parts = fullName.trim().split(/\s+/).filter(Boolean);
if (parts.length <= 1) return fullName.trim();
@@ -368,7 +394,7 @@ onUnmounted(() => {
<span>داشبورد</span>
</RouterLink>
<div class="nav-fold" :class="{ active: customersActive || openHubKey === 'customers' }">
<div class="nav-fold" v-if="hubVisible.customers" :class="{ active: customersActive || openHubKey === 'customers' }">
<button
class="nav-item nav-fold-btn"
type="button"
@@ -383,7 +409,7 @@ onUnmounted(() => {
</button>
</div>
<div class="nav-fold" :class="{ active: salesActive || openHubKey === 'sales' }">
<div class="nav-fold" v-if="hubVisible.sales" :class="{ active: salesActive || openHubKey === 'sales' }">
<button
class="nav-item nav-fold-btn"
type="button"
@@ -398,7 +424,7 @@ onUnmounted(() => {
</button>
</div>
<div class="nav-fold" :class="{ active: activitiesActive || openHubKey === 'activities' }">
<div class="nav-fold" v-if="hubVisible.activities" :class="{ active: activitiesActive || openHubKey === 'activities' }">
<button
class="nav-item nav-fold-btn"
type="button"
@@ -600,6 +626,17 @@ onUnmounted(() => {
<RouterLink class="round-btn" :to="{ name: 'tickets' }" title="پیام‌ها">
<UiIcon name="mail" :size="18" />
</RouterLink>
<div
v-if="subscription"
class="sub-chip"
:class="{ warn: subscription.warn }"
:title="`انقضا: ${subscription.expiresAt}`"
>
<UiIcon name="clock" :size="15" />
<span>
{{ toFaDigits(subscription.daysRemaining) }} روز باقی‌مانده
</span>
</div>
<div class="date-chip">
<UiIcon name="calendar" :size="16" />
<span>{{ todayLabel }}</span>
@@ -1601,6 +1638,24 @@ onUnmounted(() => {
height: 34px;
}
.sub-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
border-radius: 999px;
background: var(--accent-subtle);
color: var(--accent-deep);
font-size: 0.78rem;
font-weight: 800;
white-space: nowrap;
}
.sub-chip.warn {
background: color-mix(in srgb, var(--danger) 12%, white);
color: var(--danger);
}
.date-chip {
display: inline-flex;
align-items: center;
+1 -1
View File
@@ -26,7 +26,7 @@ api.interceptors.response.use(
(error) => {
if (axios.isAxiosError(error) && error.response?.status === 401) {
const path = window.location.pathname;
if (path.startsWith('/p/')) {
if (path.startsWith('/p/') || path.startsWith('/adm')) {
return Promise.reject(error);
}
localStorage.removeItem('mento_token');
+57 -14
View File
@@ -1,3 +1,5 @@
import type { OrgModuleKey } from '@mento/shared';
export type NavHubKey =
| 'customers'
| 'sales'
@@ -43,6 +45,7 @@ export type NavHubCard = {
icon: NavIconName;
tint: NavHubTint;
admin?: boolean;
module?: OrgModuleKey;
};
export type NavHubShortcut = {
@@ -50,6 +53,7 @@ export type NavHubShortcut = {
title: string;
icon: NavIconName;
admin?: boolean;
module?: OrgModuleKey;
};
export type NavHubDef = {
@@ -70,8 +74,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'users',
tint: 'mint',
shortcuts: [
{ to: '/contacts/people/new', title: 'شخص جدید', icon: 'user-plus' },
{ to: '/contacts/companies/new', title: 'شرکت جدید', icon: 'building' },
{ to: '/contacts/people/new', title: 'شخص جدید', icon: 'user-plus', module: 'customers' },
{ to: '/contacts/companies/new', title: 'شرکت جدید', icon: 'building', module: 'customers' },
],
cards: [
{
@@ -80,6 +84,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'مخاطبان حقیقی و پرونده‌های مشتری',
icon: 'user',
tint: 'mint',
module: 'customers',
},
{
to: '/contacts?tab=companies',
@@ -87,6 +92,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'سازمان‌ها و مشتریان حقوقی',
icon: 'building',
tint: 'violet',
module: 'customers',
},
],
},
@@ -97,8 +103,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'briefcase',
tint: 'mint',
shortcuts: [
{ to: '/deals/new', title: 'معامله جدید', icon: 'plus' },
{ to: '/quotes/new', title: 'پیش‌فاکتور جدید', icon: 'document' },
{ to: '/deals/new', title: 'معامله جدید', icon: 'plus', module: 'pipeline' },
{ to: '/quotes/new', title: 'پیش‌فاکتور جدید', icon: 'document', module: 'quotes' },
],
cards: [
{
@@ -107,6 +113,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'سرنخ‌ها، معاملات و مراحل فروش',
icon: 'filter',
tint: 'mint',
module: 'pipeline',
},
{
to: '/settings/products',
@@ -115,6 +122,7 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'box',
tint: 'peach',
admin: true,
module: 'products',
},
{
to: '/invoices',
@@ -122,6 +130,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'صدور فاکتور و پیگیری پرداخت‌ها',
icon: 'mail',
tint: 'sky',
module: 'invoices',
},
{
to: '/quotes',
@@ -129,6 +138,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'پیشنهاد قیمت و پیگیری تایید',
icon: 'document',
tint: 'amber',
module: 'quotes',
},
{
to: '/commissions',
@@ -136,6 +146,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'سهم فروشندگان از فاکتورهای صادرشده',
icon: 'dollar',
tint: 'rose',
module: 'commissions',
},
{
to: '/sales/forecast',
@@ -143,6 +154,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'تعیین اهداف و تحلیل عملکرد فروش',
icon: 'target',
tint: 'mint',
module: 'sales_forecast',
},
],
},
@@ -153,8 +165,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'check',
tint: 'sky',
shortcuts: [
{ to: '/activities/new', title: 'فعالیت جدید', icon: 'plus' },
{ to: '/activities/calendar', title: 'تقویم', icon: 'calendar' },
{ to: '/activities/new', title: 'فعالیت جدید', icon: 'plus', module: 'activities' },
{ to: '/activities/calendar', title: 'تقویم', icon: 'calendar', module: 'activities' },
],
cards: [
{
@@ -163,6 +175,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'کارها، تماس‌ها و پیگیری‌های جاری',
icon: 'check',
tint: 'mint',
module: 'activities',
},
{
to: '/activities/calendar',
@@ -170,6 +183,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'نمای روز، هفته و زمان‌بندی کارها',
icon: 'calendar',
tint: 'violet',
module: 'activities',
},
],
},
@@ -180,8 +194,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'chat',
tint: 'peach',
shortcuts: [
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus' },
{ to: '/tickets', title: 'تیکت‌ها', icon: 'chat' },
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus', module: 'tickets' },
{ to: '/tickets', title: 'تیکت‌ها', icon: 'chat', module: 'tickets' },
],
cards: [
{
@@ -190,6 +204,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'درخواست‌ها و پیام‌های پشتیبانی',
icon: 'chat',
tint: 'peach',
module: 'tickets',
},
],
},
@@ -200,8 +215,18 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'sparkle',
tint: 'violet',
shortcuts: [
{ to: '/settings/automations', title: 'قانون گردش کار', icon: 'layers' },
{ to: '/settings/message-automations', title: 'قانون پیامک', icon: 'send' },
{
to: '/settings/automations',
title: 'قانون گردش کار',
icon: 'layers',
module: 'workflow_automations',
},
{
to: '/settings/message-automations',
title: 'قانون پیامک',
icon: 'send',
module: 'message_automations',
},
],
cards: [
{
@@ -211,6 +236,7 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'layers',
tint: 'violet',
admin: true,
module: 'workflow_automations',
},
{
to: '/settings/message-automations',
@@ -219,6 +245,7 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'send',
tint: 'sky',
admin: true,
module: 'message_automations',
},
],
},
@@ -229,8 +256,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'apps-plus',
tint: 'violet',
shortcuts: [
{ to: '/project-cards/new', title: 'کارت پروژه جدید', icon: 'plus' },
{ to: '/cheques', title: 'ثبت چک', icon: 'document' },
{ to: '/project-cards/new', title: 'کارت پروژه جدید', icon: 'plus', module: 'project_boards' },
{ to: '/cheques', title: 'ثبت چک', icon: 'document', module: 'cheques' },
],
cards: [
{
@@ -240,6 +267,7 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'layers',
tint: 'mint',
admin: true,
module: 'lead_forms',
},
{
to: '/projects',
@@ -247,6 +275,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'کارت‌ها و مراحل اجرای کار',
icon: 'flag',
tint: 'violet',
module: 'project_boards',
},
{
to: '/inventory',
@@ -254,6 +283,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'موجودی کالا و گردش داخلی',
icon: 'box',
tint: 'peach',
module: 'inventory',
},
{
to: '/cheques',
@@ -261,6 +291,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'چک‌های دریافتی و پرداختی',
icon: 'document',
tint: 'sky',
module: 'cheques',
},
{
to: '/attachments',
@@ -268,6 +299,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'فایل‌های متصل به یادداشت‌ها',
icon: 'document',
tint: 'amber',
module: 'attachments',
},
],
},
@@ -278,8 +310,13 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'settings',
tint: 'sky',
shortcuts: [
{ to: '/settings/team#settings-team', title: 'تیم و نقش‌ها', icon: 'users' },
{ to: '/settings/tahesab', title: 'ته‌حساب', icon: 'chart' },
{
to: '/settings/team#settings-team',
title: 'تیم و نقش‌ها',
icon: 'users',
module: 'team_settings',
},
{ to: '/settings/tahesab', title: 'ته‌حساب', icon: 'chart', module: 'tahesab' },
],
cards: [
{
@@ -288,6 +325,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'نام سازمان و تنظیمات پایه',
icon: 'building',
tint: 'mint',
module: 'team_settings',
},
{
to: '/settings/team#settings-team',
@@ -295,6 +333,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'اعضا، دسترسی و نقش‌ها',
icon: 'users',
tint: 'violet',
module: 'team_settings',
},
{
to: '/settings/custom-fields',
@@ -302,6 +341,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'فیلدهای اضافه روی پرونده‌ها',
icon: 'pencil',
tint: 'peach',
module: 'custom_fields',
},
{
to: '/settings/currencies',
@@ -309,6 +349,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'ارز پایه و نرخ تبدیل',
icon: 'dollar',
tint: 'sky',
module: 'currencies',
},
{
to: '/settings/tahesab',
@@ -316,6 +357,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'اتصال مالی و تنظیمات حسابداری',
icon: 'chart',
tint: 'amber',
module: 'tahesab',
},
{
to: '/settings/faq',
@@ -323,6 +365,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'پرسش‌های پرتکرار سازمان',
icon: 'lightbulb',
tint: 'rose',
module: 'faq',
},
],
},
+31
View File
@@ -0,0 +1,31 @@
import axios from 'axios';
export const platformApi = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
platformApi.interceptors.request.use((config) => {
const token = localStorage.getItem('mento_platform_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
platformApi.interceptors.response.use(
(response) => response,
(error) => {
if (axios.isAxiosError(error) && error.response?.status === 401) {
const path = window.location.pathname;
localStorage.removeItem('mento_platform_token');
localStorage.removeItem('mento_platform_admin');
if (path.startsWith('/adm') && path !== '/adm') {
window.location.assign('/adm');
}
}
return Promise.reject(error);
},
);
+44
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router';
import type { UserRole } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { usePlatformAuthStore } from '@/stores/platform-auth';
export const router = createRouter({
history: createWebHistory(),
@@ -29,6 +30,39 @@ export const router = createRouter({
component: () => import('@/views/PortalView.vue'),
meta: { public: true },
},
{
path: '/adm',
name: 'adm-login',
component: () => import('@/views/adm/AdmLoginView.vue'),
meta: { public: true, platformPublic: true },
},
{
path: '/adm',
component: () => import('@/layouts/AdmLayout.vue'),
meta: { public: true, platform: true },
children: [
{
path: 'dashboard',
name: 'adm-dashboard',
component: () => import('@/views/adm/AdmDashboardView.vue'),
},
{
path: 'organizations',
name: 'adm-organizations',
component: () => import('@/views/adm/AdmOrganizationsView.vue'),
},
{
path: 'users',
name: 'adm-users',
component: () => import('@/views/adm/AdmUsersView.vue'),
},
{
path: 'admins',
name: 'adm-admins',
component: () => import('@/views/adm/AdmAdminsView.vue'),
},
],
},
{
path: '/',
component: () => import('@/layouts/AppLayout.vue'),
@@ -291,6 +325,16 @@ export const router = createRouter({
});
router.beforeEach((to) => {
if (to.path === '/adm' || to.path.startsWith('/adm/')) {
const platform = usePlatformAuthStore();
if (to.meta.platformPublic) {
if (platform.isAuthenticated) return { name: 'adm-dashboard' };
return true;
}
if (!platform.isAuthenticated) return { name: 'adm-login' };
return true;
}
const auth = useAuthStore();
if (!to.meta.public && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } };
+67
View File
@@ -0,0 +1,67 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import type { PlatformAdminDto, PlatformLoginResponse } from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
const TOKEN_KEY = 'mento_platform_token';
const ADMIN_KEY = 'mento_platform_admin';
function readStoredAdmin(): PlatformAdminDto | null {
const raw = localStorage.getItem(ADMIN_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as PlatformAdminDto;
} catch {
return null;
}
}
export const usePlatformAuthStore = defineStore('platform-auth', () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY));
const admin = ref<PlatformAdminDto | null>(readStoredAdmin());
const isAuthenticated = computed(() => Boolean(token.value));
function persist(payload: PlatformLoginResponse) {
token.value = payload.accessToken;
admin.value = payload.admin;
localStorage.setItem(TOKEN_KEY, payload.accessToken);
localStorage.setItem(ADMIN_KEY, JSON.stringify(payload.admin));
}
async function login(mobile: string, password: string) {
const { data } = await platformApi.post<PlatformLoginResponse>(
'/platform/auth/login',
{ mobile, password },
);
persist(data);
}
function logout() {
token.value = null;
admin.value = null;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ADMIN_KEY);
}
async function hydrate() {
if (!token.value) return;
try {
const { data } = await platformApi.get<PlatformAdminDto>('/platform/auth/me');
admin.value = data;
localStorage.setItem(ADMIN_KEY, JSON.stringify(data));
} catch {
logout();
}
}
if (token.value) void hydrate();
return {
token,
admin,
isAuthenticated,
login,
logout,
hydrate,
};
});
+3 -3
View File
@@ -70,12 +70,12 @@
font-display: swap;
}
/* map 800 → Black */
/* map 800 → Bold (avoid oversized heavy Black in UI) */
@font-face {
font-family: "IRANSans";
src:
url("../assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_Black.woff2") format("woff2"),
url("../assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_Black.woff") format("woff");
url("../assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_Bold.woff2") format("woff2"),
url("../assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_Bold.woff") format("woff");
font-weight: 800;
font-style: normal;
font-display: swap;
+130 -4
View File
@@ -1,20 +1,133 @@
@import "./fonts.css";
@import "./tokens.css";
:root {
--mobile-tabbar-h: 0px;
/* Surface & Background */
--bg: #f8fafc;
--surface: #ffffff;
--surface-subtle: #f1f5f9;
--surface-hover: #f8fafc;
/* Typography & Foreground */
--font: "IRANSans", Tahoma, sans-serif;
--ink: #0f172a;
--ink-secondary: #334155;
--muted: #64748b;
--muted-light: #94a3b8;
--line: #e2e8f0;
--line-light: #f1f5f9;
--line-strong: #cbd5e1;
/* Accent / Primary (Teal) */
--accent: #14b8a6;
--accent-hover: #0d9488;
--accent-deep: #0f766e;
--accent-soft: #ccfbf1;
--accent-subtle: #f0fdfa;
--accent-contrast: #ffffff;
/* Semantics */
--danger: #b42318;
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
--danger-hover: #991b1b;
--danger-soft: #fee2e2;
--danger-deep: #7f1d1d;
--success: #10b981;
--success-soft: #d1fae5;
--success-deep: #047857;
--warning: #f59e0b;
--warning-soft: #fef3c7;
--warning-deep: #b45309;
--info: #0284c7;
--info-soft: #e0f2fe;
--info-deep: #0369a1;
/* Typography Scale */
--fs-xs: 0.75rem; /* 12px */
--fs-sm: 0.8125rem; /* 13px */
--fs-base: 0.875rem; /* 14px */
--fs-md: 0.9375rem; /* 15px */
--fs-lg: 1.0625rem; /* 17px */
--fs-xl: 1.25rem; /* 20px */
--fs-2xl: 1.5rem; /* 24px */
--fs-3xl: 1.75rem; /* 28px */
--fw-normal: 400;
--fw-medium: 500;
--fw-semibold: 600;
--fw-bold: 700;
--fw-extrabold: 700;
--lh-tight: 1.25;
--lh-normal: 1.5;
--lh-relaxed: 1.625;
/* Spacing Scale */
--sp-1: 0.25rem; /* 4px */
--sp-2: 0.5rem; /* 8px */
--sp-3: 0.75rem; /* 12px */
--sp-4: 1rem; /* 16px */
--sp-5: 1.25rem; /* 20px */
--sp-6: 1.5rem; /* 24px */
--sp-8: 2rem; /* 32px */
--sp-10: 2.5rem; /* 40px */
/* Radius - Single Source of Truth (Globally 10px as requested) */
--radius: 10px;
--radius-xs: var(--radius);
--radius-sm: var(--radius);
--radius-md: var(--radius);
--radius-lg: var(--radius);
--radius-xl: var(--radius);
--radius-2xl: var(--radius);
--radius-full: 9999px;
/* Control Dimensions */
--control-h-sm: 30px;
--control-h: 36px;
--pad-modal: 24px;
--font: "IRANSans", Tahoma, sans-serif;
--control-h-md: 36px;
--control-h-lg: 42px;
--pad-modal: 16px;
/* Shadows */
--shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.05);
--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.08), 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.08);
--shadow-lg: 0 12px 32px rgba(15, 23, 42, 0.12);
--shadow-xl: 0 20px 48px rgba(15, 23, 42, 0.16);
/* Transitions */
--transition-fast: 150ms ease;
--transition-normal: 200ms ease;
/* Z-index */
--z-dropdown: 50;
--z-sticky: 60;
--z-modal-backdrop: 80;
--z-modal: 90;
--z-toast: 100;
/* Compatibility aliases for legacy views */
--color-border: var(--line);
--color-border-light: var(--line-light);
--color-text: var(--ink);
--color-text-primary: var(--ink);
--color-text-secondary: var(--ink-secondary);
--color-text-muted: var(--muted);
--color-text-emphasis: var(--ink);
--color-surface: var(--surface);
--color-surface-sunken: var(--surface-subtle);
--color-primary: var(--accent);
--color-primary-dark: var(--accent-deep);
--color-primary-light: var(--accent-soft);
--color-primary-surface: var(--accent-subtle);
--color-danger: var(--danger);
--color-danger-surface: var(--danger-soft);
--color-warning: var(--warning);
}
* {
@@ -64,16 +177,20 @@ textarea {
.btn {
border: 0;
border-radius: var(--radius);
height: var(--control-h);
min-height: var(--control-h);
padding: 0 1rem;
padding: 0 0.85rem;
cursor: pointer;
background: var(--accent);
color: white;
font-weight: 600;
font-size: var(--fs-sm);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
box-sizing: border-box;
transition: background var(--transition-fast), border-color var(--transition-fast), opacity var(--transition-fast);
}
.btn:disabled {
@@ -130,12 +247,21 @@ textarea {
border-color: var(--accent);
}
input::placeholder,
textarea::placeholder {
color: #c0c8d4;
}
.error {
color: var(--danger);
font-size: 0.9rem;
}
@media (max-width: 900px) {
:root {
--mobile-tabbar-h: calc(3.65rem + env(safe-area-inset-bottom, 0px));
}
h1 {
font-size: clamp(1.35rem, 5vw, 1.75rem);
}
+496
View File
@@ -0,0 +1,496 @@
/**
* Montact CRM (منتکت) Design System - Core Tokens & Utilities
* RTL-first, IRANSans typography, unified spacing, controls & surfaces.
*/
/* ==========================================================================
Page Layout
========================================================================== */
.crm-page,
.page,
.dashboard,
.profile,
.notes-page {
display: flex;
flex-direction: column;
gap: var(--sp-5);
padding: 0;
margin: 0;
max-width: 100%;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.crm-page.narrow,
.crm-page.wide,
.crm-page.full {
max-width: 100%;
}
.crm-page.embedded {
padding: 0;
gap: var(--sp-4);
}
/* ==========================================================================
Page Header
========================================================================== */
.crm-page-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-4);
min-width: 0;
}
.crm-page-header-titles {
display: flex;
flex-direction: column;
gap: var(--sp-1);
min-width: 0;
}
.crm-page-title {
margin: 0;
font-size: var(--fs-xl);
font-weight: var(--fw-bold);
color: var(--ink);
line-height: var(--lh-tight);
}
.crm-page-lead {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: var(--lh-normal);
}
.crm-header-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
}
/* ==========================================================================
Toolbar & Filter Bar
========================================================================== */
.crm-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-3);
min-width: 0;
width: 100%;
}
.crm-toolbar-stretch,
.crm-toolbar .toolbar-search,
.crm-toolbar .search-box,
.crm-toolbar .crm-search {
flex: 1 1 200px;
min-width: 180px;
max-width: 380px;
}
.crm-toolbar .toolbar-select,
.crm-toolbar .ui-select:not(.block) {
width: auto;
min-width: 130px;
max-width: 220px;
}
.crm-toolbar .ui-btn,
.crm-toolbar .btn {
flex-shrink: 0;
}
/* ==========================================================================
Card & Container Surfaces
========================================================================== */
.crm-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-xs);
padding: var(--sp-5);
box-sizing: border-box;
transition: box-shadow var(--transition-fast), border-color var(--transition-fast);
}
.crm-card-sm {
padding: var(--sp-3);
}
.crm-card-lg {
padding: var(--sp-6);
}
.crm-card-interactive:hover {
border-color: var(--line-strong);
box-shadow: var(--shadow-sm);
}
.crm-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding-bottom: var(--sp-3);
margin-bottom: var(--sp-4);
border-bottom: 1px solid var(--line-light);
}
.crm-card-title {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.crm-card-actions {
display: flex;
align-items: center;
gap: var(--sp-2);
}
/* ==========================================================================
Forms & Field Layouts
========================================================================== */
.crm-form {
display: flex;
flex-direction: column;
gap: var(--sp-5);
width: 100%;
}
.crm-form-section {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.crm-form-section-title {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
padding-bottom: var(--sp-2);
border-bottom: 1px solid var(--line-light);
}
.crm-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--sp-4);
width: 100%;
}
.crm-form-grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--sp-4);
width: 100%;
}
.crm-form-grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--sp-4);
width: 100%;
}
.crm-form-grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-4);
width: 100%;
}
.crm-col-span-2 {
grid-column: span 2;
}
.crm-col-span-full {
grid-column: 1 / -1;
}
.crm-field {
display: flex;
flex-direction: column;
gap: var(--sp-1);
min-width: 0;
}
.crm-label {
display: flex;
align-items: center;
gap: var(--sp-1);
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--ink-secondary);
}
.crm-required-indicator {
color: var(--danger);
}
.crm-hint {
font-size: var(--fs-xs);
color: var(--muted);
line-height: var(--lh-normal);
}
.crm-field-error {
font-size: var(--fs-xs);
color: var(--danger);
font-weight: var(--fw-medium);
display: flex;
align-items: center;
gap: var(--sp-1);
}
.crm-form-actions {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--sp-3);
padding-top: var(--sp-4);
margin-top: var(--sp-2);
border-top: 1px solid var(--line-light);
flex-wrap: wrap;
}
/* ==========================================================================
Data Table
========================================================================== */
.crm-table-container {
width: 100%;
overflow-x: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-xs);
-webkit-overflow-scrolling: touch;
}
.crm-table {
width: 100%;
border-collapse: collapse;
text-align: right;
font-size: var(--fs-sm);
}
.crm-table th {
background: var(--surface-subtle);
color: var(--muted);
font-weight: var(--fw-semibold);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.crm-table td {
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--line-light);
color: var(--ink);
vertical-align: middle;
}
.crm-table tbody tr:last-child td {
border-bottom: none;
}
.crm-table tbody tr:hover td {
background: var(--surface-hover);
}
.crm-table-actions {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
white-space: nowrap;
}
/* ==========================================================================
Badges & Status Indicators
========================================================================== */
.crm-badge {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.2rem 0.6rem;
border-radius: var(--radius-full);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
line-height: 1.2;
white-space: nowrap;
border: 1px solid transparent;
}
.crm-badge-sm {
padding: 0.1rem 0.45rem;
font-size: 0.7rem;
}
.crm-badge-neutral {
background: var(--surface-subtle);
color: var(--ink-secondary);
border-color: var(--line);
}
.crm-badge-accent,
.crm-badge-primary {
background: var(--accent-soft);
color: var(--accent-deep);
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
}
.crm-badge-success {
background: var(--success-soft);
color: var(--success-deep);
border-color: color-mix(in srgb, var(--success) 30%, transparent);
}
.crm-badge-warning {
background: var(--warning-soft);
color: var(--warning-deep);
border-color: color-mix(in srgb, var(--warning) 30%, transparent);
}
.crm-badge-danger {
background: var(--danger-soft);
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 30%, transparent);
}
.crm-badge-info {
background: var(--info-soft);
color: var(--info-deep);
border-color: color-mix(in srgb, var(--info) 30%, transparent);
}
/* ==========================================================================
Pagination
========================================================================== */
.crm-pagination {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-3);
padding-top: var(--sp-3);
}
.crm-pagination-info {
font-size: var(--fs-sm);
color: var(--muted);
}
.crm-pagination-nav {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
}
/* ==========================================================================
Empty States
========================================================================== */
.crm-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--sp-10) var(--sp-6);
gap: var(--sp-3);
color: var(--muted);
background: var(--surface);
border: 1px dashed var(--line-strong);
border-radius: var(--radius);
}
.crm-empty-title {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.crm-empty-desc {
margin: 0;
font-size: var(--fs-sm);
max-width: 360px;
line-height: var(--lh-relaxed);
}
/* ==========================================================================
Typography Utilities
========================================================================== */
.crm-text-xs { font-size: var(--fs-xs); }
.crm-text-sm { font-size: var(--fs-sm); }
.crm-text-base { font-size: var(--fs-base); }
.crm-text-md { font-size: var(--fs-md); }
.crm-text-lg { font-size: var(--fs-lg); }
.crm-text-xl { font-size: var(--fs-xl); }
.crm-text-2xl { font-size: var(--fs-2xl); }
.crm-font-normal { font-weight: var(--fw-normal); }
.crm-font-medium { font-weight: var(--fw-medium); }
.crm-font-semibold { font-weight: var(--fw-semibold); }
.crm-font-bold { font-weight: var(--fw-bold); }
.crm-font-extrabold { font-weight: var(--fw-extrabold); }
.crm-text-ink { color: var(--ink); }
.crm-text-secondary { color: var(--ink-secondary); }
.crm-text-muted { color: var(--muted); }
.crm-text-accent { color: var(--accent); }
.crm-text-danger { color: var(--danger); }
.crm-text-success { color: var(--success); }
.crm-text-warning { color: var(--warning); }
/* ==========================================================================
Responsive Breakpoints
========================================================================== */
@media (max-width: 900px) {
.crm-page {
padding: 0;
gap: var(--sp-4);
}
.crm-page-title {
font-size: var(--fs-xl);
}
.crm-page-header {
flex-direction: column;
align-items: stretch;
gap: var(--sp-3);
}
.crm-header-actions {
justify-content: stretch;
}
.crm-header-actions > * {
flex: 1 1 auto;
}
.crm-form-grid-2,
.crm-form-grid-3,
.crm-form-grid-4 {
grid-template-columns: 1fr;
}
.crm-col-span-2 {
grid-column: auto;
}
.crm-pagination {
flex-direction: column;
align-items: center;
}
}
@@ -405,7 +405,7 @@ onMounted(() => {
h1 {
margin: 0 0 0.3rem;
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
font-weight: 800;
font-weight: 600;
}
h2 {
@@ -461,7 +461,7 @@ h2 {
gap: 0.75rem;
flex-wrap: wrap;
background: #fff;
border-radius: 18px;
border-radius: var(--radius);
padding: 0.75rem 0.95rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
@@ -493,7 +493,7 @@ h2 {
.calendar {
background: #fff;
border-radius: 18px;
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
padding: 0.85rem;
min-width: 0;
@@ -519,7 +519,7 @@ h2 {
.day {
min-height: 92px;
border: 1px solid #e2e8f0;
border-radius: 14px;
border-radius: var(--radius);
background: #f8fafc;
padding: 0.45rem;
text-align: right;
@@ -553,7 +553,7 @@ h2 {
}
.day.has .num {
font-weight: 800;
font-weight: 600;
color: #0f766e;
}
@@ -594,7 +594,7 @@ h2 {
.day-panel {
background: #fff;
border-radius: 18px;
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
padding: 1rem;
display: grid;
@@ -623,7 +623,7 @@ h2 {
gap: 0.15rem;
padding: 0.75rem 0.85rem;
border: 1px solid #f1f5f9;
border-radius: 14px;
border-radius: var(--radius);
background: #f8fafc;
text-decoration: none;
color: inherit;
+111 -108
View File
@@ -10,9 +10,14 @@ import {
startOfLocalDay,
toFaDigits,
} from '@/lib/jalali';
import UiButton from '@/components/ui/UiButton.vue';
import UiModal from '@/components/ui/UiModal.vue';
import {
UiButton,
UiModal,
UiPageHeader,
UiPagination,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import ActivityFormView from '@/views/ActivityFormView.vue';
import type {
ActivityDto,
@@ -74,7 +79,6 @@ const activityModal = ref<{
const menuId = ref<string | null>(null);
const busy = ref(false);
const importMsg = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const counts = ref<Record<ActivityFilter, number>>({
today: 0,
overdue: 0,
@@ -124,8 +128,6 @@ const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE))
const filterLabel = computed(() => TABS.find((t) => t.key === filter.value)?.label ?? 'امروز');
const trulyEmpty = computed(() => !loading.value && counts.value.all === 0 && !q.value.trim());
const listEmpty = computed(() => !loading.value && !items.value.length);
const rangeFrom = computed(() => (total.value ? (page.value - 1) * PAGE_SIZE + 1 : 0));
const rangeTo = computed(() => Math.min(page.value * PAGE_SIZE, total.value));
function isLastItem(id: string) {
const rows = items.value;
@@ -284,44 +286,31 @@ async function exportExcel() {
}
}
function pickImportFile() {
fileInput.value?.click();
}
async function onImportFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
function onImportStart() {
busy.value = true;
importMsg.value = '';
error.value = '';
try {
const form = new FormData();
form.append('file', file);
const { data } = await api.post<ImportContactsResult>('/activities/import', form, {
headers: { 'Content-Type': undefined },
});
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}`;
if (data.errors.length) {
error.value = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
}
await Promise.all([load(), loadCounts()]);
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ایمپورت ناموفق بود');
} else {
error.value = 'ایمپورت ناموفق بود';
}
} finally {
busy.value = false;
}
async function onImportDone(data: ImportContactsResult) {
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}`;
if (data.errors.length) {
error.value = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
}
await Promise.all([load(), loadCounts()]);
}
function onImportFail(message: string) {
error.value = message;
}
function onImportSettled() {
busy.value = false;
}
watch(
@@ -346,38 +335,41 @@ onMounted(() => {
<span>فعالیت‌ها</span>
</nav>
<header class="page-head">
<div>
<p class="eyebrow">پیگیری</p>
<h1>فعالیت‌ها</h1>
<p class="lead">تماس، جلسه و وظیفه — امروز و عقب‌افتاده‌ها یکجا.</p>
</div>
<div class="head-actions">
<button class="btn ghost excel-btn" type="button" :disabled="busy" @click.stop="exportExcel">
<UiPageHeader
title="فعالیت‌ها"
lead="تماس، جلسه و وظیفه — امروز و عقب‌افتاده‌ها یکجا."
>
<template #actions>
<UiButton variant="ghost" type="button" :disabled="busy" @click.stop="exportExcel">
<span class="btn-full">خروجی اکسل</span>
<span class="btn-short">خروجی</span>
</button>
<button class="btn ghost excel-btn" type="button" :disabled="busy" @click.stop="pickImportFile">
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</button>
<input
ref="fileInput"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
@change="onImportFile"
/>
<UiButton type="button" @click="activityModal = {}">
</UiButton>
<ExcelImportFlow
preview-path="/activities/import/preview"
import-path="/activities/import"
:disabled="busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<UiButton variant="ghost" type="button" :disabled="busy">
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</UiButton>
</ExcelImportFlow>
<UiButton variant="primary" @click="activityModal = {}">
<UiIcon name="plus" :size="16" />
فعالیت جدید
</UiButton>
<RouterLink class="btn ghost" :to="{ name: 'activities-calendar' }">
<UiIcon name="calendar" :size="18" />
تقویم
<RouterLink :to="{ name: 'activities-calendar' }">
<UiButton variant="ghost">
<UiIcon name="calendar" :size="16" />
تقویم
</UiButton>
</RouterLink>
</div>
</header>
</template>
</UiPageHeader>
<div class="tabs" role="tablist" aria-label="فیلتر فعالیت">
<button
@@ -621,23 +613,13 @@ onMounted(() => {
</article>
</div>
<footer v-if="total" class="pager">
<p>نمایش {{ toFaDigits(rangeFrom) }} تا {{ toFaDigits(rangeTo) }} از {{ toFaDigits(total) }} فعالیت</p>
<div class="pages">
<button type="button" :disabled="page <= 1" @click="setPage(page - 1)">‹</button>
<button
v-for="n in pageCount"
:key="n"
type="button"
:class="{ on: n === page }"
@click="setPage(n)"
>
{{ toFaDigits(n) }}
</button>
<button type="button" :disabled="page >= pageCount" @click="setPage(page + 1)">›</button>
</div>
<p class="per">نمایش {{ toFaDigits(PAGE_SIZE) }} در هر صفحه</p>
</footer>
<UiPagination
v-if="total"
:page="page"
:total-pages="pageCount"
:total-items="total"
@update:page="setPage"
/>
</template>
<UiModal
@@ -737,26 +719,34 @@ h1 {
align-items: center;
justify-content: center;
gap: 0.35rem;
height: var(--control-h);
min-height: var(--control-h);
padding: 0 0.95rem;
padding: 0 0.85rem;
border-radius: var(--radius);
font-weight: 700;
font-weight: 600;
font-size: var(--fs-sm);
border: 1px solid transparent;
cursor: pointer;
font: inherit;
text-decoration: none;
box-sizing: border-box;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.btn.primary {
background: #14b8a6;
background: var(--accent);
color: #fff;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
}
.btn.ghost {
background: #fff;
color: #0f766e;
border-color: #99f6e4;
color: var(--ink-secondary);
border-color: var(--line);
}
.btn.ghost:hover {
background: var(--surface-subtle);
border-color: var(--line-strong);
}
.btn.wide {
@@ -802,25 +792,38 @@ h1 {
}
.search {
flex: 1;
min-width: 0;
flex: 1 1 200px;
min-width: 180px;
max-width: 400px;
height: var(--control-h);
min-height: var(--control-h);
display: flex;
align-items: center;
gap: 0.5rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 0 0.85rem;
color: #94a3b8;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 0.75rem;
color: var(--muted);
box-sizing: border-box;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.search:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: none;
padding: 0.75rem 0;
padding: 0;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink);
}
.filter-wrap,
@@ -839,7 +842,7 @@ h1 {
inset-inline-end: 0;
min-width: 11rem;
background: #fff;
border-radius: 12px;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
padding: 0.35rem;
display: grid;
@@ -859,7 +862,7 @@ h1 {
border: 0;
background: none;
padding: 0.6rem 0.7rem;
border-radius: 8px;
border-radius: var(--radius);
font: inherit;
cursor: pointer;
color: #0f172a;
@@ -874,7 +877,7 @@ h1 {
.empty-card {
background: #fff;
border-radius: 22px;
border-radius: var(--radius);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
padding: 2.4rem 1.2rem 2rem;
display: grid;
@@ -914,7 +917,7 @@ h1 {
font: inherit;
cursor: pointer;
background: #f8fafc;
border-radius: 10px;
border-radius: var(--radius);
padding: 0.65rem 0.8rem;
color: #0f766e;
font-weight: 700;
@@ -933,7 +936,7 @@ h1 {
.table {
background: #fff;
border-radius: 18px;
border-radius: var(--radius);
overflow: visible;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
@@ -950,9 +953,9 @@ h1 {
.thead {
color: #94a3b8;
font-size: 0.78rem;
font-weight: 700;
font-weight: 600;
border-bottom: 1px solid #f1f5f9;
border-radius: 18px 18px 0 0;
border-radius: var(--radius) var(--radius) 0 0;
}
.trow {
@@ -962,7 +965,7 @@ h1 {
.trow:last-child {
border-bottom: 0;
border-radius: 0 0 18px 18px;
border-radius: 0 0 var(--radius) var(--radius);
}
.trow.menu-open {
@@ -982,7 +985,7 @@ h1 {
.title {
color: inherit;
font-weight: 800;
font-weight: 600;
overflow-wrap: anywhere;
}
@@ -1100,7 +1103,7 @@ time.note {
border: 0;
background: none;
color: #64748b;
border-radius: 8px;
border-radius: var(--radius);
display: grid;
place-items: center;
cursor: pointer;
@@ -1114,7 +1117,7 @@ time.note {
.card {
background: #fff;
border: 1px solid #eef2f6;
border-radius: 18px;
border-radius: var(--radius);
padding: 0.9rem 0.95rem 0.75rem;
}
@@ -1132,7 +1135,7 @@ time.note {
.card-main time {
display: inline-block;
background: none;
font-weight: 800;
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 0.2rem;
}
@@ -1224,7 +1227,7 @@ time.note {
height: 32px;
border: 0;
background: #fff;
border-radius: 8px;
border-radius: var(--radius);
cursor: pointer;
}
+13 -9
View File
@@ -313,13 +313,13 @@ onMounted(load);
<style scoped>
.page {
max-width: 640px;
width: 100%;
max-width: 100%;
min-width: 0;
margin: 0 auto;
margin: 0;
display: grid;
gap: 0.9rem;
padding-bottom: 5.8rem;
padding-bottom: 5.5rem;
}
.nav {
@@ -331,7 +331,7 @@ onMounted(load);
}
.brand {
font-weight: 800;
font-weight: 600;
color: #0f766e;
letter-spacing: -0.04em;
}
@@ -341,7 +341,7 @@ onMounted(load);
height: 38px;
border: 0;
background: #fff;
border-radius: 12px;
border-radius: var(--radius);
display: grid;
place-items: center;
color: #334155;
@@ -362,7 +362,7 @@ onMounted(load);
inset-inline-end: 0;
min-width: 10rem;
background: #fff;
border-radius: 12px;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
padding: 0.35rem;
display: grid;
@@ -377,7 +377,7 @@ onMounted(load);
border: 0;
background: none;
padding: 0.62rem 0.75rem;
border-radius: 8px;
border-radius: var(--radius);
font: inherit;
cursor: pointer;
color: #0f172a;
@@ -407,7 +407,7 @@ h1 {
.card {
background: #fff;
border-radius: 20px;
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
padding: 1rem 1.05rem;
}
@@ -573,7 +573,7 @@ dd {
.dock {
position: fixed;
inset-inline: 0.85rem;
bottom: calc(0.55rem + env(safe-area-inset-bottom));
bottom: calc(0.55rem + var(--mobile-tabbar-h));
display: grid;
grid-template-columns: 1.15fr 1fr 1fr;
gap: 0.5rem;
@@ -608,6 +608,10 @@ dd {
}
@media (min-width: 900px) {
.page {
padding-bottom: 0;
}
.dock {
position: sticky;
inset-inline: auto;

Some files were not shown because too many files have changed in this diff Show More