Add reservations module and rename teams visibility label to file-access level.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-27 09:38:17 +03:30
co-authored by Cursor
parent db65b55660
commit d133c8ca34
15 changed files with 1646 additions and 15 deletions
@@ -0,0 +1,66 @@
-- CreateTable
CREATE TABLE "ReservationService" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"durationMinutes" INTEGER NOT NULL DEFAULT 30,
"workDays" INTEGER[] DEFAULT ARRAY[0, 1, 2, 3, 4, 5, 6]::INTEGER[],
"dayStartHour" INTEGER NOT NULL DEFAULT 9,
"dayEndHour" INTEGER NOT NULL DEFAULT 17,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ReservationService_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReservationAppointment" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"assigneeId" TEXT,
"startsAt" TIMESTAMP(3) NOT NULL,
"endsAt" TIMESTAMP(3) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ReservationAppointment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ReservationService_organizationId_isActive_idx" ON "ReservationService"("organizationId", "isActive");
-- CreateIndex
CREATE INDEX "ReservationAppointment_organizationId_startsAt_idx" ON "ReservationAppointment"("organizationId", "startsAt");
-- CreateIndex
CREATE INDEX "ReservationAppointment_organizationId_status_idx" ON "ReservationAppointment"("organizationId", "status");
-- CreateIndex
CREATE INDEX "ReservationAppointment_serviceId_startsAt_idx" ON "ReservationAppointment"("serviceId", "startsAt");
-- CreateIndex
CREATE INDEX "ReservationAppointment_personId_idx" ON "ReservationAppointment"("personId");
-- CreateIndex
CREATE INDEX "ReservationAppointment_assigneeId_startsAt_idx" ON "ReservationAppointment"("assigneeId", "startsAt");
-- AddForeignKey
ALTER TABLE "ReservationService" ADD CONSTRAINT "ReservationService_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "ReservationService"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+49
View File
@@ -200,6 +200,8 @@ model Organization {
smsCampaigns SmsCampaign[]
wooCommerceConnections WooCommerceConnection[]
voipConnections VoipConnection[]
reservationServices ReservationService[]
reservationAppointments ReservationAppointment[]
}
model User {
@@ -245,6 +247,7 @@ model User {
savedViews SavedView[] @relation("SavedViewOwner")
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
teamMemberships TeamMember[]
reservationAssignments ReservationAppointment[] @relation("ReservationAssignee")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -425,6 +428,7 @@ model Person {
smartNotes Note[]
quotes Quote[]
invoices Invoice[]
reservationAppointments ReservationAppointment[]
ownerId String?
owner User? @relation("PersonOwner", fields: [ownerId], references: [id], onDelete: SetNull)
tickets Ticket[]
@@ -1830,3 +1834,48 @@ model VoipCallLog {
@@index([personId])
@@index([activityId])
}
/// خدمت قابل رزرو (مثلاً مشاوره ۳۰ دقیقه‌ای)
model ReservationService {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
description String @default("")
durationMinutes Int @default(30)
/// روزهای کار ۰=یکشنبه … ۶=شنبه
workDays Int[] @default([0, 1, 2, 3, 4, 5, 6])
dayStartHour Int @default(9)
dayEndHour Int @default(17)
isActive Boolean @default(true)
appointments ReservationAppointment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, isActive])
}
model ReservationAppointment {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
serviceId String
service ReservationService @relation(fields: [serviceId], references: [id], onDelete: Restrict)
personId String
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
assigneeId String?
assignee User? @relation("ReservationAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
startsAt DateTime
endsAt DateTime
/// pending | confirmed | canceled | done
status String @default("pending")
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, startsAt])
@@index([organizationId, status])
@@index([serviceId, startsAt])
@@index([personId])
@@index([assigneeId, startsAt])
}
+2
View File
@@ -41,6 +41,7 @@ import { SmsCampaignsModule } from './sms-campaigns/sms-campaigns.module';
import { TeamsModule } from './teams/teams.module';
import { WooCommerceModule } from './integrations/woocommerce/woocommerce.module';
import { VoipModule } from './integrations/voip/voip.module';
import { ReservationsModule } from './reservations/reservations.module';
@Module({
imports: [
@@ -89,6 +90,7 @@ import { VoipModule } from './integrations/voip/voip.module';
TeamsModule,
WooCommerceModule,
VoipModule,
ReservationsModule,
],
})
export class AppModule {}
+205
View File
@@ -0,0 +1,205 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
const STATUSES = ['pending', 'confirmed', 'canceled', 'done'] as const;
export class CreateReservationServiceDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@Type(() => Number)
@IsInt()
@Min(5)
@Max(480)
durationMinutes!: number;
@IsOptional()
@IsArray()
@ArrayMaxSize(7)
@IsInt({ each: true })
@Min(0, { each: true })
@Max(6, { each: true })
workDays?: number[];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
dayStartHour?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(24)
dayEndHour?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateReservationServiceDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(120)
name?: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(5)
@Max(480)
durationMinutes?: number;
@IsOptional()
@IsArray()
@ArrayMaxSize(7)
@IsInt({ each: true })
@Min(0, { each: true })
@Max(6, { each: true })
workDays?: number[];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
dayStartHour?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(24)
dayEndHour?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class CreateReservationAppointmentDto {
@IsString()
serviceId!: string;
@IsString()
personId!: string;
@IsOptional()
@IsString()
assigneeId?: string | null;
@IsDateString()
startsAt!: string;
@IsOptional()
@IsIn(STATUSES)
status?: (typeof STATUSES)[number];
@IsOptional()
@IsString()
@MaxLength(1000)
notes?: string | null;
}
export class UpdateReservationAppointmentDto {
@IsOptional()
@IsString()
serviceId?: string;
@IsOptional()
@IsString()
personId?: string;
@IsOptional()
@IsString()
assigneeId?: string | null;
@IsOptional()
@IsDateString()
startsAt?: string;
@IsOptional()
@IsIn(STATUSES)
status?: (typeof STATUSES)[number];
@IsOptional()
@IsString()
@MaxLength(1000)
notes?: string | null;
}
export class ListReservationsQueryDto {
@IsOptional()
@IsIn(STATUSES)
status?: (typeof STATUSES)[number];
@IsOptional()
@IsDateString()
from?: string;
@IsOptional()
@IsDateString()
to?: string;
@IsOptional()
@IsString()
serviceId?: string;
@IsOptional()
@IsString()
personId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 30;
}
export class SlotsQueryDto {
@IsString()
serviceId!: string;
@IsDateString()
date!: string;
@IsOptional()
@IsString()
assigneeId?: string;
}
@@ -0,0 +1,118 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
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 type { Actor } from '../common/record-scope';
import {
CreateReservationAppointmentDto,
CreateReservationServiceDto,
ListReservationsQueryDto,
SlotsQueryDto,
UpdateReservationAppointmentDto,
UpdateReservationServiceDto,
} from './dto';
import { ReservationsService } from './reservations.service';
@Controller('reservations')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('reservations')
export class ReservationsController {
constructor(private readonly reservations: ReservationsService) {}
@Get('services')
listServices(@CurrentUser() user: Actor) {
return this.reservations.listServices(user.organizationId);
}
@Post('services')
@Roles('owner', 'admin')
createService(
@CurrentUser() user: Actor,
@Body() dto: CreateReservationServiceDto,
) {
return this.reservations.createService(user.organizationId, dto);
}
@Patch('services/:id')
@Roles('owner', 'admin')
updateService(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateReservationServiceDto,
) {
return this.reservations.updateService(user.organizationId, id, dto);
}
@Delete('services/:id')
@Roles('owner', 'admin')
async removeService(
@CurrentUser() user: Actor,
@Param('id') id: string,
) {
await this.reservations.removeService(user.organizationId, id);
return { ok: true };
}
@Get('slots')
slots(@CurrentUser() user: Actor, @Query() query: SlotsQueryDto) {
return this.reservations.listSlots(
user.organizationId,
query.serviceId,
query.date,
query.assigneeId,
);
}
@Get()
list(
@CurrentUser() user: Actor,
@Query() query: ListReservationsQueryDto,
) {
return this.reservations.listAppointments(user.organizationId, user, query);
}
@Post()
create(
@CurrentUser() user: Actor,
@Body() dto: CreateReservationAppointmentDto,
) {
return this.reservations.createAppointment(
user.organizationId,
user,
dto,
);
}
@Patch(':id')
update(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateReservationAppointmentDto,
) {
return this.reservations.updateAppointment(
user.organizationId,
user,
id,
dto,
);
}
@Delete(':id')
async remove(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.reservations.removeAppointment(user.organizationId, user, id);
return { ok: true };
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module';
import { ReservationsController } from './reservations.controller';
import { ReservationsService } from './reservations.service';
@Module({
imports: [AuthModule, BillingModule],
controllers: [ReservationsController],
providers: [ReservationsService],
exports: [ReservationsService],
})
export class ReservationsModule {}
@@ -0,0 +1,509 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateReservationAppointmentRequest,
CreateReservationServiceRequest,
ReservationAppointmentDto,
ReservationListResponse,
ReservationServiceDto,
ReservationSlotDto,
ReservationStatus,
UpdateReservationAppointmentRequest,
UpdateReservationServiceRequest,
} from '@mento/shared';
import { RESERVATION_STATUSES } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { type Actor, elevated } from '../common/record-scope';
@Injectable()
export class ReservationsService {
constructor(private readonly prisma: PrismaService) {}
/* ——— services ——— */
async listServices(organizationId: string): Promise<ReservationServiceDto[]> {
const rows = await this.prisma.reservationService.findMany({
where: { organizationId },
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
});
return rows.map((r) => this.toServiceDto(r));
}
async createService(
organizationId: string,
dto: CreateReservationServiceRequest,
): Promise<ReservationServiceDto> {
const name = dto.name?.trim();
if (!name) throw new BadRequestException('نام خدمت الزامی است');
const duration = Number(dto.durationMinutes);
if (!Number.isFinite(duration) || duration < 5) {
throw new BadRequestException('مدت خدمت نامعتبر است');
}
const dayStart = dto.dayStartHour ?? 9;
const dayEnd = dto.dayEndHour ?? 17;
if (dayEnd <= dayStart) {
throw new BadRequestException('ساعت پایان باید بعد از شروع باشد');
}
const row = await this.prisma.reservationService.create({
data: {
organizationId,
name,
description: dto.description?.trim() ?? '',
durationMinutes: duration,
workDays: dto.workDays?.length ? dto.workDays : [0, 1, 2, 3, 4, 5, 6],
dayStartHour: dayStart,
dayEndHour: dayEnd,
isActive: dto.isActive ?? true,
},
});
return this.toServiceDto(row);
}
async updateService(
organizationId: string,
id: string,
dto: UpdateReservationServiceRequest,
): Promise<ReservationServiceDto> {
const existing = await this.getServiceOrThrow(organizationId, id);
const data: Prisma.ReservationServiceUpdateInput = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('نام خدمت الزامی است');
data.name = name;
}
if (dto.description !== undefined) data.description = dto.description.trim();
if (dto.durationMinutes !== undefined) {
if (dto.durationMinutes < 5) throw new BadRequestException('مدت نامعتبر است');
data.durationMinutes = dto.durationMinutes;
}
if (dto.workDays !== undefined) data.workDays = dto.workDays;
if (dto.dayStartHour !== undefined) data.dayStartHour = dto.dayStartHour;
if (dto.dayEndHour !== undefined) data.dayEndHour = dto.dayEndHour;
if (dto.isActive !== undefined) data.isActive = dto.isActive;
const nextStart =
dto.dayStartHour !== undefined ? dto.dayStartHour : existing.dayStartHour;
const nextEnd =
dto.dayEndHour !== undefined ? dto.dayEndHour : existing.dayEndHour;
if (nextEnd <= nextStart) {
throw new BadRequestException('ساعت پایان باید بعد از شروع باشد');
}
const row = await this.prisma.reservationService.update({
where: { id },
data,
});
return this.toServiceDto(row);
}
async removeService(organizationId: string, id: string): Promise<void> {
await this.getServiceOrThrow(organizationId, id);
const count = await this.prisma.reservationAppointment.count({
where: {
serviceId: id,
status: { in: ['pending', 'confirmed'] },
},
});
if (count) {
throw new BadRequestException(
'خدمت دارای نوبت فعال است؛ ابتدا نوبت‌ها را لغو کنید یا خدمت را غیرفعال کنید',
);
}
await this.prisma.reservationService.delete({ where: { id } });
}
/* ——— slots ——— */
async listSlots(
organizationId: string,
serviceId: string,
dateIso: string,
assigneeId?: string,
): Promise<ReservationSlotDto[]> {
const service = await this.getServiceOrThrow(organizationId, serviceId);
if (!service.isActive) return [];
const dayStart = this.parseDayStart(dateIso);
const weekday = dayStart.getDay();
if (!service.workDays.includes(weekday)) return [];
const windowStart = new Date(dayStart);
windowStart.setHours(service.dayStartHour, 0, 0, 0);
const windowEnd = new Date(dayStart);
windowEnd.setHours(service.dayEndHour, 0, 0, 0);
const busy = await this.prisma.reservationAppointment.findMany({
where: {
organizationId,
status: { in: ['pending', 'confirmed'] },
startsAt: { lt: windowEnd },
endsAt: { gt: windowStart },
...(assigneeId ? { assigneeId } : { serviceId }),
},
select: { startsAt: true, endsAt: true },
});
const slots: ReservationSlotDto[] = [];
const stepMs = service.durationMinutes * 60_000;
for (
let t = windowStart.getTime();
t + stepMs <= windowEnd.getTime();
t += stepMs
) {
const startsAt = new Date(t);
const endsAt = new Date(t + stepMs);
const overlap = busy.some(
(b) => b.startsAt < endsAt && b.endsAt > startsAt,
);
if (!overlap) {
slots.push({
startsAt: startsAt.toISOString(),
endsAt: endsAt.toISOString(),
});
}
}
return slots;
}
/* ——— appointments ——— */
async listAppointments(
organizationId: string,
actor: Actor,
query: {
status?: string;
from?: string;
to?: string;
serviceId?: string;
personId?: string;
page?: number;
limit?: number;
},
): Promise<ReservationListResponse> {
const page = query.page ?? 1;
const limit = query.limit ?? 30;
const where: Prisma.ReservationAppointmentWhereInput = {
organizationId,
...(query.status ? { status: query.status } : {}),
...(query.serviceId ? { serviceId: query.serviceId } : {}),
...(query.personId ? { personId: query.personId } : {}),
...(query.from || query.to
? {
startsAt: {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
},
}
: {}),
...(!elevated(actor.role) ? { assigneeId: actor.userId } : {}),
};
const [total, rows] = await Promise.all([
this.prisma.reservationAppointment.count({ where }),
this.prisma.reservationAppointment.findMany({
where,
include: this.appointmentInclude,
orderBy: { startsAt: 'asc' },
skip: (page - 1) * limit,
take: limit,
}),
]);
return {
total,
items: rows.map((r) => this.toAppointmentDto(r)),
};
}
async createAppointment(
organizationId: string,
actor: Actor,
dto: CreateReservationAppointmentRequest,
): Promise<ReservationAppointmentDto> {
const service = await this.getServiceOrThrow(organizationId, dto.serviceId);
if (!service.isActive) throw new BadRequestException('این خدمت فعال نیست');
await this.assertPerson(organizationId, dto.personId);
const assigneeId = await this.resolveAssignee(
organizationId,
actor,
dto.assigneeId,
);
const startsAt = new Date(dto.startsAt);
if (Number.isNaN(startsAt.getTime())) {
throw new BadRequestException('زمان شروع نامعتبر است');
}
const endsAt = new Date(
startsAt.getTime() + service.durationMinutes * 60_000,
);
await this.assertNoConflict(
organizationId,
service.id,
startsAt,
endsAt,
assigneeId,
);
const status = this.normalizeStatus(dto.status ?? 'pending');
const row = await this.prisma.reservationAppointment.create({
data: {
organizationId,
serviceId: service.id,
personId: dto.personId,
assigneeId,
startsAt,
endsAt,
status,
notes: dto.notes?.trim() || null,
},
include: this.appointmentInclude,
});
return this.toAppointmentDto(row);
}
async updateAppointment(
organizationId: string,
actor: Actor,
id: string,
dto: UpdateReservationAppointmentRequest,
): Promise<ReservationAppointmentDto> {
const existing = await this.prisma.reservationAppointment.findFirst({
where: {
id,
organizationId,
...(!elevated(actor.role) ? { assigneeId: actor.userId } : {}),
},
});
if (!existing) throw new NotFoundException('نوبت پیدا نشد');
let serviceId = existing.serviceId;
let startsAt = existing.startsAt;
let endsAt = existing.endsAt;
let assigneeId = existing.assigneeId;
let personId = existing.personId;
if (dto.serviceId) {
const service = await this.getServiceOrThrow(organizationId, dto.serviceId);
serviceId = service.id;
endsAt = new Date(
startsAt.getTime() + service.durationMinutes * 60_000,
);
}
if (dto.startsAt) {
startsAt = new Date(dto.startsAt);
if (Number.isNaN(startsAt.getTime())) {
throw new BadRequestException('زمان شروع نامعتبر است');
}
const service = await this.getServiceOrThrow(organizationId, serviceId);
endsAt = new Date(
startsAt.getTime() + service.durationMinutes * 60_000,
);
}
if (dto.personId) {
await this.assertPerson(organizationId, dto.personId);
personId = dto.personId;
}
if (dto.assigneeId !== undefined) {
assigneeId = await this.resolveAssignee(
organizationId,
actor,
dto.assigneeId,
);
}
if (dto.startsAt || dto.serviceId || dto.assigneeId !== undefined) {
await this.assertNoConflict(
organizationId,
serviceId,
startsAt,
endsAt,
assigneeId,
id,
);
}
const row = await this.prisma.reservationAppointment.update({
where: { id },
data: {
serviceId,
personId,
assigneeId,
startsAt,
endsAt,
...(dto.status !== undefined
? { status: this.normalizeStatus(dto.status) }
: {}),
...(dto.notes !== undefined
? { notes: dto.notes?.trim() || null }
: {}),
},
include: this.appointmentInclude,
});
return this.toAppointmentDto(row);
}
async removeAppointment(
organizationId: string,
actor: Actor,
id: string,
): Promise<void> {
const existing = await this.prisma.reservationAppointment.findFirst({
where: {
id,
organizationId,
...(!elevated(actor.role) ? { assigneeId: actor.userId } : {}),
},
select: { id: true },
});
if (!existing) throw new NotFoundException('نوبت پیدا نشد');
await this.prisma.reservationAppointment.delete({ where: { id } });
}
/* ——— helpers ——— */
private appointmentInclude = {
service: { select: { id: true, name: true } },
person: { select: { id: true, firstName: true, lastName: true } },
assignee: { select: { id: true, fullName: true } },
} satisfies Prisma.ReservationAppointmentInclude;
private async getServiceOrThrow(organizationId: string, id: string) {
const row = await this.prisma.reservationService.findFirst({
where: { id, organizationId },
});
if (!row) throw new NotFoundException('خدمت پیدا نشد');
return row;
}
private async assertPerson(organizationId: string, personId: string) {
const p = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
select: { id: true },
});
if (!p) throw new BadRequestException('مشتری معتبر نیست');
}
private async resolveAssignee(
organizationId: string,
actor: Actor,
requested?: string | null,
): Promise<string | null> {
if (!elevated(actor.role)) return actor.userId;
if (!requested?.trim()) return null;
const m = await this.prisma.membership.findFirst({
where: {
organizationId,
userId: requested.trim(),
isActive: true,
},
select: { userId: true },
});
if (!m) throw new BadRequestException('کارشناس معتبر نیست');
return m.userId;
}
private async assertNoConflict(
organizationId: string,
serviceId: string,
startsAt: Date,
endsAt: Date,
assigneeId: string | null,
exceptId?: string,
) {
const conflict = await this.prisma.reservationAppointment.findFirst({
where: {
organizationId,
status: { in: ['pending', 'confirmed'] },
id: exceptId ? { not: exceptId } : undefined,
startsAt: { lt: endsAt },
endsAt: { gt: startsAt },
OR: [
{ serviceId },
...(assigneeId ? [{ assigneeId }] : []),
],
},
select: { id: true },
});
if (conflict) {
throw new BadRequestException('این بازه زمانی قبلاً رزرو شده است');
}
}
private normalizeStatus(value: string): ReservationStatus {
if (RESERVATION_STATUSES.includes(value as ReservationStatus)) {
return value as ReservationStatus;
}
throw new BadRequestException('وضعیت نامعتبر است');
}
private parseDayStart(dateIso: string): Date {
const d = new Date(dateIso);
if (Number.isNaN(d.getTime())) {
throw new BadRequestException('تاریخ نامعتبر است');
}
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
private toServiceDto(row: {
id: string;
name: string;
description: string;
durationMinutes: number;
workDays: number[];
dayStartHour: number;
dayEndHour: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}): ReservationServiceDto {
return {
id: row.id,
name: row.name,
description: row.description,
durationMinutes: row.durationMinutes,
workDays: row.workDays,
dayStartHour: row.dayStartHour,
dayEndHour: row.dayEndHour,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private toAppointmentDto(row: {
id: string;
serviceId: string;
personId: string;
assigneeId: string | null;
startsAt: Date;
endsAt: Date;
status: string;
notes: string | null;
createdAt: Date;
updatedAt: Date;
service: { name: string };
person: { firstName: string; lastName: string };
assignee: { fullName: string } | null;
}): ReservationAppointmentDto {
return {
id: row.id,
serviceId: row.serviceId,
serviceName: row.service.name,
personId: row.personId,
personName: `${row.person.firstName} ${row.person.lastName}`.trim(),
assigneeId: row.assigneeId,
assigneeName: row.assignee?.fullName ?? null,
startsAt: row.startsAt.toISOString(),
endsAt: row.endsAt.toISOString(),
status: row.status as ReservationStatus,
notes: row.notes,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
+2 -2
View File
@@ -169,10 +169,10 @@ export class UsersService {
dataVisibility: DataVisibility,
): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('اجازه تغییر سطح دید را ندارید');
throw new ForbiddenException('اجازه تغییر سطح دسترسی به پرونده‌ها را ندارید');
}
if (!DATA_VISIBILITIES.includes(dataVisibility)) {
throw new BadRequestException('سطح دید نامعتبر است');
throw new BadRequestException('سطح دسترسی به پرونده‌ها نامعتبر است');
}
const target = await this.memberships.findUserInOrgById(
organizationId,
+10 -6
View File
@@ -47,7 +47,7 @@ async function load() {
teams.value = data;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول تیم‌ها و دیدپذیری برای این محیط فعال نیست.';
error.value = 'ماژول تیم‌ها و سطح دسترسی به پرونده‌ها برای این محیط فعال نیست.';
} else {
error.value = 'بارگذاری تیم‌ها ناموفق بود.';
}
@@ -140,12 +140,16 @@ async function removeTeam(id: string) {
async function setVisibility(userId: string, dataVisibility: DataVisibility) {
try {
await api.patch(`/users/${userId}/visibility`, { dataVisibility });
ok.value = 'سطح دید به‌روز شد. کاربر باید دوباره وارد شود تا اعمال شود.';
ok.value =
'سطح دسترسی به پرونده‌ها به‌روز شد. کاربر باید دوباره وارد شود تا اعمال شود.';
emit('refreshUsers');
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'تغییر سطح دید ناموفق بود.')
: 'تغییر سطح دید ناموفق بود.';
? String(
(e.response?.data as { message?: string })?.message ??
'تغییر سطح دسترسی به پرونده‌ها ناموفق بود.',
)
: 'تغییر سطح دسترسی به پرونده‌ها ناموفق بود.';
}
}
@@ -232,14 +236,14 @@ onMounted(() => {
</section>
<section class="card">
<h3>سطح دید داده‌ها</h3>
<h3>سطح دسترسی به پرونده‌ها</h3>
<p class="hint">
مالک و مدیر همیشه همه پرونده‌ها را می‌بینند. برای بقیه: فقط خود، تیم‌های عضو، یا کل سازمان.
</p>
<UiEmptyState
v-if="!editableUsers.length"
title="کاربر قابل تنظیم نیست"
description="فقط نقش‌های فروش و پشتیبانی سطح دید جدا دارند."
description="فقط نقش‌های فروش و پشتیبانی سطح دسترسی جدا به پرونده‌ها دارند."
/>
<div v-else class="vis-table">
<div v-for="u in editableUsers" :key="u.id" class="vis-row">
+9 -1
View File
@@ -58,7 +58,15 @@ const automationsActive = computed(
route.path.startsWith('/settings/sms-campaigns'),
);
const toolsActive = computed(() =>
pathIn(['/settings/lead-forms', '/projects', '/project-cards', '/inventory', '/cheques', '/attachments']),
pathIn([
'/settings/lead-forms',
'/projects',
'/project-cards',
'/inventory',
'/cheques',
'/attachments',
'/reservations',
]),
);
const settingsActive = computed(() =>
pathIn([
+11 -2
View File
@@ -274,6 +274,7 @@ export const NAV_HUBS: NavHubDef[] = [
shortcuts: [
{ to: '/project-cards/new', title: 'کارت پروژه جدید', icon: 'plus', module: 'project_boards' },
{ to: '/cheques', title: 'ثبت چک', icon: 'document', module: 'cheques' },
{ to: '/reservations', title: 'نوبت جدید', icon: 'calendar', module: 'reservations' },
],
cards: [
{
@@ -309,6 +310,14 @@ export const NAV_HUBS: NavHubDef[] = [
tint: 'sky',
module: 'cheques',
},
{
to: '/reservations',
title: 'رزرو و نوبت‌دهی',
desc: 'خدمات، اسلات آزاد و ثبت نوبت مشتری',
icon: 'calendar',
tint: 'mint',
module: 'reservations',
},
{
to: '/attachments',
title: 'پیوست‌ها',
@@ -353,8 +362,8 @@ export const NAV_HUBS: NavHubDef[] = [
},
{
to: '/settings/team#settings-teams',
title: 'تیم‌ها و دیدپذیری',
desc: 'گروه‌های کاری و سطح مشاهده پرونده‌ها',
title: 'تیم‌ها و سطح دسترسی به پرونده‌ها',
desc: 'گروه‌های کاری و سطح دسترسی هر کاربر به پرونده‌ها',
icon: 'users',
tint: 'sky',
module: 'teams',
+5
View File
@@ -167,6 +167,11 @@ export const router = createRouter({
name: 'cheques',
component: () => import('@/views/ChequesView.vue'),
},
{
path: 'reservations',
name: 'reservations',
component: () => import('@/views/ReservationsView.vue'),
},
{
path: 'commissions',
name: 'commissions',
+546
View File
@@ -0,0 +1,546 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
OrgUserDto,
PaginatedResponse,
PersonDto,
ReservationAppointmentDto,
ReservationListResponse,
ReservationServiceDto,
ReservationSlotDto,
ReservationStatus,
} from '@mento/shared';
import {
RESERVATION_STATUS_LABELS,
RESERVATION_STATUSES,
} from '@mento/shared';
import {
UiButton,
UiEmptyState,
UiField,
UiInput,
UiPageHeader,
UiModal,
UiTextarea,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import {
formatClockFa,
formatJalaliNumeric,
formatLocalDateInput,
toFaDigits,
} from '@/lib/jalali';
import { canManageSettings } from '@/lib/roles';
import { useAuthStore } from '@/stores/auth';
type TabKey = 'appointments' | 'services';
const auth = useAuthStore();
const canEditServices = computed(() => canManageSettings(auth.user?.role));
const tab = ref<TabKey>('appointments');
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const appointments = ref<ReservationAppointmentDto[]>([]);
const services = ref<ReservationServiceDto[]>([]);
const people = ref<PersonDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const slots = ref<ReservationSlotDto[]>([]);
const statusFilter = ref('');
const formOpen = ref(false);
const formServiceId = ref('');
const formPersonId = ref('');
const formAssigneeId = ref('');
const formDate = ref(formatLocalDateInput(new Date()));
const formStartsAt = ref('');
const formStatus = ref<ReservationStatus>('pending');
const formNotes = ref('');
const serviceFormOpen = ref(false);
const serviceEditId = ref<string | null>(null);
const serviceName = ref('');
const serviceDesc = ref('');
const serviceDuration = ref(30);
const serviceStart = ref(9);
const serviceEnd = ref(17);
const dayNames = ['یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه'];
async function load() {
loading.value = true;
error.value = '';
try {
const [aptRes, svcRes, peopleRes, usersRes] = await Promise.all([
api.get<ReservationListResponse>('/reservations', {
params: {
limit: 50,
...(statusFilter.value ? { status: statusFilter.value } : {}),
},
}),
api.get<ReservationServiceDto[]>('/reservations/services'),
api.get<PaginatedResponse<PersonDto>>('/people', {
params: { page: 1, limit: 100 },
}),
api.get<OrgUserDto[]>('/users'),
]);
appointments.value = aptRes.data.items;
services.value = svcRes.data;
people.value = peopleRes.data.items;
users.value = usersRes.data.filter((u) => u.isActive !== false);
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول رزرو و نوبت‌دهی برای این محیط فعال نیست.';
} else {
error.value = 'بارگذاری ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function loadSlots() {
slots.value = [];
formStartsAt.value = '';
if (!formServiceId.value || !formDate.value) return;
try {
const { data } = await api.get<ReservationSlotDto[]>('/reservations/slots', {
params: {
serviceId: formServiceId.value,
date: new Date(formDate.value + 'T12:00:00').toISOString(),
...(formAssigneeId.value ? { assigneeId: formAssigneeId.value } : {}),
},
});
slots.value = data;
} catch {
slots.value = [];
}
}
watch([formServiceId, formDate, formAssigneeId], () => {
if (formOpen.value) void loadSlots();
});
function openCreate() {
formServiceId.value = services.value.find((s) => s.isActive)?.id ?? '';
formPersonId.value = '';
formAssigneeId.value = auth.user?.id ?? '';
formDate.value = formatLocalDateInput(new Date());
formStartsAt.value = '';
formStatus.value = 'pending';
formNotes.value = '';
formOpen.value = true;
void loadSlots();
}
async function saveAppointment() {
if (!formServiceId.value || !formPersonId.value || !formStartsAt.value) {
error.value = 'خدمت، مشتری و ساعت نوبت را انتخاب کنید.';
return;
}
saving.value = true;
error.value = '';
try {
await api.post('/reservations', {
serviceId: formServiceId.value,
personId: formPersonId.value,
assigneeId: formAssigneeId.value || null,
startsAt: formStartsAt.value,
status: formStatus.value,
notes: formNotes.value.trim() || null,
});
formOpen.value = false;
ok.value = 'نوبت ثبت شد.';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ثبت نوبت ناموفق بود.')
: 'ثبت نوبت ناموفق بود.';
} finally {
saving.value = false;
}
}
async function setStatus(id: string, status: ReservationStatus) {
try {
await api.patch(`/reservations/${id}`, { status });
await load();
} catch {
error.value = 'تغییر وضعیت ناموفق بود.';
}
}
async function removeAppointment(id: string) {
if (!confirm('این نوبت حذف شود؟')) return;
try {
await api.delete(`/reservations/${id}`);
await load();
} catch {
error.value = 'حذف نوبت ناموفق بود.';
}
}
function openServiceCreate() {
serviceEditId.value = null;
serviceName.value = '';
serviceDesc.value = '';
serviceDuration.value = 30;
serviceStart.value = 9;
serviceEnd.value = 17;
serviceFormOpen.value = true;
}
function openServiceEdit(s: ReservationServiceDto) {
serviceEditId.value = s.id;
serviceName.value = s.name;
serviceDesc.value = s.description;
serviceDuration.value = s.durationMinutes;
serviceStart.value = s.dayStartHour;
serviceEnd.value = s.dayEndHour;
serviceFormOpen.value = true;
}
async function saveService() {
if (!serviceName.value.trim()) {
error.value = 'نام خدمت را وارد کنید.';
return;
}
saving.value = true;
try {
const payload = {
name: serviceName.value.trim(),
description: serviceDesc.value.trim(),
durationMinutes: Number(serviceDuration.value),
dayStartHour: Number(serviceStart.value),
dayEndHour: Number(serviceEnd.value),
workDays: [0, 1, 2, 3, 4, 5, 6],
isActive: true,
};
if (serviceEditId.value) {
await api.patch(`/reservations/services/${serviceEditId.value}`, payload);
} else {
await api.post('/reservations/services', payload);
}
serviceFormOpen.value = false;
ok.value = 'خدمت ذخیره شد.';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره خدمت ناموفق بود.')
: 'ذخیره خدمت ناموفق بود.';
} finally {
saving.value = false;
}
}
async function removeService(id: string) {
if (!confirm('این خدمت حذف شود؟')) return;
try {
await api.delete(`/reservations/services/${id}`);
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'حذف ناموفق بود.')
: 'حذف ناموفق بود.';
}
}
function slotLabel(iso: string) {
return formatClockFa(new Date(iso));
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<UiPageHeader
title="رزرو و نوبت‌دهی"
description="تعریف خدمت، انتخاب بازه آزاد و ثبت نوبت برای مشتری"
>
<template #actions>
<UiButton variant="secondary" :disabled="loading" @click="load">
<UiIcon name="refresh" />
بروزرسانی
</UiButton>
<UiButton v-if="tab === 'appointments'" @click="openCreate">
نوبت جدید
</UiButton>
<UiButton
v-else-if="canEditServices"
@click="openServiceCreate"
>
خدمت جدید
</UiButton>
</template>
</UiPageHeader>
<nav class="tabs">
<button type="button" :class="{ on: tab === 'appointments' }" @click="tab = 'appointments'">
نوبت‌ها
</button>
<button type="button" :class="{ on: tab === 'services' }" @click="tab = 'services'">
خدمات
</button>
</nav>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<template v-if="tab === 'appointments'">
<div class="filters">
<label>
وضعیت
<select v-model="statusFilter" @change="load">
<option value="">همه</option>
<option v-for="s in RESERVATION_STATUSES" :key="s" :value="s">
{{ RESERVATION_STATUS_LABELS[s] }}
</option>
</select>
</label>
</div>
<UiEmptyState
v-if="!loading && !appointments.length"
title="نوبتی نیست"
description="اول خدمت تعریف کنید، بعد نوبت بسازید."
/>
<div v-else class="list">
<article v-for="a in appointments" :key="a.id" class="item">
<div>
<strong>{{ a.serviceName }} — {{ a.personName }}</strong>
<p class="muted">
{{ formatJalaliNumeric(new Date(a.startsAt)) }}
· {{ formatClockFa(new Date(a.startsAt)) }} تا {{ formatClockFa(new Date(a.endsAt)) }}
· {{ RESERVATION_STATUS_LABELS[a.status] }}
<template v-if="a.assigneeName"> · {{ a.assigneeName }}</template>
</p>
<p v-if="a.notes" class="muted">{{ a.notes }}</p>
</div>
<div class="actions">
<UiButton
v-if="a.status === 'pending'"
variant="secondary"
@click="setStatus(a.id, 'confirmed')"
>
تأیید
</UiButton>
<UiButton
v-if="a.status === 'confirmed' || a.status === 'pending'"
variant="secondary"
@click="setStatus(a.id, 'done')"
>
انجام شد
</UiButton>
<UiButton
v-if="a.status !== 'canceled' && a.status !== 'done'"
variant="secondary"
@click="setStatus(a.id, 'canceled')"
>
لغو
</UiButton>
<UiButton variant="secondary" @click="removeAppointment(a.id)">حذف</UiButton>
</div>
</article>
</div>
</template>
<template v-else>
<UiEmptyState
v-if="!loading && !services.length"
title="خدمتی نیست"
description="مثلاً مشاوره ۳۰ دقیقه‌ای تعریف کنید."
/>
<div v-else class="list">
<article v-for="s in services" :key="s.id" class="item">
<div>
<strong>{{ s.name }}</strong>
<p class="muted">
مدت {{ toFaDigits(s.durationMinutes) }} دقیقه
· ساعت {{ toFaDigits(s.dayStartHour) }} تا {{ toFaDigits(s.dayEndHour) }}
· {{ s.isActive ? 'فعال' : 'غیرفعال' }}
</p>
<p v-if="s.description" class="muted">{{ s.description }}</p>
</div>
<div v-if="canEditServices" class="actions">
<UiButton variant="secondary" @click="openServiceEdit(s)">ویرایش</UiButton>
<UiButton variant="secondary" @click="removeService(s.id)">حذف</UiButton>
</div>
</article>
</div>
</template>
<UiModal :open="formOpen" title="نوبت جدید" @close="formOpen = false">
<div class="form-grid">
<UiField label="خدمت">
<select v-model="formServiceId">
<option disabled value="">انتخاب کنید</option>
<option
v-for="s in services.filter((x) => x.isActive)"
:key="s.id"
:value="s.id"
>
{{ s.name }} ({{ toFaDigits(s.durationMinutes) }} دقیقه)
</option>
</select>
</UiField>
<UiField label="مشتری">
<select v-model="formPersonId">
<option disabled value="">انتخاب کنید</option>
<option v-for="p in people" :key="p.id" :value="p.id">
{{ p.fullName }}
</option>
</select>
</UiField>
<UiField label="کارشناس">
<select v-model="formAssigneeId">
<option value="">—</option>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
</UiField>
<UiField label="تاریخ">
<JalaliDateInput v-model="formDate" />
</UiField>
<UiField label="ساعت آزاد">
<select v-model="formStartsAt">
<option disabled value="">انتخاب کنید</option>
<option v-for="sl in slots" :key="sl.startsAt" :value="sl.startsAt">
{{ slotLabel(sl.startsAt) }}
</option>
</select>
<p v-if="formServiceId && formDate && !slots.length" class="muted">
برای این تاریخ اسلات آزادی نیست.
</p>
</UiField>
<UiField label="وضعیت">
<select v-model="formStatus">
<option v-for="s in RESERVATION_STATUSES" :key="s" :value="s">
{{ RESERVATION_STATUS_LABELS[s] }}
</option>
</select>
</UiField>
<UiField label="یادداشت">
<UiTextarea v-model="formNotes" :rows="2" />
</UiField>
</div>
<template #footer>
<UiButton variant="secondary" @click="formOpen = false">انصراف</UiButton>
<UiButton :loading="saving" @click="saveAppointment">ثبت نوبت</UiButton>
</template>
</UiModal>
<UiModal
:open="serviceFormOpen"
:title="serviceEditId ? 'ویرایش خدمت' : 'خدمت جدید'"
@close="serviceFormOpen = false"
>
<div class="form-grid">
<UiField label="نام">
<UiInput v-model="serviceName" placeholder="مشاوره حضوری" />
</UiField>
<UiField label="مدت (دقیقه)">
<UiInput v-model.number="serviceDuration" type="number" />
</UiField>
<UiField label="شروع روز (ساعت)">
<UiInput v-model.number="serviceStart" type="number" />
</UiField>
<UiField label="پایان روز (ساعت)">
<UiInput v-model.number="serviceEnd" type="number" />
</UiField>
<UiField label="توضیح">
<UiTextarea v-model="serviceDesc" :rows="2" />
</UiField>
</div>
<p class="muted">روزهای کاری پیش‌فرض: همه روزهای هفته ({{ dayNames.join('، ') }})</p>
<template #footer>
<UiButton variant="secondary" @click="serviceFormOpen = false">انصراف</UiButton>
<UiButton :loading="saving" @click="saveService">ذخیره</UiButton>
</template>
</UiModal>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.tabs {
display: flex;
gap: 0.35rem;
}
.tabs button {
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.4rem 0.9rem;
font: inherit;
cursor: pointer;
}
.tabs button.on {
background: #f0fdfa;
border-color: #99f6e4;
color: #0f766e;
font-weight: 700;
}
.filters select,
.form-grid select {
font: inherit;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.45rem 0.6rem;
width: 100%;
background: #fff;
}
.list {
display: grid;
gap: 0.75rem;
}
.item {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
background: #fff;
}
.actions {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
align-items: start;
}
.form-grid {
display: grid;
gap: 0.75rem;
}
.muted {
color: #64748b;
font-size: 0.85rem;
margin: 0.25rem 0 0;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
@media (max-width: 800px) {
.item {
flex-direction: column;
}
}
</style>
+1 -1
View File
@@ -510,7 +510,7 @@ onUnmounted(() => {
<nav class="tabs" aria-label="بخش‌های مدیریت کاربران">
<button type="button" :class="{ on: tab === 'users' }" @click="setTab('users')">لیست کاربران</button>
<button type="button" :class="{ on: tab === 'roles' }" @click="setTab('roles')">نقش‌ها</button>
<button type="button" :class="{ on: tab === 'teams' }" @click="setTab('teams')">تیم‌ها و دیدپذیری</button>
<button type="button" :class="{ on: tab === 'teams' }" @click="setTab('teams')">تیم‌ها و سطح دسترسی به پرونده‌ها</button>
<button type="button" :class="{ on: tab === 'access' }" @click="setTab('access')">تنظیمات دسترسی</button>
</nav>
+100 -3
View File
@@ -123,7 +123,8 @@ export type OrgModuleKey =
| "sms_campaigns"
| "teams"
| "woocommerce"
| "voip";
| "voip"
| "reservations";
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"customers",
@@ -157,6 +158,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"teams",
"woocommerce",
"voip",
"reservations",
];
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
@@ -188,9 +190,10 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
saved_views: "فیلترهای ذخیره‌شده",
public_api: "API و وب‌هوک",
sms_campaigns: "پیامک گروهی",
teams: "تیم‌ها و دیدپذیری",
teams: "تیم‌ها و سطح دسترسی به پرونده‌ها",
woocommerce: "ووکامرس",
voip: "تلفن سازمانی",
reservations: "رزرو و نوبت‌دهی",
};
export const ORG_MODULE_GROUPS: Array<{
@@ -232,6 +235,7 @@ export const ORG_MODULE_GROUPS: Array<{
"saved_views",
"woocommerce",
"voip",
"reservations",
],
},
{
@@ -3115,7 +3119,7 @@ export interface SmsCampaignListResponse {
total: number;
}
/* ——— تیم‌ها و دیدپذیری ——— */
/* ——— تیم‌ها و سطح دسترسی به پرونده‌ها ——— */
export type TeamMemberRole = "member" | "manager";
@@ -3280,3 +3284,96 @@ export interface VoipWebhookEvent {
caller?: string;
callee?: string;
}
/* ——— رزرو و نوبت‌دهی ——— */
export type ReservationStatus =
| "pending"
| "confirmed"
| "canceled"
| "done";
export const RESERVATION_STATUSES: ReservationStatus[] = [
"pending",
"confirmed",
"canceled",
"done",
];
export const RESERVATION_STATUS_LABELS: Record<ReservationStatus, string> = {
pending: "در انتظار",
confirmed: "تأیید شده",
canceled: "لغو شده",
done: "انجام شده",
};
export interface ReservationServiceDto {
id: string;
name: string;
description: string;
durationMinutes: number;
/** روزهای هفته ۰=یکشنبه … ۶=شنبه (JS getDay) */
workDays: number[];
dayStartHour: number;
dayEndHour: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateReservationServiceRequest {
name: string;
description?: string;
durationMinutes: number;
workDays?: number[];
dayStartHour?: number;
dayEndHour?: number;
isActive?: boolean;
}
export type UpdateReservationServiceRequest =
Partial<CreateReservationServiceRequest>;
export interface ReservationAppointmentDto {
id: string;
serviceId: string;
serviceName: string;
personId: string;
personName: string;
assigneeId: string | null;
assigneeName: string | null;
startsAt: string;
endsAt: string;
status: ReservationStatus;
notes: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateReservationAppointmentRequest {
serviceId: string;
personId: string;
assigneeId?: string | null;
startsAt: string;
status?: ReservationStatus;
notes?: string | null;
}
export interface UpdateReservationAppointmentRequest {
serviceId?: string;
personId?: string;
assigneeId?: string | null;
startsAt?: string;
status?: ReservationStatus;
notes?: string | null;
}
export interface ReservationSlotDto {
startsAt: string;
endsAt: string;
}
export interface ReservationListResponse {
items: ReservationAppointmentDto[];
total: number;
}