34
apps/api/src/widgets/dto/calendar-preview-query.dto.ts
Normal file
34
apps/api/src/widgets/dto/calendar-preview-query.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsIn,
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for querying calendar preview widget data
|
||||||
|
*/
|
||||||
|
export class CalendarPreviewQueryDto {
|
||||||
|
@IsString({ message: "view must be a string" })
|
||||||
|
@IsIn(["day", "week", "agenda"], {
|
||||||
|
message: "view must be one of: day, week, agenda",
|
||||||
|
})
|
||||||
|
view!: "day" | "week" | "agenda";
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean({ message: "showTasks must be a boolean" })
|
||||||
|
showTasks?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean({ message: "showEvents must be a boolean" })
|
||||||
|
showEvents?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber({}, { message: "daysAhead must be a number" })
|
||||||
|
@Min(1, { message: "daysAhead must be at least 1" })
|
||||||
|
@Max(30, { message: "daysAhead must not exceed 30" })
|
||||||
|
daysAhead?: number;
|
||||||
|
}
|
||||||
38
apps/api/src/widgets/dto/chart-query.dto.ts
Normal file
38
apps/api/src/widgets/dto/chart-query.dto.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsIn,
|
||||||
|
IsObject,
|
||||||
|
IsArray,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for querying chart widget data
|
||||||
|
*/
|
||||||
|
export class ChartQueryDto {
|
||||||
|
@IsString({ message: "chartType must be a string" })
|
||||||
|
@IsIn(["bar", "line", "pie", "donut"], {
|
||||||
|
message: "chartType must be one of: bar, line, pie, donut",
|
||||||
|
})
|
||||||
|
chartType!: "bar" | "line" | "pie" | "donut";
|
||||||
|
|
||||||
|
@IsString({ message: "dataSource must be a string" })
|
||||||
|
@IsIn(["tasks", "events", "projects"], {
|
||||||
|
message: "dataSource must be one of: tasks, events, projects",
|
||||||
|
})
|
||||||
|
dataSource!: "tasks" | "events" | "projects";
|
||||||
|
|
||||||
|
@IsString({ message: "groupBy must be a string" })
|
||||||
|
@IsIn(["status", "priority", "project", "day", "week", "month"], {
|
||||||
|
message: "groupBy must be one of: status, priority, project, day, week, month",
|
||||||
|
})
|
||||||
|
groupBy!: "status" | "priority" | "project" | "day" | "week" | "month";
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject({ message: "filter must be an object" })
|
||||||
|
filter?: Record<string, unknown>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: "colors must be an array" })
|
||||||
|
colors?: string[];
|
||||||
|
}
|
||||||
46
apps/api/src/widgets/dto/create-widget-config.dto.ts
Normal file
46
apps/api/src/widgets/dto/create-widget-config.dto.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsNumber,
|
||||||
|
IsObject,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for creating a widget configuration in a layout
|
||||||
|
*/
|
||||||
|
export class CreateWidgetConfigDto {
|
||||||
|
@IsString({ message: "widgetType must be a string" })
|
||||||
|
@MinLength(1, { message: "widgetType must not be empty" })
|
||||||
|
widgetType!: string;
|
||||||
|
|
||||||
|
@IsNumber({}, { message: "x must be a number" })
|
||||||
|
@Min(0, { message: "x must be at least 0" })
|
||||||
|
x!: number;
|
||||||
|
|
||||||
|
@IsNumber({}, { message: "y must be a number" })
|
||||||
|
@Min(0, { message: "y must be at least 0" })
|
||||||
|
y!: number;
|
||||||
|
|
||||||
|
@IsNumber({}, { message: "w must be a number" })
|
||||||
|
@Min(1, { message: "w must be at least 1" })
|
||||||
|
@Max(12, { message: "w must not exceed 12" })
|
||||||
|
w!: number;
|
||||||
|
|
||||||
|
@IsNumber({}, { message: "h must be a number" })
|
||||||
|
@Min(1, { message: "h must be at least 1" })
|
||||||
|
@Max(12, { message: "h must not exceed 12" })
|
||||||
|
h!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "title must be a string" })
|
||||||
|
@MaxLength(100, { message: "title must not exceed 100 characters" })
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject({ message: "config must be an object" })
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}
|
||||||
10
apps/api/src/widgets/dto/index.ts
Normal file
10
apps/api/src/widgets/dto/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Widget DTOs
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { StatCardQueryDto } from "./stat-card-query.dto";
|
||||||
|
export { ChartQueryDto } from "./chart-query.dto";
|
||||||
|
export { ListQueryDto } from "./list-query.dto";
|
||||||
|
export { CalendarPreviewQueryDto } from "./calendar-preview-query.dto";
|
||||||
|
export { CreateWidgetConfigDto } from "./create-widget-config.dto";
|
||||||
|
export { UpdateWidgetConfigDto } from "./update-widget-config.dto";
|
||||||
48
apps/api/src/widgets/dto/list-query.dto.ts
Normal file
48
apps/api/src/widgets/dto/list-query.dto.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsIn,
|
||||||
|
IsObject,
|
||||||
|
IsNumber,
|
||||||
|
IsBoolean,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for querying list widget data
|
||||||
|
*/
|
||||||
|
export class ListQueryDto {
|
||||||
|
@IsString({ message: "dataSource must be a string" })
|
||||||
|
@IsIn(["tasks", "events", "projects"], {
|
||||||
|
message: "dataSource must be one of: tasks, events, projects",
|
||||||
|
})
|
||||||
|
dataSource!: "tasks" | "events" | "projects";
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "sortBy must be a string" })
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "sortOrder must be a string" })
|
||||||
|
@IsIn(["asc", "desc"], { message: "sortOrder must be asc or desc" })
|
||||||
|
sortOrder?: "asc" | "desc";
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber({}, { message: "limit must be a number" })
|
||||||
|
@Min(1, { message: "limit must be at least 1" })
|
||||||
|
@Max(50, { message: "limit must not exceed 50" })
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject({ message: "filter must be an object" })
|
||||||
|
filter?: Record<string, unknown>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean({ message: "showStatus must be a boolean" })
|
||||||
|
showStatus?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean({ message: "showDueDate must be a boolean" })
|
||||||
|
showDueDate?: boolean;
|
||||||
|
}
|
||||||
27
apps/api/src/widgets/dto/stat-card-query.dto.ts
Normal file
27
apps/api/src/widgets/dto/stat-card-query.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsIn,
|
||||||
|
IsObject,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for querying stat card widget data
|
||||||
|
*/
|
||||||
|
export class StatCardQueryDto {
|
||||||
|
@IsString({ message: "dataSource must be a string" })
|
||||||
|
@IsIn(["tasks", "events", "projects"], {
|
||||||
|
message: "dataSource must be one of: tasks, events, projects",
|
||||||
|
})
|
||||||
|
dataSource!: "tasks" | "events" | "projects";
|
||||||
|
|
||||||
|
@IsString({ message: "metric must be a string" })
|
||||||
|
@IsIn(["count", "completed", "overdue", "upcoming"], {
|
||||||
|
message: "metric must be one of: count, completed, overdue, upcoming",
|
||||||
|
})
|
||||||
|
metric!: "count" | "completed" | "overdue" | "upcoming";
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject({ message: "filter must be an object" })
|
||||||
|
filter?: Record<string, unknown>;
|
||||||
|
}
|
||||||
7
apps/api/src/widgets/dto/update-widget-config.dto.ts
Normal file
7
apps/api/src/widgets/dto/update-widget-config.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { PartialType } from "@nestjs/mapped-types";
|
||||||
|
import { CreateWidgetConfigDto } from "./create-widget-config.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for updating a widget configuration
|
||||||
|
*/
|
||||||
|
export class UpdateWidgetConfigDto extends PartialType(CreateWidgetConfigDto) {}
|
||||||
571
apps/api/src/widgets/widget-data.service.ts
Normal file
571
apps/api/src/widgets/widget-data.service.ts
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { TaskStatus, TaskPriority, ProjectStatus } from "@prisma/client";
|
||||||
|
import type {
|
||||||
|
StatCardQueryDto,
|
||||||
|
ChartQueryDto,
|
||||||
|
ListQueryDto,
|
||||||
|
CalendarPreviewQueryDto,
|
||||||
|
} from "./dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget data response types
|
||||||
|
*/
|
||||||
|
export interface WidgetStatData {
|
||||||
|
value: number;
|
||||||
|
change?: number;
|
||||||
|
changePercent?: number;
|
||||||
|
previousValue?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetChartData {
|
||||||
|
labels: string[];
|
||||||
|
datasets: {
|
||||||
|
label: string;
|
||||||
|
data: number[];
|
||||||
|
backgroundColor?: string[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetListItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
status?: string;
|
||||||
|
priority?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
startTime?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetCalendarItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime?: string;
|
||||||
|
allDay?: boolean;
|
||||||
|
type: "task" | "event";
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for fetching widget data from various sources
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class WidgetDataService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stat card data based on configuration
|
||||||
|
*/
|
||||||
|
async getStatCardData(
|
||||||
|
workspaceId: string,
|
||||||
|
query: StatCardQueryDto
|
||||||
|
): Promise<WidgetStatData> {
|
||||||
|
const { dataSource, metric, filter } = query;
|
||||||
|
|
||||||
|
switch (dataSource) {
|
||||||
|
case "tasks":
|
||||||
|
return this.getTaskStatData(workspaceId, metric, filter);
|
||||||
|
case "events":
|
||||||
|
return this.getEventStatData(workspaceId, metric, filter);
|
||||||
|
case "projects":
|
||||||
|
return this.getProjectStatData(workspaceId, metric, filter);
|
||||||
|
default:
|
||||||
|
return { value: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chart data based on configuration
|
||||||
|
*/
|
||||||
|
async getChartData(
|
||||||
|
workspaceId: string,
|
||||||
|
query: ChartQueryDto
|
||||||
|
): Promise<WidgetChartData> {
|
||||||
|
const { dataSource, groupBy, filter, colors } = query;
|
||||||
|
|
||||||
|
switch (dataSource) {
|
||||||
|
case "tasks":
|
||||||
|
return this.getTaskChartData(workspaceId, groupBy, filter, colors);
|
||||||
|
case "events":
|
||||||
|
return this.getEventChartData(workspaceId, groupBy, filter, colors);
|
||||||
|
case "projects":
|
||||||
|
return this.getProjectChartData(workspaceId, groupBy, filter, colors);
|
||||||
|
default:
|
||||||
|
return { labels: [], datasets: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list data based on configuration
|
||||||
|
*/
|
||||||
|
async getListData(
|
||||||
|
workspaceId: string,
|
||||||
|
query: ListQueryDto
|
||||||
|
): Promise<WidgetListItem[]> {
|
||||||
|
const { dataSource, sortBy, sortOrder, limit, filter } = query;
|
||||||
|
|
||||||
|
switch (dataSource) {
|
||||||
|
case "tasks":
|
||||||
|
return this.getTaskListData(workspaceId, sortBy, sortOrder, limit, filter);
|
||||||
|
case "events":
|
||||||
|
return this.getEventListData(workspaceId, sortBy, sortOrder, limit, filter);
|
||||||
|
case "projects":
|
||||||
|
return this.getProjectListData(workspaceId, sortBy, sortOrder, limit, filter);
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get calendar preview data
|
||||||
|
*/
|
||||||
|
async getCalendarPreviewData(
|
||||||
|
workspaceId: string,
|
||||||
|
query: CalendarPreviewQueryDto
|
||||||
|
): Promise<WidgetCalendarItem[]> {
|
||||||
|
const { showTasks = true, showEvents = true, daysAhead = 7 } = query;
|
||||||
|
const items: WidgetCalendarItem[] = [];
|
||||||
|
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
const endDate = new Date(startDate);
|
||||||
|
endDate.setDate(endDate.getDate() + daysAhead);
|
||||||
|
|
||||||
|
if (showEvents) {
|
||||||
|
const events = await this.prisma.event.findMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
startTime: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: { color: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { startTime: "asc" },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
...events.map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
startTime: event.startTime.toISOString(),
|
||||||
|
endTime: event.endTime?.toISOString(),
|
||||||
|
allDay: event.allDay,
|
||||||
|
type: "event" as const,
|
||||||
|
color: event.project?.color || "#3B82F6",
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showTasks) {
|
||||||
|
const tasks = await this.prisma.task.findMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
dueDate: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
not: TaskStatus.COMPLETED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: { color: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { dueDate: "asc" },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
...tasks.map((task) => ({
|
||||||
|
id: task.id,
|
||||||
|
title: task.title,
|
||||||
|
startTime: task.dueDate!.toISOString(),
|
||||||
|
allDay: true,
|
||||||
|
type: "task" as const,
|
||||||
|
color: task.project?.color || "#10B981",
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by start time
|
||||||
|
items.sort(
|
||||||
|
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper methods
|
||||||
|
|
||||||
|
private async getTaskStatData(
|
||||||
|
workspaceId: string,
|
||||||
|
metric: string,
|
||||||
|
filter?: Record<string, unknown>
|
||||||
|
): Promise<WidgetStatData> {
|
||||||
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
||||||
|
|
||||||
|
switch (metric) {
|
||||||
|
case "count":
|
||||||
|
const count = await this.prisma.task.count({ where });
|
||||||
|
return { value: count };
|
||||||
|
|
||||||
|
case "completed":
|
||||||
|
const completed = await this.prisma.task.count({
|
||||||
|
where: { ...where, status: TaskStatus.COMPLETED },
|
||||||
|
});
|
||||||
|
return { value: completed };
|
||||||
|
|
||||||
|
case "overdue":
|
||||||
|
const overdue = await this.prisma.task.count({
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
status: { not: TaskStatus.COMPLETED },
|
||||||
|
dueDate: { lt: new Date() },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { value: overdue };
|
||||||
|
|
||||||
|
case "upcoming":
|
||||||
|
const nextWeek = new Date();
|
||||||
|
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||||
|
const upcoming = await this.prisma.task.count({
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
status: { not: TaskStatus.COMPLETED },
|
||||||
|
dueDate: { gte: new Date(), lte: nextWeek },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { value: upcoming };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { value: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getEventStatData(
|
||||||
|
workspaceId: string,
|
||||||
|
metric: string,
|
||||||
|
filter?: Record<string, unknown>
|
||||||
|
): Promise<WidgetStatData> {
|
||||||
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
||||||
|
|
||||||
|
switch (metric) {
|
||||||
|
case "count":
|
||||||
|
const count = await this.prisma.event.count({ where });
|
||||||
|
return { value: count };
|
||||||
|
|
||||||
|
case "upcoming":
|
||||||
|
const nextWeek = new Date();
|
||||||
|
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||||
|
const upcoming = await this.prisma.event.count({
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
startTime: { gte: new Date(), lte: nextWeek },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { value: upcoming };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { value: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getProjectStatData(
|
||||||
|
workspaceId: string,
|
||||||
|
metric: string,
|
||||||
|
filter?: Record<string, unknown>
|
||||||
|
): Promise<WidgetStatData> {
|
||||||
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
||||||
|
|
||||||
|
switch (metric) {
|
||||||
|
case "count":
|
||||||
|
const count = await this.prisma.project.count({ where });
|
||||||
|
return { value: count };
|
||||||
|
|
||||||
|
case "completed":
|
||||||
|
const completed = await this.prisma.project.count({
|
||||||
|
where: { ...where, status: ProjectStatus.COMPLETED },
|
||||||
|
});
|
||||||
|
return { value: completed };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { value: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTaskChartData(
|
||||||
|
workspaceId: string,
|
||||||
|
groupBy: string,
|
||||||
|
filter?: Record<string, unknown>,
|
||||||
|
colors?: string[]
|
||||||
|
): Promise<WidgetChartData> {
|
||||||
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
||||||
|
const defaultColors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
|
||||||
|
|
||||||
|
switch (groupBy) {
|
||||||
|
case "status":
|
||||||
|
const statusCounts = await this.prisma.task.groupBy({
|
||||||
|
by: ["status"],
|
||||||
|
where,
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLabels = Object.values(TaskStatus);
|
||||||
|
const statusData = statusLabels.map((status) => {
|
||||||
|
const found = statusCounts.find((s) => s.status === status);
|
||||||
|
return found ? found._count.id : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: statusLabels.map((s) => s.replace("_", " ")),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Tasks by Status",
|
||||||
|
data: statusData,
|
||||||
|
backgroundColor: colors || defaultColors,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
case "priority":
|
||||||
|
const priorityCounts = await this.prisma.task.groupBy({
|
||||||
|
by: ["priority"],
|
||||||
|
where,
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const priorityLabels = Object.values(TaskPriority);
|
||||||
|
const priorityData = priorityLabels.map((priority) => {
|
||||||
|
const found = priorityCounts.find((p) => p.priority === priority);
|
||||||
|
return found ? found._count.id : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: priorityLabels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Tasks by Priority",
|
||||||
|
data: priorityData,
|
||||||
|
backgroundColor: colors || ["#EF4444", "#F59E0B", "#3B82F6", "#10B981"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
case "project":
|
||||||
|
const projectCounts = await this.prisma.task.groupBy({
|
||||||
|
by: ["projectId"],
|
||||||
|
where: { ...where, projectId: { not: null } },
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectIds = projectCounts.map((p) => p.projectId!);
|
||||||
|
const projects = await this.prisma.project.findMany({
|
||||||
|
where: { id: { in: projectIds } },
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: projects.map((p) => p.name),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Tasks by Project",
|
||||||
|
data: projectCounts.map((p) => p._count.id),
|
||||||
|
backgroundColor: projects.map((p) => p.color || "#3B82F6"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { labels: [], datasets: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getEventChartData(
|
||||||
|
workspaceId: string,
|
||||||
|
groupBy: string,
|
||||||
|
filter?: Record<string, unknown>,
|
||||||
|
colors?: string[]
|
||||||
|
): Promise<WidgetChartData> {
|
||||||
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
||||||
|
const defaultColors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
|
||||||
|
|
||||||
|
switch (groupBy) {
|
||||||
|
case "project":
|
||||||
|
const projectCounts = await this.prisma.event.groupBy({
|
||||||
|
by: ["projectId"],
|
||||||
|
where: { ...where, projectId: { not: null } },
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectIds = projectCounts.map((p) => p.projectId!);
|
||||||
|
const projects = await this.prisma.project.findMany({
|
||||||
|
where: { id: { in: projectIds } },
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: projects.map((p) => p.name),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Events by Project",
|
||||||
|
data: projectCounts.map((p) => p._count.id),
|
||||||
|
backgroundColor: projects.map((p) => p.color || "#3B82F6"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { labels: [], datasets: [{ label: "Events", data: [], backgroundColor: colors || defaultColors }] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getProjectChartData(
|
||||||
|
workspaceId: string,
|
||||||
|
groupBy: string,
|
||||||
|
filter?: Record<string, unknown>,
|
||||||
|
colors?: string[]
|
||||||
|
): Promise<WidgetChartData> {
|
||||||
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
||||||
|
const defaultColors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
|
||||||
|
|
||||||
|
switch (groupBy) {
|
||||||
|
case "status":
|
||||||
|
const statusCounts = await this.prisma.project.groupBy({
|
||||||
|
by: ["status"],
|
||||||
|
where,
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLabels = Object.values(ProjectStatus);
|
||||||
|
const statusData = statusLabels.map((status) => {
|
||||||
|
const found = statusCounts.find((s) => s.status === status);
|
||||||
|
return found ? found._count.id : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: statusLabels.map((s) => s.replace("_", " ")),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Projects by Status",
|
||||||
|
data: statusData,
|
||||||
|
backgroundColor: colors || defaultColors,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { labels: [], datasets: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTaskListData(
|
||||||
|
workspaceId: string,
|
||||||
|
sortBy?: string,
|
||||||
|
sortOrder?: "asc" | "desc",
|
||||||
|
limit?: number,
|
||||||
|
filter?: Record<string, unknown>
|
||||||
|
): Promise<WidgetListItem[]> {
|
||||||
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
||||||
|
const orderBy: Record<string, "asc" | "desc"> = {};
|
||||||
|
|
||||||
|
if (sortBy) {
|
||||||
|
orderBy[sortBy] = sortOrder || "desc";
|
||||||
|
} else {
|
||||||
|
orderBy.createdAt = "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = await this.prisma.task.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
project: { select: { name: true, color: true } },
|
||||||
|
},
|
||||||
|
orderBy,
|
||||||
|
take: limit || 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return tasks.map((task) => ({
|
||||||
|
id: task.id,
|
||||||
|
title: task.title,
|
||||||
|
subtitle: task.project?.name,
|
||||||
|
status: task.status,
|
||||||
|
priority: task.priority,
|
||||||
|
dueDate: task.dueDate?.toISOString(),
|
||||||
|
color: task.project?.color || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getEventListData(
|
||||||
|
workspaceId: string,
|
||||||
|
sortBy?: string,
|
||||||
|
sortOrder?: "asc" | "desc",
|
||||||
|
limit?: number,
|
||||||
|
filter?: Record<string, unknown>
|
||||||
|
): Promise<WidgetListItem[]> {
|
||||||
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
||||||
|
const orderBy: Record<string, "asc" | "desc"> = {};
|
||||||
|
|
||||||
|
if (sortBy) {
|
||||||
|
orderBy[sortBy] = sortOrder || "asc";
|
||||||
|
} else {
|
||||||
|
orderBy.startTime = "asc";
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await this.prisma.event.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
project: { select: { name: true, color: true } },
|
||||||
|
},
|
||||||
|
orderBy,
|
||||||
|
take: limit || 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return events.map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
subtitle: event.project?.name,
|
||||||
|
startTime: event.startTime.toISOString(),
|
||||||
|
color: event.project?.color || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getProjectListData(
|
||||||
|
workspaceId: string,
|
||||||
|
sortBy?: string,
|
||||||
|
sortOrder?: "asc" | "desc",
|
||||||
|
limit?: number,
|
||||||
|
filter?: Record<string, unknown>
|
||||||
|
): Promise<WidgetListItem[]> {
|
||||||
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
||||||
|
const orderBy: Record<string, "asc" | "desc"> = {};
|
||||||
|
|
||||||
|
if (sortBy) {
|
||||||
|
orderBy[sortBy] = sortOrder || "desc";
|
||||||
|
} else {
|
||||||
|
orderBy.createdAt = "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await this.prisma.project.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
take: limit || 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return projects.map((project) => ({
|
||||||
|
id: project.id,
|
||||||
|
title: project.name,
|
||||||
|
subtitle: project.description || undefined,
|
||||||
|
status: project.status,
|
||||||
|
color: project.color || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,33 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
Param,
|
Param,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
Request,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { WidgetsService } from "./widgets.service";
|
import { WidgetsService } from "./widgets.service";
|
||||||
|
import { WidgetDataService } from "./widget-data.service";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import type {
|
||||||
|
StatCardQueryDto,
|
||||||
|
ChartQueryDto,
|
||||||
|
ListQueryDto,
|
||||||
|
CalendarPreviewQueryDto,
|
||||||
|
} from "./dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller for widget definition endpoints
|
* Controller for widget definition and data endpoints
|
||||||
* All endpoints require authentication
|
* All endpoints require authentication
|
||||||
* Provides read-only access to available widget definitions
|
|
||||||
*/
|
*/
|
||||||
@Controller("widgets")
|
@Controller("widgets")
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class WidgetsController {
|
export class WidgetsController {
|
||||||
constructor(private readonly widgetsService: WidgetsService) {}
|
constructor(
|
||||||
|
private readonly widgetsService: WidgetsService,
|
||||||
|
private readonly widgetDataService: WidgetDataService
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/widgets
|
* GET /api/widgets
|
||||||
@@ -36,4 +48,47 @@ export class WidgetsController {
|
|||||||
async findByName(@Param("name") name: string) {
|
async findByName(@Param("name") name: string) {
|
||||||
return this.widgetsService.findByName(name);
|
return this.widgetsService.findByName(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/widgets/data/stat-card
|
||||||
|
* Get stat card widget data
|
||||||
|
*/
|
||||||
|
@Post("data/stat-card")
|
||||||
|
async getStatCardData(@Request() req: any, @Body() query: StatCardQueryDto) {
|
||||||
|
const workspaceId = req.user?.currentWorkspaceId || req.user?.workspaceId;
|
||||||
|
return this.widgetDataService.getStatCardData(workspaceId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/widgets/data/chart
|
||||||
|
* Get chart widget data
|
||||||
|
*/
|
||||||
|
@Post("data/chart")
|
||||||
|
async getChartData(@Request() req: any, @Body() query: ChartQueryDto) {
|
||||||
|
const workspaceId = req.user?.currentWorkspaceId || req.user?.workspaceId;
|
||||||
|
return this.widgetDataService.getChartData(workspaceId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/widgets/data/list
|
||||||
|
* Get list widget data
|
||||||
|
*/
|
||||||
|
@Post("data/list")
|
||||||
|
async getListData(@Request() req: any, @Body() query: ListQueryDto) {
|
||||||
|
const workspaceId = req.user?.currentWorkspaceId || req.user?.workspaceId;
|
||||||
|
return this.widgetDataService.getListData(workspaceId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/widgets/data/calendar-preview
|
||||||
|
* Get calendar preview widget data
|
||||||
|
*/
|
||||||
|
@Post("data/calendar-preview")
|
||||||
|
async getCalendarPreviewData(
|
||||||
|
@Request() req: any,
|
||||||
|
@Body() query: CalendarPreviewQueryDto
|
||||||
|
) {
|
||||||
|
const workspaceId = req.user?.currentWorkspaceId || req.user?.workspaceId;
|
||||||
|
return this.widgetDataService.getCalendarPreviewData(workspaceId, query);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { WidgetsController } from "./widgets.controller";
|
import { WidgetsController } from "./widgets.controller";
|
||||||
import { WidgetsService } from "./widgets.service";
|
import { WidgetsService } from "./widgets.service";
|
||||||
|
import { WidgetDataService } from "./widget-data.service";
|
||||||
import { PrismaModule } from "../prisma/prisma.module";
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, AuthModule],
|
imports: [PrismaModule, AuthModule],
|
||||||
controllers: [WidgetsController],
|
controllers: [WidgetsController],
|
||||||
providers: [WidgetsService],
|
providers: [WidgetsService, WidgetDataService],
|
||||||
exports: [WidgetsService],
|
exports: [WidgetsService, WidgetDataService],
|
||||||
})
|
})
|
||||||
export class WidgetsModule {}
|
export class WidgetsModule {}
|
||||||
|
|||||||
@@ -68,7 +68,11 @@ export type WidgetComponentType =
|
|||||||
| "TasksWidget"
|
| "TasksWidget"
|
||||||
| "CalendarWidget"
|
| "CalendarWidget"
|
||||||
| "QuickCaptureWidget"
|
| "QuickCaptureWidget"
|
||||||
| "AgentStatusWidget";
|
| "AgentStatusWidget"
|
||||||
|
| "StatCardWidget"
|
||||||
|
| "ChartWidget"
|
||||||
|
| "ListWidget"
|
||||||
|
| "CalendarPreviewWidget";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for individual widgets
|
* Props for individual widgets
|
||||||
@@ -79,3 +83,83 @@ export interface WidgetProps {
|
|||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget configuration types
|
||||||
|
*/
|
||||||
|
export interface StatCardConfig {
|
||||||
|
title?: string;
|
||||||
|
dataSource: "tasks" | "events" | "projects";
|
||||||
|
metric: "count" | "completed" | "overdue" | "upcoming";
|
||||||
|
filter?: Record<string, unknown>;
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartConfig {
|
||||||
|
title?: string;
|
||||||
|
chartType: "bar" | "line" | "pie" | "donut";
|
||||||
|
dataSource: "tasks" | "events" | "projects";
|
||||||
|
groupBy: "status" | "priority" | "project" | "day" | "week" | "month";
|
||||||
|
filter?: Record<string, unknown>;
|
||||||
|
colors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListConfig {
|
||||||
|
title?: string;
|
||||||
|
dataSource: "tasks" | "events" | "projects";
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: "asc" | "desc";
|
||||||
|
limit?: number;
|
||||||
|
filter?: Record<string, unknown>;
|
||||||
|
showStatus?: boolean;
|
||||||
|
showDueDate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarPreviewConfig {
|
||||||
|
title?: string;
|
||||||
|
view: "day" | "week" | "agenda";
|
||||||
|
showTasks?: boolean;
|
||||||
|
showEvents?: boolean;
|
||||||
|
daysAhead?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget data response types
|
||||||
|
*/
|
||||||
|
export interface WidgetStatData {
|
||||||
|
value: number;
|
||||||
|
change?: number;
|
||||||
|
changePercent?: number;
|
||||||
|
previousValue?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetChartData {
|
||||||
|
labels: string[];
|
||||||
|
datasets: {
|
||||||
|
label: string;
|
||||||
|
data: number[];
|
||||||
|
backgroundColor?: string[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetListItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
status?: string;
|
||||||
|
priority?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
startTime?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetCalendarItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime?: string;
|
||||||
|
allDay?: boolean;
|
||||||
|
type: "task" | "event";
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user