Add reservations module and rename teams visibility label to file-access level.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user