Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1ff303e58 | |||
| 22dc24f8e0 | |||
| 58e3cdcbb0 | |||
| 28e944f05e | |||
| 8f21a52ca6 | |||
| 96ac9f4d7a | |||
| 4c92aaf03d |
@@ -1,162 +1,419 @@
|
||||
# TaskView™
|
||||
|
||||

|
||||
<p align="center">
|
||||
<img src="./assets/taskview/kanban-dark.png" alt="TaskView logo" width="320">
|
||||
</p>
|
||||
|
||||
TaskView is a self-hosted project and task management platform focused on clarity, ownership, and control.
|
||||
TaskView is built for teams that want a transparent, self-hosted alternative to SaaS task managers.
|
||||
<p align="center">
|
||||
<strong>Self-hosted project management for software teams.</strong>
|
||||
</p>
|
||||
|
||||
[](./LICENSE)
|
||||
[]()
|
||||
|
||||
[**Live demo**](https://app.taskview.tech) · [**Documentation**](https://taskview.tech/docs/) · [**iOS**](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867) · [**Android**](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app&hl=en)
|
||||
<p align="center">
|
||||
TaskView combines task management, custom workflows, developer integrations,
|
||||
analytics, and AI-assisted automation in a platform you can run on your own infrastructure.
|
||||
</p>
|
||||
|
||||
## Apps
|
||||
* [Docs](https://taskview.tech/docs/)
|
||||
* [Web](https://app.taskview.tech/)
|
||||
* [iOS](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867)
|
||||
* [Android](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app&hl=en)
|
||||
<details>
|
||||
<summary><strong>View more screenshots</strong></summary>
|
||||
|
||||
It is designed for teams and individuals who want:
|
||||
- full control over their data
|
||||
- transparent architecture
|
||||
- predictable workflows
|
||||
- the ability to run the system on their own infrastructure
|
||||
<br>
|
||||
|
||||
## Key Features
|
||||
- Projects, lists, tasks, and subtasks
|
||||
- Kanban boards
|
||||
- Clear responsibility assignment
|
||||
- Permissions and roles
|
||||
- Web and mobile clients
|
||||
- Self-hosted by design
|
||||
<img src="./assets/taskview/main-light.png" alt="Main dashboard (light theme)" width="100%">
|
||||
<img src="./assets/taskview/main-dark.png" alt="Main dashboard (dark theme)" width="100%">
|
||||
<img src="./assets/taskview/main-collapsed-light.png" alt="Main dashboard with collapsed sidebar (light theme)" width="100%">
|
||||
<img src="./assets/taskview/main-collapsed-dark.png" alt="Main dashboard with collapsed sidebar (dark theme)" width="100%">
|
||||
<img src="./assets/taskview/kanban-light.png" alt="Kanban board (light theme)" width="100%">
|
||||
<img src="./assets/taskview/kanban-dark.png" alt="Kanban board (dark theme)" width="100%">
|
||||
<img src="./assets/taskview/tasks-light.png" alt="Task list view (light theme)" width="100%">
|
||||
<img src="./assets/taskview/tasks-dark.png" alt="Task list view (dark theme)" width="100%">
|
||||
<img src="./assets/taskview/recent-tasks.png" alt="Recent tasks overview" width="100%">
|
||||
<img src="./assets/taskview/sprints.png" alt="Sprint planning" width="100%">
|
||||
<img src="./assets/taskview/time-tracking.png" alt="Time tracking" width="100%">
|
||||
<img src="./assets/taskview/analytics.png" alt="Analytics dashboard" width="100%">
|
||||
<img src="./assets/taskview/members.png" alt="Organization members management" width="100%">
|
||||
<img src="./assets/taskview/permissions-light.png" alt="Role permissions (light theme)" width="100%">
|
||||
<img src="./assets/taskview/permissions-dark.png" alt="Role permissions (dark theme)" width="100%">
|
||||
<img src="./assets/taskview/sso.png" alt="Single Sign-On (SSO) configuration" width="100%">
|
||||
<img src="./assets/taskview/api-tokens.png" alt="API tokens management" width="100%">
|
||||
<img src="./assets/taskview/webhooks.png" alt="Webhooks configuration" width="100%">
|
||||
<img src="./assets/taskview/integrations.png" alt="Integrations" width="100%">
|
||||
<img src="./assets/taskview/ui-customization.png" alt="UI customization" width="100%">
|
||||
<img src="./assets/taskview/settings.png" alt="Settings" width="100%">
|
||||
|
||||
## Licensing
|
||||
</details>
|
||||
|
||||
TaskView uses a source-available license.
|
||||
<p align="center">
|
||||
<a href="https://app.taskview.tech"><strong>TaskView Cloud</strong></a>
|
||||
·
|
||||
<a href="https://taskview.tech/docs/"><strong>Documentation</strong></a>
|
||||
·
|
||||
<a href="https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867"><strong>iOS</strong></a>
|
||||
·
|
||||
<a href="https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app"><strong>Android</strong></a>
|
||||
·
|
||||
<a href="https://github.com/Gimanh/taskview-community/releases">
|
||||
<strong>Releases</strong>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
You may:
|
||||
- self-host TaskView
|
||||
- modify the source code
|
||||
- use it internally within your organization
|
||||
<p align="center">
|
||||
<a href="./LICENSE">
|
||||
<img src="https://img.shields.io/badge/license-Source--Available-blue" alt="Source-Available License">
|
||||
</a>
|
||||
<img src="https://img.shields.io/badge/status-active-brightgreen" alt="Active development">
|
||||
<img src="https://img.shields.io/badge/self--hosted-Docker-2496ED" alt="Docker self-hosted">
|
||||
</p>
|
||||
|
||||
You may not:
|
||||
- offer TaskView as a hosted SaaS
|
||||
- sell TaskView as a service
|
||||
- create a competing commercial product
|
||||
- use the TaskView name or branding for derivative products
|
||||
---
|
||||
|
||||
See LICENSE for full terms.
|
||||
TaskView is a trademark of its authors.
|
||||
## About TaskView
|
||||
|
||||
## Architecture
|
||||
TaskView is a source-available project and task management platform for small and growing software teams.
|
||||
|
||||
This repository is a monorepo.
|
||||
It is designed for teams that need more structure than a simple task board, but do not want the administration overhead and complexity of large enterprise project management systems.
|
||||
|
||||
Main product packages:
|
||||
With TaskView, you can manage projects, build custom workflows, track dependencies and time, connect development tools, and automate project operations through the API, webhooks, and MCP.
|
||||
|
||||
- `api` – backend (Node.js)
|
||||
- `web` – web client and mobile
|
||||
- `taskview-packages/taskview-api` – API client library for building integrations and making HTTP requests
|
||||
- `taskview-packages/taskview-db-schemas` - Drizzle ORM schemas
|
||||
Your projects. Your infrastructure. Your data.
|
||||
|
||||
Product packages share a single version.
|
||||
## Who TaskView is for
|
||||
|
||||
TaskView is a good fit for:
|
||||
|
||||
## Versioning
|
||||
* software development teams;
|
||||
* startups and small product companies;
|
||||
* agencies managing internal and client projects;
|
||||
* organizations that require self-hosting and data ownership;
|
||||
* teams looking for a lightweight alternative to complex enterprise platforms;
|
||||
* developers using AI agents to manage and automate project work;
|
||||
* individuals managing technical or personal projects.
|
||||
|
||||
TaskView follows semantic versioning:
|
||||
- `MAJOR` – breaking API or data changes
|
||||
- `MINOR` – new features
|
||||
- `PATCH` – bug fixes
|
||||
The version represents the entire product, not individual packages.
|
||||
## Why TaskView
|
||||
|
||||
* **Own your data** - run TaskView on your own infrastructure.
|
||||
* **Flexible workflows** - configure projects, statuses, boards, roles, and permissions.
|
||||
* **Built for development teams** - connect GitHub, GitLab, API clients, and webhooks.
|
||||
* **AI-ready** - allow MCP-compatible assistants to work with tasks and projects.
|
||||
* **Available everywhere** - use TaskView from the web, iOS, and Android.
|
||||
* **No advertising or behavioral tracking** - your project data remains under your control.
|
||||
|
||||
## Status
|
||||
## Features
|
||||
|
||||
TaskView is under active development.
|
||||
Breaking changes may occur between versions.
|
||||
### Project and task management
|
||||
|
||||
## Getting started
|
||||
* Projects, lists, tasks, and nested subtasks
|
||||
* Custom Kanban boards and task statuses
|
||||
* Priorities, tags, deadlines, and assignees
|
||||
* Task dependency graph
|
||||
* Sprints and recurring tasks
|
||||
* Task history and activity tracking
|
||||
* Markdown content
|
||||
* Search and filtering
|
||||
* Reusable project workflows
|
||||
|
||||
This setup is intended for local development.
|
||||
### Team collaboration
|
||||
|
||||
### Development
|
||||
* Multiple organizations and projects
|
||||
* User invites and granular permissions
|
||||
* Clear responsibility assignment
|
||||
* Real-time updates
|
||||
* Notifications
|
||||
* Organization-level user management
|
||||
|
||||
**Prerequisites:**
|
||||
- Docker
|
||||
- PostgreSQL
|
||||
- On macOS you can use https://postgresapp.com/
|
||||
- Bun (used for API development)
|
||||
- pnpm
|
||||
### Identity and access management
|
||||
|
||||
* SAML 2.0
|
||||
* OpenID Connect
|
||||
* SCIM provisioning
|
||||
* Scoped API tokens
|
||||
* Project-level access restrictions
|
||||
* Fine-grained token permissions
|
||||
|
||||
### Developer integrations
|
||||
|
||||
* GitHub integration
|
||||
* GitLab integration
|
||||
* Signed webhooks
|
||||
* TypeScript API client
|
||||
* Public HTTP API
|
||||
* MCP server for AI assistants
|
||||
|
||||
### Time tracking and analytics
|
||||
|
||||
* Built-in time tracking
|
||||
* Billable and non-billable time
|
||||
* Project and team workload reports
|
||||
* Productivity analytics
|
||||
* Income and expense tracking
|
||||
* Financial reports
|
||||
|
||||
### Applications
|
||||
|
||||
* Web application
|
||||
* iOS application
|
||||
* Android application
|
||||
* Self-hosted server deployment
|
||||
|
||||
## AI and MCP integration
|
||||
|
||||
TaskView includes an MCP server that allows compatible AI tools to interact with your projects.
|
||||
|
||||
Depending on the permissions assigned to an API token, an AI assistant can:
|
||||
|
||||
* search and inspect projects;
|
||||
* create and update tasks;
|
||||
* manage task statuses;
|
||||
* work with project lists;
|
||||
* retrieve task context;
|
||||
* automate repetitive project operations.
|
||||
|
||||
MCP access can be restricted by permission and by selected projects.
|
||||
|
||||
See the [TaskView MCP documentation](https://taskview.tech/docs/integrations/mcp) for configuration examples.
|
||||
|
||||
## Quick start
|
||||
|
||||
Clone the repository:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/Gimanh/taskview-community.git
|
||||
cd taskview-community
|
||||
```
|
||||
|
||||
The recommended way to run TaskView is with Docker Compose.
|
||||
|
||||
For deployment instructions, environment variables, HTTPS configuration, updates, and backups, see the official documentation:
|
||||
|
||||
[TaskView self-hosting documentation](https://taskview.tech/docs/)
|
||||
|
||||
> Production deployments should use persistent volumes, HTTPS, regular database backups, and securely generated secrets.
|
||||
|
||||
## Repository structure
|
||||
|
||||
TaskView is maintained as a monorepo. Product packages share a single version.
|
||||
|
||||
```text
|
||||
taskview-community/
|
||||
├── api/
|
||||
│ └── TaskView backend and HTTP API
|
||||
├── web/
|
||||
│ └── Vue web application and Capacitor mobile application
|
||||
├── taskview-packages/
|
||||
│ ├── taskview-api/
|
||||
│ │ └── TypeScript API client
|
||||
│ └── taskview-db-schemas/
|
||||
│ │ └── Shared Drizzle ORM schemas
|
||||
│ └── taskview-mcp/
|
||||
│ └── MCP server for AI integrations
|
||||
└── build-dockers.sh
|
||||
└── Docker image build script
|
||||
```
|
||||
|
||||
### Main packages
|
||||
|
||||
| Package | Description |
|
||||
| --------------------------------------- | ----------------------------------------------- |
|
||||
| `api` | Node.js backend and API server |
|
||||
| `web` | Vue web client and Capacitor mobile application |
|
||||
| `taskview-packages/taskview-api` | TypeScript client for the TaskView API |
|
||||
| `taskview-packages/taskview-db-schemas` | Shared Drizzle ORM database schemas |
|
||||
| `taskview-packages/taskview-mcp` | MCP server for AI integrations |
|
||||
|
||||
## Technology stack
|
||||
|
||||
TaskView is primarily built with:
|
||||
|
||||
* Vue
|
||||
* Nuxt UI
|
||||
* TypeScript
|
||||
* Node.js
|
||||
* Express
|
||||
* PostgreSQL
|
||||
* Drizzle ORM
|
||||
* Capacitor
|
||||
* Docker
|
||||
|
||||
## Local development
|
||||
|
||||
These instructions are intended for contributors and local development, not for production deployment.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
You need the following tools:
|
||||
|
||||
* Docker
|
||||
* PostgreSQL
|
||||
* Bun
|
||||
* pnpm
|
||||
|
||||
On macOS, PostgreSQL can be installed using [Postgres.app](https://postgresapp.com/).
|
||||
|
||||
### Install dependencies
|
||||
|
||||
From the repository root:
|
||||
|
||||
**Steps:**
|
||||
1. Install dependencies:
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. Create an `.env` file in the **api** directory based on `.env.example` and configure your local credentials.
|
||||
|
||||
3. Run the web application:
|
||||
### Configure the API
|
||||
|
||||
Create an environment file in the `api` directory:
|
||||
|
||||
```sh
|
||||
cp api/.env.example api/.env
|
||||
```
|
||||
|
||||
Update `api/.env` with your local database credentials and application configuration.
|
||||
|
||||
### Start the web application
|
||||
|
||||
```sh
|
||||
cd web
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
1. Run the API locally (API uses Bun in development mode):
|
||||
### Start the API
|
||||
|
||||
In a separate terminal:
|
||||
|
||||
```sh
|
||||
cd api
|
||||
pnpm start
|
||||
```
|
||||
|
||||
## Building Docker Images
|
||||
The API uses Bun in the local development environment.
|
||||
|
||||
You can build Docker images using the provided bash script. The image version is automatically taken from the root `package.json`.
|
||||
## Building Docker images
|
||||
|
||||
Docker image versions are taken from the root `package.json`.
|
||||
|
||||
### Build the images
|
||||
|
||||
From the repository root:
|
||||
|
||||
**Steps:**
|
||||
1. Build images:
|
||||
```sh
|
||||
./build-dockers.sh
|
||||
```
|
||||
|
||||
2. Verify built images:
|
||||
### Verify the images
|
||||
|
||||
```sh
|
||||
docker images
|
||||
```
|
||||
You should see the following images:
|
||||
|
||||
`gimanhead/taskview-ce-api-server`
|
||||
`gimanhead/taskview-ce-webapp`
|
||||
`gimanhead/taskview-ce-db-migration`
|
||||
The following images should be available:
|
||||
|
||||
3. Test images locally using Docker Compose:
|
||||
```sh
|
||||
cd api/dev-containers-test
|
||||
docker-compose up
|
||||
```text
|
||||
gimanhead/taskview-ce-api-server
|
||||
gimanhead/taskview-ce-webapp
|
||||
gimanhead/taskview-ce-db-migration
|
||||
```
|
||||
|
||||
Make sure the image versions match the version defined in the root package.json.
|
||||
### Test the images locally
|
||||
|
||||
```sh
|
||||
cd api/dev-containers-test
|
||||
docker compose up
|
||||
```
|
||||
|
||||
Ensure that image versions match the version defined in the root `package.json`.
|
||||
|
||||
## Versioning
|
||||
|
||||
TaskView follows [Semantic Versioning](https://semver.org/):
|
||||
|
||||
* `MAJOR` - breaking API, database, or compatibility changes;
|
||||
* `MINOR` - backward-compatible features;
|
||||
* `PATCH` - backward-compatible bug fixes.
|
||||
|
||||
The version represents the entire TaskView product, not individual packages.
|
||||
|
||||
## Project status
|
||||
|
||||
TaskView is under active development.
|
||||
|
||||
New features, database migrations, API changes, and breaking changes may be introduced between releases. Review release notes and back up your database before updating a self-hosted installation.
|
||||
|
||||
## Roadmap
|
||||
|
||||
Planned areas of development include:
|
||||
|
||||
- [X] Migrate to NuxtUI or similar ui library
|
||||
- [X] Enterprise SSO and identity integrations
|
||||
- [X] Redesign
|
||||
- [X] API tokens
|
||||
- [X] Webhooks
|
||||
- [X] MCP server
|
||||
- [X] Notifications
|
||||
- [X] Analytics
|
||||
- [X] API client
|
||||
- [ ] Desktop version
|
||||
- [ ] Plugin / extension system
|
||||
* [ ] Desktop application
|
||||
* [ ] Plugin and extension system
|
||||
* [ ] Additional third-party integrations
|
||||
* [ ] Expanded automation capabilities
|
||||
* [ ] Continued UI and UX improvements
|
||||
|
||||
The roadmap may change based on product priorities and community feedback.
|
||||
|
||||
Note for contributors: contributions are accepted under the CLA (see CONTRIBUTING.md). The Project is distributed under the TaskView Source-Available License.
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome.
|
||||
|
||||
Before contributing:
|
||||
|
||||
1. Read [`CONTRIBUTING.md`](./CONTRIBUTING.md).
|
||||
2. Check existing issues and pull requests.
|
||||
3. Open an issue before starting a large architectural change.
|
||||
4. Follow the project's code style and architecture.
|
||||
5. Sign the Contributor License Agreement when required.
|
||||
|
||||
Contributions are accepted under the project's CLA.
|
||||
|
||||
## Licensing
|
||||
|
||||
TaskView is distributed under the TaskView Source-Available License.
|
||||
|
||||
You may:
|
||||
|
||||
* self-host TaskView;
|
||||
* modify the source code;
|
||||
* use TaskView internally within your organization;
|
||||
* create integrations and internal extensions.
|
||||
|
||||
You may not:
|
||||
|
||||
* offer TaskView as a hosted SaaS;
|
||||
* sell access to TaskView as a service;
|
||||
* create a competing commercial product based on TaskView;
|
||||
* use the TaskView name, logo, or branding for derivative products.
|
||||
|
||||
See [`LICENSE`](./LICENSE) for the complete terms.
|
||||
|
||||
TaskView is **source-available**, not OSI-approved open-source software.
|
||||
|
||||
For commercial licensing questions, hosted service permissions, or other use cases not covered by the license, contact the project maintainer.
|
||||
|
||||
## Security
|
||||
|
||||
Do not publish security vulnerabilities in public GitHub issues.
|
||||
|
||||
Report security issues privately using the contact information provided in the repository or on the TaskView website.
|
||||
|
||||
When running TaskView in production:
|
||||
|
||||
* use HTTPS;
|
||||
* generate unique application secrets;
|
||||
* restrict database access;
|
||||
* keep Docker images and dependencies updated;
|
||||
* configure persistent storage;
|
||||
* create regular backups;
|
||||
* restrict API-token permissions to the minimum required scope.
|
||||
|
||||
## Links
|
||||
|
||||
* [TaskView website](https://taskview.tech/)
|
||||
* [TaskView Cloud](https://app.taskview.tech/)
|
||||
* [Documentation](https://taskview.tech/docs/)
|
||||
* [iOS application](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867)
|
||||
* [Android application](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app)
|
||||
* [GitHub repository](https://github.com/Gimanh/taskview-community)
|
||||
|
||||
---
|
||||
|
||||
TaskView is developed and maintained by Nikolai Giman.
|
||||
TaskView is developed and maintained by **Nikolai Giman**.
|
||||
|
||||
Copyright © 2026 Nikolai Giman
|
||||
TaskView™ and the TaskView logo are trademarks of their respective owner.
|
||||
|
||||
Copyright © 2026 Nikolai Giman.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.45.0",
|
||||
"version": "1.48.3",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch ./server.ts",
|
||||
"start": "NODE_ENV=production node ./dist/taskview-server.js",
|
||||
@@ -83,4 +83,4 @@
|
||||
"engines": {
|
||||
"node": ">=24 <25"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export class AnalyticsController {
|
||||
scope = { kind: out.scope }
|
||||
}
|
||||
|
||||
const range = resolveRange(out.period, out.from, out.to)
|
||||
const range = resolveRange({ period: out.period, timezone: out.timezone, from: out.from, to: out.to })
|
||||
if (!range) return res.status(400).send('invalid range')
|
||||
|
||||
const sectionIds = out.sections
|
||||
@@ -41,6 +41,7 @@ export class AnalyticsController {
|
||||
organizationId: out.organizationId,
|
||||
period: out.period,
|
||||
range,
|
||||
timezone: out.timezone,
|
||||
sectionIds,
|
||||
})
|
||||
return res.tvJson(data)
|
||||
@@ -76,7 +77,7 @@ export class AnalyticsController {
|
||||
scope = { kind: out.scope }
|
||||
}
|
||||
|
||||
const range = resolveRange(out.period, out.from, out.to)
|
||||
const range = resolveRange({ period: out.period, timezone: out.timezone, from: out.from, to: out.to })
|
||||
if (!range) return res.status(400).send('invalid range')
|
||||
|
||||
const meta = parseDrillDownMeta(out.meta)
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { AppUser } from '../../core/AppUser'
|
||||
import { $logger } from '../../modules/logget'
|
||||
import { GoalPermissions } from '../../types/auth.types'
|
||||
import { AnalyticsRepository } from './AnalyticsRepository'
|
||||
import { formatDateInZone } from './helpers'
|
||||
import { SectionRegistry } from './sections/SectionRegistry'
|
||||
import type {
|
||||
AnalyticsArgBuildSections,
|
||||
@@ -45,7 +46,7 @@ export class AnalyticsManager {
|
||||
}
|
||||
|
||||
async buildSections(params: AnalyticsArgBuildSections): Promise<AnalyticsSectionsResponse> {
|
||||
const { scope, organizationId, period, range, sectionIds } = params
|
||||
const { scope, organizationId, period, range, timezone, sectionIds } = params
|
||||
|
||||
const allAccessible = await this.getAccessibleGoalIds(organizationId)
|
||||
const accessibleGoalIds = this.narrowToScope(allAccessible, scope)
|
||||
@@ -86,7 +87,7 @@ export class AnalyticsManager {
|
||||
return {
|
||||
scope,
|
||||
period,
|
||||
range: { from: range.from.toISOString(), to: range.to.toISOString() },
|
||||
range: { from: formatDateInZone(range.from, timezone), to: formatDateInZone(range.to, timezone) },
|
||||
sections,
|
||||
availableGoals,
|
||||
failedSectionIds,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type } from 'arktype'
|
||||
import { sql, type SQL } from 'drizzle-orm'
|
||||
import { DateTime } from 'luxon'
|
||||
import type { AnalyticsPeriod } from 'taskview-api'
|
||||
import { DrillDownMetaArkType, type AnalyticsRange, type DrillDownMeta } from './types'
|
||||
import { DrillDownMetaArkType, type AnalyticsRange, type DrillDownMeta, type ResolveRangeArgs } from './types'
|
||||
|
||||
const MAX_INT32 = 2147483647
|
||||
|
||||
@@ -23,32 +24,51 @@ export function parseDrillDownMeta(raw: string | undefined): DrillDownMeta {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRange(
|
||||
period: AnalyticsPeriod,
|
||||
from?: string,
|
||||
to?: string,
|
||||
): AnalyticsRange | null {
|
||||
const now = new Date()
|
||||
const DAYS_BY_PERIOD: Record<Exclude<AnalyticsPeriod, 'custom' | 'month'>, number> = {
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
'90d': 90,
|
||||
'180d': 180,
|
||||
'365d': 365,
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a period into a concrete UTC instant range, with all calendar
|
||||
* boundaries ("this month", a custom day) computed in the *viewer's* IANA
|
||||
* timezone — not the server's. The returned Dates are real instants used for
|
||||
* SQL filtering; the user-facing 'YYYY-MM-DD' labels come from
|
||||
* {@link formatDateInZone} with the same timezone.
|
||||
*/
|
||||
export function resolveRange(args: ResolveRangeArgs): AnalyticsRange | null {
|
||||
const { period, from, to } = args
|
||||
const zone = args.timezone && DateTime.now().setZone(args.timezone).isValid ? args.timezone : 'utc'
|
||||
const now = DateTime.now().setZone(zone)
|
||||
|
||||
if (period === 'custom') {
|
||||
if (!from || !to) return null
|
||||
const fromDate = new Date(from)
|
||||
const toDate = new Date(to)
|
||||
if (Number.isNaN(fromDate.getTime()) || Number.isNaN(toDate.getTime())) return null
|
||||
if (fromDate > toDate) return null
|
||||
const maxRangeMs = 365 * 24 * 60 * 60 * 1000
|
||||
if (toDate.getTime() - fromDate.getTime() > maxRangeMs) return null
|
||||
return { from: fromDate, to: toDate }
|
||||
const fromDt = DateTime.fromISO(from.slice(0, 10), { zone }).startOf('day')
|
||||
const toDt = DateTime.fromISO(to.slice(0, 10), { zone }).endOf('day')
|
||||
if (!fromDt.isValid || !toDt.isValid) return null
|
||||
if (fromDt > toDt) return null
|
||||
const maxRangeMs = 366 * 24 * 60 * 60 * 1000
|
||||
if (toDt.toMillis() - fromDt.toMillis() > maxRangeMs) return null
|
||||
return { from: fromDt.toJSDate(), to: toDt.toJSDate() }
|
||||
}
|
||||
|
||||
const daysByPeriod: Record<Exclude<AnalyticsPeriod, 'custom'>, number> = {
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
'90d': 90,
|
||||
'180d': 180,
|
||||
'365d': 365,
|
||||
if (period === 'month') {
|
||||
return { from: now.startOf('month').toJSDate(), to: now.toJSDate() }
|
||||
}
|
||||
const days = daysByPeriod[period]
|
||||
const fromDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
|
||||
return { from: fromDate, to: now }
|
||||
|
||||
return { from: now.minus({ days: DAYS_BY_PERIOD[period] }).toJSDate(), to: now.toJSDate() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a range boundary as a 'YYYY-MM-DD' calendar day in the viewer's
|
||||
* timezone. Slicing a UTC ISO string instead would shift the day across the
|
||||
* date line for offset timezones (e.g. a "this month" range rendering from the
|
||||
* 31st of the prior month).
|
||||
*/
|
||||
export function formatDateInZone(date: Date, timezone?: string): string {
|
||||
const dt = DateTime.fromJSDate(date).setZone(timezone && DateTime.fromJSDate(date).setZone(timezone).isValid ? timezone : 'utc')
|
||||
return dt.toISODate() as string
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ const nonNegativeIntFromQuery = type('string | number')
|
||||
export const AnalyticsFetchSectionsArkType = type({
|
||||
scope: "'org' | 'project'",
|
||||
organizationId: positiveIntFromQuery,
|
||||
period: "'7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
|
||||
period: "'month' | '7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
|
||||
'goalId?': positiveIntFromQuery,
|
||||
'timezone?': 'string',
|
||||
'from?': 'string',
|
||||
'to?': 'string',
|
||||
'sections?': 'string',
|
||||
@@ -30,8 +31,9 @@ export const AnalyticsFetchSectionsArkType = type({
|
||||
export const AnalyticsDrillDownArkType = type({
|
||||
scope: "'org' | 'project'",
|
||||
organizationId: positiveIntFromQuery,
|
||||
period: "'7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
|
||||
period: "'month' | '7d' | '30d' | '90d' | '180d' | '365d' | 'custom'",
|
||||
'goalId?': positiveIntFromQuery,
|
||||
'timezone?': 'string',
|
||||
'from?': 'string',
|
||||
'to?': 'string',
|
||||
'bucket?': 'string',
|
||||
@@ -53,6 +55,14 @@ export type AnalyticsRange = {
|
||||
to: Date
|
||||
}
|
||||
|
||||
export type ResolveRangeArgs = {
|
||||
period: AnalyticsPeriod
|
||||
/** IANA timezone the period boundaries are resolved in; defaults to UTC. */
|
||||
timezone?: string
|
||||
from?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
export type BuilderContext = {
|
||||
appUser: AppUser
|
||||
scope: AnalyticsScope
|
||||
@@ -97,6 +107,8 @@ export type AnalyticsArgBuildSections = {
|
||||
organizationId: number
|
||||
period: AnalyticsPeriod
|
||||
range: AnalyticsRange
|
||||
/** IANA timezone of the viewer; used to format the response range. */
|
||||
timezone?: string
|
||||
sectionIds?: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export class NotificationDispatcher implements Dispatcher {
|
||||
eventBus.on('task.updated', (data) => this.onTaskUpdated(data));
|
||||
eventBus.on('task.assigneesChanged', (data) => this.onAssigneesChanged(data));
|
||||
eventBus.on('task.deleted', (data) => this.onTaskDeleted(data));
|
||||
eventBus.on('recurrence.created', (data) => this.onRecurrenceScheduled(data));
|
||||
eventBus.on('recurrence.updated', (data) => this.onRecurrenceScheduled(data));
|
||||
}
|
||||
|
||||
async registerWorkers(): Promise<void> {
|
||||
@@ -79,6 +81,19 @@ export class NotificationDispatcher implements Dispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
private async onRecurrenceScheduled(data: AppEvents['recurrence.created'] | AppEvents['recurrence.updated']): Promise<void> {
|
||||
const instance = await new RecurrenceRepository().findOpenInstance(data.rule.id);
|
||||
if (!instance) return;
|
||||
|
||||
this.notificationsRepo.deleteByTaskAndType(instance.id, NotificationType.DEADLINE);
|
||||
await this.deadlineScheduler.cancel(instance.id);
|
||||
|
||||
if (data.rule.notifyOnOccurrence && instance.endDate) {
|
||||
$logger.info(`[NotificationDispatcher] Scheduling occurrence deadline for task=${instance.id}, rule=${data.rule.id}`);
|
||||
await this.deadlineScheduler.schedule(instance, data.initiatorId);
|
||||
}
|
||||
}
|
||||
|
||||
private async onAssigneesChanged(data: AppEvents['task.assigneesChanged']): Promise<void> {
|
||||
if (data.userIds.length === 0) return;
|
||||
|
||||
|
||||
@@ -230,6 +230,22 @@ export class RecurrenceManager {
|
||||
const updated = await this.repository.patch({ ruleId: args.ruleId, patch });
|
||||
if (!updated) return fail('invalid_state');
|
||||
|
||||
if (patch.dtstart !== undefined || patch.timezone !== undefined || patch.rrule !== undefined) {
|
||||
const openInstance = await this.repository.findOpenInstance(args.ruleId);
|
||||
if (openInstance?.recurrenceInstanceDate) {
|
||||
await this.repository.updateInstanceWindow({
|
||||
taskId: openInstance.id,
|
||||
window: RecurrenceParser.instanceWindowUtc({
|
||||
occurrenceDate: openInstance.recurrenceInstanceDate,
|
||||
dtstart: updated.dtstart,
|
||||
hasTime: updated.hasTime,
|
||||
timezone: updated.timezone,
|
||||
durationMinutes: updated.templateDurationMinutes,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.emit('recurrence.updated', { rule: updated, changes: patch, initiatorId: this.initiatorId });
|
||||
return ok(await this.cleanRuleFieldsRegardPermissions(updated));
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
CreateRuleWithOriginResult,
|
||||
RecurrenceRulePatchArgs,
|
||||
RemoveTemplateAssigneeFromGoalArgs,
|
||||
UpdateInstanceWindowArgs,
|
||||
} from './types';
|
||||
|
||||
const PG_UNIQUE_VIOLATION = '23505';
|
||||
@@ -194,6 +195,21 @@ export class RecurrenceRepository {
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
/** Re-stamp an open instance's deadline window after the series time/timezone changed. */
|
||||
async updateInstanceWindow(args: UpdateInstanceWindowArgs): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle
|
||||
.update(TasksSchema)
|
||||
.set({
|
||||
startDate: args.window.startDate,
|
||||
startTime: args.window.startTime,
|
||||
endDate: args.window.endDate,
|
||||
endTime: args.window.endTime,
|
||||
})
|
||||
.where(eq(TasksSchema.id, args.taskId))
|
||||
);
|
||||
}
|
||||
|
||||
/** Active rules with no open instance — the reconcile sweep input. Empty in normal operation. */
|
||||
async findStalledActiveRules(): Promise<RecurrenceRulesSchemaTypeForSelect[]> {
|
||||
const openInstance = this.db.dbDrizzle
|
||||
|
||||
@@ -81,6 +81,11 @@ export type InstanceWindow = {
|
||||
|
||||
/** Repository args */
|
||||
|
||||
export type UpdateInstanceWindowArgs = {
|
||||
taskId: number;
|
||||
window: InstanceWindow;
|
||||
};
|
||||
|
||||
export type RecurrenceRulePatchArgs = {
|
||||
ruleId: number;
|
||||
patch: Partial<{
|
||||
|
||||
@@ -36,6 +36,7 @@ export class TaskItemForClient {
|
||||
amount: number | null;
|
||||
transactionType: 1 | 0 | null;
|
||||
nodeGraphPosition: Record<string, unknown> | null;
|
||||
recurrenceRuleId: number | null;
|
||||
|
||||
constructor(task: TaskItemInDb, tags: number[] = [], historyId: number | null = null, assignees: number[] = []) {
|
||||
this.id = task.id;
|
||||
@@ -69,5 +70,6 @@ export class TaskItemForClient {
|
||||
this.amount = task.amount;
|
||||
this.transactionType = task.transaction_type;
|
||||
this.nodeGraphPosition = task.node_graph_position;
|
||||
this.recurrenceRuleId = task.recurrence_rule_id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export type TaskItemInDb = {
|
||||
amount: number | null;
|
||||
transaction_type: 1 | 0 | null;
|
||||
node_graph_position: Record<string, unknown> | null;
|
||||
recurrence_rule_id: number | null;
|
||||
// history_id?: number | null;
|
||||
};
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 583 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1012 KiB |
|
After Width: | Height: | Size: 879 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1019 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 984 KiB |
|
After Width: | Height: | Size: 1005 KiB |
|
After Width: | Height: | Size: 927 KiB |
|
After Width: | Height: | Size: 1018 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 998 KiB |
|
After Width: | Height: | Size: 994 KiB |
|
After Width: | Height: | Size: 928 KiB |
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: MCP Server
|
||||
description: Connect AI assistants like Claude Code and Claude Desktop to TaskView via the Model Context Protocol (MCP). Manage projects and tasks with a scoped API token.
|
||||
navigation:
|
||||
icon: i-lucide-bot
|
||||
---
|
||||
|
||||
TaskView ships an MCP (Model Context Protocol) server that lets AI assistants such as Claude Code and Claude Desktop work with your projects and tasks through the TaskView API.
|
||||
|
||||
```
|
||||
AI client ──stdio──▶ taskview-mcp ──HTTPS──▶ TaskView API
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js >= 24
|
||||
- A TaskView API token (`tvk_...`) — create one in your account settings. See [API tokens](/docs/features/api-tokens).
|
||||
|
||||
## Configuration
|
||||
|
||||
No installation is required — the server runs via `npx`. Add it to your MCP client config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"taskview": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "taskview-mcp"],
|
||||
"env": {
|
||||
"TASKVIEW_URL": "https://api.taskview.tech",
|
||||
"TASKVIEW_TOKEN": "tvk_your_token_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Claude Code** — add to `.claude/settings.json` (project) or `~/.claude.json` (global).
|
||||
- **Claude Desktop** — add to `claude_desktop_config.json`.
|
||||
|
||||
Set `TASKVIEW_URL` to your own instance when self-hosting.
|
||||
|
||||
## Permissions
|
||||
|
||||
The assistant can only do what the API token allows. Token permissions are scoped to selected projects and intersected with your RBAC role, so an AI client never exceeds your own access. Grant the minimum scope needed.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-monorepo",
|
||||
"version": "1.45.0",
|
||||
"version": "1.48.3",
|
||||
"private": true,
|
||||
"description": "TaskView CE monorepo containing web, API, and packages",
|
||||
"workspaces": [
|
||||
@@ -44,4 +44,4 @@
|
||||
"passport-github2": "^0.1.12",
|
||||
"passport-google-oauth20": "^2.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "taskview-api",
|
||||
"private": false,
|
||||
"version": "1.45.0",
|
||||
"version": "1.48.3",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -35,6 +35,7 @@ export default class TvAnalyticsApi extends TvApiBase {
|
||||
organizationId: String(arg.organizationId),
|
||||
period: arg.period,
|
||||
}
|
||||
if (arg.timezone) params.timezone = arg.timezone
|
||||
if (arg.from) params.from = arg.from
|
||||
if (arg.to) params.to = arg.to
|
||||
if (arg.sections?.length) params.sections = arg.sections.join(',')
|
||||
@@ -53,6 +54,7 @@ export default class TvAnalyticsApi extends TvApiBase {
|
||||
organizationId: String(arg.organizationId),
|
||||
period: arg.period,
|
||||
}
|
||||
if (arg.timezone) params.timezone = arg.timezone
|
||||
if (arg.from) params.from = arg.from
|
||||
if (arg.to) params.to = arg.to
|
||||
if (arg.bucket) params.bucket = arg.bucket
|
||||
|
||||
@@ -13,7 +13,7 @@ export type AnalyticsScope =
|
||||
| { kind: 'org' }
|
||||
| { kind: 'project'; goalId: number }
|
||||
|
||||
export type AnalyticsPeriod = '7d' | '30d' | '90d' | '180d' | '365d' | 'custom'
|
||||
export type AnalyticsPeriod = 'month' | '7d' | '30d' | '90d' | '180d' | '365d' | 'custom'
|
||||
|
||||
export type AnalyticsRange = {
|
||||
from: string
|
||||
@@ -136,6 +136,8 @@ export type AnalyticsFetchSectionsArg = {
|
||||
scope: AnalyticsScope
|
||||
organizationId: number
|
||||
period: AnalyticsPeriod
|
||||
/** IANA timezone of the viewer; period boundaries are resolved in it. */
|
||||
timezone?: string
|
||||
from?: string
|
||||
to?: string
|
||||
sections?: string[]
|
||||
@@ -165,6 +167,8 @@ export type AnalyticsFetchDrillDownArg = {
|
||||
scope: AnalyticsScope
|
||||
organizationId: number
|
||||
period: AnalyticsPeriod
|
||||
/** IANA timezone of the viewer; period boundaries are resolved in it. */
|
||||
timezone?: string
|
||||
from?: string
|
||||
to?: string
|
||||
bucket?: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-mcp",
|
||||
"version": "1.45.0",
|
||||
"version": "1.48.3",
|
||||
"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": {
|
||||
|
||||
@@ -107,7 +107,7 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'add_organization_member',
|
||||
{
|
||||
description: 'Add a member to an organization by email. Requires admin or owner role.',
|
||||
description: 'Add a member to an organization by email. Requires admin or owner role. NOTE: organization membership alone does NOT grant access to any project — the user will not see a project until they are also added to it via invite_collaborator. After adding the member, invite them to the relevant project(s).',
|
||||
inputSchema: {
|
||||
organizationId: z.coerce.number().describe('Organization ID'),
|
||||
email: z.string().describe('Email of the user to add'),
|
||||
|
||||
@@ -81,7 +81,7 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'update_task',
|
||||
{
|
||||
description: 'Update an existing task (description, status, priority, dates, etc.)',
|
||||
description: 'Update an existing task (description, status, priority, dates, cost, etc.)',
|
||||
inputSchema: {
|
||||
id: z.coerce.number().describe('Task ID to update'),
|
||||
description: z.string().optional().describe('New task description'),
|
||||
@@ -93,6 +93,10 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
|
||||
startDate: z.string().optional().nullable().describe('Start date (ISO format)'),
|
||||
endDate: z.string().optional().nullable().describe('End/due date (ISO format)'),
|
||||
statusId: z.coerce.number().optional().nullable().describe('Kanban column status ID'),
|
||||
amount: z.coerce.string().optional().nullable()
|
||||
.describe('Cost/value of the task (decimal as string, e.g. "1500.00"). Pass null to clear.'),
|
||||
transactionType: z.union([z.literal(1), z.literal(0)]).optional().nullable()
|
||||
.describe('Cost type: 1=income, 0=expense, null=none'),
|
||||
},
|
||||
},
|
||||
async (params) => {
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.handscreamgnl.taskview.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1420
|
||||
versionName "1.42.0"
|
||||
versionCode 14801
|
||||
versionName "1.48.1"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -304,7 +304,7 @@
|
||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1.42.0;
|
||||
CURRENT_PROJECT_VERSION = 1.48.1;
|
||||
DEVELOPMENT_TEAM = H2W2SG48JT;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
@@ -312,7 +312,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.42.0;
|
||||
MARKETING_VERSION = 1.48.1;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -329,7 +329,7 @@
|
||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1.42.0;
|
||||
CURRENT_PROJECT_VERSION = 1.48.1;
|
||||
DEVELOPMENT_TEAM = H2W2SG48JT;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
@@ -337,7 +337,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.42.0;
|
||||
MARKETING_VERSION = 1.48.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "web-nuxt-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "1.45.0",
|
||||
"version": "1.48.3",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build",
|
||||
@@ -91,4 +91,4 @@
|
||||
"vue-tsc": "^3.2.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.1"
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
@theme {
|
||||
--tv-ui-bg-elevated: #fff;
|
||||
--color-tv-ui-bg-elevated: var(--tv-ui-bg-elevated);
|
||||
--radius-10: 10px;
|
||||
--radius-14: 14px;
|
||||
}
|
||||
|
||||
/* Safe area utilities */
|
||||
|
||||
@@ -23,10 +23,10 @@
|
||||
|
||||
<UModal
|
||||
v-model:open="isOpen"
|
||||
:fullscreen="isMobile"
|
||||
:fullscreen="isFullscreenModal"
|
||||
:ui="{
|
||||
overlay: 'sm:items-center sm:justify-center',
|
||||
content: 'sm:max-w-lg sm:max-h-[80vh] sm:rounded-lg sm:m-auto w-full',
|
||||
content: isFullscreenModal ? 'w-full' : 'sm:max-w-lg sm:max-h-[80vh] sm:m-auto w-full',
|
||||
body: 'p-0!',
|
||||
footer: 'p-4!',
|
||||
}"
|
||||
@@ -91,7 +91,7 @@
|
||||
>
|
||||
<UButton
|
||||
:label="t('notifications.loadMore')"
|
||||
variant="ghost"
|
||||
variant="soft"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
block
|
||||
@@ -107,7 +107,7 @@
|
||||
<UButton
|
||||
:label="t('common.close')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@ const router = useRouter()
|
||||
const notificationsStore = useNotificationsStore()
|
||||
const userStore = useUserStore()
|
||||
const orgStore = useOrganizationStore()
|
||||
const { isMobile } = useTaskView()
|
||||
const { isFullscreenModal } = useTaskView()
|
||||
const isOpen = ref(false)
|
||||
|
||||
const notificationIconMap: Partial<Record<NotificationType, string>> = {
|
||||
|
||||
@@ -65,6 +65,7 @@ import { saveLocale } from '@/plugins/i18n'
|
||||
import { useUpdater } from '@/composables/useUpdater'
|
||||
import { useUserStore } from '@/stores/user.store'
|
||||
import { useOrganizationStore } from '@/stores/organization.store'
|
||||
import { useOrgSwitcher } from '@/composables/useOrgSwitcher'
|
||||
import { $ls } from '@/plugins/axios'
|
||||
import avatarImg from '@/assets/images/avatar-1.jpeg'
|
||||
|
||||
@@ -79,6 +80,7 @@ const router = useRouter()
|
||||
const toast = useToast()
|
||||
const userStore = useUserStore()
|
||||
const orgStore = useOrganizationStore()
|
||||
const { switchOrg } = useOrgSwitcher()
|
||||
|
||||
const prodOrDev = ref<'prod' | 'dev'>('prod')
|
||||
let counter = 0
|
||||
@@ -155,8 +157,7 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
||||
icon: org.id === orgStore.currentOrg?.id ? 'i-lucide-check' : undefined,
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
orgStore.setCurrentOrg(org)
|
||||
router.push({ name: 'user', params: { orgSlug: org.slug } })
|
||||
switchOrg(org)
|
||||
},
|
||||
})),
|
||||
},
|
||||
@@ -260,6 +261,15 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
||||
saveLocale('de')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Español',
|
||||
type: 'checkbox',
|
||||
checked: locale.value === 'es',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
saveLocale('es')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -288,6 +298,32 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('userMenu.sidebarView'),
|
||||
icon: 'i-lucide-panel-left',
|
||||
children: [
|
||||
{
|
||||
label: t('userMenu.sidebarViewSecond'),
|
||||
icon: 'i-lucide-layout-list',
|
||||
type: 'checkbox',
|
||||
checked: appStore.sidebarView === 'second',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
appStore.setSidebarView('second')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('userMenu.sidebarViewFirst'),
|
||||
icon: 'i-lucide-panel-left',
|
||||
type: 'checkbox',
|
||||
checked: appStore.sidebarView === 'first',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
appStore.setSidebarView('first')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
<div class="flex flex-col gap-6 p-1 lg:p-6 w-full max-w-full lg:max-w-2xl m-0 lg:mx-auto">
|
||||
<NotificationSettings />
|
||||
|
||||
<UPageCard class="w-full">
|
||||
<UPageCard class="w-full rounded-3xl">
|
||||
<SessionsPanel />
|
||||
</UPageCard>
|
||||
|
||||
<UPageCard class="w-full">
|
||||
<UPageCard class="w-full rounded-3xl">
|
||||
<ApiTokensPanel />
|
||||
</UPageCard>
|
||||
|
||||
<UPageCard class="w-full">
|
||||
<UPageCard class="w-full rounded-3xl">
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ t('account.management') }}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
v-model="code"
|
||||
:placeholder="t('account.enterCode')"
|
||||
spellcheck="false"
|
||||
variant="soft"
|
||||
:ui="{ base: 'rounded-xl' }"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<UPageCard class="w-full">
|
||||
<UPageCard class="w-full rounded-3xl">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">
|
||||
|
||||
@@ -5,15 +5,46 @@
|
||||
:items="scopeOptions"
|
||||
value-key="value"
|
||||
class="sm:w-64"
|
||||
size="xl"
|
||||
@update:model-value="onScopeChange"
|
||||
/>
|
||||
<USelectMenu
|
||||
:model-value="analyticsStore.period"
|
||||
:items="periodOptions"
|
||||
value-key="value"
|
||||
class="sm:w-40"
|
||||
@update:model-value="onPeriodChange"
|
||||
/>
|
||||
|
||||
<UPopover v-model:open="periodOpen">
|
||||
<UButton
|
||||
icon="i-lucide-calendar"
|
||||
:label="activatorLabel"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
class="sm:w-auto justify-start"
|
||||
:ui="{ leadingIcon: 'size-4.5' }"
|
||||
/>
|
||||
|
||||
<template #content>
|
||||
<div class="p-2 flex flex-col gap-2 w-72">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<UButton
|
||||
v-for="opt in periodOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
color="neutral"
|
||||
:variant="analyticsStore.period === opt.value ? 'solid' : 'soft'"
|
||||
size="xs"
|
||||
@click="onPresetClick(opt.value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<USeparator />
|
||||
|
||||
<UCalendar
|
||||
:model-value="rangeModel"
|
||||
range
|
||||
:week-starts-on="weekStart"
|
||||
@update:model-value="onRangeSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
|
||||
<UButton
|
||||
icon="i-lucide-refresh-cw"
|
||||
variant="ghost"
|
||||
@@ -26,14 +57,24 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AnalyticsPeriod, AnalyticsScope } from 'taskview-api'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDateFormat } from '@vueuse/core'
|
||||
import { CalendarDate } from '@internationalized/date'
|
||||
import type { DateValue } from '@internationalized/date'
|
||||
import { useAnalyticsStore } from '@/stores/analytics.store'
|
||||
import { useWeekStart } from '@/composables/useWeekStart'
|
||||
|
||||
const analyticsStore = useAnalyticsStore()
|
||||
const { t } = useI18n()
|
||||
const weekStart = useWeekStart()
|
||||
|
||||
const periodOpen = ref(false)
|
||||
|
||||
type DateRange = { start: DateValue | undefined; end: DateValue | undefined }
|
||||
|
||||
const periodOptions = computed<{ label: string; value: AnalyticsPeriod }[]>(() => [
|
||||
{ label: t('analytics.filters.periods.month'), value: 'month' },
|
||||
{ label: t('analytics.filters.periods.7d'), value: '7d' },
|
||||
{ label: t('analytics.filters.periods.30d'), value: '30d' },
|
||||
{ label: t('analytics.filters.periods.90d'), value: '90d' },
|
||||
@@ -41,6 +82,55 @@ const periodOptions = computed<{ label: string; value: AnalyticsPeriod }[]>(() =
|
||||
{ label: t('analytics.filters.periods.365d'), value: '365d' },
|
||||
])
|
||||
|
||||
function isoToCalendarDate(iso: string): CalendarDate {
|
||||
const [year, month, day] = iso.slice(0, 10).split('-').map(Number)
|
||||
return new CalendarDate(year, month, day)
|
||||
}
|
||||
|
||||
// Parse a 'YYYY-MM-DD' as a local date (new Date(iso) would treat it as UTC).
|
||||
function localDate(iso: string): Date {
|
||||
const [year, month, day] = iso.slice(0, 10).split('-').map(Number)
|
||||
return new Date(year, month - 1, day)
|
||||
}
|
||||
|
||||
// Seed the calendar with the active range so the current selection is visible.
|
||||
function rangeFromStore(): DateRange {
|
||||
const r = analyticsStore.range
|
||||
if (!r) return { start: undefined, end: undefined }
|
||||
return { start: isoToCalendarDate(r.from), end: isoToCalendarDate(r.to) }
|
||||
}
|
||||
|
||||
const rangeModel = shallowRef<DateRange>(rangeFromStore())
|
||||
|
||||
// Reflect the resolved range whenever it changes (preset clicks, refresh).
|
||||
watch(() => analyticsStore.range, () => {
|
||||
rangeModel.value = rangeFromStore()
|
||||
})
|
||||
|
||||
// User picked a range in the calendar → switch to a custom period.
|
||||
function onRangeSelect(value: DateRange | null) {
|
||||
rangeModel.value = value ?? { start: undefined, end: undefined }
|
||||
if (!value?.start || !value?.end) return
|
||||
analyticsStore.setCustomRange(value.start.toString().slice(0, 10), value.end.toString().slice(0, 10))
|
||||
periodOpen.value = false
|
||||
}
|
||||
|
||||
const activatorLabel = computed(() => {
|
||||
if (analyticsStore.period !== 'custom') {
|
||||
return periodOptions.value.find(o => o.value === analyticsStore.period)?.label ?? ''
|
||||
}
|
||||
const r = analyticsStore.range
|
||||
if (!r) return t('analytics.filters.periods.custom')
|
||||
const from = useDateFormat(localDate(r.from), 'DD MMM YYYY').value
|
||||
const to = useDateFormat(localDate(r.to), 'DD MMM YYYY').value
|
||||
return `${from} — ${to}`
|
||||
})
|
||||
|
||||
function onPresetClick(value: AnalyticsPeriod) {
|
||||
analyticsStore.setPeriod(value)
|
||||
periodOpen.value = false
|
||||
}
|
||||
|
||||
type ScopeOption = { label: string; value: string; scope: AnalyticsScope }
|
||||
|
||||
const scopeOptions = computed<ScopeOption[]>(() => {
|
||||
@@ -65,10 +155,4 @@ function onScopeChange(value: string) {
|
||||
const option = scopeOptions.value.find(o => o.value === value)
|
||||
if (option) analyticsStore.setScope(option.scope)
|
||||
}
|
||||
|
||||
function onPeriodChange(value: AnalyticsPeriod) {
|
||||
analyticsStore.setPeriod(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between p-4 border border-default rounded-lg">
|
||||
<UCard variant="soft" :ui="{body:'w-full flex items-center justify-between p-4', root:'rounded-2xl'}">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<UIcon
|
||||
name="i-lucide-key-round"
|
||||
@@ -32,10 +32,10 @@
|
||||
icon="i-lucide-trash-2"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="md"
|
||||
size="xl"
|
||||
@click="showDeleteConfirm = true"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UModal
|
||||
v-model:open="showDeleteConfirm"
|
||||
|
||||
@@ -40,9 +40,9 @@
|
||||
|
||||
<UModal
|
||||
v-model:open="showAddDialog"
|
||||
:fullscreen="isMobile"
|
||||
:fullscreen="isFullscreenModal"
|
||||
:ui="{
|
||||
content: 'sm:max-w-md sm:rounded-lg sm:m-auto w-full',
|
||||
content: isFullscreenModal ? 'w-full' : 'sm:max-w-md sm:rounded-lg sm:m-auto w-full',
|
||||
footer: 'p-4!',
|
||||
}"
|
||||
>
|
||||
@@ -106,8 +106,8 @@ import { useBreakpoints, breakpointsTailwind } from '@vueuse/core'
|
||||
import { useAdditionalServer } from '@/composables/useAdditionalServer'
|
||||
|
||||
const { t } = useI18n()
|
||||
const bp = useBreakpoints(breakpointsTailwind)
|
||||
const isMobile = bp.smaller('lg')
|
||||
const bp = useBreakpoints({ ...breakpointsTailwind, fullscreenModalMax: 1366 })
|
||||
const isFullscreenModal = bp.smallerOrEqual('fullscreenModalMax')
|
||||
|
||||
const mainServer = ref('')
|
||||
const allServers = ref<string[]>([])
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<UPopover
|
||||
v-model:open="open"
|
||||
:content="{ align: 'start' }"
|
||||
:ui="{ content: 'rounded-2xl' }"
|
||||
>
|
||||
<UButton
|
||||
:disabled="disabled"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
block
|
||||
size="xl"
|
||||
icon="i-lucide-calendar"
|
||||
:trailing-icon="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
:ui="activatorUi('text-muted')"
|
||||
>
|
||||
<span class="flex-1 flex items-center gap-2 text-left font-medium">
|
||||
<template v-if="isRange">
|
||||
<span :class="model?.start ? '' : 'text-muted'">{{ startLabel }}</span>
|
||||
<UIcon
|
||||
name="i-lucide-arrow-right"
|
||||
class="size-3.5 shrink-0 text-dimmed"
|
||||
/>
|
||||
<span :class="model?.end ? '' : 'text-muted'">{{ endLabel }}</span>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
:class="model?.end ? '' : 'text-muted'"
|
||||
>{{ endLabel }}</span>
|
||||
</span>
|
||||
</UButton>
|
||||
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton
|
||||
v-for="q in quickRanges"
|
||||
:key="q.key"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
:label="q.label"
|
||||
@click="applyQuick(q.key)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UCalendar
|
||||
v-if="isRange"
|
||||
v-model="rangeModel"
|
||||
range
|
||||
:week-starts-on="weekStart"
|
||||
/>
|
||||
<UCalendar
|
||||
v-else
|
||||
v-model="singleModel"
|
||||
:week-starts-on="weekStart"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 pt-1">
|
||||
<UCheckbox
|
||||
v-model="isRange"
|
||||
size="md"
|
||||
:ui="{ root: 'items-center' }"
|
||||
:label="t('deadline.selectRange')"
|
||||
/>
|
||||
<UButton
|
||||
v-if="model?.start || model?.end"
|
||||
icon="i-lucide-x"
|
||||
:label="t('common.delete')"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="clear"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDateFormat } from '@vueuse/core'
|
||||
import {
|
||||
type CalendarDate,
|
||||
today,
|
||||
getLocalTimeZone,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
} from '@internationalized/date'
|
||||
import { useWeekStart } from '@/composables/useWeekStart'
|
||||
import { useNuxtUiTaskItemStyles } from '@/composables/useNuxtUiTaskItemStyles'
|
||||
import type { DateRangeValue } from '@/types/tvDate.types'
|
||||
|
||||
const model = defineModel<DateRangeValue>()
|
||||
|
||||
const { disabled = false } = defineProps<{
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const weekStart = useWeekStart()
|
||||
const { activatorUi } = useNuxtUiTaskItemStyles()
|
||||
|
||||
const open = ref(false)
|
||||
const isRange = ref(false)
|
||||
|
||||
// drop the start date when switching back to single-date mode
|
||||
watch(isRange, (on) => {
|
||||
if (!on && model.value?.start) {
|
||||
model.value = { start: undefined, end: model.value.end }
|
||||
}
|
||||
})
|
||||
|
||||
// single-date mode fills the END date (the deadline)
|
||||
const singleModel = computed<CalendarDate | undefined>({
|
||||
get: () => model.value?.end,
|
||||
set: (date) => {
|
||||
model.value = { start: undefined, end: date }
|
||||
if (date) open.value = false
|
||||
},
|
||||
})
|
||||
|
||||
const rangeModel = computed<DateRangeValue>({
|
||||
get: () => ({ start: model.value?.start, end: model.value?.end }),
|
||||
set: (value) => {
|
||||
model.value = value ?? null
|
||||
if (value?.start && value?.end) open.value = false
|
||||
},
|
||||
})
|
||||
|
||||
const format = (date?: CalendarDate): string =>
|
||||
date ? useDateFormat(new Date(date.toString()), 'DD MMM').value : ''
|
||||
|
||||
const startLabel = computed(() => model.value?.start ? format(model.value.start) : t('deadline.startDate'))
|
||||
const endLabel = computed(() => {
|
||||
if (model.value?.end) return format(model.value.end)
|
||||
return isRange.value ? t('deadline.endDate') : t('tasks.deadline')
|
||||
})
|
||||
|
||||
type QuickKey = 'today' | 'tomorrow' | 'week' | 'month'
|
||||
|
||||
const quickRanges = computed<{ key: QuickKey, label: string }[]>(() => [
|
||||
{ key: 'today', label: t('deadline.today') },
|
||||
{ key: 'tomorrow', label: t('deadline.tomorrow') },
|
||||
{ key: 'week', label: t('deadline.thisWeek') },
|
||||
{ key: 'month', label: t('deadline.thisMonth') },
|
||||
])
|
||||
|
||||
function applyQuick(key: QuickKey) {
|
||||
const t0 = today(getLocalTimeZone())
|
||||
if (key === 'today') commit(t0, t0)
|
||||
else if (key === 'tomorrow') {
|
||||
const d = t0.add({ days: 1 })
|
||||
commit(d, d)
|
||||
}
|
||||
else if (key === 'week') commit(startOfWeek(t0, 'ru-RU'), endOfWeek(t0, 'ru-RU'))
|
||||
else commit(startOfMonth(t0), endOfMonth(t0))
|
||||
}
|
||||
|
||||
// in single mode keep only the end date; in range mode keep both
|
||||
function commit(start: CalendarDate, end: CalendarDate) {
|
||||
model.value = isRange.value ? { start, end } : { start: undefined, end }
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function clear() {
|
||||
model.value = null
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<TvDateRange
|
||||
:model-value="range"
|
||||
:disabled="!canEditTaskDeadline"
|
||||
@update:model-value="onUpdate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Task } from 'taskview-api'
|
||||
import { CalendarDate } from '@internationalized/date'
|
||||
import TvDateRange from '@/components/features/base/TvDateRange.vue'
|
||||
import { useTasksStore } from '@/stores/tasks.store'
|
||||
import { useGoalPermissions } from '@/composables/useGoalPermissions'
|
||||
import type { DateRangeValue } from '@/types/tvDate.types'
|
||||
|
||||
const props = defineProps<{
|
||||
task: Task
|
||||
}>()
|
||||
|
||||
const tasksStore = useTasksStore()
|
||||
const { canEditTaskDeadline } = useGoalPermissions()
|
||||
|
||||
function parse(dateStr: string | null | undefined): CalendarDate | undefined {
|
||||
if (!dateStr) return undefined
|
||||
const [year, month, day] = dateStr.split('-').map(Number)
|
||||
if (!year || !month || !day) return undefined
|
||||
return new CalendarDate(year, month, day)
|
||||
}
|
||||
|
||||
const range = computed<DateRangeValue>(() => ({
|
||||
start: parse(props.task.startDate),
|
||||
end: parse(props.task.endDate),
|
||||
}))
|
||||
|
||||
async function onUpdate(value: DateRangeValue | undefined) {
|
||||
const v = value ?? null
|
||||
await tasksStore.saveDateForTask({
|
||||
id: props.task.id,
|
||||
startDate: v?.start ? v.start.toString() : null,
|
||||
startTime: v?.start ? props.task.startTime : null,
|
||||
endDate: v?.end ? v.end.toString() : null,
|
||||
endTime: v?.end ? props.task.endTime : null,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<UPopover
|
||||
v-if="withActivator"
|
||||
v-model:open="open"
|
||||
:content="{ align: 'start' }"
|
||||
:ui="{ content: 'rounded-2xl' }"
|
||||
>
|
||||
<UButton
|
||||
:disabled="disabled"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
block
|
||||
size="xl"
|
||||
:icon="activatorIcon || (multiple ? placeholderIcon : current?.icon) || placeholderIcon"
|
||||
:trailing-icon="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
:ui="activatorUi((multiple ? undefined : current?.iconClass) ?? 'text-muted')"
|
||||
>
|
||||
<span
|
||||
class="flex-1 text-left truncate min-w-0"
|
||||
:class="hasSelection ? '' : 'text-muted'"
|
||||
:style="!multiple && current?.color ? { color: current.color } : undefined"
|
||||
>{{ activatorLabel }}</span>
|
||||
</UButton>
|
||||
|
||||
<template #content>
|
||||
<div class="p-2 min-w-56">
|
||||
<TvDropdownOptions
|
||||
v-model="model"
|
||||
:items="items"
|
||||
:disabled="disabled"
|
||||
:multiple="multiple"
|
||||
@update:model-value="onSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
|
||||
<TvDropdownOptions
|
||||
v-else
|
||||
v-model="model"
|
||||
:items="items"
|
||||
:disabled="disabled"
|
||||
:multiple="multiple"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="V, T = V">
|
||||
import { ref, computed } from 'vue'
|
||||
import TvDropdownOptions from '@/components/features/base/TvDropdownOptions.vue'
|
||||
import { useNuxtUiTaskItemStyles } from '@/composables/useNuxtUiTaskItemStyles'
|
||||
import type { TvDropdownOption } from '@/types/tvDropdown.types'
|
||||
|
||||
const model = defineModel<T>()
|
||||
|
||||
const {
|
||||
items,
|
||||
withActivator = true,
|
||||
disabled = false,
|
||||
placeholder = '',
|
||||
placeholderIcon = '',
|
||||
activatorIcon = '',
|
||||
multiple = false,
|
||||
} = defineProps<{
|
||||
items: TvDropdownOption<V>[]
|
||||
withActivator?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
placeholderIcon?: string
|
||||
/** Fixed activator icon — overrides the selected option's icon. */
|
||||
activatorIcon?: string
|
||||
multiple?: boolean
|
||||
}>()
|
||||
|
||||
const { activatorUi } = useNuxtUiTaskItemStyles()
|
||||
|
||||
const open = ref(false)
|
||||
const current = computed(() => items.find(o => o.value === (model.value as unknown as V)))
|
||||
const selectedOptions = computed(() => {
|
||||
if (Array.isArray(model.value)) {
|
||||
const selected = model.value as V[]
|
||||
return items.filter(o => selected.includes(o.value))
|
||||
}
|
||||
return current.value ? [current.value] : []
|
||||
})
|
||||
const hasSelection = computed(() => selectedOptions.value.length > 0)
|
||||
const activatorLabel = computed(() => {
|
||||
if (multiple) {
|
||||
return selectedOptions.value.length
|
||||
? selectedOptions.value.map(o => o.label).join(', ')
|
||||
: placeholder
|
||||
}
|
||||
return current.value?.label ?? placeholder
|
||||
})
|
||||
|
||||
function onSelect() {
|
||||
if (!multiple) open.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<UButton
|
||||
v-for="opt in items"
|
||||
:key="String(opt.value)"
|
||||
:disabled="disabled"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
block
|
||||
size="sm"
|
||||
:icon="opt.icon"
|
||||
class="items-center justify-between gap-3"
|
||||
:ui="dropdownItemUi(`${opt.iconClass ?? ''} size-4.5`)"
|
||||
:class="!opt.color && isSelected(opt.value) ? 'bg-primary/10' : ''"
|
||||
:style="opt.color && isSelected(opt.value) ? { backgroundColor: opt.color + '14' } : undefined"
|
||||
@click="select(opt.value)"
|
||||
>
|
||||
<span class="flex-1 flex flex-col items-start text-left gap-0.5">
|
||||
<span class="text-base text-highlighted leading-tight">{{ opt.label }}</span>
|
||||
<span
|
||||
v-if="opt.description"
|
||||
class="text-xs text-muted leading-tight"
|
||||
>{{ opt.description }}</span>
|
||||
</span>
|
||||
|
||||
<template #trailing>
|
||||
<span
|
||||
v-if="isSelected(opt.value)"
|
||||
class="flex items-center justify-center size-5.5 rounded-full shrink-0"
|
||||
:class="opt.color ? '' : 'bg-primary'"
|
||||
:style="opt.color ? { backgroundColor: opt.color } : undefined"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-check"
|
||||
class="size-4 text-white"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="size-5.5 rounded-full border-2 border-default shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="V, T = V">
|
||||
import { useNuxtUiTaskItemStyles } from '@/composables/useNuxtUiTaskItemStyles'
|
||||
import type { TvDropdownOption } from '@/types/tvDropdown.types'
|
||||
|
||||
const { dropdownItemUi } = useNuxtUiTaskItemStyles()
|
||||
|
||||
const model = defineModel<T>()
|
||||
|
||||
const { items, disabled = false, multiple = false } = defineProps<{
|
||||
items: TvDropdownOption<V>[]
|
||||
disabled?: boolean
|
||||
multiple?: boolean
|
||||
}>()
|
||||
|
||||
function isSelected(value: V): boolean {
|
||||
return Array.isArray(model.value)
|
||||
? (model.value as V[]).includes(value)
|
||||
: (model.value as unknown as V) === value
|
||||
}
|
||||
|
||||
function select(value: V) {
|
||||
if (disabled) return
|
||||
if (multiple) {
|
||||
const current = Array.isArray(model.value) ? [...(model.value as V[])] : []
|
||||
const index = current.indexOf(value)
|
||||
if (index === -1) current.push(value)
|
||||
else current.splice(index, 1)
|
||||
model.value = current as unknown as T
|
||||
} else {
|
||||
model.value = value as unknown as T
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<UPageCard
|
||||
v-bind="{ ...props }"
|
||||
class="shadow-sm"
|
||||
class="shadow-sm rounded-xl"
|
||||
:class="{ 'bg-primary/10 border border-primary/30': active }"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<TvDropdown
|
||||
v-model="model"
|
||||
:items="options"
|
||||
:with-activator="withActivator"
|
||||
:disabled="disabled"
|
||||
:placeholder="t('tasks.selectStatus')"
|
||||
placeholder-icon="i-lucide-columns-3"
|
||||
activator-icon="i-lucide-columns-3"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TvDropdown from '@/components/features/base/TvDropdown.vue'
|
||||
import { useKanbanStatusOptions, type StatusValue } from '@/composables/useKanbanStatusOptions'
|
||||
|
||||
const model = defineModel<StatusValue>()
|
||||
|
||||
const { withActivator = true, disabled = false } = defineProps<{
|
||||
withActivator?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { options } = useKanbanStatusOptions()
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<TvDropdown
|
||||
v-model="model"
|
||||
:items="options"
|
||||
:with-activator="withActivator"
|
||||
:disabled="disabled"
|
||||
:placeholder="t('tasks.noList')"
|
||||
placeholder-icon="i-lucide-inbox"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TvDropdown from '@/components/features/base/TvDropdown.vue'
|
||||
import { useGoalListOptions, type ListValue } from '@/composables/useGoalListOptions'
|
||||
|
||||
const model = defineModel<ListValue>()
|
||||
|
||||
const { withActivator = true, disabled = false } = defineProps<{
|
||||
withActivator?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { options } = useGoalListOptions()
|
||||
</script>
|
||||
@@ -1,20 +1,21 @@
|
||||
<template>
|
||||
<nav class="fixed bottom-0 left-0 right-0 z-50 lg:hidden border-t border-default pb-safe bg-default">
|
||||
<div class="flex items-center justify-between min-h-14">
|
||||
<div class="flex items-stretch justify-around min-h-16 gap-1 p-1.5">
|
||||
<UButton
|
||||
v-for="item in navItems"
|
||||
:key="item.label"
|
||||
:key="item.key"
|
||||
:icon="item.icon"
|
||||
:to="item.to"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
class="flex-1 h-full rounded-none"
|
||||
:class="{ 'text-primary': item.active?.() }"
|
||||
:label="item.label"
|
||||
:color="item.active() ? 'primary' : 'neutral'"
|
||||
:variant="item.active() ? 'soft' : 'ghost'"
|
||||
block
|
||||
class="flex-1 min-w-0"
|
||||
:ui="{
|
||||
base: ' justify-center',
|
||||
base: 'flex-col gap-1 py-1.5 rounded-xl',
|
||||
leadingIcon: 'size-5',
|
||||
label: 'text-[11px] leading-none truncate max-w-full',
|
||||
}"
|
||||
@click="item.onClick?.()"
|
||||
@click="onItemClick(item)"
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -22,89 +23,104 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter, type RouteLocationRaw } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDashboard } from '@/composables/useDashboard'
|
||||
import { useAppRouteInfo } from '@/composables/useAppRouteInfo'
|
||||
import { ALL_TASKS_LIST_ID } from 'taskview-api'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import { useGoalPermissionsFor } from '@/composables/useGoalPermissions'
|
||||
|
||||
type NavItem = {
|
||||
key: string
|
||||
label: string
|
||||
icon: string
|
||||
to?: RouteLocationRaw
|
||||
active: () => boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { isSidebarOpen } = useDashboard()
|
||||
const { isUserRoute, isAccountRoute, hasProject, projectId, hasList } = useAppRouteInfo()
|
||||
const { isUserRoute, isAccountRoute, hasProject, projectId } = useAppRouteInfo()
|
||||
const goalsStore = useGoalsStore()
|
||||
|
||||
const navItems = computed(() => {
|
||||
if (isAccountRoute.value) {
|
||||
return [
|
||||
{
|
||||
label: t('main'),
|
||||
icon: 'i-lucide-house',
|
||||
to: { name: 'user' },
|
||||
active: (): boolean => false,
|
||||
},
|
||||
]
|
||||
}
|
||||
const currentGoal = computed(() =>
|
||||
hasProject.value ? goalsStore.goalMap.get(projectId.value) ?? null : null,
|
||||
)
|
||||
const { canViewKanban, canViewGraph } = useGoalPermissionsFor(currentGoal)
|
||||
|
||||
if (isUserRoute.value && !hasProject.value) {
|
||||
return [
|
||||
{
|
||||
label: t('projects.title'),
|
||||
icon: 'i-lucide-folder',
|
||||
active: (): boolean => isSidebarOpen.value,
|
||||
onClick: () => { isSidebarOpen.value = !isSidebarOpen.value },
|
||||
},
|
||||
{
|
||||
label: t('account.title'),
|
||||
icon: 'i-lucide-settings',
|
||||
to: { name: 'account' },
|
||||
active: (): boolean => false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
// A stable core (Home · Projects · … · Account) keeps tab positions predictable
|
||||
// across routes; project shortcuts (Kanban/Graph) only slot in when inside a
|
||||
// project and the user is allowed to see them.
|
||||
const navItems = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
key: 'home',
|
||||
label: t('main'),
|
||||
icon: 'i-lucide-house',
|
||||
to: { name: 'user' },
|
||||
active: (): boolean => false,
|
||||
active: () => isUserRoute.value && !hasProject.value,
|
||||
},
|
||||
{
|
||||
key: 'projects',
|
||||
label: t('projects.title'),
|
||||
icon: 'i-lucide-folder',
|
||||
active: (): boolean => isSidebarOpen.value,
|
||||
icon: 'i-lucide-panel-left',
|
||||
active: () => isSidebarOpen.value,
|
||||
onClick: () => { isSidebarOpen.value = !isSidebarOpen.value },
|
||||
},
|
||||
{
|
||||
label: t('projects.kanban'),
|
||||
icon: 'i-lucide-kanban',
|
||||
to: { name: 'kanban', params: { projectId: projectId.value } },
|
||||
active: (): boolean => route.name === 'kanban',
|
||||
},
|
||||
{
|
||||
label: t('projects.graph'),
|
||||
icon: 'i-lucide-git-fork',
|
||||
to: { name: 'graph', params: { projectId: projectId.value } },
|
||||
active: (): boolean => route.name === 'graph',
|
||||
},
|
||||
{
|
||||
label: t('projects.collaboration'),
|
||||
icon: 'i-lucide-users',
|
||||
to: { name: 'collaboration', params: { projectId: projectId.value } },
|
||||
active: (): boolean => route.name === 'collaboration',
|
||||
},
|
||||
{
|
||||
label: t('lists.title'),
|
||||
icon: 'i-lucide-list',
|
||||
to: { name: 'user', params: { projectId: projectId.value } },
|
||||
active: (): boolean => isUserRoute.value && hasProject.value && !hasList.value,
|
||||
},
|
||||
{
|
||||
label: t('tasks.allTasks'),
|
||||
icon: 'i-lucide-list-checks',
|
||||
to: { name: 'user', params: { projectId: projectId.value, listId: ALL_TASKS_LIST_ID } },
|
||||
active: (): boolean => isUserRoute.value && hasList.value,
|
||||
},
|
||||
]
|
||||
|
||||
if (hasProject.value) {
|
||||
if (canViewKanban.value) {
|
||||
items.push({
|
||||
key: 'kanban',
|
||||
label: t('contextMenu.kanban'),
|
||||
icon: 'i-lucide-kanban',
|
||||
to: { name: 'kanban', params: { projectId: projectId.value } },
|
||||
active: () => route.name === 'kanban',
|
||||
})
|
||||
}
|
||||
if (canViewGraph.value) {
|
||||
items.push({
|
||||
key: 'graph',
|
||||
label: t('contextMenu.graph'),
|
||||
icon: 'i-lucide-git-fork',
|
||||
to: { name: 'graph', params: { projectId: projectId.value } },
|
||||
active: () => route.name === 'graph',
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
key: 'list',
|
||||
label: t('contextMenu.tasks'),
|
||||
icon: 'i-lucide-list',
|
||||
to: { name: 'user', params: { projectId: projectId.value, listId: ALL_TASKS_LIST_ID } },
|
||||
active: () => isUserRoute.value && hasProject.value,
|
||||
})
|
||||
}
|
||||
|
||||
// Inside a project the bar fills up with project shortcuts (List/Kanban/Graph),
|
||||
// so drop Settings there — it stays reachable from the global tabs.
|
||||
if (!hasProject.value) {
|
||||
items.push({
|
||||
key: 'account',
|
||||
label: t('account.nav'),
|
||||
icon: 'i-lucide-settings',
|
||||
to: { name: 'account' },
|
||||
active: () => isAccountRoute.value,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
function onItemClick(item: NavItem) {
|
||||
if (item.onClick) {
|
||||
item.onClick()
|
||||
return
|
||||
}
|
||||
if (item.to) router.push(item.to)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<TvDropdown
|
||||
v-model="model"
|
||||
:items="options"
|
||||
:with-activator="withActivator"
|
||||
:disabled="disabled"
|
||||
:placeholder="t('tasks.priority')"
|
||||
placeholder-icon="i-lucide-flag"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TvDropdown from '@/components/features/base/TvDropdown.vue'
|
||||
import { usePriorityOptions, type PriorityValue } from '@/composables/usePriorityOptions'
|
||||
|
||||
const model = defineModel<PriorityValue>()
|
||||
|
||||
const { withActivator = true, disabled = false } = defineProps<{
|
||||
withActivator?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { options } = usePriorityOptions()
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<TvDropdown
|
||||
v-if="canView"
|
||||
:model-value="currentSprintId"
|
||||
:items="options"
|
||||
:disabled="!canAssign"
|
||||
:placeholder="t('tasks.fields.sprint')"
|
||||
placeholder-icon="i-lucide-rocket"
|
||||
activator-icon="i-lucide-rocket"
|
||||
@update:model-value="selectSprint"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Sprint } from 'taskview-api'
|
||||
import { $tvApi } from '@/plugins/axios'
|
||||
import { useTasksStore } from '@/stores/tasks.store'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import { useGoalPermissionsFor } from '@/composables/useGoalPermissions'
|
||||
import { logError } from '@/helpers/Helper'
|
||||
import TvDropdown from '@/components/features/base/TvDropdown.vue'
|
||||
import type { TvDropdownOption } from '@/types/tvDropdown.types'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
goalId: number
|
||||
currentSprintId: number | null
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const tasksStore = useTasksStore()
|
||||
const goalsStore = useGoalsStore()
|
||||
|
||||
const goal = computed(() => goalsStore.goalMap.get(props.goalId) ?? null)
|
||||
const { canViewSprints: canView, canAssignSprintTasks: canAssign } = useGoalPermissionsFor(goal)
|
||||
|
||||
const sprints = ref<Sprint[]>([])
|
||||
const loadedGoalId = ref<number | null>(null)
|
||||
|
||||
const options = computed<TvDropdownOption<number | null>[]>(() => [
|
||||
{ value: null, label: t('tasks.sprintNone'), icon: 'i-lucide-rocket', iconClass: 'text-dimmed' },
|
||||
...sprints.value
|
||||
.filter(s => s.status !== 'completed')
|
||||
.map(s => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
description: s.status ? t(`sprints.status.${s.status}`) : undefined,
|
||||
icon: 'i-lucide-rocket',
|
||||
iconClass: 'text-dimmed',
|
||||
})),
|
||||
])
|
||||
|
||||
async function loadSprints() {
|
||||
if (loadedGoalId.value === props.goalId) return
|
||||
const result = await $tvApi.sprints.listForGoal({ goalId: props.goalId }).catch(logError)
|
||||
if (!result) return
|
||||
sprints.value = result
|
||||
loadedGoalId.value = props.goalId
|
||||
}
|
||||
|
||||
async function selectSprint(value: number | null | undefined) {
|
||||
const sprintId = value ?? null
|
||||
if (sprintId === props.currentSprintId) return
|
||||
|
||||
const result = await $tvApi.sprints
|
||||
.setTaskSprint({ taskId: props.taskId, sprintId })
|
||||
.catch(logError)
|
||||
|
||||
if (!result) {
|
||||
toast.add({ title: t('tasks.sprintUpdateFailed'), color: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
const selected = tasksStore.selectedTask
|
||||
if (selected && selected.id === props.taskId) {
|
||||
selected.sprintId = sprintId
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.goalId, loadSprints, { immediate: true })
|
||||
</script>
|
||||
@@ -16,6 +16,7 @@
|
||||
v-model="activeTab"
|
||||
:items="tabs"
|
||||
class="w-full"
|
||||
:ui="{ list: 'rounded-2xl', trigger: 'rounded-xl', indicator: 'rounded-xl' }"
|
||||
>
|
||||
<template #members>
|
||||
<MembersList
|
||||
|
||||
@@ -46,13 +46,13 @@
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('common.save')"
|
||||
color="primary"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between p-3 rounded-lg border border-default hover:bg-elevated transition-colors">
|
||||
<div class="flex items-center justify-between p-3 rounded-2xl border border-default hover:bg-elevated transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<UAvatar
|
||||
:alt="member.email"
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('contextMenu.delete')"
|
||||
color="error"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
@click="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<UButton
|
||||
:label="t('common.done')"
|
||||
color="primary"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between p-3 rounded-lg border border-default hover:bg-elevated transition-colors">
|
||||
<div class="flex items-center justify-between p-3 rounded-2xl border border-default hover:bg-elevated transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon
|
||||
name="i-lucide-shield"
|
||||
|
||||
@@ -29,14 +29,14 @@
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
@click="close"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
:disabled="!taskName.trim()"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
@click="addTask"
|
||||
>
|
||||
{{ t('tasks.add') }}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
:placeholder="t('kanban.addColumn')"
|
||||
icon="i-lucide-plus"
|
||||
size="xl"
|
||||
class="h-full max-w-[340px] min-w-[272px] bg-neutral-800 flex flex-col w-[91.666667%] rounded-lg"
|
||||
variant="soft"
|
||||
class="h-full max-w-[340px] min-w-[272px] flex flex-col w-[91.666667%] rounded-lg"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
|
||||
@@ -24,13 +24,14 @@
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
@click="model = false"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
color="error"
|
||||
variant="soft"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
<UInput
|
||||
v-model="formData.name"
|
||||
:placeholder="t('lists.namePlaceholder')"
|
||||
variant="soft"
|
||||
class="w-full"
|
||||
:ui="{ base: 'rounded-xl' }"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,30 @@
|
||||
<template #leading>
|
||||
<TvCollapseSidebarDesktop />
|
||||
</template>
|
||||
<template #right>
|
||||
<UButtonGroup size="md">
|
||||
<UButton
|
||||
icon="i-lucide-list"
|
||||
:color="view === 'list' ? 'primary' : 'neutral'"
|
||||
:variant="view === 'list' ? 'soft' : 'ghost'"
|
||||
square
|
||||
@click="view = 'list'"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-layout-grid"
|
||||
:color="view === 'widgets' ? 'primary' : 'neutral'"
|
||||
:variant="view === 'widgets' ? 'soft' : 'ghost'"
|
||||
square
|
||||
@click="view = 'widgets'"
|
||||
/>
|
||||
</UButtonGroup>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<DesktopScreen />
|
||||
<DesktopScreen v-if="view === 'widgets'" />
|
||||
<DashboardList v-else />
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
@@ -19,7 +38,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TvCollapseSidebarDesktop from '@/components/features/base/TvCollapseSidebarDesktop.vue'
|
||||
import { useLsRef } from '@/composables/useLsRef'
|
||||
import DesktopScreen from './screen-main/DesktopScreen.vue'
|
||||
import DashboardList from './screen-main/DashboardList.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const view = useLsRef<'list' | 'widgets'>('main-screen-view', 'list')
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="overflow-auto flex flex-col">
|
||||
<div class="sticky top-0 p-2 lg:p-5 z-10 bg-default">
|
||||
<div class="w-full max-w-3xl mx-auto">
|
||||
<SearchActivator />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col p-2 md:p-5 md:pt-0 w-full max-w-3xl mx-auto gap-5">
|
||||
<DashboardSection
|
||||
icon="i-lucide-sunrise"
|
||||
accent="indigo"
|
||||
:title="t('msg.todayTasks')"
|
||||
:subtitle="t('msg.todayExplanation')"
|
||||
:add-title="t('msg.addTaskToToday')"
|
||||
:tasks="todayPending"
|
||||
:completed-tasks="todayCompleted"
|
||||
section-key="today"
|
||||
@toggle="(task) => toggle(task, 'tasksToday')"
|
||||
/>
|
||||
|
||||
<DashboardSection
|
||||
icon="i-lucide-clock"
|
||||
accent="sky"
|
||||
:title="t('msg.upcomingTasks')"
|
||||
:subtitle="t('msg.upcomingExplanation')"
|
||||
:add-title="t('msg.addTaskToUpcoming')"
|
||||
:tasks="upcomingPending"
|
||||
:completed-tasks="upcomingCompleted"
|
||||
upcoming-task
|
||||
groupable
|
||||
section-key="upcoming"
|
||||
@toggle="(task) => toggle(task, 'tasksUpcoming')"
|
||||
/>
|
||||
|
||||
<DashboardSection
|
||||
icon="i-lucide-inbox"
|
||||
accent="violet"
|
||||
:title="t('msg.lastTasks')"
|
||||
:subtitle="t('msg.lastAddedTasksExplanation')"
|
||||
:add-title="t('msg.addTask')"
|
||||
:tasks="recentTasks"
|
||||
:completed-tasks="recentCompleted"
|
||||
no-dates
|
||||
tabbed
|
||||
section-key="recent"
|
||||
:show-progress="false"
|
||||
@toggle="toggleRecent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SearchActivator from './parts/SearchActivator.vue'
|
||||
import DashboardSection from './DashboardList/parts/DashboardSection.vue'
|
||||
import type { TaskItem } from '@/types/tasks.types'
|
||||
import { useBaseScreenStore } from '@/stores/base-screen.store'
|
||||
import { useCollaborationStore } from '@/stores/collaboration.store'
|
||||
import { useOrganizationStore } from '@/stores/organization.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const baseScreenStore = useBaseScreenStore()
|
||||
const collaborationStore = useCollaborationStore()
|
||||
const orgStore = useOrganizationStore()
|
||||
|
||||
const todayPending = computed(() => baseScreenStore.tasksToday.filter(task => !task.complete))
|
||||
const todayCompleted = computed(() => baseScreenStore.tasksToday.filter(task => task.complete))
|
||||
const upcomingPending = computed(() => baseScreenStore.tasksUpcoming.filter(task => !task.complete))
|
||||
const upcomingCompleted = computed(() => baseScreenStore.tasksUpcoming.filter(task => task.complete))
|
||||
|
||||
const recentTasks = computed(() => baseScreenStore.tasks)
|
||||
const recentCompleted = computed(() => baseScreenStore.tasksLastCompleted)
|
||||
|
||||
function toggle(task: TaskItem, prop: 'tasksToday' | 'tasksUpcoming') {
|
||||
baseScreenStore.updateTaskStatusNew({ status: !task.complete, taskId: task.id }, prop)
|
||||
}
|
||||
|
||||
function toggleRecent(task: TaskItem, bucket: 'pending' | 'completed') {
|
||||
baseScreenStore.updateTaskStatusNew(
|
||||
{ status: !task.complete, taskId: task.id },
|
||||
bucket === 'completed' ? 'tasksLastCompleted' : 'tasks',
|
||||
)
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
Promise.allSettled([
|
||||
baseScreenStore.fetchAllState(),
|
||||
baseScreenStore.fetchAllAvailableLists(),
|
||||
collaborationStore.fetchAllCollaborationUsers(),
|
||||
])
|
||||
}
|
||||
|
||||
loadData()
|
||||
|
||||
watch(() => orgStore.currentOrg, loadData)
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<MainScreenAddTaskDialog
|
||||
v-model="open"
|
||||
:title="title"
|
||||
:disabled="disabled"
|
||||
:upcoming-task="upcomingTask"
|
||||
:no-dates="noDates"
|
||||
>
|
||||
<template #activator="props">
|
||||
<UButton
|
||||
v-if="compact"
|
||||
v-bind="props"
|
||||
icon="i-lucide-plus"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
:disabled="disabled"
|
||||
:ui="{ base: 'rounded-full hover:bg-primary/10 hover:text-primary' }"
|
||||
@click.stop
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
v-bind="props"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
class="flex items-center gap-2.5 w-full px-4 py-3 rounded-2xl border border-dashed border-default text-muted hover:text-primary hover:border-primary hover:bg-primary/5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<span class="flex items-center justify-center size-6 rounded-full border border-dashed border-current shrink-0">
|
||||
<UIcon
|
||||
name="i-lucide-plus"
|
||||
class="size-3.5"
|
||||
/>
|
||||
</span>
|
||||
<span class="text-[15px]">{{ t('msg.addTask') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</MainScreenAddTaskDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MainScreenAddTaskDialog from '@/components/features/main/screen-main/parts/MainScreenAddTaskDialog.vue'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
disabled?: boolean
|
||||
upcomingTask?: boolean
|
||||
noDates?: boolean
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const open = ref(false)
|
||||
</script>
|
||||
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Timeline connecting line under the icon -->
|
||||
<div class="absolute left-6 top-14 bottom-0 w-px bg-accented hidden sm:block" />
|
||||
|
||||
<UCollapsible
|
||||
v-model:open="expanded"
|
||||
:ui="{ content: 'p-0 pl-0 sm:pl-16 pb-8' }"
|
||||
>
|
||||
<DashboardSectionHeader
|
||||
:icon="icon"
|
||||
:accent="accentClasses"
|
||||
:title="title"
|
||||
:subtitle="subtitle"
|
||||
:expanded="expanded"
|
||||
:show-progress="showProgress"
|
||||
:done-count="doneCount"
|
||||
:total-count="totalCount"
|
||||
:progress-percent="progressPercent"
|
||||
:add-title="addTitle"
|
||||
:add-disabled="!hasActiveGoals"
|
||||
:upcoming-task="upcomingTask"
|
||||
:no-dates="noDates"
|
||||
:groupable="groupable"
|
||||
:grouped="grouped"
|
||||
@toggle-group="grouped = !grouped"
|
||||
/>
|
||||
|
||||
<template #content>
|
||||
<!-- Tabbed mode: split active / completed into tabs (used by Recent) -->
|
||||
<UTabs
|
||||
v-if="tabbed"
|
||||
v-model="activeTab"
|
||||
:items="tabItems"
|
||||
variant="pill"
|
||||
size="md"
|
||||
class="w-full pt-3"
|
||||
:ui="{ list: 'rounded-full', trigger: 'rounded-full', indicator: 'rounded-full' }"
|
||||
>
|
||||
<template #active>
|
||||
<div class="flex flex-col gap-2 pt-3">
|
||||
<TaskItem
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@toggle="$emit('toggle', $event, 'pending')"
|
||||
/>
|
||||
<p
|
||||
v-if="!tasks.length"
|
||||
class="text-sm text-muted text-center py-4"
|
||||
>
|
||||
{{ t('msg.noTasks') }}
|
||||
</p>
|
||||
<DashboardAddTask
|
||||
:title="addTitle"
|
||||
:disabled="!hasActiveGoals"
|
||||
:no-dates="noDates"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #completed>
|
||||
<div class="flex flex-col gap-2 pt-3">
|
||||
<TaskItem
|
||||
v-for="task in completedTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@toggle="$emit('toggle', $event, 'completed')"
|
||||
/>
|
||||
<p
|
||||
v-if="!completedTasks.length"
|
||||
class="text-sm text-muted text-center py-4"
|
||||
>
|
||||
{{ t('msg.noTasks') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</UTabs>
|
||||
|
||||
<!-- Default stacked mode: pending list + add + completed group -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col gap-3 pt-3"
|
||||
>
|
||||
<div
|
||||
v-if="groupable && grouped && tasks.length"
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<div
|
||||
v-for="group in taskGroups"
|
||||
:key="group.key"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-1">
|
||||
<span class="text-sm text-highlighted opacity-80 font-medium">
|
||||
{{ group.label }}
|
||||
</span>
|
||||
<span class="text-xs text-muted">{{ group.tasks.length }}</span>
|
||||
<div class="flex-1 h-px bg-default" />
|
||||
</div>
|
||||
<TaskItem
|
||||
v-for="task in group.tasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@toggle="$emit('toggle', $event, 'pending')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="tasks.length"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<TaskItem
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@toggle="$emit('toggle', $event, 'pending')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DashboardAddTask
|
||||
:title="addTitle"
|
||||
:disabled="!hasActiveGoals"
|
||||
:upcoming-task="upcomingTask"
|
||||
:no-dates="noDates"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="completedTasks.length"
|
||||
class="flex flex-col gap-1 mt-2"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-1">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-success">
|
||||
{{ t('msg.completed') }} · {{ completedTasks.length }}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-default" />
|
||||
</div>
|
||||
<TaskItem
|
||||
v-for="task in completedTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@toggle="$emit('toggle', $event, 'completed')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UCollapsible>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { TabsItem } from '@nuxt/ui'
|
||||
import type { TaskItem as TaskItemType } from '@/types/tasks.types'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
import { useLsRef } from '@/composables/useLsRef'
|
||||
import TaskItem from '@/components/features/tasks/parts/TaskItem.vue'
|
||||
import DashboardAddTask from './DashboardAddTask.vue'
|
||||
import DashboardSectionHeader from './DashboardSectionHeader.vue'
|
||||
|
||||
type Accent = 'indigo' | 'sky' | 'violet'
|
||||
type TaskBucket = 'pending' | 'completed'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
icon: string
|
||||
accent: Accent
|
||||
title: string
|
||||
subtitle: string
|
||||
addTitle: string
|
||||
tasks: TaskItemType[]
|
||||
completedTasks: TaskItemType[]
|
||||
upcomingTask?: boolean
|
||||
noDates?: boolean
|
||||
showProgress?: boolean
|
||||
tabbed?: boolean
|
||||
groupable?: boolean
|
||||
sectionKey: string
|
||||
}>(), {
|
||||
showProgress: true,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
toggle: [task: TaskItemType, bucket: TaskBucket]
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { hasActiveGoals } = useTaskView()
|
||||
|
||||
const grouped = useLsRef(`taskview:dashboard:${props.sectionKey}:grouped`, true)
|
||||
|
||||
const NO_DATE_KEY = '~'
|
||||
|
||||
function dateKey(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
function groupLabel(key: string): string {
|
||||
const date = new Date(`${key}T00:00:00`)
|
||||
const day = new Intl.DateTimeFormat(locale.value, { day: 'numeric' }).format(date)
|
||||
const month = new Intl.DateTimeFormat(locale.value, { month: 'short' }).format(date)
|
||||
const weekday = new Intl.DateTimeFormat(locale.value, { weekday: 'long' }).format(date)
|
||||
const formatted = `${day} ${month} ${weekday}`
|
||||
return formatted.charAt(0).toUpperCase() + formatted.slice(1)
|
||||
}
|
||||
|
||||
const taskGroups = computed(() => {
|
||||
const groups = new Map<string, TaskItemType[]>()
|
||||
for (const task of props.tasks) {
|
||||
const key = task.endDate ? dateKey(new Date(task.endDate)) : NO_DATE_KEY
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(task)
|
||||
}
|
||||
return [...groups.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, tasks]) => ({ key, label: key === NO_DATE_KEY ? t('msg.noDeadline') : groupLabel(key), tasks }))
|
||||
})
|
||||
|
||||
const ACCENTS: Record<Accent, { bg: string; icon: string; bar: string }> = {
|
||||
indigo: { bg: 'bg-indigo-500/15', icon: 'text-indigo-500', bar: 'bg-indigo-500' },
|
||||
sky: { bg: 'bg-sky-500/15', icon: 'text-sky-500', bar: 'bg-sky-500' },
|
||||
violet: { bg: 'bg-violet-500/15', icon: 'text-violet-500', bar: 'bg-violet-500' },
|
||||
}
|
||||
|
||||
const accentClasses = computed(() => ACCENTS[props.accent])
|
||||
|
||||
const expanded = useLsRef(`taskview:dashboard:${props.sectionKey}:expanded`, true)
|
||||
|
||||
const activeTab = ref('active')
|
||||
const tabItems = computed<TabsItem[]>(() => [
|
||||
{
|
||||
label: t('msg.lastAddedTasks'),
|
||||
value: 'active',
|
||||
slot: 'active',
|
||||
badge: props.tasks.length.toString(),
|
||||
},
|
||||
{
|
||||
label: t('msg.lastCompletedTasks'),
|
||||
value: 'completed',
|
||||
slot: 'completed',
|
||||
badge: props.completedTasks.length.toString(),
|
||||
},
|
||||
])
|
||||
|
||||
const doneCount = computed(() => props.completedTasks.length)
|
||||
const totalCount = computed(() => props.tasks.length + props.completedTasks.length)
|
||||
const progressPercent = computed(() =>
|
||||
totalCount.value === 0 ? 0 : Math.round((doneCount.value / totalCount.value) * 100),
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="flex items-center sm:items-start gap-4 w-full cursor-pointer select-none">
|
||||
<div
|
||||
class="flex items-center justify-center size-12 rounded-2xl shrink-0"
|
||||
:class="accent.bg"
|
||||
>
|
||||
<UIcon
|
||||
:name="icon"
|
||||
class="size-6"
|
||||
:class="accent.icon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<h2 class="text-xl font-medium text-highlighted truncate">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-down"
|
||||
class="size-5 text-muted transition-transform duration-200 shrink-0"
|
||||
:class="{ '-rotate-90': !expanded }"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-muted hidden sm:block">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 shrink-0 self-center">
|
||||
<template v-if="showProgress">
|
||||
<span class="text-sm font-semibold text-muted">{{ doneCount }}/{{ totalCount }}</span>
|
||||
<div class="w-16 h-1.5 rounded-full bg-elevated overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-[width] duration-300"
|
||||
:class="accent.bar"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<UButton
|
||||
v-if="groupable"
|
||||
:icon="grouped ? 'i-lucide-list' : 'i-lucide-calendar-days'"
|
||||
:aria-label="grouped ? t('msg.showAsList') : t('msg.groupByDate')"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click.stop="$emit('toggle-group')"
|
||||
/>
|
||||
<DashboardAddTask
|
||||
compact
|
||||
:title="addTitle"
|
||||
:disabled="addDisabled"
|
||||
:upcoming-task="upcomingTask"
|
||||
:no-dates="noDates"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import DashboardAddTask from './DashboardAddTask.vue'
|
||||
|
||||
defineProps<{
|
||||
icon: string
|
||||
accent: { bg: string; icon: string; bar: string }
|
||||
title: string
|
||||
subtitle: string
|
||||
expanded: boolean
|
||||
showProgress: boolean
|
||||
doneCount: number
|
||||
totalCount: number
|
||||
progressPercent: number
|
||||
addTitle: string
|
||||
addDisabled?: boolean
|
||||
upcomingTask?: boolean
|
||||
noDates?: boolean
|
||||
groupable?: boolean
|
||||
grouped?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-group': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="overflow-auto flex flex-col gap-0">
|
||||
<div class="md:block sticky top-0 p-2 lg:p-5 z-10 bg-default">
|
||||
<SearchAll />
|
||||
<SearchActivator />
|
||||
</div>
|
||||
<div class="flex flex-col gap-5 p-2 md:p-5 md:pt-0 w-full mx-auto">
|
||||
<div class="flex gap-5 flex-col md:flex-row">
|
||||
@@ -14,7 +14,7 @@
|
||||
</template>
|
||||
<script async setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import SearchAll from './parts/SearchAll.vue'
|
||||
import SearchActivator from './parts/SearchActivator.vue'
|
||||
import WidgetLastAddedTasks from './parts/WidgetLastAddedTasks.vue'
|
||||
import WidgetToday from './parts/WidgetToday.vue'
|
||||
import WidgetUpcoming from './parts/WidgetUpcoming.vue'
|
||||
|
||||
@@ -18,54 +18,54 @@
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<UCard>
|
||||
<div class="flex flex-col gap-4">
|
||||
<UFormField :label="t('tasks.description')">
|
||||
<UInput
|
||||
v-model="taskDescription"
|
||||
:placeholder="t('tasks.addPlaceholder')"
|
||||
class="w-full"
|
||||
@keyup.enter="addTask"
|
||||
/>
|
||||
</UFormField>
|
||||
<!-- <UCard> -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<UFormField :label="t('tasks.description')">
|
||||
<UInput
|
||||
v-model="taskDescription"
|
||||
:placeholder="t('tasks.addPlaceholder')"
|
||||
class="w-full"
|
||||
@keyup.enter="addTask"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField :label="t('projects.project')">
|
||||
<USelectMenu
|
||||
v-model="selectedGoalId"
|
||||
:items="goalOptions"
|
||||
:placeholder="t('projects.selectProject')"
|
||||
:search-input="false"
|
||||
size="xl"
|
||||
value-key="value"
|
||||
option-key="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField :label="t('projects.project')">
|
||||
<USelectMenu
|
||||
v-model="selectedGoalId"
|
||||
:items="goalOptions"
|
||||
:placeholder="t('projects.selectProject')"
|
||||
:search-input="false"
|
||||
size="xl"
|
||||
value-key="value"
|
||||
option-key="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
v-if="!noDates"
|
||||
:label="t('tasks.deadline')"
|
||||
>
|
||||
<UPopover>
|
||||
<UButton
|
||||
:label="formattedDeadline || t('tasks.addDueDate')"
|
||||
icon="i-lucide-calendar"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
:ui="{leadingIcon: 'size-4.5'}"
|
||||
<UFormField
|
||||
v-if="!noDates"
|
||||
:label="t('tasks.deadline')"
|
||||
>
|
||||
<UPopover>
|
||||
<UButton
|
||||
:label="formattedDeadline || t('tasks.addDueDate')"
|
||||
icon="i-lucide-calendar"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
class="w-full justify-start"
|
||||
:ui="{leadingIcon: 'size-4.5'}"
|
||||
/>
|
||||
<template #content>
|
||||
<UCalendar
|
||||
v-model="(deadline as any)"
|
||||
:week-starts-on="weekStart"
|
||||
class="p-2"
|
||||
/>
|
||||
<template #content>
|
||||
<UCalendar
|
||||
v-model="(deadline as any)"
|
||||
:week-starts-on="weekStart"
|
||||
class="p-2"
|
||||
/>
|
||||
</template>
|
||||
</UPopover>
|
||||
</UFormField>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
</UPopover>
|
||||
</UFormField>
|
||||
</div>
|
||||
<!-- </UCard> -->
|
||||
</template>
|
||||
|
||||
<template #default="slotProps">
|
||||
@@ -79,7 +79,7 @@
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
@click="close"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
@@ -87,7 +87,7 @@
|
||||
<UButton
|
||||
:disabled="!canAdd"
|
||||
:loading="loading"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
@click="addTask"
|
||||
>
|
||||
{{ t('tasks.add') }}
|
||||
@@ -98,7 +98,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CalendarDate } from '@internationalized/date'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
@@ -141,14 +141,6 @@ const taskDescription = ref(props.taskName)
|
||||
const selectedGoalId = ref<number | undefined>(undefined)
|
||||
const deadline = ref<CalendarDate | null>(null)
|
||||
const loading = ref(false)
|
||||
const lastSavedProjectId = ref<number | undefined>(undefined)
|
||||
|
||||
onMounted(async () => {
|
||||
const saved = await $ls.getValue(LAST_SELECTED_PROJECT_KEY)
|
||||
if (saved) {
|
||||
lastSavedProjectId.value = Number(saved)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.taskName, (newVal) => {
|
||||
taskDescription.value = newVal
|
||||
@@ -169,16 +161,15 @@ watch(model, async (isOpen) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Set default goal from localStorage or first available
|
||||
// Pre-select the last used project. Read fresh from storage on every open so
|
||||
// it stays in sync across the multiple dialog instances on the screen.
|
||||
const activeGoals = goalsStore.goals.filter(g => !g.archive)
|
||||
const saved = await $ls.getValue(LAST_SELECTED_PROJECT_KEY)
|
||||
const savedId = saved ? Number(saved) : undefined
|
||||
const savedGoal = savedId ? activeGoals.find(g => g.id === savedId) : undefined
|
||||
|
||||
if (lastSavedProjectId.value) {
|
||||
const savedGoal = activeGoals.find(g => g.id === lastSavedProjectId.value)
|
||||
if (savedGoal) {
|
||||
selectedGoalId.value = savedGoal.id
|
||||
} else if (activeGoals.length === 1) {
|
||||
selectedGoalId.value = activeGoals[0].id
|
||||
}
|
||||
if (savedGoal) {
|
||||
selectedGoalId.value = savedGoal.id
|
||||
} else if (activeGoals.length === 1) {
|
||||
selectedGoalId.value = activeGoals[0].id
|
||||
}
|
||||
@@ -214,9 +205,8 @@ async function addTask() {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// Save selected project to localStorage
|
||||
// Remember the chosen project for the next time the dialog is opened.
|
||||
await $ls.setValue(LAST_SELECTED_PROJECT_KEY, selectedGoalId.value.toString())
|
||||
lastSavedProjectId.value = selectedGoalId.value
|
||||
|
||||
const endDate = deadline.value
|
||||
? `${deadline.value.year}-${String(deadline.value.month).padStart(2, '0')}-${String(deadline.value.day).padStart(2, '0')}`
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<UDashboardSearchButton
|
||||
:label="t('admin.search')"
|
||||
:disabled="!hasActiveGoals"
|
||||
class="w-full"
|
||||
variant="soft"
|
||||
size="xl"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const goalsStore = useGoalsStore()
|
||||
const hasActiveGoals = computed(() => goalsStore.goals.length > 0)
|
||||
</script>
|
||||
@@ -1,12 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<UDashboardSearchButton
|
||||
:label="t('admin.search')"
|
||||
:disabled="!hasActiveGoals"
|
||||
class="w-full"
|
||||
variant="subtle"
|
||||
/>
|
||||
|
||||
<UDashboardSearch
|
||||
v-model:search-term="searchTerm"
|
||||
v-model:open="isOpen"
|
||||
@@ -80,7 +73,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import MainScreenAddTaskDialog from './MainScreenAddTaskDialog.vue'
|
||||
import { useBaseScreenStore } from '@/stores/base-screen.store'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import { ALL_TASKS_LIST_ID } from 'taskview-api'
|
||||
import type { TaskItem } from '@/types/tasks.types'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
@@ -94,9 +86,6 @@ const isOpen = ref(false)
|
||||
const baseScreenStore = useBaseScreenStore()
|
||||
const tasks = ref<TaskItem[]>([])
|
||||
const addTaskDialogModel = ref(false)
|
||||
const goalsStore = useGoalsStore()
|
||||
|
||||
const hasActiveGoals = computed(() => goalsStore.goals.length > 0)
|
||||
|
||||
watch(searchTermDebounced, async (term) => {
|
||||
if (!term) {
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<UButton
|
||||
:label="t('organizations.save')"
|
||||
:loading="loading"
|
||||
variant="soft"
|
||||
@click="create"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<UModal
|
||||
v-model:open="open"
|
||||
:fullscreen="isMobile"
|
||||
:ui="{ content: 'max-w-lg max-h-[90vh] flex flex-col' }"
|
||||
:fullscreen="isFullscreenModal"
|
||||
:ui="{ content: isFullscreenModal ? 'flex flex-col' : 'max-w-lg max-h-[90vh] flex flex-col' }"
|
||||
>
|
||||
<template #content>
|
||||
<UCard
|
||||
@@ -36,6 +36,7 @@
|
||||
<UTabs
|
||||
:items="tabs"
|
||||
class="w-full"
|
||||
:ui="{ list: 'rounded-2xl', indicator: 'rounded-xl' }"
|
||||
>
|
||||
<template #general>
|
||||
<div class="flex flex-col gap-4 pt-4">
|
||||
@@ -58,6 +59,7 @@
|
||||
v-if="isAdmin"
|
||||
:label="t('organizations.save')"
|
||||
:loading="saving"
|
||||
variant="soft"
|
||||
@click="save"
|
||||
/>
|
||||
</div>
|
||||
@@ -82,7 +84,7 @@
|
||||
v-if="isOwner && !organization.isPersonal"
|
||||
:label="t('organizations.delete')"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
variant="soft"
|
||||
icon="i-lucide-trash-2"
|
||||
:ui="{leadingIcon: 'size-4.5'}"
|
||||
@click="deleteOrg"
|
||||
@@ -90,7 +92,7 @@
|
||||
<div v-else />
|
||||
<UButton
|
||||
:label="t('organizations.close')"
|
||||
variant="ghost"
|
||||
variant="soft"
|
||||
@click="open = false"
|
||||
/>
|
||||
</div>
|
||||
@@ -117,7 +119,7 @@ const props = defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { isMobile } = useTaskView()
|
||||
const { isFullscreenModal } = useTaskView()
|
||||
const orgStore = useOrganizationStore()
|
||||
const { isAdmin, isOwner } = useOrgPermissions(() => props.organization)
|
||||
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
<UButton
|
||||
icon="i-lucide-pencil"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
variant="soft"
|
||||
@click="$emit('edit')"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
variant="soft"
|
||||
color="error"
|
||||
@click="$emit('delete')"
|
||||
/>
|
||||
@@ -38,7 +38,7 @@
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
variant="soft"
|
||||
@click="copyToClipboard(callbackUrl)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
v-model="form.protocol"
|
||||
:items="protocolOptions"
|
||||
class="w-full"
|
||||
variant="soft"
|
||||
size="xl"
|
||||
/>
|
||||
</UFormField>
|
||||
@@ -39,15 +40,16 @@
|
||||
:has-secrets="hasSecrets"
|
||||
/>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="isEditing ? t('sso.save') : t('sso.create')"
|
||||
:loading="saving"
|
||||
variant="soft"
|
||||
@click="$emit('save')"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('sso.cancel')"
|
||||
variant="ghost"
|
||||
variant="soft"
|
||||
color="neutral"
|
||||
@click="$emit('cancel')"
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<UButton
|
||||
:label="t('sso.configure')"
|
||||
class="mt-3"
|
||||
variant="soft"
|
||||
@click="showForm = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<template>
|
||||
<div class="shadow-sm rounded-lg">
|
||||
<UInput
|
||||
v-model="projectName"
|
||||
:placeholder="t('projects.addPlaceholder')"
|
||||
:placeholder="placeholder ?? t('projects.addPlaceholder')"
|
||||
size="xl"
|
||||
variant="soft"
|
||||
class="w-full"
|
||||
data-testid="project-add-input"
|
||||
:ui="{
|
||||
base: 'bg-tv-ui-bg-elevated',
|
||||
}"
|
||||
|
||||
@keydown.enter="addProject"
|
||||
>
|
||||
<template #trailing>
|
||||
@@ -29,13 +26,16 @@
|
||||
/>
|
||||
</template>
|
||||
</UInput>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: [name: string]
|
||||
}>()
|
||||
|
||||
@@ -17,13 +17,14 @@
|
||||
<UButton
|
||||
:label="t('common.no')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
@click="close"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('common.yes')"
|
||||
color="error"
|
||||
class="min-w-12"
|
||||
variant="soft"
|
||||
:ui="{base: 'justify-center'}"
|
||||
data-testid="confirm-delete-button"
|
||||
@click="confirm"
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
<UInput
|
||||
v-model="form.name"
|
||||
:placeholder="t('projects.namePlaceholder')"
|
||||
variant="soft"
|
||||
class="w-full"
|
||||
:ui="{ base: 'rounded-xl' }"
|
||||
data-testid="project-edit-name"
|
||||
/>
|
||||
</UFormField>
|
||||
@@ -21,13 +23,16 @@
|
||||
v-model="form.description"
|
||||
:placeholder="t('projects.descriptionPlaceholder')"
|
||||
:rows="3"
|
||||
variant="soft"
|
||||
class="w-full"
|
||||
:ui="{ base: 'rounded-xl' }"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField :label="t('projects.estimateUnit')">
|
||||
<USelect
|
||||
v-model="form.estimateUnit"
|
||||
:items="estimateUnitItems"
|
||||
variant="soft"
|
||||
class="w-full"
|
||||
data-testid="project-edit-estimate-unit"
|
||||
/>
|
||||
@@ -40,13 +45,13 @@
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
@click="close"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('common.save')"
|
||||
color="primary"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
data-testid="project-edit-save"
|
||||
@click="save"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between p-4 border border-default rounded-lg">
|
||||
<UCard variant="soft" :ui="{body:'w-full flex items-center justify-between p-4', root:'rounded-2xl'}" class=" ">
|
||||
<div class="flex items-start gap-3 min-w-0">
|
||||
<UIcon
|
||||
:name="deviceIcon"
|
||||
@@ -28,15 +28,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
v-if="!session.isCurrent"
|
||||
icon="i-lucide-log-out"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="md"
|
||||
size="xl"
|
||||
@click="showDeleteConfirm = true"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UModal
|
||||
v-model:open="showDeleteConfirm"
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
/>
|
||||
<span
|
||||
class="truncate"
|
||||
:class="{ 'line-through text-dimmed': task.complete }"
|
||||
:class="{ 'text-dimmed': task.complete }"
|
||||
>
|
||||
{{ task.description }}
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="h-full relative flex flex-col gap-4 @container">
|
||||
<template v-if="task">
|
||||
<!-- Title & Checkbox -->
|
||||
<div class="flex items-start gap-3 shadow-sm rounded-lg dark:bg-tv-ui-bg-elevated">
|
||||
<div class="flex items-start gap-3 shadow-sm rounded-2xl dark:bg-tv-ui-bg-elevated">
|
||||
<div class="flex-1">
|
||||
<UTextarea
|
||||
v-model="titleValue"
|
||||
@@ -11,6 +11,7 @@
|
||||
:autoresize="true"
|
||||
class="w-full"
|
||||
:class="{ 'text-muted': task.complete }"
|
||||
:ui="{ base: 'rounded-2xl' }"
|
||||
>
|
||||
<template #leading>
|
||||
<div class="h-full">
|
||||
@@ -61,15 +62,17 @@
|
||||
:class="colClass(fieldId)"
|
||||
@save="updateNote"
|
||||
/>
|
||||
<TaskKanbanStatusSelect
|
||||
v-else-if="fieldId === 'status'"
|
||||
:task-id="task.id"
|
||||
:current-status-id="task.statusId"
|
||||
<TvKanbanStatusSelect
|
||||
v-else-if="fieldId === 'status' && canViewKanban"
|
||||
:model-value="task.statusId"
|
||||
:disabled="!canManageKanban"
|
||||
:class="colClass(fieldId)"
|
||||
@update:model-value="updateStatus"
|
||||
/>
|
||||
<TaskPrioritySelect
|
||||
v-else-if="fieldId === 'priority'"
|
||||
<TvPriority
|
||||
v-else-if="fieldId === 'priority' && canViewTaskPriority"
|
||||
:model-value="task.priorityId"
|
||||
:disabled="!canEditTaskPriority"
|
||||
:class="colClass(fieldId)"
|
||||
@update:model-value="updatePriority"
|
||||
/>
|
||||
@@ -79,11 +82,11 @@
|
||||
:assigned-user-ids="task.assignedUsers"
|
||||
:class="colClass(fieldId)"
|
||||
/>
|
||||
<TaskListSelect
|
||||
v-else-if="fieldId === 'list'"
|
||||
:task-id="task.id"
|
||||
:current-list-id="task.goalListId"
|
||||
<TvListSelect
|
||||
v-else-if="fieldId === 'list' && canViewLists"
|
||||
:model-value="task.goalListId"
|
||||
:class="colClass(fieldId)"
|
||||
@update:model-value="updateList"
|
||||
/>
|
||||
<TaskTagsManager
|
||||
v-else-if="fieldId === 'tags'"
|
||||
@@ -92,11 +95,16 @@
|
||||
:goal-id="projectId"
|
||||
:class="colClass(fieldId)"
|
||||
/>
|
||||
<TaskDeadline
|
||||
v-else-if="fieldId === 'deadline'"
|
||||
:task="task"
|
||||
:class="[colClass(fieldId), 'h-fit']"
|
||||
/>
|
||||
<template v-else-if="fieldId === 'deadline'">
|
||||
<TvDeadlineSelect
|
||||
:task="task"
|
||||
:class="[colClass(fieldId), 'h-fit']"
|
||||
/>
|
||||
<TaskRecurrence
|
||||
:task="task"
|
||||
:class="colClass(fieldId)"
|
||||
/>
|
||||
</template>
|
||||
<TaskAmountEditor
|
||||
v-else-if="fieldId === 'amount'"
|
||||
:task-id="task.id"
|
||||
@@ -104,7 +112,7 @@
|
||||
:transaction-type="task.transactionType"
|
||||
:class="colClass(fieldId)"
|
||||
/>
|
||||
<TaskSprintSelect
|
||||
<TvSprintSelect
|
||||
v-else-if="fieldId === 'sprint'"
|
||||
:task-id="task.id"
|
||||
:goal-id="task.goalId"
|
||||
@@ -151,19 +159,22 @@ import { useDebounceFn } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTasksStore } from '@/stores/tasks.store'
|
||||
import NoteEditor from '@/components/features/tasks/parts/NoteEditor.vue'
|
||||
import TaskPrioritySelect from '@/components/features/tasks/parts/TaskPrioritySelect.vue'
|
||||
import TvPriority from '@/components/features/base/TvPriority.vue'
|
||||
import TaskTagsManager from '@/components/features/tasks/parts/tags/TaskTagsManager.vue'
|
||||
import TaskAssigneeSelect from '@/components/features/tasks/parts/TaskAssigneeSelect.vue'
|
||||
import TaskListSelect from '@/components/features/tasks/parts/TaskListSelect.vue'
|
||||
import TaskKanbanStatusSelect from '@/components/features/tasks/parts/TaskKanbanStatusSelect.vue'
|
||||
import TvListSelect from '@/components/features/base/TvListSelect.vue'
|
||||
import TvKanbanStatusSelect from '@/components/features/base/TvKanbanStatusSelect.vue'
|
||||
import TaskAmountEditor from '@/components/features/tasks/parts/TaskAmountEditor.vue'
|
||||
import TaskTimeTracking from '@/components/features/tasks/parts/TaskTimeTracking.vue'
|
||||
import TaskHistory from '@/components/features/tasks/parts/TaskHistory.vue'
|
||||
import TaskDeadline from '@/components/features/tasks/parts/TaskDeadline.vue'
|
||||
import TvDeadlineSelect from '@/components/features/base/TvDeadlineSelect.vue'
|
||||
import TaskRecurrence from '@/components/features/tasks/parts/TaskRecurrence.vue'
|
||||
import TaskSubtasks from '@/components/features/tasks/parts/TaskSubtasks.vue'
|
||||
import TaskSprintSelect from '@/components/features/tasks/parts/TaskSprintSelect.vue'
|
||||
import TvSprintSelect from '@/components/features/base/TvSprintSelect.vue'
|
||||
import TaskEstimateInput from '@/components/features/tasks/parts/TaskEstimateInput.vue'
|
||||
import type { TaskBase } from 'taskview-api'
|
||||
import type { PriorityValue } from '@/composables/usePriorityOptions'
|
||||
import type { ListValue } from '@/composables/useGoalListOptions'
|
||||
import type { StatusValue } from '@/composables/useKanbanStatusOptions'
|
||||
import { useGoalPermissions } from '@/composables/useGoalPermissions'
|
||||
import { useUiPreferences } from '@/composables/useUiPreferences'
|
||||
import { tasksSection } from '@/uiCustomization/sections/tasks'
|
||||
@@ -173,6 +184,11 @@ const toast = useToast()
|
||||
const tasksStore = useTasksStore()
|
||||
const {
|
||||
canEditTaskStatus,
|
||||
canViewTaskPriority,
|
||||
canEditTaskPriority,
|
||||
canViewLists,
|
||||
canViewKanban,
|
||||
canManageKanban,
|
||||
} = useGoalPermissions()
|
||||
|
||||
const { catalogue: taskFieldsCatalogue } = tasksSection.useSection()
|
||||
@@ -253,13 +269,29 @@ async function updateNote(note: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePriority(priorityId: TaskBase['priorityId']) {
|
||||
if (!task.value) return
|
||||
async function updatePriority(priorityId: PriorityValue) {
|
||||
if (!task.value || priorityId == null) return
|
||||
await tasksStore.udpatePriority({
|
||||
id: task.value.id,
|
||||
priorityId,
|
||||
})
|
||||
}
|
||||
|
||||
async function updateList(goalListId: ListValue | undefined) {
|
||||
if (!task.value || goalListId === undefined || goalListId === task.value.goalListId) return
|
||||
await tasksStore.moveTaskToAnotherList({
|
||||
id: task.value.id,
|
||||
goalListId,
|
||||
})
|
||||
}
|
||||
|
||||
async function updateStatus(statusId: StatusValue | undefined) {
|
||||
if (!task.value || statusId === undefined || statusId === task.value.statusId) return
|
||||
await tasksStore.updateTaskStatusId({
|
||||
id: task.value.id,
|
||||
statusId,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -29,10 +29,10 @@
|
||||
<UModal
|
||||
v-else
|
||||
v-model:open="isOpen"
|
||||
:fullscreen="isMobile"
|
||||
:fullscreen="isFullscreenModal"
|
||||
:ui="{
|
||||
overlay: 'sm:items-center sm:justify-center ',
|
||||
content: 'sm:max-w-[1100px] sm:max-h-[80vh] sm:rounded-lg sm:m-auto w-full',
|
||||
content: isFullscreenModal ? 'w-full' : 'sm:max-w-[1100px] sm:max-h-[80vh] sm:m-auto w-full',
|
||||
body: 'p-4!',
|
||||
footer: 'p-4!',
|
||||
}"
|
||||
@@ -60,13 +60,13 @@
|
||||
<UButton
|
||||
:label="t('common.delete')"
|
||||
color="error"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
@click="openDeleteDialog"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('common.close')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
@click="closeTask"
|
||||
/>
|
||||
</div>
|
||||
@@ -102,7 +102,7 @@ const { t } = useI18n()
|
||||
const tasksStore = useTasksStore()
|
||||
const goalsStore = useGoalsStore()
|
||||
const appStore = useAppStore()
|
||||
const { isMobile } = useTaskView()
|
||||
const { isFullscreenModal } = useTaskView()
|
||||
const { isOpen, closeTask } = useTaskDetailPanel()
|
||||
const { broadcastRemoval } = useSelectedTaskSync()
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="note-editor border border-default rounded-lg dark:bg-tv-ui-bg-elevated!">
|
||||
<div class="note-editor border border-default rounded-2xl overflow-hidden dark:bg-tv-ui-bg-elevated!">
|
||||
<UEditor
|
||||
v-if="canViewTaskNote"
|
||||
#default="{ editor }"
|
||||
@@ -15,6 +15,7 @@
|
||||
:editor="editor"
|
||||
:items="toolbarItems"
|
||||
class="border-b border-default overflow-x-auto"
|
||||
:ui="{base: 'p-2'}"
|
||||
/>
|
||||
<!-- <UEditorDragHandle :editor="editor" /> -->
|
||||
</UEditor>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-fit shadow-sm rounded-lg p-2 dark:bg-tv-ui-bg-elevated">
|
||||
<div class="w-full h-fit">
|
||||
<!-- <label class="text-sm text-muted mb-2 block">{{ t('tasks.amount') }}</label> -->
|
||||
|
||||
<div class="flex gap-2">
|
||||
@@ -13,6 +13,7 @@
|
||||
size="xl"
|
||||
:placeholder="t('tasks.enterAmount')"
|
||||
class="flex-1"
|
||||
:ui="{base: 'rounded-14! h-13'}"
|
||||
:variant="isDark ? 'subtle' : 'soft'"
|
||||
@update:model-value="handleAmountInput"
|
||||
@focus="editing = true"
|
||||
@@ -33,10 +34,11 @@
|
||||
:items="tabItems"
|
||||
:content="false"
|
||||
variant="pill"
|
||||
size="md"
|
||||
size="xl"
|
||||
color="success"
|
||||
:class="{'pointer-events-none': !canDeleteTask}"
|
||||
class="shrink-0 shadow-sm rounded-lg border border-accented flex-1"
|
||||
class="shrink-0 shadow-sm rounded-lg border border-accented"
|
||||
:ui="{root: 'border-none rounded-14! shadow-none!', list:'rounded-14! grow', indicator: 'rounded-10!'}"
|
||||
@update:model-value="handleTabChange"
|
||||
/>
|
||||
</div>
|
||||
@@ -49,7 +51,9 @@
|
||||
icon="i-lucide-x"
|
||||
color="info"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
class="items-center justify-center"
|
||||
size="xl"
|
||||
:ui="{base: 'rounded-14! size-13'}"
|
||||
@click="clearAmount"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,62 +1,24 @@
|
||||
<template>
|
||||
<div
|
||||
<TvDropdown
|
||||
v-if="canViewAssignedUsersToTask"
|
||||
class="h-fit dark:bg-tv-ui-bg-elevated shadow-sm rounded-lg p-2"
|
||||
>
|
||||
<!-- Badge mode (< 10 users) -->
|
||||
<div
|
||||
v-if="users.length < 10"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<UBadge
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:label="getUserDisplayName(user)"
|
||||
:variant="isUserAssigned(user.id) ? (isDark ? 'subtle' : 'solid') : (isDark ? 'subtle' : 'outline')"
|
||||
:color="isUserAssigned(user.id) ? 'primary' : 'neutral'"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
icon="lucide:at-sign"
|
||||
class="cursor-pointer select-none truncate"
|
||||
size="xl"
|
||||
@click="toggleAssignee(user.id)"
|
||||
/>
|
||||
<span
|
||||
v-if="users.length === 0"
|
||||
class="text-sm text-muted"
|
||||
>
|
||||
{{ t('tasks.noUsersAvailable') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown mode (>= 10 users) -->
|
||||
<USelectMenu
|
||||
v-else
|
||||
v-model="selectedUserIds"
|
||||
:disabled="!canAssignUsersToTask"
|
||||
:items="userItems"
|
||||
:placeholder="t('tasks.selectAssignees')"
|
||||
:searchable="true"
|
||||
:search-placeholder="t('tasks.searchUsers')"
|
||||
:ui="{
|
||||
base:'w-full'
|
||||
}"
|
||||
multiple
|
||||
value-key="value"
|
||||
variant="soft"
|
||||
@update:model-value="handleSelectionChange"
|
||||
/>
|
||||
</div>
|
||||
:model-value="selectedUserIds"
|
||||
:items="options"
|
||||
:disabled="!canAssignUsersToTask"
|
||||
:placeholder="t('tasks.selectAssignees')"
|
||||
placeholder-icon="i-lucide-at-sign"
|
||||
activator-icon="i-lucide-at-sign"
|
||||
multiple
|
||||
@update:model-value="updateAssignees"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { CollaborationResponseFetchAllUsers } from 'taskview-api'
|
||||
import { useCollaborationStore } from '@/stores/collaboration.store'
|
||||
import { useTasksStore } from '@/stores/tasks.store'
|
||||
import { useColor } from '@/composables/useColotMode'
|
||||
import { useGoalPermissions } from '@/composables/useGoalPermissions'
|
||||
import { useProjectContext } from '@/composables/useProjectContext'
|
||||
import { useUserOptions, type UserValue } from '@/composables/useUserOptions'
|
||||
import TvDropdown from '@/components/features/base/TvDropdown.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
@@ -64,55 +26,17 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const collaborationStore = useCollaborationStore()
|
||||
const tasksStore = useTasksStore()
|
||||
const { isDark } = useColor()
|
||||
const selectedUserIds = ref<number[]>([...props.assignedUserIds])
|
||||
const { canViewAssignedUsersToTask, canAssignUsersToTask } = useGoalPermissions()
|
||||
const { options } = useUserOptions()
|
||||
|
||||
const projectContext = useProjectContext()
|
||||
const users = computed(() =>
|
||||
projectContext ? projectContext.users.value : collaborationStore.users,
|
||||
)
|
||||
|
||||
const userItems = computed(() =>
|
||||
users.value.map(user => ({
|
||||
label: getUserDisplayName(user),
|
||||
value: user.id,
|
||||
})),
|
||||
)
|
||||
|
||||
watch(() => props.assignedUserIds, (newIds) => {
|
||||
selectedUserIds.value = [...newIds]
|
||||
}, { deep: true })
|
||||
|
||||
function getUserDisplayName(user: CollaborationResponseFetchAllUsers): string {
|
||||
return user.email
|
||||
}
|
||||
|
||||
function isUserAssigned(userId: number): boolean {
|
||||
return props.assignedUserIds.includes(userId)
|
||||
}
|
||||
|
||||
async function toggleAssignee(userId: number) {
|
||||
if (!canAssignUsersToTask.value) return
|
||||
const currentAssignees = [...props.assignedUserIds]
|
||||
const isCurrentlyAssigned = currentAssignees.includes(userId)
|
||||
|
||||
const newAssignees = isCurrentlyAssigned
|
||||
? currentAssignees.filter(id => id !== userId)
|
||||
: [...currentAssignees, userId]
|
||||
const selectedUserIds = computed(() => props.assignedUserIds)
|
||||
|
||||
async function updateAssignees(value: UserValue | UserValue[] | undefined) {
|
||||
const userIds = Array.isArray(value) ? value : []
|
||||
await tasksStore.updateTaskAssignee({
|
||||
taskId: props.taskId,
|
||||
userIds: newAssignees,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSelectionChange(newSelection: number[]) {
|
||||
await tasksStore.updateTaskAssignee({
|
||||
taskId: props.taskId,
|
||||
userIds: newSelection,
|
||||
userIds,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -15,12 +15,13 @@
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
variant="soft"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('common.delete')"
|
||||
color="error"
|
||||
variant="soft"
|
||||
@click="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||