mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-12 05:49:01 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ba43489b4 | |||
| f143d536b3 | |||
| 8f091ac411 | |||
| acb86d1286 | |||
| affe83751b | |||
| c200826d31 | |||
| 065483f701 | |||
| e323a6e06e | |||
| 8972c70700 |
@@ -52,6 +52,13 @@ GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
|
||||
# Path to Firebase service account JSON file
|
||||
# FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
|
||||
|
||||
# Analytics (optional)
|
||||
# Comma-separated list of section IDs to enable in the analytics page.
|
||||
# When unset, all sections are available. When set, only the listed IDs are
|
||||
# available — in the order they appear here.
|
||||
# Example:
|
||||
# ANALYTICS_SECTIONS=kpi.total_income,kpi.total_expense,chart.income_expense_month,chart.income_expense_per_project
|
||||
|
||||
# Centrifugo (real-time notifications, optional)
|
||||
# CENTRIFUGO_API_URL=http://localhost:8000
|
||||
# CENTRIFUGO_API_KEY=your_centrifugo_api_key_here
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.42.5",
|
||||
"version": "1.44.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch ./server.ts",
|
||||
"start": "NODE_ENV=production node ./dist/taskview-server.js",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SsoManager } from '../tv-modules/sso/SsoManager';
|
||||
import { AnalyticsManager } from '../tv-modules/analytics/AnalyticsManager';
|
||||
import { TasksManager } from '../tv-modules/tasks/TasksManager';
|
||||
import { TimeTrackingManager } from '../tv-modules/time-tracking/TimeTrackingManager';
|
||||
import { UiPreferencesManager } from '../tv-modules/ui-preferences/UiPreferencesManager';
|
||||
import type { UserDbRecord, UserJwtPayload } from '../types/auth.types';
|
||||
import { GoalPermissionsFetcher } from './GoalPermissionsFetcher';
|
||||
|
||||
@@ -41,6 +42,7 @@ export class AppUser {
|
||||
public readonly ssoManager: SsoManager;
|
||||
public readonly analyticsManager: AnalyticsManager;
|
||||
public readonly timeTrackingManager: TimeTrackingManager;
|
||||
public readonly uiPreferencesManager: UiPreferencesManager;
|
||||
|
||||
constructor(userData?: UserJwtPayload) {
|
||||
this.userData = userData;
|
||||
@@ -61,6 +63,7 @@ export class AppUser {
|
||||
this.ssoManager = new SsoManager(this);
|
||||
this.analyticsManager = new AnalyticsManager(this);
|
||||
this.timeTrackingManager = new TimeTrackingManager(this);
|
||||
this.uiPreferencesManager = new UiPreferencesManager(this);
|
||||
}
|
||||
|
||||
getTokenId(): number | undefined {
|
||||
|
||||
@@ -550,5 +550,27 @@
|
||||
"Updated descriptions of timetracking_can_view and timetracking_can_manage_all to document that these permissions also expose contributor emails through the time-entry log",
|
||||
"Tightened timetracking_can_log description: it grants only start/stop/createManual, NOT edit/delete (including own entries). Edit/delete now requires timetracking_can_manage_all — see can-access-time-entry middleware change."
|
||||
]
|
||||
},
|
||||
"44": {
|
||||
"version": "1.51.0",
|
||||
"name": "Release 1.51.0",
|
||||
"releaseDate": "20260517",
|
||||
"scripts": [
|
||||
"/1.51.0/0.tasks_to_tags_indexes.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added btree indexes on tasks.tasks_to_tags(task_id) and (tag_id) — FK constraints do not create indexes in Postgres, so anti-joins (NOT EXISTS for untagged tasks) and inner joins from tasks were seq-scanning. Needed for tag-financial analytics charts."
|
||||
]
|
||||
},
|
||||
"45": {
|
||||
"version": "1.52.0",
|
||||
"name": "Release 1.52.0",
|
||||
"releaseDate": "20260517",
|
||||
"scripts": [
|
||||
"/1.52.0/0.ui_preferences.sql"
|
||||
],
|
||||
"description": [
|
||||
"Added tv_auth.ui_preferences table: per-user JSONB storage of UI customization choices (which analytics charts and task detail fields are shown and in what order). One row per user keyed by user_id."
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_to_tags_task_id
|
||||
ON tasks.tasks_to_tags(task_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_to_tags_tag_id
|
||||
ON tasks.tasks_to_tags(tag_id);
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.ui_preferences (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
prefs JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -18,6 +18,7 @@ import SsoRoutes from '../tv-modules/sso/SsoRoutes';
|
||||
import ScimRoutes from '../tv-modules/scim/ScimRoutes';
|
||||
import AnalyticsRoutes from '../tv-modules/analytics/AnalyticsRoutes';
|
||||
import TimeTrackingRoutes from '../tv-modules/time-tracking/TimeTrackingRoutes';
|
||||
import UiPreferencesRoutes from '../tv-modules/ui-preferences/UiPreferencesRoutes';
|
||||
import type { Routable } from '../types/routable.type';
|
||||
|
||||
type RoutableConstructor = new (...args: any[]) => Routable;
|
||||
@@ -42,6 +43,7 @@ const routes: Record<string, RoutableConstructor> = {
|
||||
'/module/sso': SsoRoutes,
|
||||
'/module/analytics': AnalyticsRoutes,
|
||||
'/module/time-tracking': TimeTrackingRoutes,
|
||||
'/module/ui-preferences': UiPreferencesRoutes,
|
||||
'/scim/v2': ScimRoutes,
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ import { parseDrillDownMeta, resolveRange } from './helpers'
|
||||
import { AnalyticsDrillDownArkType, AnalyticsFetchSectionsArkType } from './types'
|
||||
|
||||
export class AnalyticsController {
|
||||
fetchCatalog = async (req: Request, res: Response) => {
|
||||
const catalog = req.appUser.analyticsManager.getCatalog()
|
||||
return res.tvJson(catalog)
|
||||
}
|
||||
|
||||
fetchSections = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const out = AnalyticsFetchSectionsArkType(req.query)
|
||||
if (out instanceof type.errors) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AnalyticsDrillDownResponse,
|
||||
AnalyticsScope,
|
||||
AnalyticsSection,
|
||||
AnalyticsSectionCatalogEntry,
|
||||
AnalyticsSectionsResponse,
|
||||
} from 'taskview-api'
|
||||
import type { AppUser } from '../../core/AppUser'
|
||||
@@ -28,6 +29,10 @@ export class AnalyticsManager {
|
||||
this.registry = new SectionRegistry()
|
||||
}
|
||||
|
||||
getCatalog(): AnalyticsSectionCatalogEntry[] {
|
||||
return this.registry.catalog()
|
||||
}
|
||||
|
||||
async getAccessibleGoalIds(organizationId: number): Promise<number[]> {
|
||||
return this.fetchGoalIds(organizationId, [GoalPermissions.ANALYTICS_CAN_VIEW])
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
ActiveProjectsSectionRow,
|
||||
AgingOpenTasksSectionRow,
|
||||
AmountCoverageKpiRow,
|
||||
AmountPerProjectMonthSectionRow,
|
||||
AmountPerTagMonthSectionRow,
|
||||
BlockedByDependenciesSectionRow,
|
||||
CompletedTasksKpiRow,
|
||||
CreatedTasksKpiRow,
|
||||
@@ -30,7 +32,8 @@ import type {
|
||||
TotalIncomeKpiRow,
|
||||
WorkloadByAssigneeSectionRow,
|
||||
} from './sections/row.types'
|
||||
import type { AnalyticsRange, DrillDownTaskRow } from './types'
|
||||
import { UNTAGGED_TAG_ID } from './types'
|
||||
import type { AnalyticsRange, DrillDownTaskRow, FetchAmountPerProjectMonthArgs, FetchAmountPerTagMonthArgs } from './types'
|
||||
|
||||
type Bucket = 'day' | 'week' | 'month'
|
||||
|
||||
@@ -619,6 +622,118 @@ export class AnalyticsRepository {
|
||||
return result.rows as IncomeExpensePerProjectSectionRow[]
|
||||
}
|
||||
|
||||
async fetchAmountPerTagMonth(args: FetchAmountPerTagMonthArgs): Promise<AmountPerTagMonthSectionRow[]> {
|
||||
const { goalIds, range, transactionType } = args
|
||||
const result = await this.db.dbDrizzle.execute<AmountPerTagMonthSectionRow>(sql`
|
||||
with months as (
|
||||
select generate_series(
|
||||
date_trunc('month', ${range.from.toISOString()}::timestamp),
|
||||
date_trunc('month', ${range.to.toISOString()}::timestamp - interval '1 microsecond'),
|
||||
'1 month'::interval
|
||||
) as month
|
||||
),
|
||||
period_tasks as (
|
||||
select t.id,
|
||||
coalesce(t.amount, 0)::float as amount,
|
||||
date_trunc('month', t.date_complete) as month
|
||||
from tasks.tasks t
|
||||
where t.goal_id = any(${toIntArraySql(goalIds)})
|
||||
and t.complete = true
|
||||
and t.date_complete is not null
|
||||
and t.amount is not null
|
||||
and t.transaction_type = ${transactionType}
|
||||
and t.date_complete >= ${range.from.toISOString()}
|
||||
and t.date_complete < ${range.to.toISOString()}
|
||||
),
|
||||
task_buckets as (
|
||||
select pt.id, pt.amount, pt.month, tg.id as tag_id, tg.name as tag_name
|
||||
from period_tasks pt
|
||||
join tasks.tasks_to_tags tt on tt.task_id = pt.id
|
||||
join tasks.tags tg on tg.id = tt.tag_id
|
||||
union all
|
||||
select pt.id, pt.amount, pt.month, ${UNTAGGED_TAG_ID} as tag_id, '' as tag_name
|
||||
from period_tasks pt
|
||||
where not exists (
|
||||
select 1 from tasks.tasks_to_tags tt where tt.task_id = pt.id
|
||||
)
|
||||
),
|
||||
tag_totals as (
|
||||
select tag_id, max(tag_name) as tag_name, sum(amount) as total
|
||||
from task_buckets
|
||||
group by tag_id
|
||||
having sum(amount) > 0
|
||||
order by total desc
|
||||
),
|
||||
monthly as (
|
||||
select tb.month, tb.tag_id, sum(tb.amount)::float as amount
|
||||
from task_buckets tb
|
||||
join tag_totals tt on tt.tag_id = tb.tag_id
|
||||
group by tb.month, tb.tag_id
|
||||
)
|
||||
select
|
||||
to_char(m.month, 'YYYY-MM') as month,
|
||||
tt.tag_id::int as tag_id,
|
||||
tt.tag_name as tag_name,
|
||||
coalesce(mn.amount, 0)::float as amount
|
||||
from months m
|
||||
cross join tag_totals tt
|
||||
left join monthly mn on mn.month = m.month and mn.tag_id = tt.tag_id
|
||||
order by tt.total desc, m.month asc
|
||||
`)
|
||||
return result.rows as AmountPerTagMonthSectionRow[]
|
||||
}
|
||||
|
||||
async fetchAmountPerProjectMonth(args: FetchAmountPerProjectMonthArgs): Promise<AmountPerProjectMonthSectionRow[]> {
|
||||
const { goalIds, range, transactionType } = args
|
||||
const result = await this.db.dbDrizzle.execute<AmountPerProjectMonthSectionRow>(sql`
|
||||
with months as (
|
||||
select generate_series(
|
||||
date_trunc('month', ${range.from.toISOString()}::timestamp),
|
||||
date_trunc('month', ${range.to.toISOString()}::timestamp - interval '1 microsecond'),
|
||||
'1 month'::interval
|
||||
) as month
|
||||
),
|
||||
filtered as (
|
||||
select date_trunc('month', t.date_complete) as month,
|
||||
g.id as goal_id,
|
||||
g.name as goal_name,
|
||||
t.amount::float as amount
|
||||
from tasks.tasks t
|
||||
join tasks.goals g on g.id = t.goal_id
|
||||
where g.id = any(${toIntArraySql(goalIds)})
|
||||
and t.complete = true
|
||||
and t.date_complete is not null
|
||||
and t.amount is not null
|
||||
and t.transaction_type = ${transactionType}
|
||||
and t.date_complete >= ${range.from.toISOString()}
|
||||
and t.date_complete < ${range.to.toISOString()}
|
||||
),
|
||||
project_totals as (
|
||||
select goal_id, max(goal_name) as goal_name, sum(amount) as total
|
||||
from filtered
|
||||
group by goal_id
|
||||
having sum(amount) > 0
|
||||
order by total desc
|
||||
),
|
||||
monthly as (
|
||||
select f.month, f.goal_id, sum(f.amount)::float as amount
|
||||
from filtered f
|
||||
join project_totals pt on pt.goal_id = f.goal_id
|
||||
group by f.month, f.goal_id
|
||||
)
|
||||
select
|
||||
to_char(m.month, 'YYYY-MM') as month,
|
||||
pt.goal_id::int as goal_id,
|
||||
pt.goal_name as goal_name,
|
||||
coalesce(mn.amount, 0)::float as amount
|
||||
from months m
|
||||
cross join project_totals pt
|
||||
left join monthly mn on mn.month = m.month and mn.goal_id = pt.goal_id
|
||||
order by pt.total desc, m.month asc
|
||||
`)
|
||||
return result.rows as AmountPerProjectMonthSectionRow[]
|
||||
}
|
||||
|
||||
async fetchTopProjectsByAmount(goalIds: number[]): Promise<TopProjectsByAmountSectionRow[]> {
|
||||
const result = await this.db.dbDrizzle.execute<TopProjectsByAmountSectionRow>(sql`
|
||||
select * from (
|
||||
|
||||
@@ -22,6 +22,7 @@ export default class AnalyticsRoutes implements Routable {
|
||||
initRoutes() {
|
||||
const guards = [IsLoggedIn, RejectApiTokenAuth, CanAccessAnalytics]
|
||||
this.router.get('/sections', guards, this.controller.fetchSections)
|
||||
this.router.get('/sections-catalog', [IsLoggedIn], this.controller.fetchCatalog)
|
||||
this.router.get('/drilldown/:sectionId', guards, this.controller.fetchDrillDown)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,20 @@ import type { SectionBuilder } from '../types'
|
||||
import { CreatedTasksKpi } from './kpi/CreatedTasksKpi'
|
||||
import { CompletedTasksKpi } from './kpi/CompletedTasksKpi'
|
||||
import { OverdueKpi } from './kpi/OverdueKpi'
|
||||
import { CycleTimeKpi } from './kpi/CycleTimeKpi'
|
||||
import { ThroughputSection } from './productivity/ThroughputSection'
|
||||
// import { PriorityMixOverTimeSection } from './productivity/PriorityMixOverTimeSection'
|
||||
import { PriorityMixOverTimeSection } from './productivity/PriorityMixOverTimeSection'
|
||||
import { WorkloadByAssigneeSection } from './workload/WorkloadByAssigneeSection'
|
||||
// import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
|
||||
// import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection'
|
||||
// import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection'
|
||||
import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
|
||||
import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
|
||||
// import { CycleTimeHistogramSection } from './quality/CycleTimeHistogramSection'
|
||||
import { StaleTasksSection } from './quality/StaleTasksSection'
|
||||
// import { CycleTimePerProjectSection } from './quality/CycleTimePerProjectSection'
|
||||
// import { StatusDistributionSection } from './usage/StatusDistributionSection'
|
||||
import { StatusDistributionSection } from './usage/StatusDistributionSection'
|
||||
import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
|
||||
import { IncomeExpenseMonthSection } from './financial/IncomeExpenseMonthSection'
|
||||
import { IncomeExpensePerProjectSection } from './financial/IncomeExpensePerProjectSection'
|
||||
import { IncomePerProjectMonthSection } from './financial/IncomePerProjectMonthSection'
|
||||
import { ExpensePerProjectMonthSection } from './financial/ExpensePerProjectMonthSection'
|
||||
import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection'
|
||||
import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection'
|
||||
import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
|
||||
import { AmountCoverageKpi } from './financial/AmountCoverageKpi'
|
||||
import { TotalIncomeKpi } from './financial/TotalIncomeKpi'
|
||||
@@ -24,13 +23,54 @@ import { TotalExpenseKpi } from './financial/TotalExpenseKpi'
|
||||
import { NetProfitKpi } from './financial/NetProfitKpi'
|
||||
import { PlannedIncomeKpi } from './financial/PlannedIncomeKpi'
|
||||
import { PlannedExpenseKpi } from './financial/PlannedExpenseKpi'
|
||||
import type { AnalyticsSectionCatalogEntry } from 'taskview-api'
|
||||
import { parseEnabledSectionIds } from './sectionEnv'
|
||||
import { sectionLocales } from './locales'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disabled sections — re-enable once the underlying data model can support them
|
||||
// honestly. Until then they would report misleading numbers.
|
||||
//
|
||||
// 1. AgingOpenTasksSection (chart.aging_open_tasks)
|
||||
// Measures (now - date_creation) for every open task per assignee. Inflates
|
||||
// the number with backlog tasks nobody has started working on yet.
|
||||
// Needs: a boolean is_in_progress column on tasks.tasks, OR a "wip" flag
|
||||
// on tasks.statuses, so SQL can count only tasks that have been picked up.
|
||||
//
|
||||
// 2. CycleTimeKpi (kpi.cycle_time)
|
||||
// 3. CycleTimeHistogramSection (chart.cycle_time_histogram)
|
||||
// 4. CycleTimePerProjectSection (chart.cycle_time_per_project)
|
||||
// All three measure (date_complete - date_creation) for completed tasks.
|
||||
// That is actually lead time (includes time the task sat in backlog), not
|
||||
// cycle time. A task that spent 6 months in backlog and was then finished
|
||||
// in 2 days shows up as a 6-month "cycle".
|
||||
// Needs: a started_at timestamp on tasks.tasks (set when the task moves
|
||||
// into an in-progress status) so SQL can compute (date_complete - started_at).
|
||||
// Trigger or app logic must set started_at on the first transition into a
|
||||
// "wip" status.
|
||||
//
|
||||
// 5. TimeInKanbanStatusSection (chart.time_in_kanban_status)
|
||||
// Misleadingly named — it averages (now - edit_date) for currently-open
|
||||
// tasks grouped by their current status. It does NOT measure how long
|
||||
// each task has actually been in its current status; any unrelated edit
|
||||
// (description change, assignee change) resets the clock for the metric.
|
||||
// Needs: a tasks.status_history table tracking (task_id, status_id,
|
||||
// entered_at, exited_at). Then "time in status X" = avg(exited_at OR now
|
||||
// - entered_at) for rows in that status. Without a transition log, this
|
||||
// metric cannot be computed correctly.
|
||||
// ---------------------------------------------------------------------------
|
||||
// import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection'
|
||||
// import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection'
|
||||
// import { CycleTimeKpi } from './kpi/CycleTimeKpi'
|
||||
// import { CycleTimeHistogramSection } from './quality/CycleTimeHistogramSection'
|
||||
// import { CycleTimePerProjectSection } from './quality/CycleTimePerProjectSection'
|
||||
|
||||
const builders: SectionBuilder[] = [
|
||||
// KPI
|
||||
new CreatedTasksKpi(),
|
||||
new CompletedTasksKpi(),
|
||||
new OverdueKpi(),
|
||||
new CycleTimeKpi(),
|
||||
// new CycleTimeKpi(), // disabled — see top-of-file comment
|
||||
new TotalIncomeKpi(),
|
||||
new TotalExpenseKpi(),
|
||||
new NetProfitKpi(),
|
||||
@@ -39,43 +79,72 @@ const builders: SectionBuilder[] = [
|
||||
new AmountCoverageKpi(),
|
||||
// Productivity
|
||||
new ThroughputSection(),
|
||||
// new PriorityMixOverTimeSection(),
|
||||
new PriorityMixOverTimeSection(),
|
||||
// Workload
|
||||
new WorkloadByAssigneeSection(),
|
||||
// new BlockedByDependenciesSection(),
|
||||
// new TimeInKanbanStatusSection(),
|
||||
// new AgingOpenTasksSection(),
|
||||
new BlockedByDependenciesSection(),
|
||||
// new TimeInKanbanStatusSection(), // disabled — see top-of-file comment
|
||||
// new AgingOpenTasksSection(), // disabled — see top-of-file comment
|
||||
// Quality
|
||||
new OverdueByAgeSection(),
|
||||
// new CycleTimeHistogramSection(),
|
||||
// new CycleTimeHistogramSection(), // disabled — see top-of-file comment
|
||||
new StaleTasksSection(),
|
||||
// new CycleTimePerProjectSection(),
|
||||
// new CycleTimePerProjectSection(), // disabled — see top-of-file comment
|
||||
// Usage
|
||||
// new StatusDistributionSection(),
|
||||
new StatusDistributionSection(),
|
||||
new ActiveProjectsSection(),
|
||||
// Financial
|
||||
new IncomeExpenseMonthSection(),
|
||||
new IncomeExpensePerProjectSection(),
|
||||
new IncomePerProjectMonthSection(),
|
||||
new ExpensePerProjectMonthSection(),
|
||||
new IncomePerTagMonthSection(),
|
||||
new ExpensePerTagMonthSection(),
|
||||
new TopProjectsByAmountSection(),
|
||||
]
|
||||
|
||||
export class SectionRegistry {
|
||||
private readonly byId: Map<string, SectionBuilder>
|
||||
private readonly enabledOrder: SectionBuilder[]
|
||||
|
||||
constructor() {
|
||||
this.byId = new Map(builders.map(b => [b.id, b]))
|
||||
|
||||
const enabledIds = parseEnabledSectionIds(process.env.ANALYTICS_SECTIONS)
|
||||
if (enabledIds && enabledIds.length > 0) {
|
||||
this.enabledOrder = enabledIds
|
||||
.map((id: string) => this.byId.get(id))
|
||||
.filter((b: SectionBuilder | undefined): b is SectionBuilder => !!b)
|
||||
} else {
|
||||
this.enabledOrder = [...builders]
|
||||
}
|
||||
}
|
||||
|
||||
all(): SectionBuilder[] {
|
||||
return [...this.byId.values()]
|
||||
return [...this.enabledOrder]
|
||||
}
|
||||
|
||||
get(id: string): SectionBuilder | undefined {
|
||||
return this.byId.get(id)
|
||||
const builder = this.byId.get(id)
|
||||
if (!builder) return undefined
|
||||
return this.enabledOrder.includes(builder) ? builder : undefined
|
||||
}
|
||||
|
||||
filterByIds(ids?: string[]): SectionBuilder[] {
|
||||
if (!ids || ids.length === 0) return this.all()
|
||||
return ids.map(id => this.byId.get(id)).filter((b): b is SectionBuilder => !!b)
|
||||
const enabledSet = new Set(this.enabledOrder)
|
||||
return ids
|
||||
.map(id => this.byId.get(id))
|
||||
.filter((b): b is SectionBuilder => !!b && enabledSet.has(b))
|
||||
}
|
||||
|
||||
catalog(): AnalyticsSectionCatalogEntry[] {
|
||||
const locales = sectionLocales as Record<string, { title?: { ru: string, en: string } }>
|
||||
return this.enabledOrder.map(b => ({
|
||||
id: b.id,
|
||||
group: b.group,
|
||||
payloadKind: b.defaultChartType === null ? 'kpi' : 'series',
|
||||
title: locales[b.id]?.title ?? { ru: b.id, en: b.id },
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
import { buildProjectAmountPayload } from './projectAmountPayload'
|
||||
|
||||
export class ExpensePerProjectMonthSection implements SectionBuilder {
|
||||
readonly id = 'chart.expense_per_project_month'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['line', 'bar'] as const
|
||||
readonly defaultChartType = 'line' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchAmountPerProjectMonth({
|
||||
goalIds: ctx.accessibleGoalIds,
|
||||
range: ctx.range,
|
||||
transactionType: 0,
|
||||
})
|
||||
const loc = this.loc
|
||||
const payload = buildProjectAmountPayload({
|
||||
rows,
|
||||
xAxisLabel: loc.xAxisLabel,
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
})
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'date', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
import { buildTagAmountPayload } from './tagAmountPayload'
|
||||
|
||||
export class ExpensePerTagMonthSection implements SectionBuilder {
|
||||
readonly id = 'chart.expense_per_tag_month'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['line', 'bar'] as const
|
||||
readonly defaultChartType = 'line' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchAmountPerTagMonth({
|
||||
goalIds: ctx.accessibleGoalIds,
|
||||
range: ctx.range,
|
||||
transactionType: 0,
|
||||
})
|
||||
const loc = this.loc
|
||||
const payload = buildTagAmountPayload({
|
||||
rows,
|
||||
xAxisLabel: loc.xAxisLabel,
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
})
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'date', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
import { buildProjectAmountPayload } from './projectAmountPayload'
|
||||
|
||||
export class IncomePerProjectMonthSection implements SectionBuilder {
|
||||
readonly id = 'chart.income_per_project_month'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['line', 'bar'] as const
|
||||
readonly defaultChartType = 'line' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchAmountPerProjectMonth({
|
||||
goalIds: ctx.accessibleGoalIds,
|
||||
range: ctx.range,
|
||||
transactionType: 1,
|
||||
})
|
||||
const loc = this.loc
|
||||
const payload = buildProjectAmountPayload({
|
||||
rows,
|
||||
xAxisLabel: loc.xAxisLabel,
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
})
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'date', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AnalyticsSection } from 'taskview-api'
|
||||
import type { BuilderContext, SectionBuilder } from '../../types'
|
||||
import { sectionLocales } from '../locales'
|
||||
import { buildTagAmountPayload } from './tagAmountPayload'
|
||||
|
||||
export class IncomePerTagMonthSection implements SectionBuilder {
|
||||
readonly id = 'chart.income_per_tag_month'
|
||||
readonly group = 'financial' as const
|
||||
readonly allowedChartTypes = ['line', 'bar'] as const
|
||||
readonly defaultChartType = 'line' as const
|
||||
readonly cacheTtlSec = 900
|
||||
|
||||
private get loc() {
|
||||
return sectionLocales[this.id]
|
||||
}
|
||||
|
||||
async build(ctx: BuilderContext): Promise<AnalyticsSection | null> {
|
||||
if (ctx.accessibleGoalIds.length === 0) return this.empty()
|
||||
|
||||
const rows = await ctx.repository.fetchAmountPerTagMonth({
|
||||
goalIds: ctx.accessibleGoalIds,
|
||||
range: ctx.range,
|
||||
transactionType: 1,
|
||||
})
|
||||
const loc = this.loc
|
||||
const payload = buildTagAmountPayload({
|
||||
rows,
|
||||
xAxisLabel: loc.xAxisLabel,
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
})
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
description: loc.description,
|
||||
help: loc.help,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
private empty(): AnalyticsSection {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.loc.title,
|
||||
group: this.group,
|
||||
allowedChartTypes: [...this.allowedChartTypes],
|
||||
defaultChartType: this.defaultChartType,
|
||||
payload: { kind: 'series', labels: [], labelKind: 'date', datasets: [], unit: 'currency' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { AnalyticsDataset, AnalyticsSeriesPayload, LocalizedText } from 'taskview-api'
|
||||
import type { AmountPerProjectMonthSectionRow } from '../row.types'
|
||||
|
||||
export type BuildProjectAmountPayloadArgs = {
|
||||
rows: AmountPerProjectMonthSectionRow[]
|
||||
xAxisLabel?: LocalizedText
|
||||
yAxisLabel?: LocalizedText
|
||||
}
|
||||
|
||||
export function buildProjectAmountPayload(args: BuildProjectAmountPayloadArgs): AnalyticsSeriesPayload {
|
||||
const { rows, xAxisLabel, yAxisLabel } = args
|
||||
|
||||
const monthSet = new Set<string>()
|
||||
const projectOrder: number[] = []
|
||||
const projectNameById = new Map<number, string>()
|
||||
const valuesByProject = new Map<number, Map<string, number>>()
|
||||
|
||||
for (const row of rows) {
|
||||
monthSet.add(row.month)
|
||||
if (!projectNameById.has(row.goal_id)) {
|
||||
projectNameById.set(row.goal_id, row.goal_name)
|
||||
projectOrder.push(row.goal_id)
|
||||
}
|
||||
let perMonth = valuesByProject.get(row.goal_id)
|
||||
if (!perMonth) {
|
||||
perMonth = new Map()
|
||||
valuesByProject.set(row.goal_id, perMonth)
|
||||
}
|
||||
perMonth.set(row.month, Number(row.amount))
|
||||
}
|
||||
|
||||
const labels = [...monthSet].sort()
|
||||
|
||||
const datasets: AnalyticsDataset[] = projectOrder.map((goalId) => {
|
||||
const perMonth = valuesByProject.get(goalId) ?? new Map<string, number>()
|
||||
const name = projectNameById.get(goalId) ?? `#${goalId}`
|
||||
return {
|
||||
id: `project_${goalId}`,
|
||||
label: { ru: name, en: name },
|
||||
values: labels.map(month => perMonth.get(month) ?? 0),
|
||||
meta: { goalIds: [goalId] },
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
kind: 'series',
|
||||
labels,
|
||||
labelKind: 'date',
|
||||
datasets,
|
||||
unit: 'currency',
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { AnalyticsDataset, AnalyticsSeriesPayload, LocalizedText } from 'taskview-api'
|
||||
import type { AmountPerTagMonthSectionRow } from '../row.types'
|
||||
import { UNTAGGED_TAG_ID } from '../../types'
|
||||
|
||||
const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged' }
|
||||
|
||||
export type BuildTagAmountPayloadArgs = {
|
||||
rows: AmountPerTagMonthSectionRow[]
|
||||
xAxisLabel?: LocalizedText
|
||||
yAxisLabel?: LocalizedText
|
||||
}
|
||||
|
||||
export function buildTagAmountPayload(args: BuildTagAmountPayloadArgs): AnalyticsSeriesPayload {
|
||||
const { rows, xAxisLabel, yAxisLabel } = args
|
||||
|
||||
const monthSet = new Set<string>()
|
||||
const tagOrder: number[] = []
|
||||
const tagNameById = new Map<number, LocalizedText>()
|
||||
const valuesByTag = new Map<number, Map<string, number>>()
|
||||
|
||||
for (const row of rows) {
|
||||
monthSet.add(row.month)
|
||||
if (!tagNameById.has(row.tag_id)) {
|
||||
tagNameById.set(
|
||||
row.tag_id,
|
||||
row.tag_id === UNTAGGED_TAG_ID ? UNTAGGED_LABEL : { ru: row.tag_name, en: row.tag_name },
|
||||
)
|
||||
tagOrder.push(row.tag_id)
|
||||
}
|
||||
let perMonth = valuesByTag.get(row.tag_id)
|
||||
if (!perMonth) {
|
||||
perMonth = new Map()
|
||||
valuesByTag.set(row.tag_id, perMonth)
|
||||
}
|
||||
perMonth.set(row.month, Number(row.amount))
|
||||
}
|
||||
|
||||
const labels = [...monthSet].sort()
|
||||
|
||||
const datasets: AnalyticsDataset[] = tagOrder.map((tagId) => {
|
||||
const perMonth = valuesByTag.get(tagId) ?? new Map<string, number>()
|
||||
const isUntagged = tagId === UNTAGGED_TAG_ID
|
||||
return {
|
||||
id: isUntagged ? 'untagged' : `tag_${tagId}`,
|
||||
label: tagNameById.get(tagId) ?? { ru: '', en: '' },
|
||||
values: labels.map(month => perMonth.get(month) ?? 0),
|
||||
colorToken: isUntagged ? 'neutral' : undefined,
|
||||
meta: isUntagged ? { untagged: true } : { tagIds: [tagId] },
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
kind: 'series',
|
||||
labels,
|
||||
labelKind: 'date',
|
||||
datasets,
|
||||
unit: 'currency',
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
}
|
||||
}
|
||||
@@ -658,6 +658,166 @@ export const sectionLocales = {
|
||||
yAxisLabel: { ru: 'Сумма', en: 'Amount' },
|
||||
},
|
||||
|
||||
'chart.income_per_project_month': {
|
||||
title: { ru: 'Доходы по проектам по месяцам', en: 'Income per project by month' },
|
||||
description: {
|
||||
ru: 'Линия дохода по каждому проекту в разрезе месяцев',
|
||||
en: 'Income line per project broken down by month',
|
||||
},
|
||||
help: {
|
||||
summary: {
|
||||
ru: 'Доходы по проектам в каждом месяце периода',
|
||||
en: 'Income per project in each month of the period',
|
||||
},
|
||||
details: {
|
||||
ru: join([
|
||||
'Что показывает:',
|
||||
'Для каждого проекта — линия (или столбцы) с суммой доходов по месяцам. Берутся только завершённые задачи с типом «доход» и заполненной суммой.',
|
||||
'',
|
||||
'В отличие от «Доходы и расходы по проектам» (агрегат за весь период), здесь видно динамику: какой проект растёт по доходам, какой стагнирует, где был просадок.',
|
||||
'',
|
||||
'Что не попадает:',
|
||||
'• Задачи без указанной суммы',
|
||||
'• Задачи без типа транзакции',
|
||||
'• Незавершённые задачи (плановые доходы)',
|
||||
'• Проекты без единого доходного завершения за период',
|
||||
]),
|
||||
en: join([
|
||||
'What it shows:',
|
||||
'For each project — a line (or bars) of monthly income totals. Only completed tasks with type "income" and a filled-in amount are counted.',
|
||||
'',
|
||||
'Unlike "Income and expense per project" (total over the whole period), this surfaces the trend: which project grows in revenue, which stagnates, where the dips were.',
|
||||
'',
|
||||
'What is NOT included:',
|
||||
'• Tasks without an amount',
|
||||
'• Tasks without a transaction type',
|
||||
'• Open tasks (planned income)',
|
||||
'• Projects with no completed income tasks in the period',
|
||||
]),
|
||||
},
|
||||
},
|
||||
xAxisLabel: { ru: 'Месяц', en: 'Month' },
|
||||
yAxisLabel: { ru: 'Доход', en: 'Income' },
|
||||
},
|
||||
|
||||
'chart.expense_per_project_month': {
|
||||
title: { ru: 'Расходы по проектам по месяцам', en: 'Expense per project by month' },
|
||||
description: {
|
||||
ru: 'Линия расхода по каждому проекту в разрезе месяцев',
|
||||
en: 'Expense line per project broken down by month',
|
||||
},
|
||||
help: {
|
||||
summary: {
|
||||
ru: 'Расходы по проектам в каждом месяце периода',
|
||||
en: 'Expense per project in each month of the period',
|
||||
},
|
||||
details: {
|
||||
ru: join([
|
||||
'Что показывает:',
|
||||
'Для каждого проекта — линия (или столбцы) с суммой расходов по месяцам. Берутся только завершённые задачи с типом «расход» и заполненной суммой.',
|
||||
'',
|
||||
'В отличие от «Доходы и расходы по проектам» (агрегат за весь период), здесь видно динамику: какой проект разгоняет траты, в каком месяце был пик расходов, какой проект «остыл».',
|
||||
'',
|
||||
'Что не попадает:',
|
||||
'• Задачи без указанной суммы',
|
||||
'• Задачи без типа транзакции',
|
||||
'• Незавершённые задачи (плановые расходы)',
|
||||
'• Проекты без единого расходного завершения за период',
|
||||
]),
|
||||
en: join([
|
||||
'What it shows:',
|
||||
'For each project — a line (or bars) of monthly expense totals. Only completed tasks with type "expense" and a filled-in amount are counted.',
|
||||
'',
|
||||
'Unlike "Income and expense per project" (total over the whole period), this surfaces the trend: which project is ramping up spend, where the peak month was, which project has cooled off.',
|
||||
'',
|
||||
'What is NOT included:',
|
||||
'• Tasks without an amount',
|
||||
'• Tasks without a transaction type',
|
||||
'• Open tasks (planned expenses)',
|
||||
'• Projects with no completed expense tasks in the period',
|
||||
]),
|
||||
},
|
||||
},
|
||||
xAxisLabel: { ru: 'Месяц', en: 'Month' },
|
||||
yAxisLabel: { ru: 'Расход', en: 'Expense' },
|
||||
},
|
||||
|
||||
'chart.income_per_tag_month': {
|
||||
title: { ru: 'Доходы по тегам по месяцам', en: 'Income per tag by month' },
|
||||
description: {
|
||||
ru: 'Линия дохода по каждому тегу в разрезе месяцев',
|
||||
en: 'Income line per tag broken down by month',
|
||||
},
|
||||
help: {
|
||||
summary: {
|
||||
ru: 'Доходы по топ-тегам в каждом месяце периода',
|
||||
en: 'Income for the top tags in each month of the period',
|
||||
},
|
||||
details: {
|
||||
ru: join([
|
||||
'Что показывает:',
|
||||
'Для каждого тега — линия (или столбцы) с суммой доходов по месяцам. Берутся только завершённые задачи с типом «доход» и заполненной суммой.',
|
||||
'',
|
||||
'Как считаются задачи с несколькими тегами:',
|
||||
'Сумма задачи учитывается полностью для каждого её тега. Поэтому суммирование по всем тегам обычно больше реальной выручки — это нормально.',
|
||||
'',
|
||||
'Категория «Без тегов»:',
|
||||
'Задачи без единого тега показываются отдельной серией. Большая доля «Без тегов» = слабая категоризация финансов.',
|
||||
]),
|
||||
en: join([
|
||||
'What it shows:',
|
||||
'For each tag — a line (or bars) of monthly income totals. Only completed tasks with type "income" and a filled-in amount are counted.',
|
||||
'',
|
||||
'How tasks with multiple tags are counted:',
|
||||
'Each tag receives the full task amount. Summing across tags usually exceeds real revenue — that is expected.',
|
||||
'',
|
||||
'The "Untagged" category:',
|
||||
'Tasks without any tag appear as a separate series. A large untagged share means weak financial categorization.',
|
||||
]),
|
||||
},
|
||||
},
|
||||
xAxisLabel: { ru: 'Месяц', en: 'Month' },
|
||||
yAxisLabel: { ru: 'Доход', en: 'Income' },
|
||||
},
|
||||
|
||||
'chart.expense_per_tag_month': {
|
||||
title: { ru: 'Расходы по тегам по месяцам', en: 'Expense per tag by month' },
|
||||
description: {
|
||||
ru: 'Линия расхода по каждому тегу в разрезе месяцев',
|
||||
en: 'Expense line per tag broken down by month',
|
||||
},
|
||||
help: {
|
||||
summary: {
|
||||
ru: 'Расходы по топ-тегам в каждом месяце периода',
|
||||
en: 'Expense for the top tags in each month of the period',
|
||||
},
|
||||
details: {
|
||||
ru: join([
|
||||
'Что показывает:',
|
||||
'Для каждого тега — линия (или столбцы) с суммой расходов по месяцам. Берутся только завершённые задачи с типом «расход» и заполненной суммой.',
|
||||
'',
|
||||
'Как считаются задачи с несколькими тегами:',
|
||||
'Сумма задачи учитывается полностью для каждого её тега. Поэтому суммирование по всем тегам обычно больше реальных затрат — это нормально.',
|
||||
'',
|
||||
'Категория «Без тегов»:',
|
||||
'Задачи без единого тега показываются отдельной серией. Большая доля «Без тегов» = слабая категоризация расходов.',
|
||||
]),
|
||||
en: join([
|
||||
'What it shows:',
|
||||
'For each tag — a line (or bars) of monthly expense totals. Only completed tasks with type "expense" and a filled-in amount are counted.',
|
||||
'',
|
||||
'How tasks with multiple tags are counted:',
|
||||
'Each tag receives the full task amount. Summing across tags usually exceeds real spend — that is expected.',
|
||||
'',
|
||||
'The "Untagged" category:',
|
||||
'Tasks without any tag appear as a separate series. A large untagged share means weak expense categorization.',
|
||||
]),
|
||||
},
|
||||
},
|
||||
xAxisLabel: { ru: 'Месяц', en: 'Month' },
|
||||
yAxisLabel: { ru: 'Расход', en: 'Expense' },
|
||||
},
|
||||
|
||||
'chart.top_projects_by_amount': {
|
||||
title: { ru: 'Топ проектов по сумме', en: 'Top projects by amount' },
|
||||
description: { ru: 'Суммарный доход и расход в каждом проекте', en: 'Total income and expense per project' },
|
||||
|
||||
@@ -128,3 +128,17 @@ export type TopProjectsByAmountSectionRow = {
|
||||
income: number
|
||||
expense: number
|
||||
}
|
||||
|
||||
export type AmountPerTagMonthSectionRow = {
|
||||
month: string
|
||||
tag_id: number
|
||||
tag_name: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
export type AmountPerProjectMonthSectionRow = {
|
||||
month: string
|
||||
goal_id: number
|
||||
goal_name: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export function parseEnabledSectionIds(raw: string | undefined): string[] | null {
|
||||
if (!raw) return null
|
||||
const ids = raw
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
return ids.length > 0 ? ids : null
|
||||
}
|
||||
@@ -4,12 +4,7 @@ import { sectionLocales } from '../locales'
|
||||
|
||||
type StatusKey = 'active' | 'fading' | 'dead' | 'empty'
|
||||
|
||||
const COLOR_BY_STATUS: Record<StatusKey, 'success' | 'warning' | 'danger' | 'neutral'> = {
|
||||
active: 'success',
|
||||
fading: 'warning',
|
||||
dead: 'danger',
|
||||
empty: 'neutral',
|
||||
}
|
||||
const STATUS_ORDER: readonly StatusKey[] = ['active', 'fading', 'dead', 'empty']
|
||||
|
||||
export class ActiveProjectsSection implements SectionBuilder {
|
||||
readonly id = 'chart.active_projects'
|
||||
@@ -27,7 +22,8 @@ export class ActiveProjectsSection implements SectionBuilder {
|
||||
|
||||
const rows = await ctx.repository.fetchActiveProjects(ctx.accessibleGoalIds)
|
||||
const loc = this.loc
|
||||
const labelTexts = rows.map(r => loc.labels![r.status_key])
|
||||
const countByStatus = new Map(rows.map(r => [r.status_key, Number(r.count)]))
|
||||
const labelTexts = STATUS_ORDER.map(key => loc.labels![key])
|
||||
|
||||
const payload: AnalyticsSeriesPayload = {
|
||||
kind: 'series',
|
||||
@@ -38,19 +34,14 @@ export class ActiveProjectsSection implements SectionBuilder {
|
||||
{
|
||||
id: 'count',
|
||||
label: loc.datasets!.count,
|
||||
values: rows.map(r => Number(r.count)),
|
||||
meta: { statusKeys: rows.map(r => r.status_key) },
|
||||
values: STATUS_ORDER.map(key => countByStatus.get(key) ?? 0),
|
||||
meta: { statusKeys: STATUS_ORDER },
|
||||
},
|
||||
],
|
||||
unit: 'count',
|
||||
yAxisLabel: loc.yAxisLabel,
|
||||
}
|
||||
|
||||
const firstRow = rows[0]
|
||||
if (firstRow) {
|
||||
payload.datasets[0].colorToken = COLOR_BY_STATUS[firstRow.status_key] ?? 'primary'
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
title: loc.title,
|
||||
|
||||
@@ -108,3 +108,20 @@ export type AnalyticsArgDrillDown = {
|
||||
range: AnalyticsRange
|
||||
arg: SectionDrillDownArg
|
||||
}
|
||||
|
||||
export type FetchAmountPerTagMonthArgs = {
|
||||
goalIds: number[]
|
||||
range: AnalyticsRange
|
||||
transactionType: 0 | 1
|
||||
}
|
||||
|
||||
export type FetchAmountPerProjectMonthArgs = {
|
||||
goalIds: number[]
|
||||
range: AnalyticsRange
|
||||
transactionType: 0 | 1
|
||||
}
|
||||
|
||||
// Sentinel id used in tag-grouped analytics queries to represent tasks that
|
||||
// have no tags assigned. tasks.tags.id is GENERATED ALWAYS AS IDENTITY (positive
|
||||
// integers only), so -1 cannot collide with a real tag.
|
||||
export const UNTAGGED_TAG_ID = -1
|
||||
|
||||
@@ -139,7 +139,7 @@ export class OrganizationRepository {
|
||||
})
|
||||
.from(OrganizationMembersSchema)
|
||||
.innerJoin(OrganizationsSchema, eq(OrganizationMembersSchema.organizationId, OrganizationsSchema.id))
|
||||
.where(eq(OrganizationMembersSchema.email, email))
|
||||
.where(eq(OrganizationMembersSchema.email, email.toLowerCase()))
|
||||
)
|
||||
|
||||
if (!result) return []
|
||||
@@ -192,7 +192,7 @@ export class OrganizationRepository {
|
||||
.where(
|
||||
and(
|
||||
eq(OrganizationMembersSchema.organizationId, orgId),
|
||||
eq(OrganizationMembersSchema.email, email),
|
||||
eq(OrganizationMembersSchema.email, email.toLowerCase()),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -220,7 +220,7 @@ export class OrganizationRepository {
|
||||
.where(
|
||||
and(
|
||||
eq(OrganizationMembersSchema.organizationId, orgId),
|
||||
eq(OrganizationMembersSchema.email, email),
|
||||
eq(OrganizationMembersSchema.email, email.toLowerCase()),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -603,7 +603,16 @@ export class TasksRepository {
|
||||
}
|
||||
}
|
||||
|
||||
const order = +data.firstNew === 1 ? desc(TasksSchema.id) : asc(TasksSchema.id);
|
||||
const descending = +data.firstNew === 1;
|
||||
const order =
|
||||
data.sortBy === 'priority'
|
||||
? [
|
||||
descending
|
||||
? sql`${TasksSchema.priorityId} DESC NULLS LAST`
|
||||
: sql`${TasksSchema.priorityId} ASC NULLS LAST`,
|
||||
desc(TasksSchema.id),
|
||||
]
|
||||
: [descending ? desc(TasksSchema.id) : asc(TasksSchema.id)];
|
||||
|
||||
const limit = 30;
|
||||
const offset = (data.page ?? 0) * limit;
|
||||
@@ -614,13 +623,13 @@ export class TasksRepository {
|
||||
.select()
|
||||
.from(TasksSchema)
|
||||
.where(and(...conditions))
|
||||
.orderBy(order);
|
||||
.orderBy(...order);
|
||||
} else {
|
||||
dbv = this.db.dbDrizzle
|
||||
.select()
|
||||
.from(TasksSchema)
|
||||
.where(and(...conditions))
|
||||
.orderBy(order)
|
||||
.orderBy(...order)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export const TaskArkTypeFetchTasksNew = type({
|
||||
page: TaskArkTypeNumberFromString,
|
||||
showCompleted: TaskArkTypeZeroOneToNumber,
|
||||
firstNew: TaskArkTypeZeroOneToNumber,
|
||||
"sortBy?": "'date' | 'priority'",
|
||||
'searchText?': 'string',
|
||||
'filters?': type('object | string')
|
||||
.pipe((v) => {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type } from 'arktype'
|
||||
import type { Request, Response } from 'express'
|
||||
import { logError } from '../../utils/api'
|
||||
import { UiPreferencesArkType } from './types'
|
||||
|
||||
export class UiPreferencesController {
|
||||
get = async (req: Request, res: Response) => {
|
||||
const prefs = await req.appUser.uiPreferencesManager.get().catch(logError)
|
||||
return res.tvJson(prefs ?? {})
|
||||
}
|
||||
|
||||
update = async (req: Request, res: Response) => {
|
||||
const out = UiPreferencesArkType(req.body)
|
||||
if (out instanceof type.errors) {
|
||||
return res.status(400).send(out.summary)
|
||||
}
|
||||
const prefs = await req.appUser.uiPreferencesManager.update(out).catch(logError)
|
||||
if (!prefs) return res.status(500).end()
|
||||
return res.tvJson(prefs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AppUser } from '../../core/AppUser'
|
||||
import { UiPreferencesRepository } from './UiPreferencesRepository'
|
||||
import type { UiPreferences } from './types'
|
||||
|
||||
export class UiPreferencesManager {
|
||||
private readonly repository: UiPreferencesRepository
|
||||
private readonly user: AppUser
|
||||
|
||||
constructor(user: AppUser) {
|
||||
this.user = user
|
||||
this.repository = new UiPreferencesRepository()
|
||||
}
|
||||
|
||||
async get(): Promise<UiPreferences> {
|
||||
const userId = this.user.getUserData()?.id
|
||||
if (!userId) return {}
|
||||
return this.repository.getForUser(userId)
|
||||
}
|
||||
|
||||
async update(prefs: UiPreferences): Promise<UiPreferences | null> {
|
||||
const userId = this.user.getUserData()?.id
|
||||
if (!userId) return null
|
||||
return this.repository.upsert({ userId, prefs })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { UiPreferencesSchema } from 'taskview-db-schemas'
|
||||
import { Database } from '../../modules/db'
|
||||
import { callWithCatch } from '../../utils/helpers'
|
||||
import type { UiPreferences, UpdateUiPreferencesArgs } from './types'
|
||||
|
||||
export class UiPreferencesRepository {
|
||||
private readonly db: Database
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance()
|
||||
}
|
||||
|
||||
async getForUser(userId: number): Promise<UiPreferences> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.select({ prefs: UiPreferencesSchema.prefs })
|
||||
.from(UiPreferencesSchema)
|
||||
.where(eq(UiPreferencesSchema.userId, userId)),
|
||||
)
|
||||
if (!result || result.length === 0) return {}
|
||||
return (result[0].prefs ?? {}) as UiPreferences
|
||||
}
|
||||
|
||||
async upsert(args: UpdateUiPreferencesArgs): Promise<UiPreferences> {
|
||||
const { userId, prefs } = args
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.insert(UiPreferencesSchema)
|
||||
.values({ userId, prefs, updatedAt: new Date() })
|
||||
.onConflictDoUpdate({
|
||||
target: UiPreferencesSchema.userId,
|
||||
set: { prefs, updatedAt: sql`now()` },
|
||||
})
|
||||
.returning({ prefs: UiPreferencesSchema.prefs }),
|
||||
)
|
||||
if (!result || result.length === 0) return prefs
|
||||
return (result[0].prefs ?? {}) as UiPreferences
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Router } from 'express'
|
||||
import type { Routable } from '../../types/routable.type'
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
|
||||
import { UiPreferencesController } from './UiPreferencesController'
|
||||
|
||||
export default class UiPreferencesRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
private readonly controller: UiPreferencesController
|
||||
|
||||
constructor() {
|
||||
this.router = Router()
|
||||
this.controller = new UiPreferencesController()
|
||||
this.initRoutes()
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('', [IsLoggedIn], this.controller.get)
|
||||
this.router.put('', [IsLoggedIn], this.controller.update)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { type } from 'arktype'
|
||||
|
||||
const MAX_SECTIONS = 20
|
||||
const MAX_ITEMS_PER_SECTION = 200
|
||||
const ID_MAX_LENGTH = 100
|
||||
const ID_PATTERN = /^[a-zA-Z0-9_.\-:]+$/
|
||||
|
||||
const idArkType = type('string').narrow((v, ctx) => {
|
||||
if (v.length === 0 || v.length > ID_MAX_LENGTH) {
|
||||
return ctx.mustBe(`a string of 1..${ID_MAX_LENGTH} chars`)
|
||||
}
|
||||
if (!ID_PATTERN.test(v)) {
|
||||
return ctx.mustBe('a string matching [a-zA-Z0-9_.\\-:]+')
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export const UiPreferencesItemArkType = type({
|
||||
id: idArkType,
|
||||
order: 'number.integer >= 0',
|
||||
hidden: 'boolean',
|
||||
'width?': "'narrow' | 'wide'",
|
||||
})
|
||||
|
||||
const UiPreferencesSectionArkType = UiPreferencesItemArkType.array().narrow((arr, ctx) => {
|
||||
if (arr.length > MAX_ITEMS_PER_SECTION) {
|
||||
return ctx.mustBe(`at most ${MAX_ITEMS_PER_SECTION} items per section`)
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
for (const item of arr) {
|
||||
if (seen.has(item.id)) {
|
||||
return ctx.mustBe(`unique item ids (duplicate: ${item.id})`)
|
||||
}
|
||||
seen.add(item.id)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export const UiPreferencesArkType = type({
|
||||
'[string]': UiPreferencesSectionArkType,
|
||||
}).narrow((obj, ctx) => {
|
||||
const keys = Object.keys(obj)
|
||||
if (keys.length > MAX_SECTIONS) {
|
||||
return ctx.mustBe(`at most ${MAX_SECTIONS} sections`)
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (key.length === 0 || key.length > ID_MAX_LENGTH) {
|
||||
return ctx.mustBe(`section key length 1..${ID_MAX_LENGTH} (got "${key}")`)
|
||||
}
|
||||
if (!ID_PATTERN.test(key)) {
|
||||
return ctx.mustBe(`section key matching [a-zA-Z0-9_.\\-:]+ (got "${key}")`)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export type UiPreferencesItem = typeof UiPreferencesItemArkType.infer
|
||||
export type UiPreferences = typeof UiPreferencesArkType.infer
|
||||
|
||||
export type UpdateUiPreferencesArgs = {
|
||||
userId: number
|
||||
prefs: UiPreferences
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-monorepo",
|
||||
"version": "1.42.5",
|
||||
"version": "1.44.0",
|
||||
"private": true,
|
||||
"description": "TaskView CE monorepo containing web, API, and packages",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
{
|
||||
"name": "taskview-api",
|
||||
"private": false,
|
||||
"version": "1.42.5",
|
||||
"version": "1.44.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Gimanh/taskview-community.git",
|
||||
"directory": "taskview-packages/taskview-api"
|
||||
},
|
||||
"homepage": "https://github.com/Gimanh/taskview-community/tree/main/taskview-packages/taskview-api#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/Gimanh/taskview-community/issues"
|
||||
},
|
||||
"main": "./dist/taskview-api.umd.js",
|
||||
"module": "./dist/taskview-api.es.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AnalyticsFetchDrillDownArg,
|
||||
AnalyticsDrillDownResponse,
|
||||
AnalyticsFetchSectionsArg,
|
||||
AnalyticsSectionCatalogEntry,
|
||||
AnalyticsSectionsResponse,
|
||||
AnalyticsScope,
|
||||
} from './analytics.types'
|
||||
@@ -20,6 +21,14 @@ function scopeToParams(scope: AnalyticsScope): Record<string, string> {
|
||||
export default class TvAnalyticsApi extends TvApiBase {
|
||||
protected moduleUrl = '/module/analytics'
|
||||
|
||||
public async fetchSectionsCatalog() {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<AnalyticsSectionCatalogEntry[]>>(
|
||||
`${this.moduleUrl}/sections-catalog`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
public async fetchSections(arg: AnalyticsFetchSectionsArg, signal?: AbortSignal) {
|
||||
const params: Record<string, string> = {
|
||||
...scopeToParams(arg.scope),
|
||||
|
||||
@@ -116,6 +116,13 @@ export type AnalyticsAvailableGoal = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type AnalyticsSectionCatalogEntry = {
|
||||
id: string
|
||||
group: AnalyticsSectionGroup
|
||||
payloadKind: 'kpi' | 'series'
|
||||
title: LocalizedText
|
||||
}
|
||||
|
||||
export type AnalyticsSectionsResponse = {
|
||||
scope: AnalyticsScope
|
||||
period: AnalyticsPeriod
|
||||
|
||||
@@ -5,6 +5,7 @@ export type Organization = {
|
||||
ownerId: number
|
||||
logoUrl: string | null
|
||||
plan: string
|
||||
isPersonal: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
currentUserRole: 'owner' | 'admin' | 'member'
|
||||
|
||||
@@ -67,12 +67,15 @@ export type TaskFilters = {
|
||||
selectedTags?: Record<string, true>;
|
||||
};
|
||||
|
||||
export type TaskSortBy = 'date' | 'priority';
|
||||
|
||||
export type TaskArgFetch = {
|
||||
goalId: number;
|
||||
componentId: number | typeof ALL_TASKS_LIST_ID;
|
||||
page: number;
|
||||
showCompleted: 0 | 1;
|
||||
firstNew: 0 | 1;
|
||||
sortBy?: TaskSortBy;
|
||||
searchText?: string;
|
||||
filters?: TaskFilters;
|
||||
unlimited?: boolean;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import TvApiBase from './base'
|
||||
import type { AppResponse } from './base.types'
|
||||
import type { UiPreferences } from './ui-preferences.types'
|
||||
|
||||
export default class TvUiPreferencesApi extends TvApiBase {
|
||||
protected moduleUrl = '/module/ui-preferences'
|
||||
|
||||
public async fetch() {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<UiPreferences>>(this.moduleUrl),
|
||||
)
|
||||
}
|
||||
|
||||
public async update(prefs: UiPreferences) {
|
||||
return this.request(
|
||||
this.$axios.put<AppResponse<UiPreferences>>(this.moduleUrl, prefs),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type UiPreferencesItem = {
|
||||
id: string
|
||||
order: number
|
||||
hidden: boolean
|
||||
width?: 'narrow' | 'wide'
|
||||
}
|
||||
|
||||
export type UiPreferences = Record<string, UiPreferencesItem[]>
|
||||
@@ -17,4 +17,5 @@ export * from '@/api/sessions.types';
|
||||
export * from '@/api/organizations.types';
|
||||
export * from '@/api/sso.types';
|
||||
export * from '@/api/analytics.types';
|
||||
export * from '@/api/time-tracking.types';
|
||||
export * from '@/api/time-tracking.types';
|
||||
export * from '@/api/ui-preferences.types';
|
||||
@@ -15,6 +15,7 @@ import TvOrganizationsApi from "./api/organizations";
|
||||
import TvSsoApi from "./api/sso";
|
||||
import TvAnalyticsApi from "./api/analytics";
|
||||
import TvTimeTrackingApi from "./api/time-tracking";
|
||||
import TvUiPreferencesApi from "./api/ui-preferences";
|
||||
|
||||
export class TvApi {
|
||||
|
||||
@@ -52,6 +53,8 @@ export class TvApi {
|
||||
|
||||
public timeTracking: TvTimeTrackingApi;
|
||||
|
||||
public uiPreferences: TvUiPreferencesApi;
|
||||
|
||||
constructor($axios: AxiosInstance) {
|
||||
this.$axios = $axios;
|
||||
|
||||
@@ -86,6 +89,8 @@ export class TvApi {
|
||||
this.analytics = new TvAnalyticsApi(this.$axios);
|
||||
|
||||
this.timeTracking = new TvTimeTrackingApi(this.$axios);
|
||||
|
||||
this.uiPreferences = new TvUiPreferencesApi(this.$axios);
|
||||
}
|
||||
|
||||
public setBaseUrl(baseUrl: string) {
|
||||
|
||||
@@ -19,3 +19,4 @@ export * from './schemas/organizations.schema';
|
||||
export * from './schemas/sso.schema';
|
||||
export * from './schemas/time-entries.schema';
|
||||
export * from './schemas/time-entries-history.schema';
|
||||
export * from './schemas/ui-preferences.schema';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { integer, jsonb, pgSchema, timestamp } from 'drizzle-orm/pg-core'
|
||||
import { UsersSchema } from './users.schema'
|
||||
|
||||
export const UiPreferencesSchema = pgSchema('tv_auth').table('ui_preferences', {
|
||||
userId: integer('user_id').primaryKey().references(() => UsersSchema.id, { onDelete: 'cascade' }),
|
||||
prefs: jsonb().notNull().default({}),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export type UiPreferencesSchemaTypeForSelect = typeof UiPreferencesSchema.$inferSelect
|
||||
export type UiPreferencesSchemaTypeForInsert = typeof UiPreferencesSchema.$inferInsert
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-mcp",
|
||||
"version": "1.42.5",
|
||||
"version": "1.44.0",
|
||||
"description": "MCP (Model Context Protocol) server for TaskView — lets AI assistants (Claude Code, Claude Desktop, etc.) manage projects and tasks via the TaskView API",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -33,10 +33,10 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Gimanh/taskview-community",
|
||||
"directory": "community/taskview-packages/taskview-mcp"
|
||||
"url": "git+https://github.com/Gimanh/taskview-community.git",
|
||||
"directory": "taskview-packages/taskview-mcp"
|
||||
},
|
||||
"homepage": "https://github.com/Gimanh/taskview-community",
|
||||
"homepage": "https://github.com/Gimanh/taskview-community/tree/main/taskview-packages/taskview-mcp#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/Gimanh/taskview-community/issues"
|
||||
},
|
||||
|
||||
@@ -8,15 +8,28 @@ const TASKVIEW_TOKEN_PROVIDED = process.env.TASKVIEW_TOKEN
|
||||
let createdTokenId: number | null = null
|
||||
let createdToken: string | null = null
|
||||
|
||||
async function loginWithRetry(attempts = 5) {
|
||||
let lastError: unknown
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
return await axios.post(`${TASKVIEW_URL}/module/auth/login`, {
|
||||
login: TASKVIEW_LOGIN,
|
||||
password: TASKVIEW_PASSWORD,
|
||||
})
|
||||
} catch (e) {
|
||||
lastError = e
|
||||
await new Promise((r) => setTimeout(r, 500 * (i + 1)))
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
export async function setup() {
|
||||
if (!TASKVIEW_URL) return
|
||||
if (TASKVIEW_TOKEN_PROVIDED) return
|
||||
if (!TASKVIEW_LOGIN || !TASKVIEW_PASSWORD) return
|
||||
|
||||
const loginRes = await axios.post(`${TASKVIEW_URL}/module/auth/login`, {
|
||||
login: TASKVIEW_LOGIN,
|
||||
password: TASKVIEW_PASSWORD,
|
||||
})
|
||||
const loginRes = await loginWithRetry()
|
||||
const jwt = loginRes.data.access
|
||||
if (!jwt) throw new Error('Login succeeded but no access token returned')
|
||||
|
||||
@@ -45,5 +58,5 @@ export async function teardown() {
|
||||
data: { id: createdTokenId },
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => { })
|
||||
}
|
||||
|
||||
@@ -188,6 +188,53 @@ describe('tasks integration', () => {
|
||||
await call(tools, 'delete_task', { taskId: created.id }).catch(() => {})
|
||||
})
|
||||
|
||||
it('lists tasks sorted by priority (desc highest-first, asc lowest-first)', async () => {
|
||||
const sortGoalId = parse(await call(tools, 'create_goal', { name: `Sort Test ${ts()}` })).id
|
||||
try {
|
||||
// Create in an order where task id does NOT correlate with priority, so the
|
||||
// assertions can only pass if the backend truly orders by priority (not by id).
|
||||
const mid = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `mid ${ts()}`, priorityId: 2 }))
|
||||
const low = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `low ${ts()}`, priorityId: 1 }))
|
||||
const high = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `high ${ts()}`, priorityId: 3 }))
|
||||
|
||||
const descResult = await call(tools, 'list_tasks', { goalId: sortGoalId, sortBy: 'priority', descending: true, showCompleted: false })
|
||||
if (descResult.isError) {
|
||||
expect(descResult.content[0].text).toContain('403')
|
||||
return
|
||||
}
|
||||
const descIds = (parse(descResult) as Array<{ id: number }>).map((t) => t.id)
|
||||
expect(descIds).toEqual([high.id, mid.id, low.id])
|
||||
|
||||
const ascIds = (parse(await call(tools, 'list_tasks', { goalId: sortGoalId, sortBy: 'priority', descending: false, showCompleted: false })) as Array<{ id: number }>).map((t) => t.id)
|
||||
expect(ascIds).toEqual([low.id, mid.id, high.id])
|
||||
} finally {
|
||||
await call(tools, 'delete_goal', { goalId: sortGoalId }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
it('lists tasks sorted by date (desc newest-first, asc oldest-first)', async () => {
|
||||
const sortGoalId = parse(await call(tools, 'create_goal', { name: `Date Sort Test ${ts()}` })).id
|
||||
try {
|
||||
// Created oldest -> newest, so creation order is also the ascending date order.
|
||||
const first = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `first ${ts()}` }))
|
||||
const second = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `second ${ts()}` }))
|
||||
const third = parse(await call(tools, 'create_task', { goalId: sortGoalId, description: `third ${ts()}` }))
|
||||
|
||||
const descResult = await call(tools, 'list_tasks', { goalId: sortGoalId, sortBy: 'date', descending: true, showCompleted: false })
|
||||
if (descResult.isError) {
|
||||
expect(descResult.content[0].text).toContain('403')
|
||||
return
|
||||
}
|
||||
const descIds = (parse(descResult) as Array<{ id: number }>).map((t) => t.id)
|
||||
expect(descIds).toEqual([third.id, second.id, first.id])
|
||||
|
||||
const ascIds = (parse(await call(tools, 'list_tasks', { goalId: sortGoalId, sortBy: 'date', descending: false, showCompleted: false })) as Array<{ id: number }>).map((t) => t.id)
|
||||
expect(ascIds).toEqual([first.id, second.id, third.id])
|
||||
} finally {
|
||||
await call(tools, 'delete_goal', { goalId: sortGoalId }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
it('lists tasks filtered by componentId (list)', async () => {
|
||||
const list = parse(await call(tools, 'create_list', { goalId, name: `Filter List ${ts()}` }))
|
||||
const inList = parse(await call(tools, 'create_task', { goalId, goalListId: list.id, description: `In-list ${ts()}` }))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ALL_TASKS_LIST_ID } from 'taskview-api'
|
||||
import { registerTasksTools } from '../tools/tasks.js'
|
||||
import { mockServer, mockApi, apiReturn, apiThrow, findTool, ts } from './setup.js'
|
||||
|
||||
@@ -41,7 +42,7 @@ describe('tasks tools', () => {
|
||||
expect(captured.searchText).toBe('test')
|
||||
})
|
||||
|
||||
it('list_tasks defaults componentId and page', async () => {
|
||||
it('list_tasks applies defaults (all-tasks list, page 0, date sort ascending)', async () => {
|
||||
const { server, tools } = mockServer()
|
||||
let captured: Record<string, unknown> = {}
|
||||
const fetch = (params: Record<string, unknown>) => {
|
||||
@@ -51,9 +52,76 @@ describe('tasks tools', () => {
|
||||
registerTasksTools(server, mockApi({ tasks: { fetch } }))
|
||||
|
||||
await findTool(tools, 'list_tasks').cb({ goalId: 1 })
|
||||
expect(captured.componentId).toBe(0)
|
||||
expect(captured.page).toBe(1)
|
||||
expect(captured.componentId).toBe(ALL_TASKS_LIST_ID)
|
||||
expect(captured.page).toBe(0)
|
||||
expect(captured.showCompleted).toBe(0)
|
||||
expect(captured.sortBy).toBe('date')
|
||||
expect(captured.firstNew).toBe(0)
|
||||
})
|
||||
|
||||
it('list_tasks exposes sortBy and descending in its schema', () => {
|
||||
const { server, tools } = mockServer()
|
||||
registerTasksTools(server, mockApi())
|
||||
|
||||
const schema = findTool(tools, 'list_tasks').config.inputSchema as Record<string, unknown>
|
||||
expect(schema).toHaveProperty('sortBy')
|
||||
expect(schema).toHaveProperty('descending')
|
||||
})
|
||||
|
||||
it('list_tasks passes sortBy=priority with descending direction (firstNew=1)', async () => {
|
||||
const { server, tools } = mockServer()
|
||||
let captured: Record<string, unknown> = {}
|
||||
const fetch = (params: Record<string, unknown>) => {
|
||||
captured = params
|
||||
return Promise.resolve({ response: [], rid: `rid-${ts()}` })
|
||||
}
|
||||
registerTasksTools(server, mockApi({ tasks: { fetch } }))
|
||||
|
||||
await findTool(tools, 'list_tasks').cb({ goalId: 1, sortBy: 'priority', descending: true })
|
||||
expect(captured.sortBy).toBe('priority')
|
||||
expect(captured.firstNew).toBe(1)
|
||||
})
|
||||
|
||||
it('list_tasks maps descending=false to ascending direction (firstNew=0)', async () => {
|
||||
const { server, tools } = mockServer()
|
||||
let captured: Record<string, unknown> = {}
|
||||
const fetch = (params: Record<string, unknown>) => {
|
||||
captured = params
|
||||
return Promise.resolve({ response: [], rid: `rid-${ts()}` })
|
||||
}
|
||||
registerTasksTools(server, mockApi({ tasks: { fetch } }))
|
||||
|
||||
await findTool(tools, 'list_tasks').cb({ goalId: 1, sortBy: 'priority', descending: false })
|
||||
expect(captured.sortBy).toBe('priority')
|
||||
expect(captured.firstNew).toBe(0)
|
||||
})
|
||||
|
||||
it('list_tasks passes sortBy=date with descending direction (newest first, firstNew=1)', async () => {
|
||||
const { server, tools } = mockServer()
|
||||
let captured: Record<string, unknown> = {}
|
||||
const fetch = (params: Record<string, unknown>) => {
|
||||
captured = params
|
||||
return Promise.resolve({ response: [], rid: `rid-${ts()}` })
|
||||
}
|
||||
registerTasksTools(server, mockApi({ tasks: { fetch } }))
|
||||
|
||||
await findTool(tools, 'list_tasks').cb({ goalId: 1, sortBy: 'date', descending: true })
|
||||
expect(captured.sortBy).toBe('date')
|
||||
expect(captured.firstNew).toBe(1)
|
||||
})
|
||||
|
||||
it('list_tasks passes sortBy=date with ascending direction (oldest first, firstNew=0)', async () => {
|
||||
const { server, tools } = mockServer()
|
||||
let captured: Record<string, unknown> = {}
|
||||
const fetch = (params: Record<string, unknown>) => {
|
||||
captured = params
|
||||
return Promise.resolve({ response: [], rid: `rid-${ts()}` })
|
||||
}
|
||||
registerTasksTools(server, mockApi({ tasks: { fetch } }))
|
||||
|
||||
await findTool(tools, 'list_tasks').cb({ goalId: 1, sortBy: 'date', descending: false })
|
||||
expect(captured.sortBy).toBe('date')
|
||||
expect(captured.firstNew).toBe(0)
|
||||
})
|
||||
|
||||
it('get_task returns task', async () => {
|
||||
|
||||
@@ -11,11 +11,28 @@ import { registerNotificationsTools } from './tools/notifications.js'
|
||||
import { registerOrganizationsTools } from './tools/organizations.js'
|
||||
import { registerTimeTrackingTools } from './tools/time-tracking.js'
|
||||
|
||||
const INSTRUCTIONS = `TaskView is a project and task management platform.
|
||||
|
||||
TERMINOLOGY — IMPORTANT: a "goal" is a PROJECT. The two words are interchangeable. Every tool, parameter, and ID that mentions a "goal" (e.g. goalId, list_goals, create_goal) refers to a project — "goal" is TaskView's internal name for a project. When the user talks about "projects", use the *_goal tools.
|
||||
|
||||
DATA MODEL (top to bottom):
|
||||
- Organization — a workspace that groups projects and members.
|
||||
- Project (goal) — a project, identified by goalId. Managed with list_goals / create_goal / update_goal / delete_goal.
|
||||
- List (component) — a section/list inside a project, identified by componentId. Pass it to list_tasks to scope tasks to one list; omit it to see all tasks in the project.
|
||||
- Task — a unit of work inside a project (and optionally inside a list). Supports subtasks, assignees, tags, priority (1=low, 2=medium, 3=high), deadlines, and dependencies.
|
||||
|
||||
WORKFLOW: all IDs are numeric and must be resolved first — never guess them. Map a name to its id with the matching list_* tool (project → list_goals, list → list_lists, members → list_collaborators_for_goal, kanban columns → list_kanban_columns, tags → list_tags), then pass that id to the create/update/delete tools.
|
||||
|
||||
list_tasks is paginated: page is 0-based, ~30 tasks per page — request the next page until one returns fewer than 30. Completed tasks are hidden unless showCompleted is set. Use sortBy ("date" or "priority") with descending to control ordering.`
|
||||
|
||||
export function createMcpServer(api: TvApi) {
|
||||
const server = new McpServer({
|
||||
name: 'taskview',
|
||||
version: '1.0.0',
|
||||
})
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: 'taskview',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{ instructions: INSTRUCTIONS },
|
||||
)
|
||||
|
||||
registerGoalsTools(server, api)
|
||||
registerListsTools(server, api)
|
||||
|
||||
@@ -45,7 +45,7 @@ export function registerGoalsTools(server: McpServer, api: TvApi) {
|
||||
{
|
||||
description: 'Update a project (goal) — name, description, color',
|
||||
inputSchema: {
|
||||
id: z.coerce.number().describe('Goal ID'),
|
||||
id: z.coerce.number().describe('Project (goal) ID'),
|
||||
name: z.string().optional().describe('New name'),
|
||||
description: z.string().optional().describe('New description'),
|
||||
color: z.string().optional().describe('New color (hex)'),
|
||||
@@ -66,7 +66,7 @@ export function registerGoalsTools(server: McpServer, api: TvApi) {
|
||||
{
|
||||
description: 'Delete a project (goal)',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Goal ID to delete'),
|
||||
goalId: z.coerce.number().describe('Project (goal) ID to delete'),
|
||||
},
|
||||
},
|
||||
async ({ goalId }) => {
|
||||
|
||||
@@ -14,16 +14,21 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
|
||||
page: z.coerce.number().optional().default(0).describe('Page number (0-based, default: 0)'),
|
||||
showCompleted: z.boolean().optional().default(false).describe('Include completed tasks'),
|
||||
searchText: z.string().optional().describe('Search text to filter tasks'),
|
||||
sortBy: z.enum(['date', 'priority']).optional().default('date')
|
||||
.describe('Sort field: "date" (creation order) or "priority"'),
|
||||
descending: z.boolean().optional().default(false)
|
||||
.describe('Sort descending: newest first for date, highest priority first for priority'),
|
||||
},
|
||||
},
|
||||
async ({ goalId, componentId, page, showCompleted, searchText }) => {
|
||||
async ({ goalId, componentId, page, showCompleted, searchText, sortBy, descending }) => {
|
||||
try {
|
||||
const tasks = await api.tasks.fetch({
|
||||
goalId,
|
||||
componentId: componentId ?? ALL_TASKS_LIST_ID,
|
||||
page: page ?? 0,
|
||||
showCompleted: showCompleted ? 1 : 0,
|
||||
firstNew: 0,
|
||||
firstNew: descending ? 1 : 0,
|
||||
sortBy: sortBy ?? 'date',
|
||||
searchText,
|
||||
})
|
||||
return ok(tasks)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "web-nuxt-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "1.42.5",
|
||||
"version": "1.44.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build",
|
||||
|
||||
@@ -169,6 +169,13 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
||||
router.push({ name: 'account' })
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('userMenu.uiCustomization'),
|
||||
icon: 'i-lucide-sliders-horizontal',
|
||||
onSelect() {
|
||||
router.push({ name: 'ui-customization' })
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('userMenu.organizations'),
|
||||
icon: 'i-lucide-building-2',
|
||||
|
||||
@@ -144,5 +144,3 @@ watch(() => props.section, () => {
|
||||
watch(currentChartType, () => render())
|
||||
watch(locale, () => render())
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<UCard>
|
||||
<UCard :ui="{ body: 'p-2 lg:p-4' }">
|
||||
<template #header>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -22,7 +22,7 @@
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="section.payload.kind === 'series' && section.payload.datasets.length === 0"
|
||||
v-if="isEmpty"
|
||||
class="py-12 text-center text-sm text-zinc-500"
|
||||
>
|
||||
{{ t('analytics.sectionCard.noData') }}
|
||||
@@ -36,17 +36,29 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { AnalyticsSection } from 'taskview-api'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AnalyticsChart from './AnalyticsChart.vue'
|
||||
import AnalyticsHelpButton from './AnalyticsHelpButton.vue'
|
||||
import { useAnalyticsLocale } from './composables/useAnalyticsLocale'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
section: AnalyticsSection
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isEmpty = computed(() => {
|
||||
const p = props.section.payload
|
||||
if (p.kind !== 'series') return false
|
||||
if (p.datasets.length === 0) return true
|
||||
if (p.labels.length === 0) return true
|
||||
const allEmpty = p.datasets.every(d =>
|
||||
d.values.length === 0 || d.values.every(v => v === null || v === 0),
|
||||
)
|
||||
return allEmpty
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'drill-down', payload: { sectionId: string; datasetId: string; bucket: string; index: number; meta?: Record<string, unknown> }): void
|
||||
}>()
|
||||
@@ -57,5 +69,3 @@ function onDrillDown(section: AnalyticsSection, payload: { datasetId: string; bu
|
||||
emit('drill-down', { sectionId: section.id, ...payload })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ export function useAnalyticsChartConfig() {
|
||||
stacked: boolean,
|
||||
): AnyChartConfig {
|
||||
const labels = displayLabels(payload)
|
||||
const useMultiColor = payload.datasets.length === 1 && labels.length > 1
|
||||
const useMultiColor = payload.datasets.length === 1 && labels.length > 1 && payload.labelKind !== 'date'
|
||||
|
||||
return {
|
||||
type: 'bar',
|
||||
@@ -240,7 +240,7 @@ export function useAnalyticsChartConfig() {
|
||||
interaction: { mode: 'index' as const, intersect: false },
|
||||
plugins: {
|
||||
legend: {
|
||||
display: payload.datasets.length > 1,
|
||||
display: payload.datasets.length > 1 || payload.labelKind === 'date',
|
||||
position: 'bottom' as const,
|
||||
},
|
||||
tooltip: tooltipConfig(payload),
|
||||
|
||||
@@ -9,23 +9,30 @@ const palette: Record<AnalyticsColorToken, string> = {
|
||||
info: '#3b82f6',
|
||||
}
|
||||
|
||||
const fallbackOrder: AnalyticsColorToken[] = [
|
||||
'primary',
|
||||
'info',
|
||||
'warning',
|
||||
'success',
|
||||
'danger',
|
||||
'neutral',
|
||||
// Distinct hues for datasets that have no semantic colorToken. Excludes green
|
||||
// and red so they don't visually collide with the success/danger semantics
|
||||
// used on other charts.
|
||||
const fallbackPalette: string[] = [
|
||||
'#3b82f6', // blue
|
||||
'#a855f7', // purple
|
||||
'#ec4899', // pink
|
||||
'#f59e0b', // amber
|
||||
'#14b8a6', // teal
|
||||
'#6366f1', // indigo
|
||||
'#06b6d4', // cyan
|
||||
'#f97316', // orange
|
||||
'#8b5cf6', // violet
|
||||
'#d946ef', // fuchsia
|
||||
]
|
||||
|
||||
export function useAnalyticsTheme() {
|
||||
function colorFor(token: AnalyticsColorToken | undefined, index = 0): string {
|
||||
if (token) return palette[token]
|
||||
return palette[fallbackOrder[index % fallbackOrder.length]]
|
||||
return fallbackPalette[index % fallbackPalette.length]
|
||||
}
|
||||
|
||||
function paletteForCount(count: number): string[] {
|
||||
return Array.from({ length: count }, (_, i) => palette[fallbackOrder[i % fallbackOrder.length]])
|
||||
return Array.from({ length: count }, (_, i) => fallbackPalette[i % fallbackPalette.length])
|
||||
}
|
||||
|
||||
function transparentize(hex: string, alpha: number): string {
|
||||
|
||||
@@ -10,15 +10,25 @@
|
||||
:ui="{ root: 'flex flex-col flex-1 min-h-0', body: 'flex-1 min-h-0 overflow-y-auto' }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold">
|
||||
{{ organization.name }}
|
||||
</h3>
|
||||
<UButton
|
||||
icon="i-lucide-x"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="open = false"
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold">
|
||||
{{ organization.name }}
|
||||
</h3>
|
||||
<UButton
|
||||
icon="i-lucide-x"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="open = false"
|
||||
/>
|
||||
</div>
|
||||
<UAlert
|
||||
v-if="organization.isPersonal"
|
||||
:title="t('organizations.personal')"
|
||||
:description="t('organizations.personalHint')"
|
||||
icon="i-lucide-user"
|
||||
color="info"
|
||||
variant="subtle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -69,7 +79,7 @@
|
||||
<template #footer>
|
||||
<div class="flex justify-between">
|
||||
<UButton
|
||||
v-if="isOwner"
|
||||
v-if="isOwner && !organization.isPersonal"
|
||||
:label="t('organizations.delete')"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
|
||||
@@ -40,90 +40,82 @@
|
||||
<span class="truncate underline underline-offset-2">{{ task.sourceUrl }}</span>
|
||||
</a>
|
||||
|
||||
<!-- Subtasks -->
|
||||
<TaskSubtasks
|
||||
:parent-task-id="task.id"
|
||||
:goal-id="task.goalId"
|
||||
:subtasks="task.subtasks"
|
||||
class="pl-10"
|
||||
/>
|
||||
|
||||
<!-- Note -->
|
||||
<NoteEditor
|
||||
:key="task.id"
|
||||
:content="task.note || ''"
|
||||
:content-type="task.sourceUrl ? 'markdown' : 'html'"
|
||||
:placeholder="t('tasks.addNote')"
|
||||
@save="updateNote"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col-reverse @lg:flex-row gap-4">
|
||||
<!-- Status -->
|
||||
<TaskKanbanStatusSelect
|
||||
:task-id="task.id"
|
||||
:current-status-id="task.statusId"
|
||||
class="flex-1 w-full @lg:max-w-1/2"
|
||||
/>
|
||||
|
||||
<!-- Priority -->
|
||||
<TaskPrioritySelect
|
||||
:model-value="task.priorityId"
|
||||
class="flex-1 w-full @lg:max-w-1/2"
|
||||
@update:model-value="updatePriority"
|
||||
/>
|
||||
<div class="grid grid-cols-1 @lg:grid-cols-2 gap-4">
|
||||
<template
|
||||
v-for="fieldId in orderedFieldIds"
|
||||
:key="fieldId"
|
||||
>
|
||||
<TaskSubtasks
|
||||
v-if="fieldId === 'subtasks'"
|
||||
:parent-task-id="task.id"
|
||||
:goal-id="task.goalId"
|
||||
:subtasks="task.subtasks"
|
||||
:class="[colClass(fieldId), 'pl-10']"
|
||||
/>
|
||||
<NoteEditor
|
||||
v-else-if="fieldId === 'note'"
|
||||
:key="task.id"
|
||||
:content="task.note || ''"
|
||||
:content-type="task.sourceUrl ? 'markdown' : 'html'"
|
||||
:placeholder="t('tasks.addNote')"
|
||||
:class="colClass(fieldId)"
|
||||
@save="updateNote"
|
||||
/>
|
||||
<TaskKanbanStatusSelect
|
||||
v-else-if="fieldId === 'status'"
|
||||
:task-id="task.id"
|
||||
:current-status-id="task.statusId"
|
||||
:class="colClass(fieldId)"
|
||||
/>
|
||||
<TaskPrioritySelect
|
||||
v-else-if="fieldId === 'priority'"
|
||||
:model-value="task.priorityId"
|
||||
:class="colClass(fieldId)"
|
||||
@update:model-value="updatePriority"
|
||||
/>
|
||||
<TaskAssigneeSelect
|
||||
v-else-if="fieldId === 'assignees'"
|
||||
:task-id="task.id"
|
||||
:assigned-user-ids="task.assignedUsers"
|
||||
:class="colClass(fieldId)"
|
||||
/>
|
||||
<TaskListSelect
|
||||
v-else-if="fieldId === 'list'"
|
||||
:task-id="task.id"
|
||||
:current-list-id="task.goalListId"
|
||||
:class="colClass(fieldId)"
|
||||
/>
|
||||
<TaskTagsManager
|
||||
v-else-if="fieldId === 'tags'"
|
||||
:task-id="task.id"
|
||||
:task-tag-ids="task.tags"
|
||||
:goal-id="projectId"
|
||||
:class="colClass(fieldId)"
|
||||
/>
|
||||
<TaskDeadline
|
||||
v-else-if="fieldId === 'deadline'"
|
||||
:task="task"
|
||||
:class="[colClass(fieldId), 'h-fit']"
|
||||
/>
|
||||
<TaskAmountEditor
|
||||
v-else-if="fieldId === 'amount'"
|
||||
:task-id="task.id"
|
||||
:amount="task.amount"
|
||||
:transaction-type="task.transactionType"
|
||||
:class="colClass(fieldId)"
|
||||
/>
|
||||
<TaskTimeTracking
|
||||
v-else-if="fieldId === 'timeTracking'"
|
||||
:task-id="task.id"
|
||||
:class="colClass(fieldId)"
|
||||
/>
|
||||
<TaskHistory
|
||||
v-else-if="fieldId === 'history'"
|
||||
:task-id="task.id"
|
||||
:class="colClass(fieldId)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Assignees -->
|
||||
<div class="flex flex-col @lg:flex-row gap-4">
|
||||
<TaskAssigneeSelect
|
||||
:task-id="task.id"
|
||||
:assigned-user-ids="task.assignedUsers"
|
||||
class="flex-1 w-full @lg:max-w-1/2"
|
||||
/>
|
||||
|
||||
<!-- List -->
|
||||
<TaskListSelect
|
||||
:task-id="task.id"
|
||||
:current-list-id="task.goalListId"
|
||||
class="flex-1 w-full @lg:max-w-1/2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col @lg:flex-row gap-4">
|
||||
<!-- Tags -->
|
||||
<TaskTagsManager
|
||||
:task-id="task.id"
|
||||
:task-tag-ids="task.tags"
|
||||
:goal-id="projectId"
|
||||
class="flex-1 w-full @lg:max-w-1/2"
|
||||
/>
|
||||
<!-- Deadline -->
|
||||
<TaskDeadline
|
||||
:task="task"
|
||||
class="flex-1 w-full @lg:max-w-1/2 h-fit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Amount -->
|
||||
<TaskAmountEditor
|
||||
:task-id="task.id"
|
||||
:amount="task.amount"
|
||||
:transaction-type="task.transactionType"
|
||||
class="flex-1"
|
||||
/>
|
||||
|
||||
<!-- Time tracking -->
|
||||
<TaskTimeTracking
|
||||
v-if="canViewTimeTracking || canLogTime"
|
||||
:task-id="task.id"
|
||||
class="flex-1"
|
||||
/>
|
||||
|
||||
<!-- History -->
|
||||
<TaskHistory
|
||||
:task-id="task.id"
|
||||
class="flex-1"
|
||||
/>
|
||||
<br />
|
||||
</template>
|
||||
|
||||
@@ -157,15 +149,37 @@ import TaskDeadline from '@/components/features/tasks/parts/TaskDeadline.vue'
|
||||
import TaskSubtasks from '@/components/features/tasks/parts/TaskSubtasks.vue'
|
||||
import type { TaskBase } from 'taskview-api'
|
||||
import { useGoalPermissions } from '@/composables/useGoalPermissions'
|
||||
import { useUiPreferences } from '@/composables/useUiPreferences'
|
||||
import { tasksSection } from '@/uiCustomization/sections/tasks'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const tasksStore = useTasksStore()
|
||||
const {
|
||||
canEditTaskStatus,
|
||||
canViewTimeTracking,
|
||||
canLogTime,
|
||||
} = useGoalPermissions()
|
||||
|
||||
const { catalogue: taskFieldsCatalogue } = tasksSection.useSection()
|
||||
|
||||
const { resolved: resolvedTaskFields } = useUiPreferences(
|
||||
tasksSection.id,
|
||||
() => taskFieldsCatalogue.value,
|
||||
)
|
||||
const visibleFields = computed(() =>
|
||||
resolvedTaskFields.value.filter(f => !f.hidden),
|
||||
)
|
||||
const orderedFieldIds = computed(() => visibleFields.value.map(f => f.id))
|
||||
const fieldWidthById = computed(() => {
|
||||
const map = new Map<string, 'narrow' | 'wide'>()
|
||||
for (const f of visibleFields.value) {
|
||||
if (f.width) map.set(f.id, f.width)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function colClass(id: string): string {
|
||||
return fieldWidthById.value.get(id) === 'wide' ? '@lg:col-span-2' : 'w-full'
|
||||
}
|
||||
const task = computed(() => tasksStore.selectedTask ?? null)
|
||||
const projectId = computed(() => task.value?.goalId ?? 0)
|
||||
const titleValue = ref(task.value?.description ?? '')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-end gap-2 p-2 bg-tv-ui-bg-elevated rounded-lg">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 p-2 bg-tv-ui-bg-elevated rounded-lg">
|
||||
<!-- Filter -->
|
||||
<UTooltip :text="t('filters.title')">
|
||||
<UButton
|
||||
@@ -25,18 +25,29 @@
|
||||
/>
|
||||
</UTooltip>
|
||||
|
||||
<!-- Sort Order -->
|
||||
<UTooltip :text="sortTooltip">
|
||||
<UButton
|
||||
:icon="firstNew ? 'i-lucide-arrow-down-narrow-wide' : 'i-lucide-arrow-up-narrow-wide'"
|
||||
<!-- Sort -->
|
||||
<UFieldGroup size="sm">
|
||||
<USelect
|
||||
v-model="sortBy"
|
||||
:items="sortItems"
|
||||
value-key="value"
|
||||
icon="i-lucide-arrow-up-down"
|
||||
color="info"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
:loading="loading"
|
||||
variant="subtle"
|
||||
:disabled="loading"
|
||||
@click="toggleSort"
|
||||
:ui="{ base: 'min-w-32' }"
|
||||
/>
|
||||
</UTooltip>
|
||||
<UTooltip :text="sortTooltip">
|
||||
<UButton
|
||||
:icon="firstNew ? 'i-lucide-arrow-down-wide-narrow' : 'i-lucide-arrow-down-narrow-wide'"
|
||||
color="info"
|
||||
variant="subtle"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
@click="toggleSort"
|
||||
/>
|
||||
</UTooltip>
|
||||
</UFieldGroup>
|
||||
|
||||
<!-- Reset All -->
|
||||
<UButton
|
||||
@@ -107,6 +118,7 @@ import { computed, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { TaskSortBy } from 'taskview-api'
|
||||
import { useTasksStore } from '@/stores/tasks.store'
|
||||
import TasksFilterDrawer from './TasksFilterDrawer.vue'
|
||||
|
||||
@@ -133,13 +145,26 @@ const hasActiveFilters = computed(() => {
|
||||
const showCompleted = computed(() => fetchRules.value.showCompleted === 1)
|
||||
const firstNew = computed(() => fetchRules.value.firstNew === 1)
|
||||
|
||||
const sortItems = computed(() => [
|
||||
{ label: t('tasks.sortByDate'), value: 'date' as const },
|
||||
{ label: t('tasks.sortByPriority'), value: 'priority' as const },
|
||||
])
|
||||
|
||||
const sortBy = computed<TaskSortBy>({
|
||||
get: () => fetchRules.value.sortBy,
|
||||
set: (value) => applySort({ sortBy: value }),
|
||||
})
|
||||
|
||||
const showCompletedTooltip = computed(() =>
|
||||
showCompleted.value ? t('tasks.hideCompleted') : t('tasks.showCompleted'),
|
||||
)
|
||||
|
||||
const sortTooltip = computed(() =>
|
||||
firstNew.value ? t('tasks.sortNewestFirst') : t('tasks.sortOldestFirst'),
|
||||
)
|
||||
const sortTooltip = computed(() => {
|
||||
if (fetchRules.value.sortBy === 'priority') {
|
||||
return firstNew.value ? t('tasks.sortHighestFirst') : t('tasks.sortLowestFirst')
|
||||
}
|
||||
return firstNew.value ? t('tasks.sortNewestFirst') : t('tasks.sortOldestFirst')
|
||||
})
|
||||
|
||||
async function toggleCompleted() {
|
||||
if (loading.value) return
|
||||
@@ -162,14 +187,14 @@ async function toggleCompleted() {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function toggleSort() {
|
||||
async function applySort(rules: Partial<typeof fetchRules.value>) {
|
||||
if (loading.value) return
|
||||
|
||||
loading.value = true
|
||||
|
||||
tasksStore.resetTasks()
|
||||
tasksStore.updateFetchRules({
|
||||
firstNew: firstNew.value ? 0 : 1,
|
||||
...rules,
|
||||
currentPage: 0,
|
||||
endOfTasks: false,
|
||||
})
|
||||
@@ -178,6 +203,10 @@ async function toggleSort() {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function toggleSort() {
|
||||
return applySort({ firstNew: firstNew.value ? 0 : 1 })
|
||||
}
|
||||
|
||||
async function resetSearch() {
|
||||
loading.value = true
|
||||
tasksStore.resetTasks()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 p-3 bg-elevated rounded-lg">
|
||||
<UIcon
|
||||
name="i-lucide-grip-vertical"
|
||||
class="size-4 text-muted shrink-0 cursor-grab drag-handle"
|
||||
/>
|
||||
<span
|
||||
class="flex-1 truncate"
|
||||
:class="{ 'text-muted': hidden }"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<UButton
|
||||
v-if="width"
|
||||
:icon="width === 'wide' ? 'i-lucide-rectangle-horizontal' : 'i-lucide-square'"
|
||||
:label="width === 'wide' ? t('uiCustomization.wide') : t('uiCustomization.narrow')"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
@click="$emit('toggleWidth')"
|
||||
/>
|
||||
<UButton
|
||||
:icon="hidden ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
:label="hidden ? t('uiCustomization.show') : t('uiCustomization.hide')"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
@click="$emit('toggle')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { ItemWidth } from '@/composables/useUiPreferences'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
hidden: boolean
|
||||
width?: ItemWidth
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
toggle: []
|
||||
toggleWidth: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="resolved.length === 0"
|
||||
class="py-6 text-center text-sm text-muted"
|
||||
>
|
||||
{{ t('uiCustomization.empty') }}
|
||||
</div>
|
||||
|
||||
<draggable
|
||||
v-else
|
||||
:list="localItems"
|
||||
:animation="150"
|
||||
handle=".drag-handle"
|
||||
item-key="id"
|
||||
class="flex flex-col gap-2"
|
||||
@end="emitChange"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<UiCustomizationItem
|
||||
:label="element.label"
|
||||
:hidden="element.hidden"
|
||||
:width="element.width"
|
||||
@toggle="toggleHidden(element.id)"
|
||||
@toggle-width="toggleWidth(element.id)"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import draggable from 'vuedraggable'
|
||||
import type { UiPreferencesItem } from 'taskview-api'
|
||||
import type { ResolvedItem } from '@/composables/useUiPreferences'
|
||||
import UiCustomizationItem from './UiCustomizationItem.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
resolved: ResolvedItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [items: UiPreferencesItem[]]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const localItems = ref<ResolvedItem[]>([])
|
||||
|
||||
watch(() => props.resolved, (next) => {
|
||||
localItems.value = next.map(r => ({ ...r }))
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
const serialized = computed<UiPreferencesItem[]>(() =>
|
||||
localItems.value.map((item, idx) => ({
|
||||
id: item.id,
|
||||
order: idx,
|
||||
hidden: item.hidden,
|
||||
...(item.width !== undefined ? { width: item.width } : {}),
|
||||
})),
|
||||
)
|
||||
|
||||
function toggleHidden(id: string) {
|
||||
const target = localItems.value.find(item => item.id === id)
|
||||
if (!target) return
|
||||
target.hidden = !target.hidden
|
||||
emitChange()
|
||||
}
|
||||
|
||||
function toggleWidth(id: string) {
|
||||
const target = localItems.value.find(item => item.id === id)
|
||||
if (!target || !target.width) return
|
||||
target.width = target.width === 'wide' ? 'narrow' : 'wide'
|
||||
emitChange()
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
emit('change', serialized.value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
import { computed } from 'vue'
|
||||
import type { UiPreferencesItem } from 'taskview-api'
|
||||
import { useUiPreferencesStore } from '@/stores/uiPreferences.store'
|
||||
|
||||
export type ItemWidth = 'narrow' | 'wide'
|
||||
|
||||
export type CatalogueEntry<T extends string = string> = {
|
||||
id: T
|
||||
// Visible to user — must be passed in by the caller (it usually depends on i18n).
|
||||
label: string
|
||||
// Default width for the item. If set, the customization UI will render a
|
||||
// width toggle for this item. If undefined, width customization is disabled
|
||||
// for that item.
|
||||
width?: ItemWidth
|
||||
}
|
||||
|
||||
export type ResolvedItem<T extends string = string> = {
|
||||
id: T
|
||||
label: string
|
||||
hidden: boolean
|
||||
order: number
|
||||
width?: ItemWidth
|
||||
}
|
||||
|
||||
// Merges a catalogue of allowed items with stored user prefs.
|
||||
// Order: items present in prefs keep their saved order; items new to the catalogue
|
||||
// (never seen by this user) are appended in their catalogue order with hidden=false.
|
||||
// Width: user-saved width wins; otherwise catalogue default is used.
|
||||
export function useUiPreferences(section: string, catalogue: () => CatalogueEntry[]) {
|
||||
const store = useUiPreferencesStore()
|
||||
|
||||
const resolved = computed<ResolvedItem[]>(() => {
|
||||
const entries = catalogue()
|
||||
const entryById = new Map(entries.map(e => [e.id, e]))
|
||||
const allowedIds = new Set(entries.map(e => e.id))
|
||||
const saved: UiPreferencesItem[] = store.getSection(section)
|
||||
|
||||
const used = new Set<string>()
|
||||
const fromSaved: ResolvedItem[] = []
|
||||
for (const item of [...saved].sort((a, b) => a.order - b.order)) {
|
||||
const entry = entryById.get(item.id)
|
||||
if (!entry) continue
|
||||
if (!allowedIds.has(item.id)) continue
|
||||
used.add(item.id)
|
||||
fromSaved.push({
|
||||
id: item.id,
|
||||
label: entry.label,
|
||||
hidden: item.hidden,
|
||||
order: fromSaved.length,
|
||||
width: entry.width !== undefined ? (item.width ?? entry.width) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (used.has(entry.id)) continue
|
||||
fromSaved.push({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
hidden: false,
|
||||
order: fromSaved.length,
|
||||
width: entry.width,
|
||||
})
|
||||
}
|
||||
|
||||
return fromSaved
|
||||
})
|
||||
|
||||
const visibleIds = computed(() =>
|
||||
resolved.value.filter(r => !r.hidden).map(r => r.id),
|
||||
)
|
||||
|
||||
function isVisible(id: string): boolean {
|
||||
return visibleIds.value.includes(id)
|
||||
}
|
||||
|
||||
function orderOf(id: string): number {
|
||||
const idx = resolved.value.findIndex(r => r.id === id)
|
||||
return idx === -1 ? Number.MAX_SAFE_INTEGER : idx
|
||||
}
|
||||
|
||||
return { resolved, visibleIds, isVisible, orderOf }
|
||||
}
|
||||
@@ -59,6 +59,7 @@ import { usePushNotifications } from '@/composables/usePushNotifications'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import { useOrganizationStore } from '@/stores/organization.store'
|
||||
import { useTimeTrackingStore } from '@/stores/time-tracking.store'
|
||||
import { useUiPreferencesStore } from '@/stores/uiPreferences.store'
|
||||
|
||||
const { isSidebarOpen, isSidebarCollapsed } = useDashboard()
|
||||
const { connect: connectCentrifugo } = useCentrifugo()
|
||||
@@ -69,6 +70,7 @@ const appStore = useAppStore()
|
||||
const goalsStore = useGoalsStore()
|
||||
const orgStore = useOrganizationStore()
|
||||
const timeTrackingStore = useTimeTrackingStore()
|
||||
const uiPrefsStore = useUiPreferencesStore()
|
||||
|
||||
watch(
|
||||
() => timeTrackingStore.lastError,
|
||||
@@ -139,6 +141,7 @@ onMounted(async () => {
|
||||
|
||||
await goalsStore.fetchGoals()
|
||||
timeTrackingStore.fetchActive()
|
||||
if (!uiPrefsStore.loaded) uiPrefsStore.fetch()
|
||||
|
||||
useEventListener(document, 'visibilitychange', () => {
|
||||
if (!document.hidden) timeTrackingStore.fetchActive()
|
||||
|
||||
@@ -372,6 +372,11 @@ export default {
|
||||
hideCompleted: 'Hide completed',
|
||||
sortNewestFirst: 'Newest first',
|
||||
sortOldestFirst: 'Oldest first',
|
||||
sortBy: 'Sort by',
|
||||
sortByDate: 'Creation date',
|
||||
sortByPriority: 'Priority',
|
||||
sortHighestFirst: 'Highest first',
|
||||
sortLowestFirst: 'Lowest first',
|
||||
deleteConfirm: 'Delete Task',
|
||||
deleteMessage: 'Are you sure you want to delete "{name}"? This action cannot be undone.',
|
||||
},
|
||||
@@ -437,6 +442,7 @@ export default {
|
||||
github: 'GitHub repository',
|
||||
docker: 'Docker Images',
|
||||
accountSettings: 'Account settings',
|
||||
uiCustomization: 'UI customization',
|
||||
organizations: 'Organizations',
|
||||
analytics: 'Analytics',
|
||||
timeReports: 'Time reports',
|
||||
@@ -550,6 +556,35 @@ export default {
|
||||
roleUpdated: 'Role updated',
|
||||
close: 'Close',
|
||||
slugTaken: 'This slug is already taken',
|
||||
personal: 'This is your personal workspace',
|
||||
personalHint: 'Auto-created together with your account. It cannot be deleted.',
|
||||
},
|
||||
uiCustomization: {
|
||||
title: 'UI customization',
|
||||
description: 'Toggle visibility and reorder items across app sections. Drag by the handle on the left to reorder. All available items are shown here — permission checks apply at the render site and may still hide an item even when enabled.',
|
||||
show: 'Show',
|
||||
hide: 'Hide',
|
||||
narrow: 'Narrow',
|
||||
wide: 'Wide',
|
||||
empty: 'No items to customize',
|
||||
sections: {
|
||||
analyticsIndicators: 'Analytics — Indicators',
|
||||
analyticsCharts: 'Analytics — Charts',
|
||||
tasks: 'Tasks',
|
||||
},
|
||||
taskFields: {
|
||||
subtasks: 'Subtasks',
|
||||
note: 'Description',
|
||||
status: 'Status',
|
||||
priority: 'Priority',
|
||||
assignees: 'Assignees',
|
||||
list: 'List',
|
||||
tags: 'Tags',
|
||||
deadline: 'Deadline',
|
||||
amount: 'Amount',
|
||||
timeTracking: 'Time tracking',
|
||||
history: 'History',
|
||||
},
|
||||
},
|
||||
sso: {
|
||||
noConfig: 'SSO is not configured for this organization',
|
||||
|
||||
@@ -345,6 +345,11 @@ export default {
|
||||
hideCompleted: 'Скрыть выполненные',
|
||||
sortNewestFirst: 'Сначала новые',
|
||||
sortOldestFirst: 'Сначала старые',
|
||||
sortBy: 'Сортировка',
|
||||
sortByDate: 'Дате создания',
|
||||
sortByPriority: 'Приоритету',
|
||||
sortHighestFirst: 'Сначала высокий',
|
||||
sortLowestFirst: 'Сначала низкий',
|
||||
deleteConfirm: 'Удалить задачу',
|
||||
deleteMessage: 'Вы уверены, что хотите удалить "{name}"? Это действие нельзя отменить.',
|
||||
},
|
||||
@@ -410,6 +415,7 @@ export default {
|
||||
github: 'GitHub репозиторий',
|
||||
docker: 'Docker образы',
|
||||
accountSettings: 'Настройки аккаунта',
|
||||
uiCustomization: 'Настройка интерфейса',
|
||||
organizations: 'Организации',
|
||||
analytics: 'Аналитика',
|
||||
timeReports: 'Отчёты по времени',
|
||||
@@ -523,6 +529,35 @@ export default {
|
||||
roleUpdated: 'Роль обновлена',
|
||||
close: 'Закрыть',
|
||||
slugTaken: 'Этот slug уже занят',
|
||||
personal: 'Это персональный проект',
|
||||
personalHint: 'Создан автоматически вместе с аккаунтом. Удалить его нельзя.',
|
||||
},
|
||||
uiCustomization: {
|
||||
title: 'Настройка интерфейса',
|
||||
description: 'Управляйте видимостью и порядком элементов в разделах приложения. Перетаскивайте за иконку слева, чтобы изменить порядок. Здесь показаны все доступные элементы — права доступа проверяются на месте отображения и могут скрыть элемент даже если он включён.',
|
||||
show: 'Показывать',
|
||||
hide: 'Скрывать',
|
||||
narrow: 'Узкий',
|
||||
wide: 'Широкий',
|
||||
empty: 'Нет элементов для настройки',
|
||||
sections: {
|
||||
analyticsIndicators: 'Аналитика — показатели',
|
||||
analyticsCharts: 'Аналитика — графики',
|
||||
tasks: 'Задачи',
|
||||
},
|
||||
taskFields: {
|
||||
subtasks: 'Подзадачи',
|
||||
note: 'Описание',
|
||||
status: 'Статус',
|
||||
priority: 'Приоритет',
|
||||
assignees: 'Исполнители',
|
||||
list: 'Список',
|
||||
tags: 'Теги',
|
||||
deadline: 'Дедлайн',
|
||||
amount: 'Сумма',
|
||||
timeTracking: 'Учёт времени',
|
||||
history: 'История',
|
||||
},
|
||||
},
|
||||
sso: {
|
||||
noConfig: 'SSO не настроен для этой организации',
|
||||
|
||||
@@ -84,6 +84,11 @@ const router = createRouter({
|
||||
name: 'analytics',
|
||||
component: () => import('./pages/user/analytics.vue'),
|
||||
},
|
||||
{
|
||||
path: 'ui-customization',
|
||||
name: 'ui-customization',
|
||||
component: () => import('./pages/user/ui-customization.vue'),
|
||||
},
|
||||
{
|
||||
path: 'time-reports',
|
||||
name: 'time-reports',
|
||||
|
||||
@@ -31,11 +31,11 @@
|
||||
|
||||
<template v-else-if="analyticsStore.sections.length > 0">
|
||||
<div
|
||||
v-if="analyticsStore.kpiSections.length > 0"
|
||||
v-if="visibleKpis.length > 0"
|
||||
class="grid grid-cols-2 gap-3 lg:grid-cols-4"
|
||||
>
|
||||
<AnalyticsKpiCard
|
||||
v-for="kpi in analyticsStore.kpiSections"
|
||||
v-for="kpi in visibleKpis"
|
||||
:key="kpi.id"
|
||||
:section="kpi"
|
||||
@drill-down="onDrillDown"
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<AnalyticsSectionCard
|
||||
v-for="section in analyticsStore.chartSections"
|
||||
v-for="section in visibleCharts"
|
||||
:key="section.id"
|
||||
:section="section"
|
||||
@drill-down="onDrillDown"
|
||||
@@ -83,6 +83,7 @@ import { useAnalyticsLocale } from '@/components/features/analytics/composables/
|
||||
import { useAnalyticsStore } from '@/stores/analytics.store'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import { useOrganizationStore } from '@/stores/organization.store'
|
||||
import { useUiPreferences } from '@/composables/useUiPreferences'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { pick } = useAnalyticsLocale()
|
||||
@@ -92,6 +93,28 @@ const orgStore = useOrganizationStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const kpiCatalogue = computed(() =>
|
||||
analyticsStore.kpiSections.map(s => ({ id: s.id, label: pick(s.title) })),
|
||||
)
|
||||
const chartsCatalogue = computed(() =>
|
||||
analyticsStore.chartSections.map(s => ({ id: s.id, label: pick(s.title) })),
|
||||
)
|
||||
const { isVisible: isKpiVisible, orderOf: kpiOrder } =
|
||||
useUiPreferences('analyticsIndicators', () => kpiCatalogue.value)
|
||||
const { isVisible: isChartVisible, orderOf: chartOrder } =
|
||||
useUiPreferences('analyticsCharts', () => chartsCatalogue.value)
|
||||
|
||||
const visibleKpis = computed(() =>
|
||||
analyticsStore.kpiSections
|
||||
.filter(s => isKpiVisible(s.id))
|
||||
.sort((a, b) => kpiOrder(a.id) - kpiOrder(b.id)),
|
||||
)
|
||||
const visibleCharts = computed(() =>
|
||||
analyticsStore.chartSections
|
||||
.filter(s => isChartVisible(s.id))
|
||||
.sort((a, b) => chartOrder(a.id) - chartOrder(b.id)),
|
||||
)
|
||||
|
||||
const errorMessage = computed(() => {
|
||||
const e = analyticsStore.error
|
||||
if (!e) return null
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<UDashboardPanel id="ui-customization">
|
||||
<template #header>
|
||||
<UDashboardNavbar :title="t('uiCustomization.title')">
|
||||
<template #leading>
|
||||
<UDashboardSidebarCollapse />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-6 p-2 lg:p-6 max-w-3xl mx-auto w-full">
|
||||
<p class="text-sm text-muted">
|
||||
{{ t('uiCustomization.description') }}
|
||||
</p>
|
||||
|
||||
<UTabs
|
||||
v-model="activeTab"
|
||||
:items="tabs"
|
||||
:orientation="isMobile ? 'vertical' : 'horizontal'"
|
||||
class="w-full"
|
||||
:ui="{ root: 'flex-col', list: 'w-full' }"
|
||||
>
|
||||
<template
|
||||
v-for="s in sections"
|
||||
:key="s.id"
|
||||
#[s.id]
|
||||
>
|
||||
<UiCustomizationSection
|
||||
:title="t(s.labelKey)"
|
||||
:resolved="s.resolved"
|
||||
@change="items => prefsStore.setSection(s.id, items)"
|
||||
/>
|
||||
</template>
|
||||
</UTabs>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import UiCustomizationSection from '@/components/features/ui-customization/UiCustomizationSection.vue'
|
||||
import { useUiPreferences } from '@/composables/useUiPreferences'
|
||||
import { uiCustomizationSections } from '@/uiCustomization/registry'
|
||||
import { useUiPreferencesStore } from '@/stores/uiPreferences.store'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isMobile } = useTaskView()
|
||||
const prefsStore = useUiPreferencesStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const sections = reactive(
|
||||
uiCustomizationSections.map((def) => {
|
||||
const used = def.useSection()
|
||||
const prefs = useUiPreferences(def.id, () => used.catalogue.value)
|
||||
return {
|
||||
id: def.id,
|
||||
labelKey: def.labelKey,
|
||||
resolved: prefs.resolved,
|
||||
init: used.init,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const tabs = computed(() =>
|
||||
sections.map(s => ({ value: s.id, label: t(s.labelKey), slot: s.id })),
|
||||
)
|
||||
|
||||
const activeTab = computed({
|
||||
get: () => {
|
||||
const q = route.query.tab
|
||||
return typeof q === 'string' && sections.some(s => s.id === q) ? q : sections[0].id
|
||||
},
|
||||
set: (next: string) => {
|
||||
router.replace({ query: { ...route.query, tab: next } }).catch(() => { })
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!prefsStore.loaded) await prefsStore.fetch()
|
||||
await Promise.all(sections.map(s => s.init?.()))
|
||||
})
|
||||
</script>
|
||||
@@ -37,6 +37,7 @@ export const useAnalyticsStore = defineStore('analytics', {
|
||||
customFrom: null,
|
||||
customTo: null,
|
||||
sections: [],
|
||||
sectionsCatalog: [],
|
||||
failedSectionIds: [],
|
||||
availableGoals: [],
|
||||
range: null,
|
||||
@@ -68,6 +69,15 @@ export const useAnalyticsStore = defineStore('analytics', {
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async fetchSectionsCatalog(): Promise<void> {
|
||||
try {
|
||||
const result = await $tvApi.analytics.fetchSectionsCatalog()
|
||||
this.sectionsCatalog = result ?? []
|
||||
} catch {
|
||||
this.sectionsCatalog = []
|
||||
}
|
||||
},
|
||||
|
||||
setScope(scope: AnalyticsScope) {
|
||||
this.scope = scope
|
||||
return this.fetchSections()
|
||||
|
||||
@@ -27,6 +27,7 @@ export const useTasksStore = defineStore('tasks', {
|
||||
currentPage: 0,
|
||||
searchText: '',
|
||||
firstNew: 1,
|
||||
sortBy: 'date',
|
||||
filters: {},
|
||||
goalId: -1,
|
||||
},
|
||||
@@ -135,6 +136,7 @@ export const useTasksStore = defineStore('tasks', {
|
||||
showCompleted: +showCompleted as 0 | 1,
|
||||
goalId: +goalId,
|
||||
firstNew: +firstNew as 0 | 1,
|
||||
sortBy: this.fetchRules.sortBy,
|
||||
componentId: ALL_TASKS_LIST_ID,
|
||||
page: +this.fetchRules.currentPage,
|
||||
searchText: this.fetchRules.searchText,
|
||||
@@ -162,6 +164,7 @@ export const useTasksStore = defineStore('tasks', {
|
||||
showCompleted: +this.fetchRules.showCompleted as 0 | 1,
|
||||
goalId: +this.fetchRules.goalId,
|
||||
firstNew: +this.fetchRules.firstNew as 0 | 1,
|
||||
sortBy: this.fetchRules.sortBy,
|
||||
componentId: +this.fetchRules.currentListId,
|
||||
page: +this.fetchRules.currentPage,
|
||||
searchText: this.fetchRules.searchText,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import type { UiPreferences, UiPreferencesItem } from 'taskview-api'
|
||||
import { $tvApi } from '@/plugins/axios'
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 250
|
||||
|
||||
export const useUiPreferencesStore = defineStore('uiPreferences', () => {
|
||||
const prefs = ref<UiPreferences>({})
|
||||
const loaded = ref(false)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function fetch() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await $tvApi.uiPreferences.fetch()
|
||||
prefs.value = result ?? {}
|
||||
loaded.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const flush = useDebounceFn(async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await $tvApi.uiPreferences.update(prefs.value)
|
||||
if (result) prefs.value = result
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}, SAVE_DEBOUNCE_MS)
|
||||
|
||||
function setSection(section: string, items: UiPreferencesItem[]) {
|
||||
prefs.value = { ...prefs.value, [section]: items }
|
||||
flush()
|
||||
}
|
||||
|
||||
function getSection(section: string): UiPreferencesItem[] {
|
||||
return prefs.value[section] ?? []
|
||||
}
|
||||
|
||||
return {
|
||||
prefs,
|
||||
loaded,
|
||||
loading,
|
||||
saving,
|
||||
fetch,
|
||||
setSection,
|
||||
getSection,
|
||||
}
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AnalyticsPeriod,
|
||||
AnalyticsScope,
|
||||
AnalyticsSection,
|
||||
AnalyticsSectionCatalogEntry,
|
||||
AnalyticsSectionsResponse,
|
||||
} from 'taskview-api'
|
||||
|
||||
@@ -31,6 +32,7 @@ export type AnalyticsState = {
|
||||
customFrom: string | null
|
||||
customTo: string | null
|
||||
sections: AnalyticsSection[]
|
||||
sectionsCatalog: AnalyticsSectionCatalogEntry[]
|
||||
failedSectionIds: string[]
|
||||
availableGoals: AnalyticsAvailableGoal[]
|
||||
range: AnalyticsSectionsResponse['range'] | null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GoalItem, GoalListItem, Task, TaskFilters } from 'taskview-api'
|
||||
import type { GoalItem, GoalListItem, Task, TaskFilters, TaskSortBy } from 'taskview-api'
|
||||
|
||||
export type TaskItem = Task;
|
||||
|
||||
@@ -19,6 +19,7 @@ export type TasksStoreState = {
|
||||
currentPage: number;
|
||||
searchText: string;
|
||||
firstNew: 1 | 0;
|
||||
sortBy: TaskSortBy;
|
||||
filters: TaskFilters;
|
||||
};
|
||||
loading: boolean;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { analyticsIndicatorsSection, analyticsChartsSection } from './sections/analytics'
|
||||
import { tasksSection } from './sections/tasks'
|
||||
import type { UiCustomizationSectionDef } from './types'
|
||||
|
||||
export const uiCustomizationSections: UiCustomizationSectionDef[] = [
|
||||
analyticsIndicatorsSection,
|
||||
analyticsChartsSection,
|
||||
tasksSection,
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
import { computed } from 'vue'
|
||||
import { useAnalyticsLocale } from '@/components/features/analytics/composables/useAnalyticsLocale'
|
||||
import { useAnalyticsStore } from '@/stores/analytics.store'
|
||||
import type { UiCustomizationSectionDef } from '../types'
|
||||
|
||||
type AnalyticsSubsectionArgs = {
|
||||
id: string
|
||||
labelKey: string
|
||||
payloadKind: 'kpi' | 'series'
|
||||
}
|
||||
|
||||
function buildAnalyticsSection(args: AnalyticsSubsectionArgs): UiCustomizationSectionDef {
|
||||
const { id, labelKey, payloadKind } = args
|
||||
return {
|
||||
id,
|
||||
labelKey,
|
||||
useSection() {
|
||||
const store = useAnalyticsStore()
|
||||
const { pick } = useAnalyticsLocale()
|
||||
return {
|
||||
catalogue: computed(() =>
|
||||
store.sectionsCatalog
|
||||
.filter(entry => entry.payloadKind === payloadKind)
|
||||
.map(entry => ({
|
||||
id: entry.id,
|
||||
label: pick(entry.title),
|
||||
})),
|
||||
),
|
||||
init: async () => {
|
||||
if (store.sectionsCatalog.length === 0) {
|
||||
await store.fetchSectionsCatalog()
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const analyticsIndicatorsSection = buildAnalyticsSection({
|
||||
id: 'analyticsIndicators',
|
||||
labelKey: 'uiCustomization.sections.analyticsIndicators',
|
||||
payloadKind: 'kpi',
|
||||
})
|
||||
|
||||
export const analyticsChartsSection = buildAnalyticsSection({
|
||||
id: 'analyticsCharts',
|
||||
labelKey: 'uiCustomization.sections.analyticsCharts',
|
||||
payloadKind: 'series',
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { UiCustomizationSectionDef } from '../types'
|
||||
import { TASK_DETAIL_FIELDS } from './tasks.types'
|
||||
|
||||
export const tasksSection: UiCustomizationSectionDef = {
|
||||
id: 'tasks',
|
||||
labelKey: 'uiCustomization.sections.tasks',
|
||||
useSection() {
|
||||
const { t } = useI18n()
|
||||
return {
|
||||
catalogue: computed(() =>
|
||||
TASK_DETAIL_FIELDS.map(f => ({
|
||||
id: f.id,
|
||||
label: t(`uiCustomization.taskFields.${f.id}`),
|
||||
width: f.width,
|
||||
})),
|
||||
),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type TaskDetailFieldWidth = 'narrow' | 'wide'
|
||||
|
||||
export type TaskDetailField = {
|
||||
id: string
|
||||
width: TaskDetailFieldWidth
|
||||
}
|
||||
|
||||
export const TASK_DETAIL_FIELDS = [
|
||||
{ id: 'subtasks', width: 'wide' },
|
||||
{ id: 'note', width: 'wide' },
|
||||
{ id: 'status', width: 'narrow' },
|
||||
{ id: 'priority', width: 'narrow' },
|
||||
{ id: 'assignees', width: 'narrow' },
|
||||
{ id: 'list', width: 'narrow' },
|
||||
{ id: 'tags', width: 'narrow' },
|
||||
{ id: 'deadline', width: 'narrow' },
|
||||
{ id: 'amount', width: 'wide' },
|
||||
{ id: 'timeTracking', width: 'wide' },
|
||||
{ id: 'history', width: 'wide' },
|
||||
] as const satisfies readonly TaskDetailField[]
|
||||
|
||||
export type TaskDetailFieldId = typeof TASK_DETAIL_FIELDS[number]['id']
|
||||
|
||||
const widthById = new Map<string, TaskDetailFieldWidth>(
|
||||
TASK_DETAIL_FIELDS.map(f => [f.id, f.width]),
|
||||
)
|
||||
|
||||
export function taskFieldWidth(id: string): TaskDetailFieldWidth {
|
||||
return widthById.get(id) ?? 'narrow'
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ComputedRef } from 'vue'
|
||||
import type { CatalogueEntry } from '@/composables/useUiPreferences'
|
||||
|
||||
export type UiCustomizationSectionUseReturn = {
|
||||
catalogue: ComputedRef<CatalogueEntry[]>
|
||||
init?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export type UiCustomizationSectionDef = {
|
||||
id: string
|
||||
labelKey: string
|
||||
useSection: () => UiCustomizationSectionUseReturn
|
||||
}
|
||||
Reference in New Issue
Block a user