Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebf13cfde5 | ||
|
|
0e580b19c3 | ||
|
|
4a9fe0b07c | ||
|
|
04d37a9d12 | ||
|
|
605e1d3340 | ||
|
|
b016b4754f | ||
|
|
ff5ad21eb9 | ||
|
|
4776d1a3c3 | ||
|
|
9c7dad3677 | ||
|
|
a428c6f135 | ||
|
|
3b643d7bb9 | ||
|
|
dfd35c45ef | ||
|
|
1ae87c97b7 |
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user