Compare commits
9 Commits
feature/ca
...
ed8ff61205
| Author | SHA1 | Date | |
|---|---|---|---|
| ed8ff61205 | |||
| 2f8c9f0f4d | |||
| 3f2b332bd3 | |||
| 394aa98314 | |||
| 10959e3183 | |||
| 22466490bf | |||
| 893cd5b128 | |||
| 65cd548a9a | |||
| 4c3aea2b5c |
7
backend/prisma/migrations/20260710150528/migration.sql
Normal file
7
backend/prisma/migrations/20260710150528/migration.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "lab_case_tasks" ALTER COLUMN "updatedAt" DROP DEFAULT,
|
||||
ALTER COLUMN "prosthesisTypeCode" DROP DEFAULT,
|
||||
ALTER COLUMN "workflowStepCode" DROP DEFAULT;
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_s" RENAME TO "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCo_key";
|
||||
@@ -17,6 +17,7 @@ import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-ca
|
||||
import { CatalogModule } from './modules/catalog/catalog.module';
|
||||
import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module';
|
||||
import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
|
||||
import { TodayModule } from './modules/today/today.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -37,6 +38,7 @@ import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comm
|
||||
LabCaseCommentsModule,
|
||||
StaffModule,
|
||||
OrganizationModule,
|
||||
TodayModule,
|
||||
AdminModule.forRoot(),
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
33
backend/src/modules/today/dto/today-summary-query.dto.ts
Normal file
33
backend/src/modules/today/dto/today-summary-query.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsISO8601, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class TodaySummaryQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Start of the local day range (ISO 8601). Defaults to UTC midnight today.',
|
||||
example: '2026-07-10T00:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'End of the local day range (ISO 8601, exclusive). Defaults to next UTC midnight.',
|
||||
example: '2026-07-11T00:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
to?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Client UTC offset in minutes (same sign as Date.getTimezoneOffset negated). Used for hourly buckets.',
|
||||
example: 210,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(-840)
|
||||
@Max(840)
|
||||
utcOffsetMinutes?: number;
|
||||
}
|
||||
31
backend/src/modules/today/today.controller.ts
Normal file
31
backend/src/modules/today/today.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { TodaySummaryQueryDto } from './dto/today-summary-query.dto';
|
||||
import { TodayService } from './today.service';
|
||||
|
||||
@ApiTags('today')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('today')
|
||||
export class TodayController {
|
||||
constructor(private readonly todayService: TodayService) {}
|
||||
|
||||
@Get('summary')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Permission-aware dashboard summary for the Today tab (TAB_TODAY_READ or owner)',
|
||||
})
|
||||
getSummary(
|
||||
@Query() query: TodaySummaryQueryDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string; language?: string } },
|
||||
) {
|
||||
const organizationId = this.todayService.getOrganizationIdFromUser(req.user);
|
||||
return this.todayService.getSummary(
|
||||
req.user.id,
|
||||
organizationId,
|
||||
query,
|
||||
req.user.language,
|
||||
);
|
||||
}
|
||||
}
|
||||
11
backend/src/modules/today/today.module.ts
Normal file
11
backend/src/modules/today/today.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StaffModule } from '../staff/staff.module';
|
||||
import { TodayController } from './today.controller';
|
||||
import { TodayService } from './today.service';
|
||||
|
||||
@Module({
|
||||
imports: [StaffModule],
|
||||
controllers: [TodayController],
|
||||
providers: [TodayService],
|
||||
})
|
||||
export class TodayModule {}
|
||||
977
backend/src/modules/today/today.service.ts
Normal file
977
backend/src/modules/today/today.service.ts
Normal file
@@ -0,0 +1,977 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { LabTaskStatus, LinkStatus, CatalogEntityKind } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import {
|
||||
isUnlimitedSeats,
|
||||
normalizeTabPermissions,
|
||||
} from '../../common/permissions';
|
||||
import {
|
||||
OrganizationTypeName,
|
||||
ownerPermissionsForOrgType,
|
||||
} from '../../common/organization-type';
|
||||
import {
|
||||
CatalogLabelService,
|
||||
normalizeCatalogLocale,
|
||||
type CatalogLocale,
|
||||
} from '../catalog/catalog-label.service';
|
||||
import { StaffWorkingHoursService } from '../staff/staff-working-hours.service';
|
||||
import { TodaySummaryQueryDto } from './dto/today-summary-query.dto';
|
||||
|
||||
type ChartBucket = { code: string; label: string; count: number };
|
||||
|
||||
type StackedDayBucket = {
|
||||
code: string;
|
||||
label: string;
|
||||
completed: number;
|
||||
received: number;
|
||||
};
|
||||
|
||||
type CaseCompletionChart = {
|
||||
completed: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
};
|
||||
|
||||
type TodayCharts = {
|
||||
treatmentMixWeek?: ChartBucket[];
|
||||
tasksByWorkflowStep?: ChartBucket[];
|
||||
appointmentsByProvider?: ChartBucket[];
|
||||
caseCompletion?: CaseCompletionChart;
|
||||
appointmentsWeekAll?: ChartBucket[];
|
||||
appointmentsWeekMine?: ChartBucket[];
|
||||
labTaskActivityWeek?: StackedDayBucket[];
|
||||
inProgressTasksByProsthesis?: ChartBucket[];
|
||||
};
|
||||
|
||||
type TodayActions = {
|
||||
upcomingAppointmentsToday?: Array<{
|
||||
id: string;
|
||||
patientName: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
purpose: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type TodayWidgets = {
|
||||
appointmentsToday?: { count: number };
|
||||
patientsToday?: { count: number };
|
||||
treatmentsToday?: { count: number };
|
||||
draftTreatments?: { count: number };
|
||||
labCasesPendingSend?: { count: number };
|
||||
casesReceivedToday?: { count: number };
|
||||
casesInProgress?: { count: number };
|
||||
tasksInProgress?: { count: number };
|
||||
importantTasks?: { count: number };
|
||||
pendingConnections?: { count: number };
|
||||
seats?: { used: number; limit: number | null; unlimited: boolean };
|
||||
pendingStaffInvites?: { count: number };
|
||||
providersWithoutWorkingHours?: { count: number };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TodayService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly catalogLabels: CatalogLabelService,
|
||||
private readonly staffWorkingHoursService: StaffWorkingHoursService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
|
||||
async getSummary(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
query: TodaySummaryQueryDto,
|
||||
localeInput?: string | null,
|
||||
) {
|
||||
const membership = await this.getActiveMembership(userId, organizationId);
|
||||
const permissionNames = this.resolvePermissionNames(membership);
|
||||
this.assertCanViewToday(membership.isOwner, permissionNames);
|
||||
|
||||
const orgType = membership.organization.type.name as OrganizationTypeName;
|
||||
const { from, to } = this.resolveDayRange(query);
|
||||
const locale = normalizeCatalogLocale(localeInput);
|
||||
|
||||
const widgets: TodayWidgets = {};
|
||||
const charts: TodayCharts = {};
|
||||
const actions: TodayActions = {};
|
||||
const tasks: Promise<void>[] = [];
|
||||
|
||||
if (orgType === 'CLINIC') {
|
||||
if (this.canViewAppointments(membership.isOwner, permissionNames)) {
|
||||
tasks.push(
|
||||
this.loadAppointmentsToday(organizationId, from, to, widgets),
|
||||
);
|
||||
tasks.push(
|
||||
this.loadAppointmentsByProvider(organizationId, from, to, charts),
|
||||
);
|
||||
tasks.push(
|
||||
this.loadAppointmentsWeekAll(
|
||||
organizationId,
|
||||
to,
|
||||
query.utcOffsetMinutes,
|
||||
charts,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.canViewTreatment(membership.isOwner, permissionNames)) {
|
||||
tasks.push(
|
||||
this.loadTreatmentsToday(organizationId, from, to, widgets),
|
||||
);
|
||||
tasks.push(this.loadDraftTreatments(organizationId, widgets));
|
||||
tasks.push(this.loadLabCasesPendingSend(organizationId, widgets));
|
||||
tasks.push(
|
||||
this.loadTreatmentMixWeek(organizationId, to, locale, charts),
|
||||
);
|
||||
tasks.push(
|
||||
this.loadAppointmentsWeekMine(
|
||||
organizationId,
|
||||
userId,
|
||||
to,
|
||||
query.utcOffsetMinutes,
|
||||
charts,
|
||||
),
|
||||
);
|
||||
tasks.push(
|
||||
this.loadUpcomingAppointmentsToday(
|
||||
organizationId,
|
||||
userId,
|
||||
from,
|
||||
to,
|
||||
actions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.canViewPatients(membership.isOwner, permissionNames) ||
|
||||
this.canViewAppointments(membership.isOwner, permissionNames)
|
||||
) {
|
||||
tasks.push(
|
||||
this.loadPatientsToday(organizationId, from, to, widgets),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.canViewStaff(membership.isOwner, permissionNames)) {
|
||||
tasks.push(
|
||||
this.loadProvidersWithoutWorkingHours(organizationId, widgets),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (orgType === 'LAB') {
|
||||
const canViewLabWork =
|
||||
this.canViewCases(membership.isOwner, permissionNames) ||
|
||||
this.canViewTasks(membership.isOwner, permissionNames);
|
||||
|
||||
if (this.canViewCases(membership.isOwner, permissionNames)) {
|
||||
tasks.push(
|
||||
this.loadCasesReceivedToday(organizationId, from, to, widgets),
|
||||
);
|
||||
tasks.push(this.loadCasesInProgress(organizationId, widgets));
|
||||
tasks.push(this.loadCaseCompletion(organizationId, charts));
|
||||
}
|
||||
|
||||
if (this.canViewTasks(membership.isOwner, permissionNames)) {
|
||||
tasks.push(this.loadTasksInProgress(organizationId, widgets));
|
||||
tasks.push(this.loadImportantTasks(organizationId, widgets));
|
||||
tasks.push(this.loadTasksByWorkflowStep(organizationId, charts));
|
||||
}
|
||||
|
||||
if (canViewLabWork) {
|
||||
tasks.push(
|
||||
this.loadLabTaskActivityWeek(
|
||||
organizationId,
|
||||
to,
|
||||
query.utcOffsetMinutes,
|
||||
charts,
|
||||
),
|
||||
);
|
||||
tasks.push(
|
||||
this.loadInProgressTasksByProsthesis(organizationId, locale, charts),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.canManageOrganizations(membership.isOwner, permissionNames)) {
|
||||
tasks.push(
|
||||
this.loadPendingConnections(organizationId, widgets),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.canViewStaff(membership.isOwner, permissionNames)) {
|
||||
tasks.push(this.loadSeats(organizationId, membership.organization.plan, widgets));
|
||||
tasks.push(this.loadPendingStaffInvites(organizationId, widgets));
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
orgType,
|
||||
range: { from: from.toISOString(), to: to.toISOString() },
|
||||
widgets,
|
||||
charts,
|
||||
actions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async loadTreatmentMixWeek(
|
||||
organizationId: string,
|
||||
rangeEnd: Date,
|
||||
locale: CatalogLocale,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
const weekStart = new Date(rangeEnd.getTime() - 7 * 86_400_000);
|
||||
const grouped = await this.prisma.treatmentDetail.groupBy({
|
||||
by: ['treatmentType'],
|
||||
where: {
|
||||
treatment: {
|
||||
organizationId,
|
||||
treatmentAt: { gte: weekStart, lt: rangeEnd },
|
||||
},
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const sorted = grouped
|
||||
.map((row) => ({
|
||||
code: row.treatmentType,
|
||||
count: aggregateCount(row._count),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 8);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
charts.treatmentMixWeek = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = await this.catalogLabels.resolveLabels(
|
||||
CatalogEntityKind.TREATMENT_TYPE,
|
||||
sorted.map((row) => row.code),
|
||||
locale,
|
||||
);
|
||||
|
||||
charts.treatmentMixWeek = sorted.map((row) => ({
|
||||
code: row.code,
|
||||
label: labels.get(row.code) ?? row.code,
|
||||
count: row.count,
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadTasksByWorkflowStep(
|
||||
labOrganizationId: string,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
const grouped = await this.prisma.labCaseTask.groupBy({
|
||||
by: ['workflowStepCode', 'stepLabel'],
|
||||
where: {
|
||||
status: LabTaskStatus.IN_PROGRESS,
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
charts.tasksByWorkflowStep = grouped
|
||||
.map((row) => ({
|
||||
code: row.workflowStepCode,
|
||||
label: row.stepLabel,
|
||||
count: aggregateCount(row._count),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
private resolveDayRange(query: TodaySummaryQueryDto): { from: Date; to: Date } {
|
||||
if (query.from && query.to) {
|
||||
const from = new Date(query.from);
|
||||
const to = new Date(query.to);
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
||||
throw new BadRequestException('Invalid date range');
|
||||
}
|
||||
if (to <= from) {
|
||||
throw new BadRequestException('Range "to" must be after "from"');
|
||||
}
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const from = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0),
|
||||
);
|
||||
const to = new Date(from.getTime() + 86_400_000);
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
private async loadAppointmentsToday(
|
||||
organizationId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
widgets: TodayWidgets,
|
||||
) {
|
||||
const count = await this.prisma.appointment.count({
|
||||
where: {
|
||||
organizationId,
|
||||
startAt: { lt: to },
|
||||
endAt: { gt: from },
|
||||
},
|
||||
});
|
||||
widgets.appointmentsToday = { count };
|
||||
}
|
||||
|
||||
private async loadUpcomingAppointmentsToday(
|
||||
organizationId: string,
|
||||
providerUserId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
actions: TodayActions,
|
||||
) {
|
||||
const now = new Date();
|
||||
const items = await this.prisma.appointment.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
providerUserId,
|
||||
startAt: { lt: to },
|
||||
endAt: { gt: now > from ? now : from },
|
||||
},
|
||||
include: {
|
||||
patient: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { startAt: 'asc' },
|
||||
take: 3,
|
||||
});
|
||||
|
||||
actions.upcomingAppointmentsToday = items.map((appointment) => ({
|
||||
id: appointment.id,
|
||||
patientName: `${appointment.patient.firstName} ${appointment.patient.lastName}`.trim(),
|
||||
startAt: appointment.startAt.toISOString(),
|
||||
endAt: appointment.endAt.toISOString(),
|
||||
purpose: appointment.purpose,
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadPatientsToday(
|
||||
organizationId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
widgets: TodayWidgets,
|
||||
) {
|
||||
const rows = await this.prisma.appointment.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
startAt: { lt: to },
|
||||
endAt: { gt: from },
|
||||
},
|
||||
select: { patientId: true },
|
||||
distinct: ['patientId'],
|
||||
});
|
||||
widgets.patientsToday = { count: rows.length };
|
||||
}
|
||||
|
||||
private async loadTreatmentsToday(
|
||||
organizationId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
widgets: TodayWidgets,
|
||||
) {
|
||||
const count = await this.prisma.treatment.count({
|
||||
where: {
|
||||
organizationId,
|
||||
treatmentAt: { gte: from, lt: to },
|
||||
},
|
||||
});
|
||||
widgets.treatmentsToday = { count };
|
||||
}
|
||||
|
||||
private async loadDraftTreatments(organizationId: string, widgets: TodayWidgets) {
|
||||
const count = await this.prisma.treatment.count({
|
||||
where: {
|
||||
organizationId,
|
||||
details: { none: {} },
|
||||
},
|
||||
});
|
||||
widgets.draftTreatments = { count };
|
||||
}
|
||||
|
||||
private async loadLabCasesPendingSend(organizationId: string, widgets: TodayWidgets) {
|
||||
const count = await this.prisma.labCase.count({
|
||||
where: {
|
||||
sentAt: null,
|
||||
destinationOrganizationId: { not: null },
|
||||
treatment: { organizationId },
|
||||
},
|
||||
});
|
||||
widgets.labCasesPendingSend = { count };
|
||||
}
|
||||
|
||||
private async loadCasesReceivedToday(
|
||||
labOrganizationId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
widgets: TodayWidgets,
|
||||
) {
|
||||
const count = await this.prisma.labCase.count({
|
||||
where: {
|
||||
sentAt: { gte: from, lt: to },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
});
|
||||
widgets.casesReceivedToday = { count };
|
||||
}
|
||||
|
||||
private async loadTasksInProgress(labOrganizationId: string, widgets: TodayWidgets) {
|
||||
const count = await this.prisma.labCaseTask.count({
|
||||
where: {
|
||||
status: LabTaskStatus.IN_PROGRESS,
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
widgets.tasksInProgress = { count };
|
||||
}
|
||||
|
||||
private async loadImportantTasks(labOrganizationId: string, widgets: TodayWidgets) {
|
||||
const count = await this.prisma.labCaseTask.count({
|
||||
where: {
|
||||
status: LabTaskStatus.IN_PROGRESS,
|
||||
labCase: {
|
||||
isImportant: true,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
widgets.importantTasks = { count };
|
||||
}
|
||||
|
||||
private async loadPendingConnections(organizationId: string, widgets: TodayWidgets) {
|
||||
const links = await this.prisma.organizationLink.findMany({
|
||||
where: {
|
||||
status: LinkStatus.PENDING,
|
||||
OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }],
|
||||
},
|
||||
select: { sharedDataTypes: true },
|
||||
});
|
||||
|
||||
const count = links.filter((link) => {
|
||||
const requesterOrgId = this.getRequesterOrganizationId(link.sharedDataTypes);
|
||||
return requesterOrgId !== null && requesterOrgId !== organizationId;
|
||||
}).length;
|
||||
|
||||
widgets.pendingConnections = { count };
|
||||
}
|
||||
|
||||
private async loadSeats(
|
||||
organizationId: string,
|
||||
plan: { maxUsers: number } | null,
|
||||
widgets: TodayWidgets,
|
||||
) {
|
||||
const used = await this.prisma.membership.count({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ isOwner: true }, { isActive: true }],
|
||||
},
|
||||
});
|
||||
|
||||
const maxUsers = plan?.maxUsers ?? 0;
|
||||
const unlimited = isUnlimitedSeats(maxUsers);
|
||||
|
||||
widgets.seats = {
|
||||
used,
|
||||
limit: unlimited ? null : maxUsers,
|
||||
unlimited,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadPendingStaffInvites(organizationId: string, widgets: TodayWidgets) {
|
||||
const members = await this.prisma.membership.findMany({
|
||||
where: { organizationId, isOwner: false, isActive: false },
|
||||
include: {
|
||||
invitations: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const count = members.filter((member) => {
|
||||
const invitation = member.invitations[0];
|
||||
if (!invitation || invitation.acceptedAt || invitation.revokedAt) {
|
||||
return false;
|
||||
}
|
||||
return invitation.expiresAt.getTime() > Date.now();
|
||||
}).length;
|
||||
|
||||
widgets.pendingStaffInvites = { count };
|
||||
}
|
||||
|
||||
private async loadAppointmentsByProvider(
|
||||
organizationId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
const appointments = await this.prisma.appointment.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
startAt: { lt: to },
|
||||
endAt: { gt: from },
|
||||
},
|
||||
select: { providerUserId: true },
|
||||
});
|
||||
|
||||
const countsByProvider = new Map<string, number>();
|
||||
for (const appointment of appointments) {
|
||||
countsByProvider.set(
|
||||
appointment.providerUserId,
|
||||
(countsByProvider.get(appointment.providerUserId) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
if (countsByProvider.size === 0) {
|
||||
charts.appointmentsByProvider = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const sorted = [...countsByProvider.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 8);
|
||||
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { id: { in: sorted.map(([userId]) => userId) } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const nameById = new Map(users.map((user) => [user.id, user.name]));
|
||||
|
||||
charts.appointmentsByProvider = sorted.map(([userId, count]) => ({
|
||||
code: userId,
|
||||
label: nameById.get(userId) ?? userId,
|
||||
count,
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadProvidersWithoutWorkingHours(
|
||||
organizationId: string,
|
||||
widgets: TodayWidgets,
|
||||
) {
|
||||
const members = await this.prisma.membership.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
isOwner: false,
|
||||
isActive: true,
|
||||
permissions: {
|
||||
some: {
|
||||
permission: { name: 'TAB_TREATMENT_EDIT' },
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (members.length === 0) {
|
||||
widgets.providersWithoutWorkingHours = { count: 0 };
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduleBlocksByMembership =
|
||||
await this.staffWorkingHoursService.loadScheduleBlocksByMembershipIds(
|
||||
members.map((member) => member.id),
|
||||
);
|
||||
|
||||
const count = members.filter((member) => {
|
||||
const blocks = scheduleBlocksByMembership.get(member.id) ?? [];
|
||||
return blocks.length === 0;
|
||||
}).length;
|
||||
|
||||
widgets.providersWithoutWorkingHours = { count };
|
||||
}
|
||||
|
||||
private async loadCasesInProgress(labOrganizationId: string, widgets: TodayWidgets) {
|
||||
const cases = await this.prisma.labCase.findMany({
|
||||
where: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
include: {
|
||||
tasks: { select: { status: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const count = cases.filter((labCase) => {
|
||||
if (labCase.tasks.length === 0) return false;
|
||||
const completed = labCase.tasks.filter(
|
||||
(task) => task.status === LabTaskStatus.COMPLETED,
|
||||
).length;
|
||||
return completed < labCase.tasks.length;
|
||||
}).length;
|
||||
|
||||
widgets.casesInProgress = { count };
|
||||
}
|
||||
|
||||
private async loadCaseCompletion(labOrganizationId: string, charts: TodayCharts) {
|
||||
const tasks = await this.prisma.labCaseTask.findMany({
|
||||
where: {
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
},
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
const total = tasks.length;
|
||||
const completed = tasks.filter(
|
||||
(task) => task.status === LabTaskStatus.COMPLETED,
|
||||
).length;
|
||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
charts.caseCompletion = { completed, total, percent };
|
||||
}
|
||||
|
||||
private async loadAppointmentsWeekAll(
|
||||
organizationId: string,
|
||||
rangeEnd: Date,
|
||||
utcOffsetMinutes: number | undefined,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
charts.appointmentsWeekAll = await this.loadAppointmentsWeekSeries(
|
||||
organizationId,
|
||||
rangeEnd,
|
||||
utcOffsetMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
private async loadAppointmentsWeekMine(
|
||||
organizationId: string,
|
||||
providerUserId: string,
|
||||
rangeEnd: Date,
|
||||
utcOffsetMinutes: number | undefined,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
charts.appointmentsWeekMine = await this.loadAppointmentsWeekSeries(
|
||||
organizationId,
|
||||
rangeEnd,
|
||||
utcOffsetMinutes,
|
||||
providerUserId,
|
||||
);
|
||||
}
|
||||
|
||||
private async loadAppointmentsWeekSeries(
|
||||
organizationId: string,
|
||||
rangeEnd: Date,
|
||||
utcOffsetMinutes: number | undefined,
|
||||
providerUserId?: string,
|
||||
): Promise<ChartBucket[]> {
|
||||
const dayBuckets = buildLastSevenLocalDayBuckets(rangeEnd, utcOffsetMinutes);
|
||||
const weekStart = dayBuckets[0]?.start ?? rangeEnd;
|
||||
const weekEnd = rangeEnd;
|
||||
|
||||
const appointments = await this.prisma.appointment.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
startAt: { gte: weekStart, lt: weekEnd },
|
||||
...(providerUserId ? { providerUserId } : {}),
|
||||
},
|
||||
select: { startAt: true },
|
||||
});
|
||||
|
||||
const countsByDay = new Map<string, number>();
|
||||
for (const bucket of dayBuckets) {
|
||||
countsByDay.set(bucket.code, 0);
|
||||
}
|
||||
|
||||
const offsetMs = (utcOffsetMinutes ?? 0) * 60_000;
|
||||
for (const appointment of appointments) {
|
||||
const dayKey = localDayKeyFromDate(appointment.startAt, offsetMs);
|
||||
if (countsByDay.has(dayKey)) {
|
||||
countsByDay.set(dayKey, (countsByDay.get(dayKey) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return dayBuckets.map((bucket) => ({
|
||||
code: bucket.code,
|
||||
label: bucket.label,
|
||||
count: countsByDay.get(bucket.code) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadLabTaskActivityWeek(
|
||||
labOrganizationId: string,
|
||||
rangeEnd: Date,
|
||||
utcOffsetMinutes: number | undefined,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
const dayBuckets = buildLastSevenLocalDayBuckets(rangeEnd, utcOffsetMinutes);
|
||||
const weekStart = dayBuckets[0]?.start ?? rangeEnd;
|
||||
const weekEnd = rangeEnd;
|
||||
const offsetMs = (utcOffsetMinutes ?? 0) * 60_000;
|
||||
|
||||
const completedCounts = new Map<string, number>();
|
||||
const receivedCounts = new Map<string, number>();
|
||||
for (const bucket of dayBuckets) {
|
||||
completedCounts.set(bucket.code, 0);
|
||||
receivedCounts.set(bucket.code, 0);
|
||||
}
|
||||
|
||||
const completedTasks = await this.prisma.labCaseTask.findMany({
|
||||
where: {
|
||||
status: LabTaskStatus.COMPLETED,
|
||||
lastStatusChangedAt: { gte: weekStart, lt: weekEnd },
|
||||
labCase: {
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
},
|
||||
select: { lastStatusChangedAt: true },
|
||||
});
|
||||
|
||||
for (const task of completedTasks) {
|
||||
if (!task.lastStatusChangedAt) continue;
|
||||
const dayKey = localDayKeyFromDate(task.lastStatusChangedAt, offsetMs);
|
||||
if (completedCounts.has(dayKey)) {
|
||||
completedCounts.set(dayKey, (completedCounts.get(dayKey) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const sends = await this.prisma.labCaseSend.findMany({
|
||||
where: {
|
||||
organizationId: labOrganizationId,
|
||||
sentAt: { gte: weekStart, lt: weekEnd },
|
||||
},
|
||||
include: {
|
||||
labCase: { select: { tasks: { select: { id: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const send of sends) {
|
||||
const dayKey = localDayKeyFromDate(send.sentAt, offsetMs);
|
||||
if (receivedCounts.has(dayKey)) {
|
||||
receivedCounts.set(
|
||||
dayKey,
|
||||
(receivedCounts.get(dayKey) ?? 0) + send.labCase.tasks.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
charts.labTaskActivityWeek = dayBuckets.map((bucket) => ({
|
||||
code: bucket.code,
|
||||
label: bucket.label,
|
||||
completed: completedCounts.get(bucket.code) ?? 0,
|
||||
received: receivedCounts.get(bucket.code) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadInProgressTasksByProsthesis(
|
||||
labOrganizationId: string,
|
||||
locale: CatalogLocale,
|
||||
charts: TodayCharts,
|
||||
) {
|
||||
const grouped = await this.prisma.labCaseTask.groupBy({
|
||||
by: ['prosthesisTypeCode'],
|
||||
where: {
|
||||
status: LabTaskStatus.IN_PROGRESS,
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const sorted = grouped
|
||||
.map((row) => ({
|
||||
code: row.prosthesisTypeCode,
|
||||
count: aggregateCount(row._count),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
charts.inProgressTasksByProsthesis = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = await this.catalogLabels.resolveLabels(
|
||||
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||
sorted.map((row) => row.code),
|
||||
locale,
|
||||
);
|
||||
|
||||
charts.inProgressTasksByProsthesis = sorted.map((row) => ({
|
||||
code: row.code,
|
||||
label: labels.get(row.code) ?? row.code,
|
||||
count: row.count,
|
||||
}));
|
||||
}
|
||||
|
||||
private getRequesterOrganizationId(sharedDataTypes: unknown): string | null {
|
||||
if (!sharedDataTypes || typeof sharedDataTypes !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const requester = (sharedDataTypes as { requesterOrganizationId?: unknown })
|
||||
.requesterOrganizationId;
|
||||
return typeof requester === 'string' ? requester : null;
|
||||
}
|
||||
|
||||
private async getActiveMembership(userId: string, organizationId: string) {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organizationId,
|
||||
OR: [{ isOwner: true }, { isActive: true }],
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
include: {
|
||||
type: true,
|
||||
plan: true,
|
||||
},
|
||||
},
|
||||
permissions: { include: { permission: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
private resolvePermissionNames(membership: {
|
||||
isOwner: boolean;
|
||||
organization: {
|
||||
planId: string | null;
|
||||
type: { name: string };
|
||||
};
|
||||
permissions: { permission: { name: string } }[];
|
||||
}): string[] {
|
||||
if (membership.isOwner) {
|
||||
const orgType = (membership.organization.type.name === 'LAB'
|
||||
? 'LAB'
|
||||
: 'CLINIC') as OrganizationTypeName;
|
||||
return ownerPermissionsForOrgType(orgType, Boolean(membership.organization.planId));
|
||||
}
|
||||
return normalizeTabPermissions(
|
||||
membership.permissions.map((p) => p.permission.name),
|
||||
);
|
||||
}
|
||||
|
||||
private assertCanViewToday(isOwner: boolean, permissionNames: string[]) {
|
||||
if (isOwner) return;
|
||||
if (!permissionNames.includes('TAB_TODAY_READ')) {
|
||||
throw new ForbiddenException('You do not have access to Today');
|
||||
}
|
||||
}
|
||||
|
||||
private canViewAppointments(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
[
|
||||
'TAB_APPOINTMENTS_READ',
|
||||
'TAB_APPOINTMENTS_EDIT',
|
||||
'TAB_TREATMENT_READ',
|
||||
'TAB_TREATMENT_EDIT',
|
||||
].includes(p),
|
||||
);
|
||||
}
|
||||
|
||||
private canViewPatients(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
['TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT'].includes(p),
|
||||
);
|
||||
}
|
||||
|
||||
private canViewTreatment(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'].includes(p),
|
||||
);
|
||||
}
|
||||
|
||||
private canViewCases(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
['TAB_CASES_READ', 'TAB_CASES_EDIT'].includes(p),
|
||||
);
|
||||
}
|
||||
|
||||
private canViewTasks(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
['TAB_TASKS_READ', 'TAB_TASKS_EDIT'].includes(p),
|
||||
);
|
||||
}
|
||||
|
||||
private canManageOrganizations(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.includes('TAB_ORGANIZATIONS_EDIT');
|
||||
}
|
||||
|
||||
private canViewStaff(isOwner: boolean, names: string[]): boolean {
|
||||
if (isOwner) return true;
|
||||
return names.some((p) =>
|
||||
['TAB_STAFF_READ', 'TAB_STAFF_EDIT'].includes(p),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function aggregateCount(
|
||||
count: true | { _all?: number } | undefined,
|
||||
): number {
|
||||
if (!count || count === true) return 0;
|
||||
return count._all ?? 0;
|
||||
}
|
||||
|
||||
type LocalDayBucket = { code: string; label: string; start: Date; end: Date };
|
||||
|
||||
function buildLastSevenLocalDayBuckets(
|
||||
rangeEnd: Date,
|
||||
utcOffsetMinutes?: number,
|
||||
): LocalDayBucket[] {
|
||||
const offsetMs = (utcOffsetMinutes ?? 0) * 60_000;
|
||||
const dayMs = 86_400_000;
|
||||
const buckets: LocalDayBucket[] = [];
|
||||
|
||||
for (let index = 0; index < 7; index += 1) {
|
||||
const start = new Date(rangeEnd.getTime() - (7 - index) * dayMs);
|
||||
const end = new Date(start.getTime() + dayMs);
|
||||
const code = localDayKeyFromDate(start, offsetMs);
|
||||
buckets.push({
|
||||
code,
|
||||
label: code,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
}
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
function localDayKeyFromDate(date: Date, offsetMs: number): string {
|
||||
const localMs = date.getTime() + offsetMs;
|
||||
const local = new Date(localMs);
|
||||
const year = local.getUTCFullYear();
|
||||
const month = String(local.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(local.getUTCDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
@@ -77,6 +77,8 @@
|
||||
"confirmPassword": "Confirm password",
|
||||
"fullName": "Full name",
|
||||
"rememberMe": "Remember me",
|
||||
"showPassword": "Show password",
|
||||
"hidePassword": "Hide password",
|
||||
"forgotPassword": "Forgot your password?",
|
||||
"invalidCredentials": "Invalid email or password",
|
||||
"loginFailed": "Login failed",
|
||||
@@ -192,10 +194,50 @@
|
||||
"noSubscriptionNotice": "This organization does not have an active subscription yet.",
|
||||
"choosePlanLink": "Choose a plan",
|
||||
"noSubscriptionCta": "to start the purchase process.",
|
||||
"cardTodaysAppointments": "Today's Appointments",
|
||||
"cardActivePatients": "Active Patients",
|
||||
"cardNewLabCase": "New Lab Case",
|
||||
"cardTodayInvoices": "Today invoices"
|
||||
"noWidgets": "No dashboard metrics are available for your current permissions.",
|
||||
"loadError": "Could not load dashboard metrics.",
|
||||
"seatsUnlimited": "Unlimited plan",
|
||||
"widgetAppointmentsToday": "Today's Appointments",
|
||||
"widgetPatientsToday": "Patients Today",
|
||||
"widgetTreatmentsToday": "Treatments Today",
|
||||
"widgetDraftTreatments": "Draft Treatments",
|
||||
"widgetLabCasesPendingSend": "Lab Cases Pending Send",
|
||||
"widgetCasesReceivedToday": "Cases Received Today",
|
||||
"widgetCasesInProgress": "Cases In Progress",
|
||||
"widgetTasksInProgress": "Tasks In Progress",
|
||||
"widgetImportantTasks": "Important Tasks",
|
||||
"widgetPendingConnections": "Pending Connections",
|
||||
"widgetProvidersWithoutWorkingHours": "Providers Without Working Hours",
|
||||
"widgetSeats": "Seat Usage",
|
||||
"widgetPendingStaffInvites": "Pending Staff Invites",
|
||||
"chartAppointmentsWeekAllTitle": "Appointments This Week",
|
||||
"chartAppointmentsWeekAllSubtitle": "All providers — last 7 days",
|
||||
"chartAppointmentsWeekMineTitle": "My Appointments This Week",
|
||||
"chartAppointmentsWeekMineSubtitle": "Your schedule — last 7 days",
|
||||
"chartLabTaskActivityTitle": "Lab Task Activity",
|
||||
"chartLabTaskActivitySubtitle": "Last 7 days",
|
||||
"chartLabTaskCompletedLegend": "Completed",
|
||||
"chartLabTaskReceivedLegend": "Received",
|
||||
"chartProsthesisMixTitle": "In-Progress Tasks by Prosthesis",
|
||||
"chartProsthesisMixSubtitle": "Current workload mix",
|
||||
"chartAppointmentsByProviderTitle": "Appointments by Provider",
|
||||
"chartAppointmentsByProviderSubtitle": "Today",
|
||||
"chartTreatmentMixTitle": "Treatment Mix",
|
||||
"chartTreatmentMixSubtitle": "Last 7 days",
|
||||
"chartCaseCompletionTitle": "Case Completion",
|
||||
"chartCaseCompletionSubtitle": "All active cases",
|
||||
"chartCaseCompletionPercent": "{percent}%",
|
||||
"chartCaseCompletionTasks": "Tasks completed",
|
||||
"chartTasksByStepTitle": "Tasks by Workflow Step",
|
||||
"chartTasksByStepSubtitle": "In progress now",
|
||||
"chartEmpty": "No data for this period yet.",
|
||||
"upcomingAppointmentsTitle": "Upcoming Today",
|
||||
"upcomingAppointmentsSubtitle": "Appointments not yet finished",
|
||||
"viewAllAppointments": "View schedule",
|
||||
"noUpcomingAppointments": "No upcoming appointments for the rest of today.",
|
||||
"retryLoad": "Try again",
|
||||
"sectionLoadError": "This section could not be displayed.",
|
||||
"lastUpdated": "Updated at {time}"
|
||||
},
|
||||
"staff": {
|
||||
"redirecting": "Redirecting…",
|
||||
|
||||
@@ -77,6 +77,8 @@
|
||||
"confirmPassword": "تأیید رمز عبور",
|
||||
"fullName": "نام و نام خانوادگی",
|
||||
"rememberMe": "مرا به خاطر بسپار",
|
||||
"showPassword": "نمایش رمز عبور",
|
||||
"hidePassword": "پنهان کردن رمز عبور",
|
||||
"forgotPassword": "رمز عبور خود را فراموش کردهاید؟",
|
||||
"invalidCredentials": "ایمیل یا رمز عبور نامعتبر است",
|
||||
"loginFailed": "ورود ناموفق بود",
|
||||
@@ -192,10 +194,50 @@
|
||||
"noSubscriptionNotice": "این سازمان هنوز اشتراک فعالی ندارد.",
|
||||
"choosePlanLink": "انتخاب طرح",
|
||||
"noSubscriptionCta": "برای شروع فرآیند خرید.",
|
||||
"cardTodaysAppointments": "نوبتهای امروز",
|
||||
"cardActivePatients": "بیماران فعال",
|
||||
"cardNewLabCase": "پرونده جدید لابراتوار",
|
||||
"cardTodayInvoices": "صورتحسابهای امروز"
|
||||
"noWidgets": "هیچ معیاری برای دسترسی فعلی شما در دسترس نیست.",
|
||||
"loadError": "بارگذاری معیارهای داشبورد ناموفق بود.",
|
||||
"seatsUnlimited": "طرح نامحدود",
|
||||
"widgetAppointmentsToday": "نوبتهای امروز",
|
||||
"widgetPatientsToday": "بیماران امروز",
|
||||
"widgetTreatmentsToday": "درمانهای امروز",
|
||||
"widgetDraftTreatments": "درمانهای پیشنویس",
|
||||
"widgetLabCasesPendingSend": "پروندههای در انتظار ارسال",
|
||||
"widgetCasesReceivedToday": "پروندههای دریافتی امروز",
|
||||
"widgetCasesInProgress": "پروندههای در حال انجام",
|
||||
"widgetTasksInProgress": "وظایف در حال انجام",
|
||||
"widgetImportantTasks": "وظایف مهم",
|
||||
"widgetPendingConnections": "درخواستهای اتصال در انتظار",
|
||||
"widgetProvidersWithoutWorkingHours": "ارائهدهندگان بدون ساعات کاری",
|
||||
"widgetSeats": "استفاده از صندلی",
|
||||
"widgetPendingStaffInvites": "دعوتهای کارکنان در انتظار",
|
||||
"chartAppointmentsWeekAllTitle": "نوبتهای این هفته",
|
||||
"chartAppointmentsWeekAllSubtitle": "همه ارائهدهندگان — ۷ روز گذشته",
|
||||
"chartAppointmentsWeekMineTitle": "نوبتهای من این هفته",
|
||||
"chartAppointmentsWeekMineSubtitle": "برنامه شما — ۷ روز گذشته",
|
||||
"chartLabTaskActivityTitle": "فعالیت وظایف آزمایشگاه",
|
||||
"chartLabTaskActivitySubtitle": "۷ روز گذشته",
|
||||
"chartLabTaskCompletedLegend": "تکمیلشده",
|
||||
"chartLabTaskReceivedLegend": "دریافتشده",
|
||||
"chartProsthesisMixTitle": "وظایف در حال انجام بر اساس پروتز",
|
||||
"chartProsthesisMixSubtitle": "ترکیب بار کاری فعلی",
|
||||
"chartAppointmentsByProviderTitle": "نوبتها بر اساس ارائهدهنده",
|
||||
"chartAppointmentsByProviderSubtitle": "امروز",
|
||||
"chartTreatmentMixTitle": "ترکیب درمانها",
|
||||
"chartTreatmentMixSubtitle": "۷ روز گذشته",
|
||||
"chartCaseCompletionTitle": "تکمیل پروندهها",
|
||||
"chartCaseCompletionSubtitle": "همه پروندههای فعال",
|
||||
"chartCaseCompletionPercent": "{percent}٪",
|
||||
"chartCaseCompletionTasks": "وظایف تکمیلشده",
|
||||
"chartTasksByStepTitle": "وظایف بر اساس مرحله گردش کار",
|
||||
"chartTasksByStepSubtitle": "در حال انجام",
|
||||
"chartEmpty": "هنوز دادهای برای این بازه وجود ندارد.",
|
||||
"upcomingAppointmentsTitle": "نوبتهای پیش رو",
|
||||
"upcomingAppointmentsSubtitle": "نوبتهای باقیمانده امروز",
|
||||
"viewAllAppointments": "مشاهده برنامه",
|
||||
"noUpcomingAppointments": "نوبت پیشرویی برای باقی امروز وجود ندارد.",
|
||||
"retryLoad": "تلاش مجدد",
|
||||
"sectionLoadError": "نمایش این بخش ممکن نشد.",
|
||||
"lastUpdated": "بهروزرسانی در {time}"
|
||||
},
|
||||
"staff": {
|
||||
"redirecting": "در حال انتقال...",
|
||||
|
||||
@@ -77,6 +77,8 @@
|
||||
"confirmPassword": "Bevestig wachtwoord",
|
||||
"fullName": "Volledige naam",
|
||||
"rememberMe": "Onthoud mij",
|
||||
"showPassword": "Wachtwoord tonen",
|
||||
"hidePassword": "Wachtwoord verbergen",
|
||||
"forgotPassword": "Wachtwoord vergeten?",
|
||||
"invalidCredentials": "Ongeldig e-mailadres of wachtwoord",
|
||||
"loginFailed": "Inloggen mislukt",
|
||||
@@ -192,10 +194,50 @@
|
||||
"noSubscriptionNotice": "Deze organisatie heeft nog geen actief abonnement.",
|
||||
"choosePlanLink": "Kies een abonnement",
|
||||
"noSubscriptionCta": "om het aankoopproces te starten.",
|
||||
"cardTodaysAppointments": "Afspraken van vandaag",
|
||||
"cardActivePatients": "Actieve patiënten",
|
||||
"cardNewLabCase": "Nieuwe laboratoriumcase",
|
||||
"cardTodayInvoices": "Facturen van vandaag"
|
||||
"noWidgets": "Geen dashboardstatistieken beschikbaar voor uw huidige rechten.",
|
||||
"loadError": "Dashboardstatistieken konden niet worden geladen.",
|
||||
"seatsUnlimited": "Onbeperkt abonnement",
|
||||
"widgetAppointmentsToday": "Afspraken van vandaag",
|
||||
"widgetPatientsToday": "Patiënten vandaag",
|
||||
"widgetTreatmentsToday": "Behandelingen vandaag",
|
||||
"widgetDraftTreatments": "Conceptbehandelingen",
|
||||
"widgetLabCasesPendingSend": "Labcases wachten op verzending",
|
||||
"widgetCasesReceivedToday": "Cases ontvangen vandaag",
|
||||
"widgetCasesInProgress": "Cases in uitvoering",
|
||||
"widgetTasksInProgress": "Taken in uitvoering",
|
||||
"widgetImportantTasks": "Belangrijke taken",
|
||||
"widgetPendingConnections": "Openstaande koppelingsverzoeken",
|
||||
"widgetProvidersWithoutWorkingHours": "Behandelaars zonder werktijden",
|
||||
"widgetSeats": "Zitplaatsgebruik",
|
||||
"widgetPendingStaffInvites": "Openstaande medewerkersuitnodigingen",
|
||||
"chartAppointmentsWeekAllTitle": "Afspraken deze week",
|
||||
"chartAppointmentsWeekAllSubtitle": "Alle behandelaars — afgelopen 7 dagen",
|
||||
"chartAppointmentsWeekMineTitle": "Mijn afspraken deze week",
|
||||
"chartAppointmentsWeekMineSubtitle": "Uw planning — afgelopen 7 dagen",
|
||||
"chartLabTaskActivityTitle": "Labtaakactiviteit",
|
||||
"chartLabTaskActivitySubtitle": "Afgelopen 7 dagen",
|
||||
"chartLabTaskCompletedLegend": "Voltooid",
|
||||
"chartLabTaskReceivedLegend": "Ontvangen",
|
||||
"chartProsthesisMixTitle": "Lopende taken per prothese",
|
||||
"chartProsthesisMixSubtitle": "Huidige werklastmix",
|
||||
"chartAppointmentsByProviderTitle": "Afspraken per behandelaar",
|
||||
"chartAppointmentsByProviderSubtitle": "Vandaag",
|
||||
"chartTreatmentMixTitle": "Behandelingsmix",
|
||||
"chartTreatmentMixSubtitle": "Afgelopen 7 dagen",
|
||||
"chartCaseCompletionTitle": "Casevoltooiing",
|
||||
"chartCaseCompletionSubtitle": "Alle actieve cases",
|
||||
"chartCaseCompletionPercent": "{percent}%",
|
||||
"chartCaseCompletionTasks": "Taken voltooid",
|
||||
"chartTasksByStepTitle": "Taken per workflowstap",
|
||||
"chartTasksByStepSubtitle": "Nu in uitvoering",
|
||||
"chartEmpty": "Nog geen gegevens voor deze periode.",
|
||||
"upcomingAppointmentsTitle": "Komende afspraken vandaag",
|
||||
"upcomingAppointmentsSubtitle": "Afspraken die nog niet zijn afgerond",
|
||||
"viewAllAppointments": "Bekijk planning",
|
||||
"noUpcomingAppointments": "Geen komende afspraken meer voor vandaag.",
|
||||
"retryLoad": "Opnieuw proberen",
|
||||
"sectionLoadError": "Dit onderdeel kon niet worden weergegeven.",
|
||||
"lastUpdated": "Bijgewerkt om {time}"
|
||||
},
|
||||
"staff": {
|
||||
"redirecting": "Bezig met doorsturen...",
|
||||
|
||||
419
frontend/package-lock.json
generated
419
frontend/package-lock.json
generated
@@ -18,6 +18,7 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"recharts": "^3.9.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -77,6 +78,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1585,6 +1587,32 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -1598,6 +1626,12 @@
|
||||
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
@@ -2128,6 +2162,69 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2170,8 +2267,9 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2186,6 +2284,12 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz",
|
||||
@@ -2231,6 +2335,7 @@
|
||||
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.57.0",
|
||||
"@typescript-eslint/types": "8.57.0",
|
||||
@@ -2756,6 +2861,7 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3059,6 +3165,7 @@
|
||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
@@ -3126,6 +3233,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3242,6 +3350,15 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -3307,9 +3424,130 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -3389,6 +3627,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -3679,6 +3923,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.49.0",
|
||||
"resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.49.0.tgz",
|
||||
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -3708,6 +3962,7 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3893,6 +4148,7 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -4132,6 +4388,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -4632,6 +4894,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.11",
|
||||
"resolved": "https://registry.npmmirror.com/immer/-/immer-11.1.11.tgz",
|
||||
"integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -4674,6 +4946,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/intl-messageformat": {
|
||||
"version": "11.2.8",
|
||||
"resolved": "https://registry.npmmirror.com/intl-messageformat/-/intl-messageformat-11.2.8.tgz",
|
||||
@@ -5865,17 +6146,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl/node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz",
|
||||
@@ -6295,6 +6565,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -6304,6 +6575,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -6316,6 +6588,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
||||
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -6331,8 +6604,78 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmmirror.com/recharts/-/recharts-3.9.2.tgz",
|
||||
"integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^11.1.8",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.2.0",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
@@ -6378,6 +6721,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.2.0.tgz",
|
||||
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -6967,6 +7316,12 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -7008,6 +7363,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -7170,6 +7526,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -7325,6 +7682,37 @@
|
||||
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
|
||||
@@ -7465,6 +7853,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"recharts": "^3.9.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,619 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const messagesDir = path.join(__dirname, '..', 'messages');
|
||||
|
||||
const en = {
|
||||
common: {
|
||||
appName: 'DyoLink',
|
||||
loading: 'Loading...',
|
||||
loadingApp: 'Loading app...',
|
||||
loadingWorkspace: 'Loading workspace...',
|
||||
continue: 'Continue',
|
||||
back: 'Back',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete',
|
||||
edit: 'Edit',
|
||||
next: 'Next',
|
||||
dismiss: 'Dismiss',
|
||||
or: 'Or',
|
||||
and: 'and',
|
||||
close: 'Close',
|
||||
redirecting: 'Redirecting…',
|
||||
readOnlyAccess: 'Read-only access for this organization.',
|
||||
errorGeneric: 'Something went wrong',
|
||||
loadingEllipsis: 'Loading...',
|
||||
search: 'Search',
|
||||
action: 'Action',
|
||||
status: 'Status',
|
||||
name: 'Name',
|
||||
email: 'Email',
|
||||
date: 'Date',
|
||||
organization: 'Organization',
|
||||
backToApp: '← Back to app',
|
||||
copied: 'Copied',
|
||||
copyLink: 'Copy link',
|
||||
none: 'None',
|
||||
preview: 'Preview',
|
||||
},
|
||||
language: {
|
||||
label: 'Language',
|
||||
selectLanguage: 'Select language',
|
||||
en: 'English',
|
||||
fa: 'Persian',
|
||||
nl: 'Dutch',
|
||||
},
|
||||
theme: {
|
||||
switchToLight: 'Switch to light mode',
|
||||
switchToDark: 'Switch to dark mode',
|
||||
lightMode: 'Light mode',
|
||||
darkMode: 'Dark mode',
|
||||
},
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
staff: 'Staff',
|
||||
patients: 'Patients',
|
||||
appointment: 'Appointment',
|
||||
treatment: 'Treatment',
|
||||
billing: 'Billing',
|
||||
reports: 'Reports',
|
||||
clinics: 'Clinics',
|
||||
labs: 'Labs',
|
||||
},
|
||||
auth: {
|
||||
login: 'Login',
|
||||
signIn: 'Sign in',
|
||||
signOut: 'Log out',
|
||||
register: 'Register',
|
||||
startTrial: 'Start Trial',
|
||||
startFreeTrial: 'Start Free Trial',
|
||||
dashboard: 'Dashboard',
|
||||
signInTitle: 'Sign in to your account',
|
||||
signInPrompt: 'Or {link}',
|
||||
startTrialLink: 'start your free trial',
|
||||
registerTitle: 'Start your 30-day free trial',
|
||||
registerPrompt: 'Already have an account?',
|
||||
signInLink: 'Sign in',
|
||||
email: 'Email address',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm password',
|
||||
fullName: 'Full name',
|
||||
rememberMe: 'Remember me',
|
||||
forgotPassword: 'Forgot your password?',
|
||||
invalidCredentials: 'Invalid email or password',
|
||||
loginFailed: 'Login failed',
|
||||
registrationFailed: 'Registration failed. Please try again.',
|
||||
startMyFreeTrial: 'Start my free trial',
|
||||
trialIncludes: 'Your trial includes:',
|
||||
trialTeamMembers: 'Up to 5 team members',
|
||||
trialFullAccess: 'Full access to all features',
|
||||
trialNoCard: '30 days free, no credit card required',
|
||||
termsAgreement: 'By signing up, you agree to our {terms} and {privacy}',
|
||||
termsOfService: 'Terms of Service',
|
||||
privacyPolicy: 'Privacy Policy',
|
||||
signedIn: 'Signed in',
|
||||
switchOrganization: 'Switch organization',
|
||||
subscriptions: 'Subscriptions',
|
||||
account: 'Account',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
passwordPlaceholder: '••••••••',
|
||||
namePlaceholder: 'John Doe',
|
||||
termsIntro: 'By signing up, you agree to our',
|
||||
errorRegistrationFailed: 'Registration failed',
|
||||
errorLoginFailed: 'Login failed',
|
||||
errorCreateOrganization: 'Failed to create organization',
|
||||
acceptInviteTitle: 'Accept invitation',
|
||||
loadingInvitation: 'Loading invitation...',
|
||||
invalidInvitationLink: 'Invalid invitation link',
|
||||
invitationAlreadyAccepted: 'This invitation is already accepted. You can log in now.',
|
||||
errorLoadInvitation: 'Could not load invitation',
|
||||
organizationLabel: 'Organization:',
|
||||
emailLabel: 'Email:',
|
||||
nameRequired: 'Name is required',
|
||||
passwordMinLength8: 'Password must be at least 8 characters',
|
||||
passwordsDoNotMatch: 'Passwords do not match',
|
||||
labelName: 'Name',
|
||||
labelCreatePassword: 'Create password',
|
||||
labelConfirmPassword: 'Confirm password',
|
||||
activateAccount: 'Activate account',
|
||||
invitationAcceptedRedirect: 'Invitation Accepted. Redirecting to login...',
|
||||
errorAcceptInvitation: 'Could not accept invitation',
|
||||
alreadyHaveAccess: 'Already have access?',
|
||||
goToLogin: 'Go to login',
|
||||
acceptOrganizationTitle: 'Accept organization invitation',
|
||||
alreadyHaveAccount: 'Already have an account?',
|
||||
invitedBy: 'Invited by:',
|
||||
ownerEmail: 'Owner email',
|
||||
activateOrganization: 'Activate organization',
|
||||
organizationAcceptedRedirect: 'Invitation accepted. Redirecting to login...',
|
||||
stepAccount: 'Account',
|
||||
stepOrganization: 'Organization',
|
||||
organizationName: 'Organization name',
|
||||
organizationNamePlaceholder: 'Sunshine Dental Clinic',
|
||||
organizationEmail: 'Organization email',
|
||||
organizationEmailPlaceholder: 'contact@sunshineclinic.com',
|
||||
organizationType: 'Organization type',
|
||||
dentalClinic: 'Dental Clinic',
|
||||
dentalLab: 'Dental Lab',
|
||||
},
|
||||
landing: {
|
||||
heroTitle: 'Connect Dental Clinics & Labs',
|
||||
heroHighlight: 'Seamlessly',
|
||||
heroSubtitle:
|
||||
'Streamline communication between dental professionals. Start with a 30-day free trial, no credit card required.',
|
||||
featureClinicsTitle: 'For Clinics',
|
||||
featureClinicsDescription:
|
||||
'Manage patients, appointments, and send cases to labs instantly.',
|
||||
featureLabsTitle: 'For Labs',
|
||||
featureLabsDescription: 'Receive cases, track progress, and communicate with clinics.',
|
||||
featureTeamTitle: 'Team Management',
|
||||
featureTeamDescription: 'Add up to 5 team members during trial. Scale as you grow.',
|
||||
featureTrialTitle: '30-Day Trial',
|
||||
featureTrialDescription: 'Full access to all features. No credit card required.',
|
||||
featureRealtimeTitle: 'Real-time Updates',
|
||||
featureRealtimeDescription: 'Get instant notifications on case status changes.',
|
||||
featureSecurityTitle: 'Secure & Compliant',
|
||||
featureSecurityDescription: 'HIPAA-compliant with enterprise-grade security.',
|
||||
footerCopyright: '© 2026 DyoLink. All rights reserved.',
|
||||
termsAndConditions: 'Terms & Conditions',
|
||||
},
|
||||
accountMenu: {
|
||||
noActiveSubscription: 'No active subscription — review Subscriptions',
|
||||
trialEnded: 'Trial ended — review Subscriptions',
|
||||
trialEndingSoon: 'Trial ending soon — review Subscriptions',
|
||||
seatsLow: 'Seats running low — review Subscriptions',
|
||||
reviewSubscriptions: 'Review Subscriptions',
|
||||
},
|
||||
validation: {
|
||||
emailInvalid: 'Please enter a valid email address',
|
||||
passwordRequired: 'Password is required',
|
||||
nameMinLength: 'Name must be at least 2 characters',
|
||||
passwordMinLength: 'Password must be at least 8 characters',
|
||||
passwordUppercase: 'Password must contain at least one uppercase letter',
|
||||
passwordNumber: 'Password must contain at least one number',
|
||||
organizationNameMinLength: 'Organization name must be at least 2 characters',
|
||||
organizationEmailInvalid: 'Please enter a valid organization email',
|
||||
organizationTypeRequired: 'Please select organization type',
|
||||
passwordsDoNotMatch: "Passwords don't match",
|
||||
},
|
||||
today: {
|
||||
welcomeBack: 'Welcome back!!',
|
||||
noSubscriptionNotice: 'This organization does not have an active subscription yet.',
|
||||
choosePlanLink: 'Choose a plan',
|
||||
noSubscriptionCta: 'to start the purchase process.',
|
||||
cardTodaysAppointments: "Today's Appointments",
|
||||
cardActivePatients: 'Active Patients',
|
||||
cardNewLabCase: 'New Lab Case',
|
||||
cardTodayInvoices: 'Today invoices',
|
||||
},
|
||||
staff: {
|
||||
redirecting: 'Redirecting…',
|
||||
title: 'Staff Management',
|
||||
subtitle: 'Invite teammates, set tab access, and stay within your plan seat limit.',
|
||||
inviteMember: 'Invite member',
|
||||
seatsLabel: 'Seats:',
|
||||
unlimitedPlan: '(unlimited plan)',
|
||||
seatLimitReached: 'Plan seat limit reached for this organization.',
|
||||
noActivePlan:
|
||||
'No active plan selected for this organization. Choose a subscription plan to invite members.',
|
||||
invitedPending:
|
||||
'Invitation is pending until they open the link, set a password, and log in.',
|
||||
invitedAccepted: 'Invitation was accepted immediately.',
|
||||
inviteLinkHeading: 'Invite link',
|
||||
shareLinkHint:
|
||||
'Share this link manually via SMS or email. A new link is generated if the previous one expired or was lost.',
|
||||
loadingTeam: 'Loading team…',
|
||||
tableName: 'Name',
|
||||
tableEmail: 'Email',
|
||||
tableRole: 'Role',
|
||||
tableStatus: 'Status',
|
||||
tableAccess: 'Access',
|
||||
tableAction: 'Action',
|
||||
roleOwner: 'Owner',
|
||||
roleStaff: 'Staff',
|
||||
statusActive: 'Active',
|
||||
statusPending: 'Pending',
|
||||
statusDisabled: 'Disabled',
|
||||
statusExpired: 'Expired',
|
||||
allFeatures: 'All features',
|
||||
inviteModalTitle: 'Invite team member',
|
||||
stepOf: 'Step {step} of 2',
|
||||
permissionView: 'View',
|
||||
permissionEdit: 'Edit',
|
||||
labelEmail: 'Email',
|
||||
labelDisplayName: 'Display name',
|
||||
tabAccess: 'Tab access',
|
||||
sendInvite: 'Send invite',
|
||||
skipForNow: 'Skip for now',
|
||||
enableModalTitle: 'Enable team member',
|
||||
enableConfirm: 'Enable {name} ({email})?',
|
||||
enableBullet1: 'They can sign in to this organization again with their existing account.',
|
||||
enableBullet2: 'No new invitation is sent and no data was removed while they were disabled.',
|
||||
enableBullet3: 'Enabling uses one seat on your plan.',
|
||||
noSeatsAvailable:
|
||||
'No seats are available. Disable another member or upgrade your plan before enabling this person.',
|
||||
enableMemberButton: 'Enable member',
|
||||
disableModalTitle: 'Disable team member',
|
||||
disableConfirm: 'Disable {name} ({email})?',
|
||||
disableBullet1: 'They will not be able to sign in to this organization.',
|
||||
disableBullet2: 'No data will be removed.',
|
||||
disableBullet3: 'Disabling frees one seat on your plan so you can invite someone else.',
|
||||
disableMemberButton: 'Disable member',
|
||||
editModalTitle: 'Edit member',
|
||||
loadingWorkingHours: 'Loading working hours…',
|
||||
errorLoadStaff: 'Failed to load staff.',
|
||||
errorCopyInvite: 'Could not copy invitation link.',
|
||||
errorSendInvite: 'Failed to send invitation.',
|
||||
errorLoadWorkingHours: 'Failed to load working hours.',
|
||||
successMemberUpdated: 'Member updated.',
|
||||
errorUpdateMember: 'Failed to update member.',
|
||||
errorDeleteNotImplemented: 'Delete is not implemented yet.',
|
||||
successMemberDisabled: '{name} was disabled. A seat is now available.',
|
||||
errorDisableMember: 'Failed to disable member.',
|
||||
successMemberEnabled: '{name} was enabled and can sign in again.',
|
||||
errorEnableMember: 'Failed to enable member.',
|
||||
successInvited: '{name} ({email}) was invited.',
|
||||
copyInviteLink: 'Copy invitation link',
|
||||
copyInviteLinkTitle: 'Copy invitation link (generates a new link if needed)',
|
||||
enableMemberAria: 'Enable member',
|
||||
enableMemberTitle: 'Enable member (uses a seat)',
|
||||
disableMemberAria: 'Disable member',
|
||||
disableMemberTitle: 'Disable member (frees a seat)',
|
||||
editMemberAria: 'Edit member',
|
||||
deleteMemberAria: 'Delete member',
|
||||
deleteMemberTitle: 'Delete member (not implemented)',
|
||||
features: {
|
||||
featureToday: 'Today',
|
||||
featureStaff: 'Staff',
|
||||
featureOrganizations: 'Organizations',
|
||||
featureClinics: 'Clinics',
|
||||
featureLabs: 'Labs',
|
||||
featurePatients: 'Patients',
|
||||
featureAppointment: 'Appointment',
|
||||
featureTreatment: 'Treatment',
|
||||
featureBilling: 'Billing',
|
||||
featureReports: 'Reports',
|
||||
noTabAccess: 'No tab access',
|
||||
readOnlySuffix: '(Read only)',
|
||||
},
|
||||
workingHours: {
|
||||
recommendedTitle: 'Working hours recommended',
|
||||
recommendedBody:
|
||||
'Staff with treatment edit access appear as provider columns in Appointments. Set their weekly hours so the schedule grid shows the right bookable times.',
|
||||
intro:
|
||||
'Set weekly working hours for this provider. The appointments grid uses these hours to show bookable time slots.',
|
||||
workingDay: 'Working day',
|
||||
start: 'Start',
|
||||
end: 'End',
|
||||
removeShift: 'Remove shift',
|
||||
addShift: 'Add shift',
|
||||
autoRepeatWeekly: 'Repeat these hours at the start of each week (copy forward on Monday)',
|
||||
weekdayMon: 'Mon',
|
||||
weekdayTue: 'Tue',
|
||||
weekdayWed: 'Wed',
|
||||
weekdayThu: 'Thu',
|
||||
weekdayFri: 'Fri',
|
||||
weekdaySat: 'Sat',
|
||||
weekdaySun: 'Sun',
|
||||
validationNeedsShift: '{day} needs at least one shift or should be marked off.',
|
||||
validationEndAfterStart: '{day} shift end time must be after start time.',
|
||||
validationOverlap: '{day} shifts cannot overlap.',
|
||||
},
|
||||
},
|
||||
patients: {
|
||||
title: 'Patients',
|
||||
newPatient: 'New Patient',
|
||||
errorLoadPatients: 'Failed to load patients.',
|
||||
successPatientSaved: 'Patient {firstName} {lastName} was saved successfully.',
|
||||
errorSavePatient: 'Failed to save patient.',
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
phone: 'Phone',
|
||||
savePatient: 'Save Patient',
|
||||
dialogTitle: 'New patient',
|
||||
searchPlaceholder: 'Search patients by name, phone, email',
|
||||
loadingPatients: 'Loading patients...',
|
||||
noResults: 'No patients found for this search.',
|
||||
noContact: 'No contact',
|
||||
selectPatient: 'Select a patient to view details.',
|
||||
phoneLabel: 'Phone:',
|
||||
emailLabel: 'Email:',
|
||||
statusLabel: 'Status:',
|
||||
statusActive: 'Active',
|
||||
statusInactive: 'Inactive',
|
||||
emptyValue: '-',
|
||||
},
|
||||
appointments: {
|
||||
title: 'Appointments',
|
||||
subtitle: 'Search a patient, pick a date, then click a time slot under a provider to book.',
|
||||
loadingSchedule: 'Loading schedule…',
|
||||
infoPastViewOnly: 'Past appointments are view-only.',
|
||||
infoSelectPatient: 'Select a patient before booking.',
|
||||
errorOutsideHours:
|
||||
"This appointment falls outside the provider's current working hours and cannot be edited.",
|
||||
successUpdated: 'Appointment updated.',
|
||||
successSaved: 'Appointment saved.',
|
||||
errorUpdate: 'Could not update appointment.',
|
||||
errorSave: 'Could not save appointment.',
|
||||
confirmRemove: 'Remove this appointment?',
|
||||
successRemoved: 'Appointment removed.',
|
||||
errorDelete: 'Could not delete appointment.',
|
||||
errorLoadSchedule: 'Failed to load schedule.',
|
||||
successPatientSaved: 'Patient {firstName} {lastName} was saved.',
|
||||
searchPlaceholder: 'Search existing patients',
|
||||
searching: 'Searching…',
|
||||
searchHint: 'Type to search patients by name, phone, or email.',
|
||||
noPermissionAdd: 'You do not have permission to add patients.',
|
||||
editTitle: 'Edit appointment',
|
||||
newTitle: 'New appointment',
|
||||
providerLabel: 'Provider:',
|
||||
patientLabel: 'Patient',
|
||||
startLabel: 'Start',
|
||||
endLabel: 'End',
|
||||
purposeLabel: 'Purpose',
|
||||
errorSelectPatient: 'Select a patient first.',
|
||||
errorEndAfterStart: 'End time must be after start time.',
|
||||
errorPastSchedule: 'Cannot schedule in the past.',
|
||||
errorPastViewOnly: 'Past appointments are view-only.',
|
||||
errorMissingDetails: 'Missing appointment details.',
|
||||
noProviders:
|
||||
'No providers available. Add staff with treatment edit access to see columns here.',
|
||||
noWorkingHours:
|
||||
'No working hours are configured for this day. Set provider working hours in Staff management.',
|
||||
noHoursSet: 'No hours set',
|
||||
offToday: 'Off today',
|
||||
slotOffToday: 'Provider is off today',
|
||||
slotHoursNotConfigured: 'Working hours not configured',
|
||||
slotOutsideHours: 'Outside working hours',
|
||||
slotCannotCreate: 'You cannot create appointments',
|
||||
slotBookAt: 'Book {time}',
|
||||
outsideHoursBlocked: 'Outside working hours — editing blocked',
|
||||
overlappingChoose: '{count} overlapping — click to choose',
|
||||
overlapping: '{count} overlapping',
|
||||
legend: 'Legend',
|
||||
overlappingTitle: 'Overlapping appointments ({count})',
|
||||
purposeConsultation: 'Consultation',
|
||||
purposeFilling: 'Filling',
|
||||
purposeEndo: 'Endo',
|
||||
purposeVisit: 'Visit',
|
||||
purposeHygiene: 'Hygiene',
|
||||
},
|
||||
treatment: {
|
||||
loading: 'Loading…',
|
||||
noPermissionTitle: 'Treatment workspace',
|
||||
noPermissionBody: 'You do not have permission to view the Treatment tab for this organization.',
|
||||
title: 'Treatment',
|
||||
subtitleEdit:
|
||||
'Document cases for your appointments, save drafts, and send work to linked organizations.',
|
||||
subtitleReadOnly:
|
||||
'View-only access — you can review appointments and treatment history but cannot edit.',
|
||||
pastDayNotice:
|
||||
'Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.',
|
||||
selectedPatient: 'Selected patient',
|
||||
purposeLabel: 'Purpose:',
|
||||
loadingAppointments: 'Loading appointments…',
|
||||
selectDayWithAppointment: 'Select a day with at least one appointment.',
|
||||
confirmDiscard: 'You have unsaved changes. Discard them and continue?',
|
||||
successDraftSaved: 'Treatment draft saved.',
|
||||
errorChooseOrg: 'Choose at least one active organization to send this case.',
|
||||
successCaseSent: 'Case sent to selected organizations.',
|
||||
successFilesUploaded: '{count} file(s) uploaded successfully.',
|
||||
errorLoadAppointments: 'Failed to load appointments.',
|
||||
errorLoadOrgs: 'Failed to load linked organizations.',
|
||||
errorLoadHistory: 'Failed to load treatment history.',
|
||||
errorLoadDraft: 'Failed to load treatment draft.',
|
||||
errorUpload: 'Failed to upload attachments.',
|
||||
errorSaveDraft: 'Failed to save treatment draft.',
|
||||
errorSendCase: 'Failed to send case.',
|
||||
errorCaseMustSave: 'Case must be saved before sending.',
|
||||
draftTitle: 'Draft · {patientName}',
|
||||
hiddenMessage: 'Appointments are hidden.',
|
||||
showAppointments: 'Show appointments',
|
||||
appointmentsTitle: 'My appointments',
|
||||
hideAppointments: 'Hide appointments',
|
||||
emptyDay: 'No appointments assigned to you on this day.',
|
||||
casesTitle: 'Treatment cases',
|
||||
casesSubtitle: 'Each case has its own teeth, notes, attachments, and destinations for send.',
|
||||
addCase: 'Add case',
|
||||
caseLabel: 'Case {n}',
|
||||
comments: 'Comments',
|
||||
commentsPlaceholder: 'Write clinical notes for this case…',
|
||||
treatmentType: 'Treatment type',
|
||||
typeConsultation: 'consultation',
|
||||
typeFilling: 'filling',
|
||||
typeEndo: 'endo',
|
||||
typeVisit: 'visit',
|
||||
typeHygiene: 'hygiene',
|
||||
attachments: 'Attachments',
|
||||
attachFiles: 'Attach files for this treatment case',
|
||||
chooseFiles: 'Choose files',
|
||||
sendToOrgs: 'Send this case to linked organizations',
|
||||
searchOrgsPlaceholder: 'Search active organizations...',
|
||||
recent: 'Recent:',
|
||||
noOrgMatch: 'No active organization matches your search.',
|
||||
sendThisCase: 'Send this case',
|
||||
saveDraft: 'Save treatment draft',
|
||||
unsavedChanges: 'Unsaved changes',
|
||||
draftSaved: 'Draft saved',
|
||||
sendSavesFirst: 'Sending is per case and saves first automatically.',
|
||||
historyTitle: 'Previous treatments',
|
||||
historySubtitle: 'Completed treatments for this patient. Each case is listed separately.',
|
||||
loadingHistory: 'Loading history…',
|
||||
historyEmpty: 'No prior treatments for this patient.',
|
||||
statusLabel: 'Status:',
|
||||
historyCaseLabel: 'Case {n} · {type}',
|
||||
teethLabel: 'Teeth:',
|
||||
teethNone: 'None selected',
|
||||
reviewDetails: 'Review details',
|
||||
previewTitle: 'Treatment preview',
|
||||
previewDraft: 'Preview current draft',
|
||||
selectAppointment: 'Select an appointment to preview its draft.',
|
||||
caseCount: '{n} case(s)',
|
||||
attachmentCount: '{n} attachment(s)',
|
||||
caseSummary: 'Case {n}: {type}',
|
||||
teethPrefix: '· Teeth',
|
||||
moreCases: '+ {n} more case(s)',
|
||||
previewDialogTitle: 'Treatment preview',
|
||||
previewDialogSubtitle: 'Review cases, attachments, and send destinations.',
|
||||
noCases: 'No cases in this treatment.',
|
||||
typeLabel: 'Type:',
|
||||
commentsLabel: 'Comments:',
|
||||
commentsEmpty: 'Comments: —',
|
||||
attachFilesShort: 'Attach files',
|
||||
sendCase: 'Send this case',
|
||||
sendToLinkedOrgs: 'Send to linked organizations',
|
||||
noActiveOrgs: 'No active linked organizations.',
|
||||
confirmSend: 'Confirm send',
|
||||
toothChartTitle: 'FDI tooth chart',
|
||||
toothChartHint: 'Tap teeth to multi-select. Applies to the active case.',
|
||||
selectedLabel: 'Selected:',
|
||||
selectedEmpty: '—',
|
||||
upperArch: 'Upper arch',
|
||||
lowerArch: 'Lower arch',
|
||||
toothAria: 'FDI tooth {fdi}',
|
||||
toothSelectedSuffix: ', selected',
|
||||
sentToAt: 'Sent to {orgName} at {datetime}',
|
||||
fallbackOrgName: 'organization',
|
||||
},
|
||||
organizations: {
|
||||
loadingOrganization: 'Loading organization...',
|
||||
subtitle:
|
||||
'Search organizations, send connection requests to existing accounts, or invitation links when they are not on DyoLink yet.',
|
||||
invitationHistory: 'Invitation History',
|
||||
searchPlaceholder: 'Search {counterpart} by name, email, or phone...',
|
||||
backToList: 'Back to list',
|
||||
tableOrganization: 'Organization',
|
||||
tableOwnerEmail: 'Owner email',
|
||||
tableDate: 'Date',
|
||||
tableStatus: 'Status',
|
||||
tableAction: 'Action',
|
||||
emptyConnections: 'No connections yet. Search to send a connection request or an invitation link.',
|
||||
statusInvitationPending: 'Invitation pending',
|
||||
statusConnectionPending: 'Connection request pending',
|
||||
statusConnected: 'Connected',
|
||||
statusDeclined: 'Connection request declined',
|
||||
statusFound: 'Found',
|
||||
statusToday: 'Today',
|
||||
acceptRequest: 'Accept connection request',
|
||||
declineRequest: 'Decline connection request',
|
||||
removeConnection: 'Remove connection',
|
||||
sendRequest: 'Send connection request',
|
||||
noDirectoryResults: 'No organization found in directory search.',
|
||||
hideInvitationFields: 'Hide invitation fields',
|
||||
sendInvitationLink: 'Send invitation link',
|
||||
counterpartNameLabel: '{counterpart} name',
|
||||
ownerEmailLabel: 'Owner email',
|
||||
sendInvitation: 'Send invitation',
|
||||
successConnectionSent: '{counterpart} connection request sent.',
|
||||
successInviteCreated: 'Invitation link created for {email}',
|
||||
successLinkCopied: 'Invitation link copied to clipboard.',
|
||||
successAccepted: 'Connection request accepted.',
|
||||
successDeclined: 'Connection request declined.',
|
||||
successRemoved: 'Connection removed.',
|
||||
historyTitle: 'Invitation History',
|
||||
loadingHistory: 'Loading invitation history...',
|
||||
historyEmpty: 'No invitations yet.',
|
||||
tableInvitationLink: 'Invitation link',
|
||||
statusPending: 'Invitation pending',
|
||||
statusAccepted: 'Invitation accepted',
|
||||
statusRejected: 'Invitation rejected',
|
||||
statusExpired: 'Invitation expired',
|
||||
copyInvitationLink: 'Copy invitation link',
|
||||
copyInvitationLinkTitle: 'Copy invitation link (generates a new link if needed)',
|
||||
selectorTitle: 'Organizations',
|
||||
selectorSubtitleWithCreate: 'Select an organization to continue, or create a new one.',
|
||||
selectorSubtitleSelectOnly: 'Select an organization to continue.',
|
||||
createOrganization: 'Create Organization',
|
||||
createAndContinue: 'Create and Continue',
|
||||
emptyCanCreate: 'No organizations found. Create your first one to continue.',
|
||||
emptyAskOwner: 'No organizations found. Ask an organization owner to invite you.',
|
||||
continueArrow: 'Continue →',
|
||||
planLabel: 'Plan: {name} • {maxUsers} users',
|
||||
},
|
||||
settings: {
|
||||
accountTitle: 'Account',
|
||||
accountSubtitle: 'Profile and security settings for your login.',
|
||||
accountPlaceholder:
|
||||
'Password change and profile editing will be wired here next (e.g. invite flow, reset password).',
|
||||
subscriptionsTitle: 'Subscriptions',
|
||||
subscriptionsSubtitle:
|
||||
'Your DyoLink workspace plan and seats for {orgName}. Clinic and lab income tracking stays under the sidebar Billing tab.',
|
||||
noSubscriptionNotice:
|
||||
'This organization has no active subscription. Select a plan below to start the purchase process.',
|
||||
currentPlan: 'Current plan',
|
||||
planPrice: 'Plan price',
|
||||
seatsUsed: 'Seats used',
|
||||
seatsRemaining: 'Seats remaining',
|
||||
daysRemaining: 'Days remaining',
|
||||
unlimited: 'Unlimited',
|
||||
unlimitedSeats: 'Unlimited seats',
|
||||
seatsCount: '{n} seats',
|
||||
pricePerMonth: '${price} / month',
|
||||
noActiveSubscription: 'No active subscription for this organization.',
|
||||
trialEnded: 'Trial period has ended. Choose a plan when checkout is available.',
|
||||
trialEndsIn: 'Trial ends in {days} day(s).',
|
||||
seatsLow: 'Seat usage is high for this organization.',
|
||||
choosePlanIntro:
|
||||
'Choose a plan to continue. Purchase integration is not active yet, so this currently prepares the selection step only.',
|
||||
planSolo: 'Solo',
|
||||
planSmall: 'Small',
|
||||
planMedium: 'Medium',
|
||||
planLarge: 'Large',
|
||||
planEnterprise: 'Enterprise',
|
||||
startPurchase: 'Start purchase process',
|
||||
purchaseNotice:
|
||||
'Purchase flow will be enabled soon. {plan} is selected and ready for checkout setup.',
|
||||
},
|
||||
schedule: {
|
||||
defaultLabel: 'Schedule date',
|
||||
previousDay: 'Previous day',
|
||||
nextDay: 'Next day',
|
||||
chooseDate: 'Choose schedule date',
|
||||
year: 'Year',
|
||||
month: 'Month',
|
||||
day: 'Day',
|
||||
monthJanuary: 'January',
|
||||
monthFebruary: 'February',
|
||||
monthMarch: 'March',
|
||||
monthApril: 'April',
|
||||
monthMay: 'May',
|
||||
monthJune: 'June',
|
||||
monthJuly: 'July',
|
||||
monthAugust: 'August',
|
||||
monthSeptember: 'September',
|
||||
monthOctober: 'October',
|
||||
monthNovember: 'November',
|
||||
monthDecember: 'December',
|
||||
},
|
||||
};
|
||||
|
||||
function deepMerge(base, overlay) {
|
||||
const result = { ...base };
|
||||
for (const key of Object.keys(base)) {
|
||||
const baseVal = base[key];
|
||||
const overlayVal = overlay?.[key];
|
||||
if (baseVal && typeof baseVal === 'object' && !Array.isArray(baseVal)) {
|
||||
result[key] = deepMerge(baseVal, overlayVal ?? {});
|
||||
} else if (overlayVal !== undefined) {
|
||||
result[key] = overlayVal;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function writeJson(file, data) {
|
||||
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
writeJson(path.join(messagesDir, 'en.json'), en);
|
||||
|
||||
for (const locale of ['fa', 'nl']) {
|
||||
const file = path.join(messagesDir, `${locale}.json`);
|
||||
const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
|
||||
writeJson(file, deepMerge(en, existing));
|
||||
}
|
||||
|
||||
console.log('Messages built: en.json updated; fa.json and nl.json merged with existing translations.');
|
||||
@@ -103,6 +103,11 @@ export default function AccountSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const passwordToggleLabels = {
|
||||
show: tAuth('showPassword'),
|
||||
hide: tAuth('hidePassword'),
|
||||
};
|
||||
|
||||
if (!isAuthReady || !user) {
|
||||
return (
|
||||
<p className="text-text-secondary text-sm">{tCommon('loadingEllipsis')}</p>
|
||||
@@ -139,6 +144,7 @@ export default function AccountSettingsPage() {
|
||||
placeholder={tAuth('passwordPlaceholder')}
|
||||
error={errors.currentPassword?.message}
|
||||
icon={<Lock className="h-5 w-5 icon-flat" />}
|
||||
passwordToggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -149,6 +155,7 @@ export default function AccountSettingsPage() {
|
||||
placeholder={tAuth('passwordPlaceholder')}
|
||||
error={errors.newPassword?.message}
|
||||
icon={<Lock className="h-5 w-5 icon-flat" />}
|
||||
passwordToggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
|
||||
<Input
|
||||
@@ -158,6 +165,7 @@ export default function AccountSettingsPage() {
|
||||
placeholder={tAuth('passwordPlaceholder')}
|
||||
error={errors.confirmPassword?.message}
|
||||
icon={<Lock className="h-5 w-5 icon-flat" />}
|
||||
passwordToggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -1,24 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
import {
|
||||
canAccessAppointmentsSection,
|
||||
canViewAppointmentsTab,
|
||||
canViewCases,
|
||||
canViewLabCasesOrTasks,
|
||||
canViewTasks,
|
||||
canViewTreatment,
|
||||
} from '@/components/shared/permissions';
|
||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||
import { TodayKpiGrid } from '@/components/today/TodayKpiGrid';
|
||||
import { TodayChartsSection } from '@/components/today/TodayChartsSection';
|
||||
import { TodayUpcomingAppointments } from '@/components/today/TodayUpcomingAppointments';
|
||||
import { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner';
|
||||
import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback';
|
||||
import { TodayWidgetErrorBoundary } from '@/components/today/TodayWidgetErrorBoundary';
|
||||
import { useTodaySummary } from '@/lib/hooks/useTodaySummary';
|
||||
|
||||
export default function TodayPage() {
|
||||
const t = useTranslations('today');
|
||||
const { currentOrganization } = useAuth();
|
||||
const orgId = currentOrganization?.id;
|
||||
const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId);
|
||||
|
||||
const showNoSubscriptionNotice =
|
||||
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
|
||||
|
||||
const showUpcoming =
|
||||
currentOrganization?.type === 'CLINIC' && canViewTreatment(currentOrganization);
|
||||
const showCharts = useMemo(() => {
|
||||
const orgType = currentOrganization?.type;
|
||||
if (!orgType) return false;
|
||||
|
||||
if (orgType === 'CLINIC') {
|
||||
return (
|
||||
canAccessAppointmentsSection(currentOrganization) ||
|
||||
canViewAppointmentsTab(currentOrganization) ||
|
||||
canViewTreatment(currentOrganization)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
canViewCases(currentOrganization) ||
|
||||
canViewTasks(currentOrganization) ||
|
||||
canViewLabCasesOrTasks(currentOrganization)
|
||||
);
|
||||
}, [currentOrganization]);
|
||||
|
||||
const sectionErrorMessage = t('sectionLoadError');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold mb-6">
|
||||
{t('welcomeBack')}
|
||||
</h1>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||
<h1 className="text-2xl font-semibold">{t('welcomeBack')}</h1>
|
||||
{data?.generatedAt && !isInitialLoad ? (
|
||||
<p className="text-xs text-text-muted">
|
||||
{t('lastUpdated', {
|
||||
time: new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(data.generatedAt)),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showNoSubscriptionNotice && (
|
||||
<div className="mb-6 rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
|
||||
<div className="rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
|
||||
<p className="text-sm text-amber-200">
|
||||
{t('noSubscriptionNotice')}{' '}
|
||||
<Link href="/settings/subscriptions" className="font-medium underline underline-offset-2">
|
||||
@@ -29,27 +81,59 @@ export default function TodayPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<p className="text-sm text-card-muted">{t('cardTodaysAppointments')}</p>
|
||||
<p className="text-2xl font-semibold mt-2">12</p>
|
||||
<p className="text-xs text-text-muted mt-1">Monday 2/5/2026</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-sm text-card-muted">{t('cardActivePatients')}</p>
|
||||
<p className="text-2xl font-semibold mt-2">675</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-sm text-card-muted">{t('cardNewLabCase')}</p>
|
||||
<p className="text-2xl font-semibold mt-2">5</p>
|
||||
<p className="text-xs text-text-muted mt-1">35 ↑</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-sm text-card-muted">{t('cardTodayInvoices')}</p>
|
||||
<p className="text-2xl font-semibold mt-2">1200$</p>
|
||||
<p className="text-xs text-text-muted mt-1">21,300 $</p>
|
||||
</Card>
|
||||
</div>
|
||||
{error ? (
|
||||
<TodayLoadErrorBanner
|
||||
message={formatApiErrorMessage(error, t('loadError'))}
|
||||
retryLabel={t('retryLoad')}
|
||||
onRetry={() => void reload()}
|
||||
isRetrying={loading && Boolean(data)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TodayWidgetErrorBoundary
|
||||
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
|
||||
>
|
||||
<TodayKpiGrid
|
||||
widgets={data?.widgets ?? {}}
|
||||
loading={loading}
|
||||
isInitialLoad={isInitialLoad}
|
||||
hasError={Boolean(error)}
|
||||
/>
|
||||
</TodayWidgetErrorBoundary>
|
||||
|
||||
{showUpcoming && (!error || data) ? (
|
||||
<TodayWidgetErrorBoundary
|
||||
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
|
||||
>
|
||||
<div
|
||||
className={`grid grid-cols-1 gap-4 lg:grid-cols-2 ${loading ? 'opacity-70 transition-opacity' : ''}`}
|
||||
>
|
||||
<TodayUpcomingAppointments
|
||||
actions={data?.actions ?? {}}
|
||||
loading={loading}
|
||||
isInitialLoad={isInitialLoad}
|
||||
/>
|
||||
{showCharts ? (
|
||||
<TodayChartsSection
|
||||
charts={data?.charts ?? {}}
|
||||
loading={loading}
|
||||
isInitialLoad={isInitialLoad}
|
||||
embedded
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</TodayWidgetErrorBoundary>
|
||||
) : showCharts && (!error || data) ? (
|
||||
<TodayWidgetErrorBoundary
|
||||
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
|
||||
>
|
||||
<TodayChartsSection
|
||||
charts={data?.charts ?? {}}
|
||||
loading={loading}
|
||||
isInitialLoad={isInitialLoad}
|
||||
/>
|
||||
</TodayWidgetErrorBoundary>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { TreatmentWorkspace } from '@/components/ui/treatment/TreatmentWorkspace';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
|
||||
export default function TreatmentPage() {
|
||||
const t = useTranslations('treatment');
|
||||
const { user, currentOrganization, isAuthReady } = useAuth();
|
||||
const searchParams = useSearchParams();
|
||||
const initialAppointmentId = searchParams.get('appointmentId');
|
||||
|
||||
if (!isAuthReady || !user) {
|
||||
return (
|
||||
@@ -15,6 +18,10 @@ export default function TreatmentPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<TreatmentWorkspace userId={user.id} currentOrganization={currentOrganization} />
|
||||
<TreatmentWorkspace
|
||||
userId={user.id}
|
||||
currentOrganization={currentOrganization}
|
||||
initialAppointmentId={initialAppointmentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,6 +93,11 @@ function AcceptInviteContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const passwordToggleLabels = {
|
||||
show: t('showPassword'),
|
||||
hide: t('hidePassword'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen app-web-bg flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md surface-card p-6 space-y-5">
|
||||
@@ -134,12 +139,14 @@ function AcceptInviteContent() {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
passwordToggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
<Input
|
||||
label={t('labelConfirmPassword')}
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
passwordToggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
<Button type="button" fullWidth isLoading={submitting} onClick={() => void onAccept()}>
|
||||
{t('activateAccount')}
|
||||
|
||||
@@ -154,6 +154,11 @@ function AcceptOrganizationInviteContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const passwordToggleLabels = {
|
||||
show: t('showPassword'),
|
||||
hide: t('hidePassword'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen app-web-bg flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
@@ -226,6 +231,7 @@ function AcceptOrganizationInviteContent() {
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
error={errors.password?.message}
|
||||
icon={<Lock className="h-5 w-5 icon-flat" />}
|
||||
passwordToggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
<Input
|
||||
label={t('confirmPassword')}
|
||||
@@ -234,6 +240,7 @@ function AcceptOrganizationInviteContent() {
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
error={errors.confirmPassword?.message}
|
||||
icon={<Lock className="h-5 w-5 icon-flat" />}
|
||||
passwordToggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
<Button type="button" variant="primary" onClick={() => void handleNext()} fullWidth>
|
||||
{tCommon('continue')}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Mail, Lock } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { getRememberedEmail } from '@/lib/auth/rememberMe';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
import { Input } from '@/components/ui/shared/Input';
|
||||
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
|
||||
|
||||
@@ -48,6 +49,8 @@ export default function LoginPage() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
@@ -57,6 +60,8 @@ export default function LoginPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const rememberMe = watch('rememberMe');
|
||||
|
||||
const onSubmit = async (data: LoginForm) => {
|
||||
try {
|
||||
setError(null);
|
||||
@@ -114,20 +119,18 @@ export default function LoginPage() {
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
error={errors.password?.message}
|
||||
icon={<Lock className="h-5 w-5 icon-flat" />}
|
||||
passwordToggleLabels={{
|
||||
show: t('showPassword'),
|
||||
hide: t('hidePassword'),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-border bg-background-secondary text-primary focus:ring-primary/40"
|
||||
{...register('rememberMe')}
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-text-secondary">
|
||||
{t('rememberMe')}
|
||||
</label>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={rememberMe}
|
||||
onChange={(checked) => setValue('rememberMe', checked)}
|
||||
label={t('rememberMe')}
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<Link href="/forgot-password" className="font-medium text-primary hover:opacity-90">
|
||||
{t('forgotPassword')}
|
||||
|
||||
@@ -115,6 +115,11 @@ export default function RegisterPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const passwordToggleLabels = {
|
||||
show: t('showPassword'),
|
||||
hide: t('hidePassword'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen app-web-bg flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="absolute top-4 right-4">
|
||||
@@ -189,6 +194,7 @@ export default function RegisterPage() {
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
error={errors.password?.message}
|
||||
icon={<Lock className="h-5 w-5 icon-flat" />}
|
||||
passwordToggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
<Input
|
||||
label={t('confirmPassword')}
|
||||
@@ -197,6 +203,7 @@ export default function RegisterPage() {
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
error={errors.confirmPassword?.message}
|
||||
icon={<Lock className="h-5 w-5 icon-flat" />}
|
||||
passwordToggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
<Button type="button" variant="primary" onClick={handleNext} fullWidth>
|
||||
{tCommon('continue')}
|
||||
|
||||
@@ -210,3 +210,18 @@ export function canEditTasks(org: Organization | null): boolean {
|
||||
if (org.isOwner) return true;
|
||||
return hasPermission(org, 'TAB_TASKS_EDIT');
|
||||
}
|
||||
|
||||
/** Appointments tab only (excludes treatment-only access). */
|
||||
export function canViewAppointmentsTab(org: Organization | null): boolean {
|
||||
if (!org) return false;
|
||||
if (org.type !== 'CLINIC') return false;
|
||||
if (org.isOwner) return true;
|
||||
return (
|
||||
hasPermission(org, 'TAB_APPOINTMENTS_READ') ||
|
||||
hasPermission(org, 'TAB_APPOINTMENTS_EDIT')
|
||||
);
|
||||
}
|
||||
|
||||
export function canViewLabCasesOrTasks(org: Organization | null): boolean {
|
||||
return canViewCases(org) || canViewTasks(org);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { isSameLocalCalendarDay } from '@/components/appointments/appointmentTim
|
||||
import type { TreatmentAppointment } from '@/types/treatment';
|
||||
|
||||
/**
|
||||
* For the selected calendar day: if it is today, pick the appointment whose time range contains now;
|
||||
* otherwise pick the first appointment of that day. Returns null when there are no appointments.
|
||||
* For the selected calendar day: if it is today, pick the in-progress appointment,
|
||||
* otherwise the appointment whose start time is nearest to now; on other days pick
|
||||
* the first appointment of that day. Returns null when there are no appointments.
|
||||
*/
|
||||
export function pickAutoAppointment(
|
||||
appointments: TreatmentAppointment[],
|
||||
@@ -19,7 +20,23 @@ export function pickAutoAppointment(
|
||||
const e = new Date(a.endAt).getTime();
|
||||
if (t >= s && t <= e) return a.id;
|
||||
}
|
||||
|
||||
let nearest = appointments[0];
|
||||
let nearestDistance = Math.abs(new Date(nearest.startAt).getTime() - t);
|
||||
for (const appointment of appointments.slice(1)) {
|
||||
const distance = Math.abs(new Date(appointment.startAt).getTime() - t);
|
||||
if (distance < nearestDistance) {
|
||||
nearest = appointment;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
}
|
||||
return nearest.id;
|
||||
}
|
||||
|
||||
return appointments[0].id;
|
||||
}
|
||||
|
||||
export function treatmentAppointmentHref(appointmentId?: string): string {
|
||||
if (!appointmentId) return '/treatment';
|
||||
return `/treatment?appointmentId=${encodeURIComponent(appointmentId)}`;
|
||||
}
|
||||
|
||||
44
frontend/src/components/today/ChartCard.tsx
Normal file
44
frontend/src/components/today/ChartCard.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
import { ChartCardSkeleton } from '@/components/today/TodaySkeleton';
|
||||
|
||||
interface ChartCardProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
emptyMessage?: string;
|
||||
isEmpty?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function ChartCard({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
emptyMessage,
|
||||
isEmpty = false,
|
||||
loading = false,
|
||||
}: ChartCardProps) {
|
||||
if (loading) {
|
||||
return <ChartCardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="min-h-[280px] flex flex-col">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-base font-semibold text-card-foreground">{title}</h2>
|
||||
{subtitle ? (
|
||||
<p className="text-xs text-text-muted mt-1">{subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="flex-1 min-h-[220px] flex items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20">
|
||||
<p className="text-sm text-text-muted text-center px-4">{emptyMessage}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 min-h-[220px] flex-col">{children}</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/today/KpiCard.tsx
Normal file
67
frontend/src/components/today/KpiCard.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
import type { KpiCardColor } from '@/components/today/widget-registry';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
const colorClasses: Record<KpiCardColor, string> = {
|
||||
blue: '!bg-purpose-visit-bg !text-purpose-visit-fg !border-purpose-visit-border',
|
||||
yellow: '!bg-badge-warning-bg !text-badge-warning-fg !border-badge-warning-border',
|
||||
green: '!bg-badge-success-bg !text-badge-success-fg !border-badge-success-border',
|
||||
red: '!bg-badge-danger-bg !text-badge-danger-fg !border-badge-danger-border',
|
||||
purple: '!bg-purpose-consultation-bg !text-purpose-consultation-fg !border-purpose-consultation-border',
|
||||
default: '',
|
||||
};
|
||||
|
||||
interface KpiCardProps {
|
||||
title: string;
|
||||
value: string;
|
||||
subtitle?: string | null;
|
||||
icon?: LucideIcon;
|
||||
color?: KpiCardColor;
|
||||
loading?: boolean;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export function KpiCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
color = 'default',
|
||||
loading = false,
|
||||
href,
|
||||
}: KpiCardProps) {
|
||||
const card = (
|
||||
<Card
|
||||
className={`${colorClasses[color]} ${href && !loading ? 'transition-opacity hover:opacity-90' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
{Icon ? (
|
||||
<Icon
|
||||
className="h-4 w-4 shrink-0 !text-current"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="mt-2 h-8 w-16 animate-pulse rounded bg-current/10" />
|
||||
) : (
|
||||
<p className="text-2xl font-bold mt-2">{value}</p>
|
||||
)}
|
||||
{subtitle ? (
|
||||
<p className="text-xs opacity-80 mt-1">{subtitle}</p>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (href && !loading) {
|
||||
return (
|
||||
<Link href={href} className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 rounded-[var(--radius-lg)]">
|
||||
{card}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
66
frontend/src/components/today/TodayAreaChart.tsx
Normal file
66
frontend/src/components/today/TodayAreaChart.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { TodayChartBucket } from '@/types/today';
|
||||
import {
|
||||
TODAY_CHART_AXIS_COLOR,
|
||||
TODAY_CHART_GRID_COLOR,
|
||||
TODAY_CHART_PRIMARY_COLOR,
|
||||
TODAY_CHART_TOOLTIP_STYLE,
|
||||
} from '@/components/today/chart-theme';
|
||||
|
||||
interface TodayAreaChartProps {
|
||||
data: TodayChartBucket[];
|
||||
}
|
||||
|
||||
export function TodayAreaChart({ data }: TodayAreaChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="todayAreaFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={TODAY_CHART_PRIMARY_COLOR} stopOpacity={0.45} />
|
||||
<stop offset="100%" stopColor={TODAY_CHART_PRIMARY_COLOR} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
interval={1}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: 'rgba(0, 188, 255, 0.25)' }}
|
||||
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||
labelFormatter={(label) => String(label)}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke={TODAY_CHART_PRIMARY_COLOR}
|
||||
strokeWidth={2}
|
||||
fill="url(#todayAreaFill)"
|
||||
dot={{ r: 3, fill: TODAY_CHART_PRIMARY_COLOR, strokeWidth: 0 }}
|
||||
activeDot={{ r: 5, fill: TODAY_CHART_PRIMARY_COLOR }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
115
frontend/src/components/today/TodayBarChart.tsx
Normal file
115
frontend/src/components/today/TodayBarChart.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { TodayChartBucket } from '@/types/today';
|
||||
import {
|
||||
TODAY_CHART_AXIS_COLOR,
|
||||
TODAY_CHART_COLORS,
|
||||
TODAY_CHART_GRID_COLOR,
|
||||
TODAY_CHART_TOOLTIP_BG,
|
||||
TODAY_CHART_TOOLTIP_BORDER,
|
||||
} from '@/components/today/chart-theme';
|
||||
|
||||
interface TodayBarChartProps {
|
||||
data: TodayChartBucket[];
|
||||
colorForCode?: (code: string, index: number) => string;
|
||||
}
|
||||
|
||||
export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) {
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
shortLabel: truncateLabel(item.label),
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 8, right: 8, left: -12, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="shortLabel"
|
||||
tick={
|
||||
colorForCode
|
||||
? (props) => {
|
||||
const { x, y, payload } = props as {
|
||||
x: number;
|
||||
y: number;
|
||||
payload: { value: string };
|
||||
};
|
||||
const index = chartData.findIndex((row) => row.shortLabel === payload.value);
|
||||
const entry = chartData[index];
|
||||
const fill =
|
||||
entry != null
|
||||
? colorForCode(entry.code, index >= 0 ? index : 0)
|
||||
: TODAY_CHART_AXIS_COLOR;
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
dy={16}
|
||||
textAnchor="middle"
|
||||
fill={fill}
|
||||
fontSize={11}
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
: { fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }
|
||||
}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
|
||||
contentStyle={{
|
||||
backgroundColor: TODAY_CHART_TOOLTIP_BG,
|
||||
border: `1px solid ${TODAY_CHART_TOOLTIP_BORDER}`,
|
||||
borderRadius: '6px',
|
||||
color: '#f5f9ff',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
labelFormatter={(_, payload) => {
|
||||
const row = payload?.[0]?.payload as TodayChartBucket | undefined;
|
||||
return row?.label ?? '';
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={48}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.code}
|
||||
fill={
|
||||
colorForCode?.(entry.code, index) ??
|
||||
TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function truncateLabel(label: string, max = 12): string {
|
||||
if (label.length <= max) return label;
|
||||
return `${label.slice(0, max - 1)}…`;
|
||||
}
|
||||
243
frontend/src/components/today/TodayChartsSection.tsx
Normal file
243
frontend/src/components/today/TodayChartsSection.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { ChartCard } from '@/components/today/ChartCard';
|
||||
import { TodayAreaChart } from '@/components/today/TodayAreaChart';
|
||||
import { TodayBarChart } from '@/components/today/TodayBarChart';
|
||||
import {
|
||||
formatTodayChartDayLabel,
|
||||
mapWeekChartBuckets,
|
||||
useTodayDayLabelFormatter,
|
||||
} from '@/components/today/chart-day-labels';
|
||||
import { TodayDonutChart } from '@/components/today/TodayDonutChart';
|
||||
import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart';
|
||||
import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart';
|
||||
import { TodayStackedBarChart } from '@/components/today/TodayStackedBarChart';
|
||||
import { ChartCardSkeleton } from '@/components/today/TodaySkeleton';
|
||||
import { prosthesisTypeColor, prosthesisTypeSwatchStyle } from '@/components/ui/treatment/prosthesisTypeDisplay';
|
||||
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||
import type { TodaySummaryCharts } from '@/types/today';
|
||||
|
||||
interface TodayChartsSectionProps {
|
||||
charts: TodaySummaryCharts;
|
||||
loading?: boolean;
|
||||
isInitialLoad?: boolean;
|
||||
className?: string;
|
||||
/** When true, chart cards render as siblings (no outer grid wrapper). */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function TodayChartsSection({
|
||||
charts,
|
||||
loading = false,
|
||||
isInitialLoad = false,
|
||||
className = '',
|
||||
embedded = false,
|
||||
}: TodayChartsSectionProps) {
|
||||
const t = useTranslations('today');
|
||||
const dayLabelFormatter = useTodayDayLabelFormatter();
|
||||
const { currentOrganization } = useAuth();
|
||||
const orgType = currentOrganization?.type;
|
||||
|
||||
const showAppointmentsByProvider =
|
||||
orgType === 'CLINIC' && charts.appointmentsByProvider !== undefined;
|
||||
const showAppointmentsWeekAll =
|
||||
orgType === 'CLINIC' && charts.appointmentsWeekAll !== undefined;
|
||||
const showAppointmentsWeekMine =
|
||||
orgType === 'CLINIC' && charts.appointmentsWeekMine !== undefined;
|
||||
const showTreatmentMix =
|
||||
orgType === 'CLINIC' && charts.treatmentMixWeek !== undefined;
|
||||
const showCaseCompletion =
|
||||
orgType === 'LAB' && charts.caseCompletion !== undefined;
|
||||
const showTasksByStep =
|
||||
orgType === 'LAB' && charts.tasksByWorkflowStep !== undefined;
|
||||
const showLabTaskActivityWeek =
|
||||
orgType === 'LAB' && charts.labTaskActivityWeek !== undefined;
|
||||
const showInProgressTasksByProsthesis =
|
||||
orgType === 'LAB' && charts.inProgressTasksByProsthesis !== undefined;
|
||||
|
||||
const visibleChartCount =
|
||||
Number(showAppointmentsByProvider) +
|
||||
Number(showAppointmentsWeekAll) +
|
||||
Number(showAppointmentsWeekMine) +
|
||||
Number(showTreatmentMix) +
|
||||
Number(showCaseCompletion) +
|
||||
Number(showTasksByStep) +
|
||||
Number(showLabTaskActivityWeek) +
|
||||
Number(showInProgressTasksByProsthesis);
|
||||
|
||||
const appointmentsByProviderData = charts.appointmentsByProvider ?? [];
|
||||
const appointmentsWeekAllData = useMemo(
|
||||
() => mapWeekChartBuckets(charts.appointmentsWeekAll ?? [], dayLabelFormatter),
|
||||
[charts.appointmentsWeekAll, dayLabelFormatter],
|
||||
);
|
||||
const appointmentsWeekMineData = useMemo(
|
||||
() => mapWeekChartBuckets(charts.appointmentsWeekMine ?? [], dayLabelFormatter),
|
||||
[charts.appointmentsWeekMine, dayLabelFormatter],
|
||||
);
|
||||
const treatmentData = charts.treatmentMixWeek ?? [];
|
||||
const tasksData = charts.tasksByWorkflowStep ?? [];
|
||||
const labTaskActivityData = useMemo(
|
||||
() => mapWeekChartBuckets(charts.labTaskActivityWeek ?? [], dayLabelFormatter),
|
||||
[charts.labTaskActivityWeek, dayLabelFormatter],
|
||||
);
|
||||
const prosthesisData = charts.inProgressTasksByProsthesis ?? [];
|
||||
const caseCompletion = charts.caseCompletion ?? { completed: 0, total: 0, percent: 0 };
|
||||
|
||||
const formatDayLabel = (code: string) =>
|
||||
formatTodayChartDayLabel(code, dayLabelFormatter);
|
||||
|
||||
if (visibleChartCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isInitialLoad) {
|
||||
const skeletons = Array.from({ length: Math.min(visibleChartCount, 4) }).map((_, index) => (
|
||||
<ChartCardSkeleton key={index} />
|
||||
));
|
||||
|
||||
if (embedded) {
|
||||
return <>{skeletons}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-1 gap-4 lg:grid-cols-2 ${className}`}>{skeletons}</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gridClass =
|
||||
visibleChartCount > 1 ? 'grid grid-cols-1 lg:grid-cols-2' : 'grid grid-cols-1';
|
||||
|
||||
const chartCards = (
|
||||
<>
|
||||
{showAppointmentsWeekAll ? (
|
||||
<ChartCard
|
||||
title={t('chartAppointmentsWeekAllTitle')}
|
||||
subtitle={t('chartAppointmentsWeekAllSubtitle')}
|
||||
isEmpty={appointmentsWeekAllData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayAreaChart data={appointmentsWeekAllData} />
|
||||
</ChartCard>
|
||||
) : null}
|
||||
|
||||
{showAppointmentsWeekMine ? (
|
||||
<ChartCard
|
||||
title={t('chartAppointmentsWeekMineTitle')}
|
||||
subtitle={t('chartAppointmentsWeekMineSubtitle')}
|
||||
isEmpty={appointmentsWeekMineData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayAreaChart data={appointmentsWeekMineData} />
|
||||
</ChartCard>
|
||||
) : null}
|
||||
|
||||
{showLabTaskActivityWeek ? (
|
||||
<ChartCard
|
||||
title={t('chartLabTaskActivityTitle')}
|
||||
subtitle={t('chartLabTaskActivitySubtitle')}
|
||||
isEmpty={labTaskActivityData.every(
|
||||
(row) => row.completed === 0 && row.received === 0,
|
||||
)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayStackedBarChart
|
||||
data={labTaskActivityData}
|
||||
completedLabel={t('chartLabTaskCompletedLegend')}
|
||||
receivedLabel={t('chartLabTaskReceivedLegend')}
|
||||
formatDayLabel={formatDayLabel}
|
||||
/>
|
||||
</ChartCard>
|
||||
) : null}
|
||||
|
||||
{showInProgressTasksByProsthesis ? (
|
||||
<ChartCard
|
||||
title={t('chartProsthesisMixTitle')}
|
||||
subtitle={t('chartProsthesisMixSubtitle')}
|
||||
isEmpty={prosthesisData.length === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayDonutChart
|
||||
data={prosthesisData}
|
||||
labelForCode={(code) =>
|
||||
prosthesisData.find((row) => row.code === code)?.label ?? code
|
||||
}
|
||||
colorForCode={(code, index) => prosthesisTypeColor(code, index)}
|
||||
swatchStyleForCode={(code, index) => prosthesisTypeSwatchStyle(code, index)}
|
||||
variant="pie"
|
||||
sideLegend
|
||||
/>
|
||||
</ChartCard>
|
||||
) : null}
|
||||
|
||||
{showAppointmentsByProvider ? (
|
||||
<ChartCard
|
||||
title={t('chartAppointmentsByProviderTitle')}
|
||||
subtitle={t('chartAppointmentsByProviderSubtitle')}
|
||||
isEmpty={appointmentsByProviderData.length === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayHorizontalBarChart data={appointmentsByProviderData} />
|
||||
</ChartCard>
|
||||
) : null}
|
||||
|
||||
{showTreatmentMix ? (
|
||||
<ChartCard
|
||||
title={t('chartTreatmentMixTitle')}
|
||||
subtitle={t('chartTreatmentMixSubtitle')}
|
||||
isEmpty={treatmentData.length === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayBarChart
|
||||
data={treatmentData}
|
||||
colorForCode={(code, index) => treatmentTypeColor(code, index)}
|
||||
/>
|
||||
</ChartCard>
|
||||
) : null}
|
||||
|
||||
{showCaseCompletion ? (
|
||||
<ChartCard
|
||||
title={t('chartCaseCompletionTitle')}
|
||||
subtitle={t('chartCaseCompletionSubtitle')}
|
||||
isEmpty={caseCompletion.total === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayRadialGaugeChart
|
||||
percent={caseCompletion.percent}
|
||||
completed={caseCompletion.completed}
|
||||
total={caseCompletion.total}
|
||||
percentLabel={t('chartCaseCompletionPercent', {
|
||||
percent: caseCompletion.percent,
|
||||
})}
|
||||
tasksLabel={t('chartCaseCompletionTasks')}
|
||||
/>
|
||||
</ChartCard>
|
||||
) : null}
|
||||
|
||||
{showTasksByStep ? (
|
||||
<ChartCard
|
||||
title={t('chartTasksByStepTitle')}
|
||||
subtitle={t('chartTasksByStepSubtitle')}
|
||||
isEmpty={tasksData.length === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayBarChart data={tasksData} />
|
||||
</ChartCard>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return chartCards;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${gridClass} gap-4 ${loading ? 'opacity-70 transition-opacity' : ''} ${className}`}
|
||||
>
|
||||
{chartCards}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
frontend/src/components/today/TodayDonutChart.tsx
Normal file
125
frontend/src/components/today/TodayDonutChart.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import type { TodayChartBucket } from '@/types/today';
|
||||
import {
|
||||
TODAY_CHART_COLORS,
|
||||
TODAY_CHART_TOOLTIP_STYLE,
|
||||
} from '@/components/today/chart-theme';
|
||||
|
||||
interface TodayDonutChartProps {
|
||||
data: TodayChartBucket[];
|
||||
labelForCode: (code: string) => string;
|
||||
colorForCode?: (code: string, index: number) => string;
|
||||
swatchStyleForCode?: (code: string, index: number) => CSSProperties;
|
||||
variant?: 'donut' | 'pie';
|
||||
sideLegend?: boolean;
|
||||
}
|
||||
|
||||
export function TodayDonutChart({
|
||||
data,
|
||||
labelForCode,
|
||||
colorForCode,
|
||||
swatchStyleForCode,
|
||||
variant = 'donut',
|
||||
sideLegend = false,
|
||||
}: TodayDonutChartProps) {
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
displayLabel: labelForCode(item.code),
|
||||
}));
|
||||
|
||||
const resolveColor = (code: string, index: number) =>
|
||||
colorForCode?.(code, index) ??
|
||||
TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length];
|
||||
|
||||
const resolveSwatchStyle = (code: string, index: number): CSSProperties =>
|
||||
swatchStyleForCode?.(code, index) ?? {
|
||||
backgroundColor: resolveColor(code, index),
|
||||
borderColor: 'rgba(0, 0, 0, 0.18)',
|
||||
};
|
||||
|
||||
const innerRadius = variant === 'pie' ? 0 : 62;
|
||||
const outerRadius = sideLegend ? 100 : 92;
|
||||
|
||||
const chart = (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="count"
|
||||
nameKey="displayLabel"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
paddingAngle={variant === 'pie' ? 1 : 2}
|
||||
stroke="transparent"
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={entry.code} fill={resolveColor(entry.code, index)} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||
formatter={(value, _name, item) => {
|
||||
const row = item?.payload as TodayChartBucket | undefined;
|
||||
return [value, row ? labelForCode(row.code) : ''];
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
if (!sideLegend) {
|
||||
return chart;
|
||||
}
|
||||
|
||||
const rowClass = 'flex h-4 items-center text-xs leading-none';
|
||||
const legendInset = 'px-12';
|
||||
|
||||
return (
|
||||
<div className={`flex h-full min-h-[220px] items-center ${legendInset}`}>
|
||||
<div className="flex min-w-0 flex-1 items-center overflow-y-auto max-h-full py-0.5">
|
||||
<div className="flex flex-col items-start gap-1.5 shrink-0">
|
||||
{chartData.map((entry, index) => (
|
||||
<span key={entry.code} className={rowClass}>
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-sm border"
|
||||
style={resolveSwatchStyle(entry.code, index)}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex flex-col items-start gap-1.5">
|
||||
{chartData.map((entry) => (
|
||||
<span
|
||||
key={entry.code}
|
||||
className={`${rowClass} max-w-full truncate text-left text-text-primary`}
|
||||
>
|
||||
{entry.displayLabel}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-3 flex shrink-0 flex-col items-end gap-1.5">
|
||||
{chartData.map((entry) => (
|
||||
<span
|
||||
key={entry.code}
|
||||
className={`${rowClass} tabular-nums text-right text-text-muted`}
|
||||
>
|
||||
{entry.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex h-[240px] w-[min(100%,220px)] max-w-[48%] shrink-0 items-center justify-center">
|
||||
{chart}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/today/TodayHorizontalBarChart.tsx
Normal file
78
frontend/src/components/today/TodayHorizontalBarChart.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { TodayChartBucket } from '@/types/today';
|
||||
import {
|
||||
TODAY_CHART_AXIS_COLOR,
|
||||
TODAY_CHART_COLORS,
|
||||
TODAY_CHART_GRID_COLOR,
|
||||
TODAY_CHART_TOOLTIP_STYLE,
|
||||
} from '@/components/today/chart-theme';
|
||||
|
||||
interface TodayHorizontalBarChartProps {
|
||||
data: TodayChartBucket[];
|
||||
}
|
||||
|
||||
export function TodayHorizontalBarChart({ data }: TodayHorizontalBarChartProps) {
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
shortLabel: truncateLabel(item.label, 18),
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={Math.max(220, chartData.length * 36)}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 4, right: 12, left: 4, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
allowDecimals={false}
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="shortLabel"
|
||||
width={96}
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
|
||||
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||
labelFormatter={(_, payload) => {
|
||||
const row = payload?.[0]?.payload as TodayChartBucket | undefined;
|
||||
return row?.label ?? '';
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]} maxBarSize={28}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.code}
|
||||
fill={TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function truncateLabel(label: string, max = 18): string {
|
||||
if (label.length <= max) return label;
|
||||
return `${label.slice(0, max - 1)}…`;
|
||||
}
|
||||
78
frontend/src/components/today/TodayKpiGrid.tsx
Normal file
78
frontend/src/components/today/TodayKpiGrid.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { KpiCard } from '@/components/today/KpiCard';
|
||||
import { KpiCardSkeleton } from '@/components/today/TodaySkeleton';
|
||||
import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry';
|
||||
import type { TodaySummaryWidgets } from '@/types/today';
|
||||
|
||||
interface TodayKpiGridProps {
|
||||
widgets: TodaySummaryWidgets;
|
||||
loading?: boolean;
|
||||
isInitialLoad?: boolean;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
export function TodayKpiGrid({
|
||||
widgets,
|
||||
loading = false,
|
||||
isInitialLoad = false,
|
||||
hasError = false,
|
||||
}: TodayKpiGridProps) {
|
||||
const t = useTranslations('today');
|
||||
const { currentOrganization } = useAuth();
|
||||
const definitions = isInitialLoad
|
||||
? getEligibleTodayKpis(currentOrganization)
|
||||
: getVisibleTodayKpis(currentOrganization, widgets);
|
||||
|
||||
if (hasError && !loading && definitions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!loading && !hasError && definitions.length === 0) {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/30 px-4 py-6 text-center">
|
||||
<p className="text-sm text-text-muted">{t('noWidgets')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isInitialLoad) {
|
||||
const skeletonCount = Math.max(getEligibleTodayKpis(currentOrganization).length, 4);
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{Array.from({ length: skeletonCount }, (_, index) => (
|
||||
<KpiCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 ${loading ? 'opacity-70 transition-opacity' : ''}`}
|
||||
>
|
||||
{definitions.map((definition) => {
|
||||
const value = definition.formatValue(widgets) ?? '—';
|
||||
const subtitleKey = definition.formatSubtitle?.(widgets);
|
||||
const subtitle =
|
||||
subtitleKey === 'unlimited'
|
||||
? t('seatsUnlimited')
|
||||
: definition.formatSubtitle?.(widgets);
|
||||
|
||||
return (
|
||||
<KpiCard
|
||||
key={definition.key}
|
||||
title={t(definition.titleKey)}
|
||||
value={value}
|
||||
subtitle={subtitle}
|
||||
icon={definition.icon}
|
||||
color={definition.color}
|
||||
href={definition.href}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/today/TodayLoadErrorBanner.tsx
Normal file
33
frontend/src/components/today/TodayLoadErrorBanner.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
|
||||
interface TodayLoadErrorBannerProps {
|
||||
message: string;
|
||||
retryLabel: string;
|
||||
onRetry: () => void;
|
||||
isRetrying?: boolean;
|
||||
}
|
||||
|
||||
export function TodayLoadErrorBanner({
|
||||
message,
|
||||
retryLabel,
|
||||
onRetry,
|
||||
isRetrying = false,
|
||||
}: TodayLoadErrorBannerProps) {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-md)] border border-badge-danger-border bg-badge-danger-bg/40 p-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<p className="text-sm text-badge-danger-fg">{message}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
isLoading={isRetrying}
|
||||
className="shrink-0 border-badge-danger-border text-badge-danger-fg hover:bg-badge-danger-bg/30"
|
||||
>
|
||||
{retryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/today/TodayRadialGaugeChart.tsx
Normal file
56
frontend/src/components/today/TodayRadialGaugeChart.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { RadialBar, RadialBarChart, ResponsiveContainer } from 'recharts';
|
||||
|
||||
import { TODAY_CHART_PRIMARY_COLOR } from '@/components/today/chart-theme';
|
||||
|
||||
interface TodayRadialGaugeChartProps {
|
||||
percent: number;
|
||||
completed: number;
|
||||
total: number;
|
||||
percentLabel: string;
|
||||
tasksLabel: string;
|
||||
}
|
||||
|
||||
export function TodayRadialGaugeChart({
|
||||
percent,
|
||||
completed,
|
||||
total,
|
||||
percentLabel,
|
||||
tasksLabel,
|
||||
}: TodayRadialGaugeChartProps) {
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
const data = [{ name: 'completion', value: clamped, fill: TODAY_CHART_PRIMARY_COLOR }];
|
||||
|
||||
return (
|
||||
<div className="relative h-[240px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadialBarChart
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="68%"
|
||||
outerRadius="100%"
|
||||
barSize={14}
|
||||
data={data}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
>
|
||||
<RadialBar
|
||||
background={{ fill: 'rgba(41, 69, 106, 0.55)' }}
|
||||
dataKey="value"
|
||||
cornerRadius={8}
|
||||
/>
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||
<span className="text-3xl font-semibold text-text-primary">{percentLabel}</span>
|
||||
<span className="mt-1 text-xs text-text-muted">{tasksLabel}</span>
|
||||
{total > 0 ? (
|
||||
<span className="mt-0.5 text-[11px] text-text-secondary">
|
||||
{completed}/{total}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
frontend/src/components/today/TodaySectionErrorFallback.tsx
Normal file
13
frontend/src/components/today/TodaySectionErrorFallback.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
|
||||
interface TodaySectionErrorFallbackProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function TodaySectionErrorFallback({ message }: TodaySectionErrorFallbackProps) {
|
||||
return (
|
||||
<Card className="min-h-[120px] flex items-center justify-center border-badge-danger-border/40 bg-badge-danger-bg/20">
|
||||
<p className="text-sm text-badge-danger-fg text-center px-4">{message}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
36
frontend/src/components/today/TodaySkeleton.tsx
Normal file
36
frontend/src/components/today/TodaySkeleton.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
interface SkeletonBlockProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SkeletonBlock({ className = '' }: SkeletonBlockProps) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-pulse rounded-[var(--radius-md)] bg-background-secondary/60 ${className}`}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiCardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-lg)] border border-card-border bg-card p-4">
|
||||
<SkeletonBlock className="h-4 w-2/3" />
|
||||
<SkeletonBlock className="h-8 w-16 mt-3" />
|
||||
<SkeletonBlock className="h-3 w-1/3 mt-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartCardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-lg)] border border-card-border bg-card p-4 min-h-[280px] flex flex-col">
|
||||
<SkeletonBlock className="h-4 w-1/3" />
|
||||
<SkeletonBlock className="h-3 w-1/4 mt-2" />
|
||||
<SkeletonBlock className="flex-1 min-h-[220px] mt-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListRowSkeleton({ compact = false }: { compact?: boolean }) {
|
||||
return <SkeletonBlock className={`w-full ${compact ? 'h-8' : 'h-12'}`} />;
|
||||
}
|
||||
87
frontend/src/components/today/TodayStackedBarChart.tsx
Normal file
87
frontend/src/components/today/TodayStackedBarChart.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { TodayStackedDayBucket } from '@/types/today';
|
||||
import {
|
||||
TODAY_CHART_AXIS_COLOR,
|
||||
TODAY_CHART_COMPLETED_COLOR,
|
||||
TODAY_CHART_GRID_COLOR,
|
||||
TODAY_CHART_RECEIVED_COLOR,
|
||||
TODAY_CHART_TOOLTIP_STYLE,
|
||||
} from '@/components/today/chart-theme';
|
||||
|
||||
interface TodayStackedBarChartProps {
|
||||
data: TodayStackedDayBucket[];
|
||||
completedLabel: string;
|
||||
receivedLabel: string;
|
||||
formatDayLabel: (code: string) => string;
|
||||
}
|
||||
|
||||
export function TodayStackedBarChart({
|
||||
data,
|
||||
completedLabel,
|
||||
receivedLabel,
|
||||
formatDayLabel,
|
||||
}: TodayStackedBarChartProps) {
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
dayLabel: formatDayLabel(item.code),
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="dayLabel"
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
|
||||
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||
labelFormatter={(_, payload) => {
|
||||
const row = payload?.[0]?.payload as TodayStackedDayBucket | undefined;
|
||||
return row ? formatDayLabel(row.code) : '';
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px', color: TODAY_CHART_AXIS_COLOR }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="completed"
|
||||
name={completedLabel}
|
||||
stackId="activity"
|
||||
fill={TODAY_CHART_COMPLETED_COLOR}
|
||||
radius={[0, 0, 0, 0]}
|
||||
maxBarSize={48}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="received"
|
||||
name={receivedLabel}
|
||||
stackId="activity"
|
||||
fill={TODAY_CHART_RECEIVED_COLOR}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={48}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
132
frontend/src/components/today/TodayUpcomingAppointments.tsx
Normal file
132
frontend/src/components/today/TodayUpcomingAppointments.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
import { formatTimeForInput } from '@/components/appointments/appointmentTime';
|
||||
import { purposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||
import { treatmentAppointmentHref } from '@/components/shared/treatmentSelection';
|
||||
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||
import { canViewTreatment } from '@/components/shared/permissions';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||
import { ListRowSkeleton } from '@/components/today/TodaySkeleton';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { TodaySummaryActions } from '@/types/today';
|
||||
|
||||
interface TodayUpcomingAppointmentsProps {
|
||||
actions: TodaySummaryActions;
|
||||
loading?: boolean;
|
||||
isInitialLoad?: boolean;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
|
||||
export function TodayUpcomingAppointments({
|
||||
actions,
|
||||
loading = false,
|
||||
isInitialLoad = false,
|
||||
}: TodayUpcomingAppointmentsProps) {
|
||||
const t = useTranslations('today');
|
||||
const { currentOrganization } = useAuth();
|
||||
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void treatmentCatalogApi
|
||||
.list()
|
||||
.then((response) => setTreatmentCatalog(response.data))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (
|
||||
!currentOrganization ||
|
||||
currentOrganization.type !== 'CLINIC' ||
|
||||
!canViewTreatment(currentOrganization)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const appointments = (actions.upcomingAppointmentsToday ?? []).slice(0, MAX_VISIBLE);
|
||||
|
||||
if (isInitialLoad) {
|
||||
return (
|
||||
<Card className="min-h-[140px] p-3">
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<div className="h-3.5 w-32 animate-pulse rounded bg-background-secondary/60" />
|
||||
<div className="h-3 w-44 animate-pulse rounded bg-background-secondary/60" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[0, 1].map((key) => (
|
||||
<ListRowSkeleton key={key} compact />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="min-h-[140px] p-3">
|
||||
<div className="mb-2 flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-card-foreground">
|
||||
{t('upcomingAppointmentsTitle')}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-[11px] text-text-muted">{t('upcomingAppointmentsSubtitle')}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={treatmentAppointmentHref()}
|
||||
className="shrink-0 text-[11px] font-medium text-primary hover:underline underline-offset-2"
|
||||
>
|
||||
{t('viewAllAppointments')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{appointments.length === 0 ? (
|
||||
<div className="flex min-h-[72px] items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20 px-3">
|
||||
<p className="text-xs text-text-muted text-center">{t('noUpcomingAppointments')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/40">
|
||||
{appointments.map((appointment) => {
|
||||
const start = new Date(appointment.startAt);
|
||||
const end = new Date(appointment.endAt);
|
||||
const timeLabel = `${formatTimeForInput(start)} – ${formatTimeForInput(end)}`;
|
||||
const purposeIndex = treatmentCatalog.findIndex((entry) => entry.code === appointment.purpose);
|
||||
const purposeTextColor = treatmentTypeColor(
|
||||
appointment.purpose,
|
||||
purposeIndex < 0 ? 0 : purposeIndex,
|
||||
);
|
||||
const purposeDisplay = purposeLabel(appointment.purpose, treatmentCatalog);
|
||||
|
||||
return (
|
||||
<li key={appointment.id}>
|
||||
<Link
|
||||
href={treatmentAppointmentHref(appointment.id)}
|
||||
className="group -mx-1 flex items-center justify-between gap-2 rounded-[var(--radius-md)] px-1 py-2 transition-colors hover:bg-background-secondary/45"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium text-text-primary">
|
||||
{appointment.patientName}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[11px] text-text-muted">
|
||||
{timeLabel}
|
||||
{appointment.purpose ? (
|
||||
<span style={{ color: purposeTextColor }}> · {purposeDisplay}</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5 shrink-0 text-text-muted opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/today/TodayWidgetErrorBoundary.tsx
Normal file
34
frontend/src/components/today/TodayWidgetErrorBoundary.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
|
||||
interface TodayWidgetErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback: ReactNode;
|
||||
}
|
||||
|
||||
interface TodayWidgetErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class TodayWidgetErrorBoundary extends Component<
|
||||
TodayWidgetErrorBoundaryProps,
|
||||
TodayWidgetErrorBoundaryState
|
||||
> {
|
||||
state: TodayWidgetErrorBoundaryState = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): TodayWidgetErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('Today widget render error:', error, info);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
34
frontend/src/components/today/chart-day-labels.ts
Normal file
34
frontend/src/components/today/chart-day-labels.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export function useTodayDayLabelFormatter() {
|
||||
return useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export function formatTodayChartDayLabel(
|
||||
code: string,
|
||||
formatter: Intl.DateTimeFormat,
|
||||
): string {
|
||||
const [year, month, day] = code.split('-').map(Number);
|
||||
if (!year || !month || !day) return code;
|
||||
return formatter.format(new Date(year, month - 1, day));
|
||||
}
|
||||
|
||||
export function mapWeekChartBuckets<T extends { code: string; label: string }>(
|
||||
buckets: T[],
|
||||
formatter: Intl.DateTimeFormat,
|
||||
): T[] {
|
||||
return buckets.map((bucket) => ({
|
||||
...bucket,
|
||||
label: formatTodayChartDayLabel(bucket.code, formatter),
|
||||
}));
|
||||
}
|
||||
24
frontend/src/components/today/chart-theme.ts
Normal file
24
frontend/src/components/today/chart-theme.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { CATALOG_PALETTE_COLORS } from '@/components/ui/treatment/catalog-type-colors';
|
||||
|
||||
/** Chart series colors — same palette as treatment / prosthesis catalog types. */
|
||||
export const TODAY_CHART_COLORS = CATALOG_PALETTE_COLORS;
|
||||
|
||||
/** Primary accent for single-series charts (area, gauge). */
|
||||
export const TODAY_CHART_PRIMARY_COLOR = CATALOG_PALETTE_COLORS[5] ?? '#c4b5fd';
|
||||
|
||||
/** Stacked bar segments for lab task activity. */
|
||||
export const TODAY_CHART_COMPLETED_COLOR = CATALOG_PALETTE_COLORS[8] ?? '#86efac';
|
||||
export const TODAY_CHART_RECEIVED_COLOR = CATALOG_PALETTE_COLORS[11] ?? '#bae6fd';
|
||||
|
||||
export const TODAY_CHART_AXIS_COLOR = '#8ea3bf';
|
||||
export const TODAY_CHART_GRID_COLOR = 'rgba(41, 69, 106, 0.55)';
|
||||
export const TODAY_CHART_TOOLTIP_BG = '#14253d';
|
||||
export const TODAY_CHART_TOOLTIP_BORDER = '#29456a';
|
||||
|
||||
export const TODAY_CHART_TOOLTIP_STYLE = {
|
||||
backgroundColor: TODAY_CHART_TOOLTIP_BG,
|
||||
border: `1px solid ${TODAY_CHART_TOOLTIP_BORDER}`,
|
||||
borderRadius: '6px',
|
||||
color: '#f5f9ff',
|
||||
fontSize: '12px',
|
||||
} as const;
|
||||
257
frontend/src/components/today/widget-registry.ts
Normal file
257
frontend/src/components/today/widget-registry.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
ClipboardList,
|
||||
FlaskConical,
|
||||
Link2,
|
||||
Stethoscope,
|
||||
UserCog,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { Organization } from '@/types/organization';
|
||||
import {
|
||||
canAccessAppointmentsSection,
|
||||
canEditStaff,
|
||||
canViewCases,
|
||||
canViewStaff,
|
||||
canViewTasks,
|
||||
canViewTreatment,
|
||||
hasPermission,
|
||||
type OrgTypeName,
|
||||
} from '@/components/shared/permissions';
|
||||
import type { TodaySummaryWidgets, TodayWidgetKey } from '@/types/today';
|
||||
|
||||
export type KpiCardColor = 'blue' | 'yellow' | 'green' | 'red' | 'purple' | 'default';
|
||||
|
||||
export interface TodayKpiDefinition {
|
||||
key: TodayWidgetKey;
|
||||
titleKey: string;
|
||||
icon: LucideIcon;
|
||||
color: KpiCardColor;
|
||||
orgTypes: OrgTypeName[];
|
||||
href: string;
|
||||
isVisible: (org: Organization | null) => boolean;
|
||||
formatValue: (widgets: TodaySummaryWidgets) => string | null;
|
||||
formatSubtitle?: (widgets: TodaySummaryWidgets) => string | null;
|
||||
}
|
||||
|
||||
function countWidget(
|
||||
widgets: TodaySummaryWidgets,
|
||||
key: TodayWidgetKey,
|
||||
): number | null {
|
||||
const value = widgets[key];
|
||||
if (!value || !('count' in value)) return null;
|
||||
return value.count;
|
||||
}
|
||||
|
||||
function canManageOrganizations(org: Organization | null): boolean {
|
||||
if (!org) return false;
|
||||
if (org.isOwner) return true;
|
||||
return hasPermission(org, 'TAB_ORGANIZATIONS_EDIT');
|
||||
}
|
||||
|
||||
function canViewPatients(org: Organization | null): boolean {
|
||||
if (!org) return false;
|
||||
return (
|
||||
hasPermission(org, 'TAB_PATIENTS_READ') ||
|
||||
hasPermission(org, 'TAB_PATIENTS_EDIT')
|
||||
);
|
||||
}
|
||||
|
||||
export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
||||
{
|
||||
key: 'appointmentsToday',
|
||||
titleKey: 'widgetAppointmentsToday',
|
||||
icon: CalendarDays,
|
||||
color: 'blue',
|
||||
orgTypes: ['CLINIC'],
|
||||
href: '/appointments',
|
||||
isVisible: (org) => canAccessAppointmentsSection(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'appointmentsToday');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'patientsToday',
|
||||
titleKey: 'widgetPatientsToday',
|
||||
icon: Users,
|
||||
color: 'green',
|
||||
orgTypes: ['CLINIC'],
|
||||
href: '/patients',
|
||||
isVisible: (org) => canViewPatients(org) || canAccessAppointmentsSection(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'patientsToday');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'treatmentsToday',
|
||||
titleKey: 'widgetTreatmentsToday',
|
||||
icon: Stethoscope,
|
||||
color: 'purple',
|
||||
orgTypes: ['CLINIC'],
|
||||
href: '/treatment',
|
||||
isVisible: (org) => canViewTreatment(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'treatmentsToday');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'draftTreatments',
|
||||
titleKey: 'widgetDraftTreatments',
|
||||
icon: ClipboardList,
|
||||
color: 'yellow',
|
||||
orgTypes: ['CLINIC'],
|
||||
href: '/treatment',
|
||||
isVisible: (org) => canViewTreatment(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'draftTreatments');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'labCasesPendingSend',
|
||||
titleKey: 'widgetLabCasesPendingSend',
|
||||
icon: FlaskConical,
|
||||
color: 'red',
|
||||
orgTypes: ['CLINIC'],
|
||||
href: '/treatment',
|
||||
isVisible: (org) => canViewTreatment(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'labCasesPendingSend');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'providersWithoutWorkingHours',
|
||||
titleKey: 'widgetProvidersWithoutWorkingHours',
|
||||
icon: UserCog,
|
||||
color: 'yellow',
|
||||
orgTypes: ['CLINIC'],
|
||||
href: '/staff',
|
||||
isVisible: (org) => canViewStaff(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'providersWithoutWorkingHours');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'casesReceivedToday',
|
||||
titleKey: 'widgetCasesReceivedToday',
|
||||
icon: FlaskConical,
|
||||
color: 'blue',
|
||||
orgTypes: ['LAB'],
|
||||
href: '/cases',
|
||||
isVisible: (org) => canViewCases(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'casesReceivedToday');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'casesInProgress',
|
||||
titleKey: 'widgetCasesInProgress',
|
||||
icon: FlaskConical,
|
||||
color: 'yellow',
|
||||
orgTypes: ['LAB'],
|
||||
href: '/cases',
|
||||
isVisible: (org) => canViewCases(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'casesInProgress');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'tasksInProgress',
|
||||
titleKey: 'widgetTasksInProgress',
|
||||
icon: ClipboardList,
|
||||
color: 'yellow',
|
||||
orgTypes: ['LAB'],
|
||||
href: '/tasks',
|
||||
isVisible: (org) => canViewTasks(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'tasksInProgress');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'importantTasks',
|
||||
titleKey: 'widgetImportantTasks',
|
||||
icon: AlertCircle,
|
||||
color: 'red',
|
||||
orgTypes: ['LAB'],
|
||||
href: '/tasks',
|
||||
isVisible: (org) => canViewTasks(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'importantTasks');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'pendingConnections',
|
||||
titleKey: 'widgetPendingConnections',
|
||||
icon: Link2,
|
||||
color: 'yellow',
|
||||
orgTypes: ['CLINIC', 'LAB'],
|
||||
href: '/organizations',
|
||||
isVisible: (org) => canManageOrganizations(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'pendingConnections');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'seats',
|
||||
titleKey: 'widgetSeats',
|
||||
icon: UserCog,
|
||||
color: 'default',
|
||||
orgTypes: ['CLINIC', 'LAB'],
|
||||
href: '/staff',
|
||||
isVisible: (org) => canViewStaff(org),
|
||||
formatValue: (widgets) => {
|
||||
const seats = widgets.seats;
|
||||
if (!seats || !('used' in seats)) return null;
|
||||
if (seats.unlimited) return String(seats.used);
|
||||
return `${seats.used}/${seats.limit ?? 0}`;
|
||||
},
|
||||
formatSubtitle: (widgets) => {
|
||||
const seats = widgets.seats;
|
||||
if (!seats || !('used' in seats)) return null;
|
||||
return seats.unlimited ? 'unlimited' : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'pendingStaffInvites',
|
||||
titleKey: 'widgetPendingStaffInvites',
|
||||
icon: UserCog,
|
||||
color: 'purple',
|
||||
orgTypes: ['CLINIC', 'LAB'],
|
||||
href: '/staff',
|
||||
isVisible: (org) => canEditStaff(org) || Boolean(org?.isOwner),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'pendingStaffInvites');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function getEligibleTodayKpis(org: Organization | null): TodayKpiDefinition[] {
|
||||
if (!org) return [];
|
||||
|
||||
return TODAY_KPI_DEFINITIONS.filter((definition) => {
|
||||
if (!definition.orgTypes.includes(org.type)) return false;
|
||||
return definition.isVisible(org);
|
||||
});
|
||||
}
|
||||
|
||||
export function getVisibleTodayKpis(
|
||||
org: Organization | null,
|
||||
widgets: TodaySummaryWidgets,
|
||||
): TodayKpiDefinition[] {
|
||||
return getEligibleTodayKpis(org).filter(
|
||||
(definition) => definition.formatValue(widgets) !== null,
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import React, { forwardRef, useId } from 'react';
|
||||
import React, { forwardRef, useId, useState } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
icon?: React.ReactNode;
|
||||
endIcon?: React.ReactNode;
|
||||
passwordToggleLabels?: {
|
||||
show: string;
|
||||
hide: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, icon, className = '', id, ...props }, ref) => {
|
||||
(
|
||||
{
|
||||
label,
|
||||
error,
|
||||
icon,
|
||||
endIcon,
|
||||
passwordToggleLabels,
|
||||
className = '',
|
||||
id,
|
||||
type,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const genId = useId();
|
||||
const inputId = id ?? genId;
|
||||
|
||||
const resolvedEndIcon =
|
||||
endIcon ??
|
||||
(passwordToggleLabels ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((visible) => !visible)}
|
||||
className="rounded p-0.5 text-text-muted transition-colors hover:text-text-secondary"
|
||||
aria-label={
|
||||
showPassword ? passwordToggleLabels.hide : passwordToggleLabels.show
|
||||
}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 icon-flat" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 icon-flat" />
|
||||
)}
|
||||
</button>
|
||||
) : undefined);
|
||||
|
||||
const resolvedType = passwordToggleLabels
|
||||
? showPassword
|
||||
? 'text'
|
||||
: 'password'
|
||||
: type;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
@@ -31,15 +76,22 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resolvedEndIcon && (
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center text-text-muted">
|
||||
{resolvedEndIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
type={resolvedType}
|
||||
className={`
|
||||
w-full rounded-[var(--radius-md)] border
|
||||
${error ? 'border-red-500' : 'border-border'}
|
||||
bg-background-secondary/90 text-text-primary
|
||||
|
||||
${icon ? 'pl-10' : 'pl-4'} pr-4 py-2
|
||||
${icon ? 'pl-10' : 'pl-4'} ${resolvedEndIcon ? 'pr-10' : 'pr-4'} py-2
|
||||
|
||||
placeholder:text-text-muted
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
|
||||
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
||||
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
|
||||
@@ -254,10 +255,16 @@ function detailsToPreviewTreatment(
|
||||
interface TreatmentWorkspaceProps {
|
||||
userId: string;
|
||||
currentOrganization: Organization | null;
|
||||
initialAppointmentId?: string | null;
|
||||
}
|
||||
|
||||
export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWorkspaceProps) {
|
||||
export function TreatmentWorkspace({
|
||||
userId,
|
||||
currentOrganization,
|
||||
initialAppointmentId = null,
|
||||
}: TreatmentWorkspaceProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const router = useRouter();
|
||||
const { showError, showSuccess, messages: toastMessages } = useToast();
|
||||
const canView = canViewTreatment(currentOrganization);
|
||||
const canEdit = canEditTreatment(currentOrganization);
|
||||
@@ -308,6 +315,15 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
const labCaseDraftsRef = useRef(labCaseDrafts);
|
||||
labCaseDraftsRef.current = labCaseDrafts;
|
||||
const skipNextGetDraftRef = useRef(false);
|
||||
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
|
||||
|
||||
useEffect(() => {
|
||||
pendingAppointmentIdRef.current = initialAppointmentId;
|
||||
if (initialAppointmentId) {
|
||||
setSelectedDay(startOfLocalDay(new Date()));
|
||||
setSelectionLocked(false);
|
||||
}
|
||||
}, [initialAppointmentId]);
|
||||
|
||||
const [sendBusyId, setSendBusyId] = useState<string | null>(null);
|
||||
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
|
||||
@@ -464,7 +480,16 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
.map(mapAppointment);
|
||||
setAppointments(list);
|
||||
if (!selectionLockedRef.current) {
|
||||
setSelectedAppointmentId(pickAutoAppointment(list, selectedDay));
|
||||
const pendingId = pendingAppointmentIdRef.current;
|
||||
if (pendingId && list.some((appointment) => appointment.id === pendingId)) {
|
||||
setSelectedAppointmentId(pendingId);
|
||||
setSelectionLocked(true);
|
||||
pendingAppointmentIdRef.current = null;
|
||||
router.replace('/treatment', { scroll: false });
|
||||
} else {
|
||||
pendingAppointmentIdRef.current = null;
|
||||
setSelectedAppointmentId(pickAutoAppointment(list, selectedDay));
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
@@ -477,7 +502,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId, selectedDay, showError, t]);
|
||||
}, [userId, selectedDay, showError, t, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const today = startOfLocalDay(new Date());
|
||||
|
||||
83
frontend/src/components/ui/treatment/catalog-type-colors.ts
Normal file
83
frontend/src/components/ui/treatment/catalog-type-colors.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Shared pastel palette for treatment types, prosthesis types, and dashboard charts.
|
||||
* Treatment types own the canonical hex values; prosthesis types reuse the same codes.
|
||||
*/
|
||||
|
||||
export const TREATMENT_TYPE_COLORS: Record<string, string> = {
|
||||
restoration: '#fed7aa',
|
||||
specialized_restoration: '#fdba74',
|
||||
radiography: '#cbd5e1',
|
||||
endo: '#fecaca',
|
||||
surgery: '#fca5a5',
|
||||
prosthesis: '#c4b5fd',
|
||||
implant: '#a5b4fc',
|
||||
orthodontics: '#93c5fd',
|
||||
perio: '#86efac',
|
||||
pediatrics: '#fde68a',
|
||||
extraction: '#f9a8d4',
|
||||
clinic_visit: '#bae6fd',
|
||||
continue_treatment: '#99f6e4',
|
||||
};
|
||||
|
||||
/** Prosthesis codes mapped to treatment-palette hex values (mapping is arbitrary). */
|
||||
export const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
|
||||
pfm_crown: '#cbd5e1',
|
||||
pfz_crown: '#86efac',
|
||||
monolithic_zirconia: '#99f6e4',
|
||||
glass_ceramic_crown: '#fde68a',
|
||||
full_metal_crown: '#cbd5e1',
|
||||
temporary_resin_crown: '#bae6fd',
|
||||
pmma: '#93c5fd',
|
||||
peek_crown: '#99f6e4',
|
||||
veneer_zirconia: '#86efac',
|
||||
veneer_ips_press: '#fed7aa',
|
||||
veneer_ips_cad: '#fdba74',
|
||||
soft_structure: '#ddd6fe',
|
||||
customized_abutment: '#a5b4fc',
|
||||
prefabricated_abutment: '#93c5fd',
|
||||
ti_base_abutment: '#bae6fd',
|
||||
multi_unit_abutment: '#a5b4fc',
|
||||
zirconia_abutment: '#86efac',
|
||||
screw_retained: '#c4b5fd',
|
||||
zirconia_overlay: '#99f6e4',
|
||||
ips_overlay: '#fde68a',
|
||||
smile_design: '#f9a8d4',
|
||||
mockup: '#fbcfe8',
|
||||
};
|
||||
|
||||
export const CATALOG_FALLBACK_COLORS = [
|
||||
'#ddd6fe',
|
||||
'#fed7aa',
|
||||
'#fecaca',
|
||||
'#bae6fd',
|
||||
'#d9f99d',
|
||||
'#fbcfe8',
|
||||
] as const;
|
||||
|
||||
/** Ordered palette for charts and rotating unknown catalog codes. */
|
||||
export const CATALOG_PALETTE_COLORS: readonly string[] = [
|
||||
'#fed7aa',
|
||||
'#fdba74',
|
||||
'#cbd5e1',
|
||||
'#fecaca',
|
||||
'#fca5a5',
|
||||
'#c4b5fd',
|
||||
'#a5b4fc',
|
||||
'#93c5fd',
|
||||
'#86efac',
|
||||
'#fde68a',
|
||||
'#f9a8d4',
|
||||
'#bae6fd',
|
||||
'#99f6e4',
|
||||
'#ddd6fe',
|
||||
'#d9f99d',
|
||||
'#fbcfe8',
|
||||
];
|
||||
|
||||
export function resolveCatalogTypeColor(
|
||||
code: string,
|
||||
colorMap: Record<string, string>,
|
||||
index = 0,
|
||||
): string {
|
||||
return colorMap[code] ?? CATALOG_FALLBACK_COLORS[index % CATALOG_FALLBACK_COLORS.length];
|
||||
}
|
||||
@@ -1,56 +1,21 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import {
|
||||
PROSTHESIS_TYPE_COLORS,
|
||||
resolveCatalogTypeColor,
|
||||
} from '@/components/ui/treatment/catalog-type-colors';
|
||||
|
||||
/**
|
||||
* Prosthesis-type colors for lab-facing surfaces (Tasks list, Cases detail group
|
||||
* headers / badges). Grouped by material family, loosely inspired by exocad's
|
||||
* material color conventions:
|
||||
* - Zirconia family → pale green/cream
|
||||
* - PFM / full metal → steel gray
|
||||
* - Glass-ceramic / IPS (press & CAD) → warm amber
|
||||
* - Resin / PMMA / PEEK / temporary → mint/teal
|
||||
* - Abutments / screw-retained → slate blue
|
||||
* - Smile design / mockup → lavender/pink
|
||||
* headers / badges). Uses the same hex palette as treatment types.
|
||||
*
|
||||
* Clinic-facing dispatch flows intentionally do NOT use these colors.
|
||||
*/
|
||||
const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
|
||||
// Zirconia family
|
||||
monolithic_zirconia: '#d9f2e6',
|
||||
pfz_crown: '#c7ede0',
|
||||
veneer_zirconia: '#b8e6d5',
|
||||
zirconia_abutment: '#a7dcc8',
|
||||
zirconia_overlay: '#cdeede',
|
||||
// PFM / metal
|
||||
pfm_crown: '#cbd5e1',
|
||||
full_metal_crown: '#b8c2cf',
|
||||
// Glass-ceramic / IPS
|
||||
glass_ceramic_crown: '#fde3a7',
|
||||
veneer_ips_press: '#fcd88f',
|
||||
veneer_ips_cad: '#f9cf9c',
|
||||
ips_overlay: '#fbe0b0',
|
||||
// Resin / PMMA / PEEK / temporary
|
||||
temporary_resin_crown: '#bfeaf0',
|
||||
pmma: '#a9e2ea',
|
||||
peek_crown: '#b7e4dd',
|
||||
soft_structure: '#d4eef0',
|
||||
// Abutments / screw-retained
|
||||
customized_abutment: '#aec6e8',
|
||||
prefabricated_abutment: '#9db8e0',
|
||||
ti_base_abutment: '#c0d0ec',
|
||||
multi_unit_abutment: '#b4c4e6',
|
||||
screw_retained: '#a8bce2',
|
||||
// Design / mockup
|
||||
smile_design: '#e9d5ff',
|
||||
mockup: '#f5d0fe',
|
||||
};
|
||||
|
||||
const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d', '#fbcfe8'];
|
||||
|
||||
/** Dark ink that stays readable on every pastel in the palette. */
|
||||
const BADGE_INK = '#14253d';
|
||||
|
||||
export function prosthesisTypeColor(code: string, index = 0): string {
|
||||
return PROSTHESIS_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
|
||||
return resolveCatalogTypeColor(code, PROSTHESIS_TYPE_COLORS, index);
|
||||
}
|
||||
|
||||
/** Filled swatch (small indicator dots). */
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import {
|
||||
resolveCatalogTypeColor,
|
||||
TREATMENT_TYPE_COLORS,
|
||||
} from '@/components/ui/treatment/catalog-type-colors';
|
||||
|
||||
/**
|
||||
* Single source of truth for treatment-type colors across the app
|
||||
@@ -10,23 +14,6 @@ import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
* conventions), so this is a curated pastel palette. Extend it as new treatment
|
||||
* types are added; unknown codes fall back to a rotating pastel set by index.
|
||||
*/
|
||||
const TREATMENT_TYPE_COLORS: Record<string, string> = {
|
||||
restoration: '#fed7aa',
|
||||
specialized_restoration: '#fdba74',
|
||||
radiography: '#cbd5e1',
|
||||
endo: '#fecaca',
|
||||
surgery: '#fca5a5',
|
||||
prosthesis: '#c4b5fd',
|
||||
implant: '#a5b4fc',
|
||||
orthodontics: '#93c5fd',
|
||||
perio: '#86efac',
|
||||
pediatrics: '#fde68a',
|
||||
extraction: '#f9a8d4',
|
||||
clinic_visit: '#bae6fd',
|
||||
continue_treatment: '#99f6e4',
|
||||
};
|
||||
|
||||
const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d', '#fbcfe8'];
|
||||
|
||||
/** Dark ink that stays readable on every pastel in the palette. */
|
||||
const BANNER_INK = '#14253d';
|
||||
@@ -34,7 +21,7 @@ const BANNER_INK = '#14253d';
|
||||
export const DROPDOWN_OPTION_BG = '#14253d';
|
||||
|
||||
export function treatmentTypeColor(code: string, index = 0): string {
|
||||
return TREATMENT_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
|
||||
return resolveCatalogTypeColor(code, TREATMENT_TYPE_COLORS, index);
|
||||
}
|
||||
|
||||
/** Filled swatch (legend dots, small indicators). */
|
||||
|
||||
15
frontend/src/lib/api/today.ts
Normal file
15
frontend/src/lib/api/today.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { apiClient } from './client';
|
||||
import type { TodaySummaryResponse } from '@/types/today';
|
||||
|
||||
export interface TodaySummaryParams {
|
||||
from: string;
|
||||
to: string;
|
||||
utcOffsetMinutes?: number;
|
||||
}
|
||||
|
||||
export const todayApi = {
|
||||
summary: async (params: TodaySummaryParams): Promise<TodaySummaryResponse> => {
|
||||
const response = await apiClient.get('/today/summary', { params });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
64
frontend/src/lib/hooks/useTodaySummary.ts
Normal file
64
frontend/src/lib/hooks/useTodaySummary.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getLocalDayIsoRange } from '@/components/appointments/appointmentTime';
|
||||
import { todayApi } from '@/lib/api/today';
|
||||
import type { TodaySummaryData } from '@/types/today';
|
||||
import type { ApiError } from '@/types/api';
|
||||
|
||||
interface UseTodaySummaryResult {
|
||||
data: TodaySummaryData | null;
|
||||
loading: boolean;
|
||||
isInitialLoad: boolean;
|
||||
error: ApiError | null;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useTodaySummary(organizationId?: string | null): UseTodaySummaryResult {
|
||||
const enabled = Boolean(organizationId);
|
||||
const [data, setData] = useState<TodaySummaryData | null>(null);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!organizationId) {
|
||||
setData(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const range = getLocalDayIsoRange(new Date());
|
||||
const utcOffsetMinutes = -new Date().getTimezoneOffset();
|
||||
const response = await todayApi.summary({ ...range, utcOffsetMinutes });
|
||||
setData(response.data);
|
||||
} catch (err) {
|
||||
setError(err as ApiError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [organizationId]);
|
||||
|
||||
useEffect(() => {
|
||||
setData(null);
|
||||
setError(null);
|
||||
if (!organizationId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
void reload();
|
||||
}, [organizationId, reload]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
isInitialLoad: loading && !data,
|
||||
error,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
76
frontend/src/types/today.ts
Normal file
76
frontend/src/types/today.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export type TodayUpcomingAppointment = {
|
||||
id: string;
|
||||
patientName: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
purpose: string;
|
||||
};
|
||||
|
||||
export type TodaySummaryActions = {
|
||||
upcomingAppointmentsToday?: TodayUpcomingAppointment[];
|
||||
};
|
||||
|
||||
export type TodayChartBucket = {
|
||||
code: string;
|
||||
label: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type TodayStackedDayBucket = {
|
||||
code: string;
|
||||
label: string;
|
||||
completed: number;
|
||||
received: number;
|
||||
};
|
||||
|
||||
export type TodaySummaryCharts = {
|
||||
treatmentMixWeek?: TodayChartBucket[];
|
||||
tasksByWorkflowStep?: TodayChartBucket[];
|
||||
appointmentsByProvider?: TodayChartBucket[];
|
||||
caseCompletion?: {
|
||||
completed: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
};
|
||||
appointmentsWeekAll?: TodayChartBucket[];
|
||||
appointmentsWeekMine?: TodayChartBucket[];
|
||||
labTaskActivityWeek?: TodayStackedDayBucket[];
|
||||
inProgressTasksByProsthesis?: TodayChartBucket[];
|
||||
};
|
||||
|
||||
export type TodayWidgetKey =
|
||||
| 'appointmentsToday'
|
||||
| 'patientsToday'
|
||||
| 'treatmentsToday'
|
||||
| 'draftTreatments'
|
||||
| 'labCasesPendingSend'
|
||||
| 'casesReceivedToday'
|
||||
| 'casesInProgress'
|
||||
| 'tasksInProgress'
|
||||
| 'importantTasks'
|
||||
| 'pendingConnections'
|
||||
| 'seats'
|
||||
| 'pendingStaffInvites'
|
||||
| 'providersWithoutWorkingHours';
|
||||
|
||||
export type TodaySummaryWidgets = Partial<
|
||||
Record<
|
||||
TodayWidgetKey,
|
||||
| { count: number }
|
||||
| { used: number; limit: number | null; unlimited: boolean }
|
||||
>
|
||||
>;
|
||||
|
||||
export interface TodaySummaryData {
|
||||
generatedAt: string;
|
||||
orgType: 'CLINIC' | 'LAB';
|
||||
range: { from: string; to: string };
|
||||
widgets: TodaySummaryWidgets;
|
||||
charts: TodaySummaryCharts;
|
||||
actions: TodaySummaryActions;
|
||||
}
|
||||
|
||||
export interface TodaySummaryResponse {
|
||||
success: boolean;
|
||||
data: TodaySummaryData;
|
||||
}
|
||||
Reference in New Issue
Block a user