feat: update UI
This commit is contained in:
@@ -0,0 +1,76 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Vue 3 + TypeScript SPA — frontend of MWS (My Workspace), an internal tool for small teams to manage projects, documents, and tasks. Talks to a .NET 10 backend over REST + JWT.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # install deps
|
||||||
|
npm run dev # vite dev server on :5173, calls backend at http://localhost:5000
|
||||||
|
npm run build # vue-tsc typecheck + vite build → dist/
|
||||||
|
npm run preview # serve built dist locally
|
||||||
|
```
|
||||||
|
|
||||||
|
No test runner, no linter configured. `vue-tsc -b` during `build` is the only static check.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- `VITE_API_BASE_URL` — backend base URL (default `http://localhost:5000`). Set in `.env.local` for dev, or pass `--build-arg VITE_API_BASE_URL=...` to Docker.
|
||||||
|
- Backend dev port is **5000**, not the production 8080. The `api.ts` interceptor reads `localStorage.mws_token` and attaches `Authorization: Bearer …`; 401s clear storage and redirect to `/login`.
|
||||||
|
|
||||||
|
## Layout & routing
|
||||||
|
|
||||||
|
Two nested layouts (`src/layouts/`):
|
||||||
|
|
||||||
|
- `MainLayout` — app shell with sidebar + topbar. Wraps all authenticated routes. Hosts the global `Toast` and `ConfirmDialog` (in `App.vue`).
|
||||||
|
- `ProjectLayout` — header + tabs (Overview / Documents / Tasks / Members). Loads the project by `:id` route param on mount and renders `<router-view />` for the tab content.
|
||||||
|
|
||||||
|
Routes (`src/router/index.ts`) guard everything except `/login` via `meta.public`. Auth users hitting `/login` redirect to `/projects`. Unknown paths redirect to `/projects`.
|
||||||
|
|
||||||
|
Domain areas (each is a folder under `views/`):
|
||||||
|
|
||||||
|
- `auth/` — `LoginView.vue`
|
||||||
|
- `projects/` — list, overview, members
|
||||||
|
- `documents/` — tree + editor
|
||||||
|
- `tasks/` — list, kanban board, detail dialog
|
||||||
|
|
||||||
|
## Service layer pattern
|
||||||
|
|
||||||
|
Three files in `src/services/`:
|
||||||
|
|
||||||
|
- `api.ts` — shared axios instance + interceptors + `errorMessage(error)` helper for surfacing backend `{ message }` errors.
|
||||||
|
- `backend.ts` — auth, projects, members, users (small subset).
|
||||||
|
- `modules.ts` — documents + tasks (split here to keep files scannable).
|
||||||
|
|
||||||
|
Every API call is a thin typed wrapper around `api.get/post/put/delete`. Views call these directly — there is no separate data layer / composable cache. New endpoints → add a typed function to `backend.ts` or `modules.ts` and a matching interface in `src/types/index.ts`.
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
Only one Pinia store: `src/stores/auth.ts`. Token + user object, persisted to `localStorage` under `mws_token` / `mws_user`. Everything else (projects, documents, tasks) is fetched per-view — no client cache. Add a new store only if cross-view shared state actually appears.
|
||||||
|
|
||||||
|
## Auto-imports
|
||||||
|
|
||||||
|
`vite.config.ts` auto-imports:
|
||||||
|
|
||||||
|
- Vue / vue-router / pinia globals
|
||||||
|
- PrimeVue composables: `useToast`, `useConfirm`, `useDialog` (call them without imports in components)
|
||||||
|
- PrimeVue components via `@primevue/auto-import-resolver` (no need to import `<Button>`, `<DataTable>`, etc. — `Components()` resolves them)
|
||||||
|
|
||||||
|
Generated types live in `src/auto-imports.d.ts` and `src/components.d.ts` — do not hand-edit.
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
- Tailwind v4 via `@tailwindcss/vite` plugin (no `tailwind.config.js` — theme is CSS-only).
|
||||||
|
- PrimeVue `Lara` preset wired in `main.ts`. Use PrimeVue components first; reach for raw HTML when PrimeVue has no equivalent.
|
||||||
|
- `src/style.css` defines a `.field` label helper used across forms.
|
||||||
|
|
||||||
|
## Adding a new view / feature
|
||||||
|
|
||||||
|
1. Add typed function(s) to `services/backend.ts` or `services/modules.ts`, types to `types/index.ts`.
|
||||||
|
2. Create the `.vue` under the matching `views/<area>/` folder.
|
||||||
|
3. Register the route in `router/index.ts` — pick the right layout (`MainLayout` for top-level, `ProjectLayout` for project-scoped tabs).
|
||||||
|
4. Use auto-imported PrimeVue components and composables — no manual `import` for them.
|
||||||
|
|
||||||
|
## Container
|
||||||
|
|
||||||
|
`Dockerfile` is a multi-stage build (node:24-alpine → nginx:1.27-alpine). `nginx.conf` serves the SPA and proxies `/api/` and `/openapi/` to `http://backend:8080`. Production container listens on 80; `docker-compose.yml` maps host `:5173 → 80`.
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
---
|
||||||
|
version: alpha
|
||||||
|
name: Discord Analysis
|
||||||
|
description: An analysis of Discord's design language — a loud, playful gaming-native system built on a deep-indigo canvas lit by Blurple, electric green, and vibrant magenta gradients. Heavy ABC Ginto Nord display type shouts in all-caps over generously rounded media, gradient feature panels, and full-bleed Blurple bands; the mood is arcade-energetic, never corporate.
|
||||||
|
|
||||||
|
colors:
|
||||||
|
primary: "#5865f2"
|
||||||
|
on-primary: "#ffffff"
|
||||||
|
green: "#35ed7e"
|
||||||
|
magenta: "#ec48bd"
|
||||||
|
link: "#00b0f4"
|
||||||
|
canvas: "#0a0d3a"
|
||||||
|
surface-indigo: "#1e2353"
|
||||||
|
surface-onyx: "#23272a"
|
||||||
|
surface-black: "#000000"
|
||||||
|
ink: "#ffffff"
|
||||||
|
ink-dark: "#000000"
|
||||||
|
muted: "#333333"
|
||||||
|
hairline: "#23272a"
|
||||||
|
|
||||||
|
typography:
|
||||||
|
display-xl:
|
||||||
|
fontFamily: ABC Ginto Nord
|
||||||
|
fontSize: 82px
|
||||||
|
fontWeight: 800
|
||||||
|
lineHeight: 1.0
|
||||||
|
letterSpacing: 0
|
||||||
|
display-lg:
|
||||||
|
fontFamily: ABC Ginto Nord
|
||||||
|
fontSize: 62px
|
||||||
|
fontWeight: 800
|
||||||
|
lineHeight: 1.05
|
||||||
|
letterSpacing: 0
|
||||||
|
display-md:
|
||||||
|
fontFamily: ABC Ginto Nord
|
||||||
|
fontSize: 56px
|
||||||
|
fontWeight: 700
|
||||||
|
lineHeight: 1.05
|
||||||
|
letterSpacing: 0
|
||||||
|
heading-lg:
|
||||||
|
fontFamily: ABC Ginto Nord
|
||||||
|
fontSize: 48px
|
||||||
|
fontWeight: 700
|
||||||
|
lineHeight: 1.1
|
||||||
|
letterSpacing: 0
|
||||||
|
heading-sm:
|
||||||
|
fontFamily: ABC Ginto Nord
|
||||||
|
fontSize: 22px
|
||||||
|
fontWeight: 700
|
||||||
|
lineHeight: 1.2
|
||||||
|
letterSpacing: 0
|
||||||
|
body-lg:
|
||||||
|
fontFamily: ABC Ginto
|
||||||
|
fontSize: 20px
|
||||||
|
fontWeight: 500
|
||||||
|
lineHeight: 1.4
|
||||||
|
letterSpacing: 0
|
||||||
|
link-lg:
|
||||||
|
fontFamily: ABC Ginto
|
||||||
|
fontSize: 18px
|
||||||
|
fontWeight: 500
|
||||||
|
lineHeight: 1.4
|
||||||
|
letterSpacing: 0
|
||||||
|
body:
|
||||||
|
fontFamily: ggsans
|
||||||
|
fontSize: 16px
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.5
|
||||||
|
letterSpacing: 0
|
||||||
|
link:
|
||||||
|
fontFamily: ABC Ginto
|
||||||
|
fontSize: 16px
|
||||||
|
fontWeight: 500
|
||||||
|
lineHeight: 1.4
|
||||||
|
letterSpacing: 0
|
||||||
|
link-sm:
|
||||||
|
fontFamily: ABC Ginto
|
||||||
|
fontSize: 14px
|
||||||
|
fontWeight: 500
|
||||||
|
lineHeight: 1.4
|
||||||
|
letterSpacing: 0
|
||||||
|
|
||||||
|
rounded:
|
||||||
|
xs: 6px
|
||||||
|
sm: 12px
|
||||||
|
md: 14px
|
||||||
|
lg: 16px
|
||||||
|
xl: 40px
|
||||||
|
pill: 50px
|
||||||
|
jumbo: 120px
|
||||||
|
full: 9999px
|
||||||
|
|
||||||
|
spacing:
|
||||||
|
xxs: 4px
|
||||||
|
xs: 8px
|
||||||
|
sm: 12px
|
||||||
|
md: 16px
|
||||||
|
lg: 20px
|
||||||
|
xl: 24px
|
||||||
|
xxl: 32px
|
||||||
|
section: 40px
|
||||||
|
|
||||||
|
components:
|
||||||
|
nav-bar:
|
||||||
|
backgroundColor: "{colors.canvas}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.link}"
|
||||||
|
padding: "{spacing.md} {spacing.xl}"
|
||||||
|
button-primary:
|
||||||
|
backgroundColor: "{colors.primary}"
|
||||||
|
textColor: "{colors.on-primary}"
|
||||||
|
typography: "{typography.link-lg}"
|
||||||
|
rounded: "{rounded.sm}"
|
||||||
|
padding: "{spacing.lg} {spacing.xl}"
|
||||||
|
button-green:
|
||||||
|
backgroundColor: "{colors.green}"
|
||||||
|
textColor: "{colors.ink-dark}"
|
||||||
|
typography: "{typography.link-lg}"
|
||||||
|
rounded: "{rounded.sm}"
|
||||||
|
padding: "{spacing.sm} {spacing.xl}"
|
||||||
|
button-white:
|
||||||
|
backgroundColor: "{colors.ink}"
|
||||||
|
textColor: "{colors.ink-dark}"
|
||||||
|
typography: "{typography.link}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "{spacing.xs} {spacing.md}"
|
||||||
|
button-ghost:
|
||||||
|
backgroundColor: "{colors.surface-indigo}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.link}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "{spacing.md}"
|
||||||
|
button-ghost-sm:
|
||||||
|
backgroundColor: "{colors.surface-indigo}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.link-sm}"
|
||||||
|
rounded: "{rounded.xs}"
|
||||||
|
padding: "{spacing.sm} {spacing.xxl}"
|
||||||
|
hero:
|
||||||
|
backgroundColor: "{colors.canvas}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.display-xl}"
|
||||||
|
padding: "{spacing.section}"
|
||||||
|
feature-card-gradient:
|
||||||
|
backgroundColor: "{colors.magenta}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
rounded: "{rounded.xl}"
|
||||||
|
padding: "{spacing.section}"
|
||||||
|
feature-card-dark:
|
||||||
|
backgroundColor: "{colors.surface-indigo}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
rounded: "{rounded.xl}"
|
||||||
|
padding: "{spacing.xxl}"
|
||||||
|
showcase-band-black:
|
||||||
|
backgroundColor: "{colors.surface-black}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
rounded: "{rounded.xl}"
|
||||||
|
padding: "{spacing.section}"
|
||||||
|
stat-card:
|
||||||
|
backgroundColor: "{colors.primary}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.display-md}"
|
||||||
|
rounded: "{rounded.xl}"
|
||||||
|
padding: "{spacing.xxl}"
|
||||||
|
step-card:
|
||||||
|
backgroundColor: "{colors.magenta}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.heading-sm}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "{spacing.xl}"
|
||||||
|
cta-band:
|
||||||
|
backgroundColor: "{colors.primary}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.display-md}"
|
||||||
|
rounded: "{rounded.xl}"
|
||||||
|
padding: "{spacing.section}"
|
||||||
|
marquee-band:
|
||||||
|
backgroundColor: "{colors.primary}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.display-lg}"
|
||||||
|
padding: "{spacing.lg}"
|
||||||
|
pricing-table:
|
||||||
|
backgroundColor: "{colors.surface-indigo}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.body}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "{spacing.xl}"
|
||||||
|
game-rank-feature:
|
||||||
|
backgroundColor: "{colors.surface-indigo}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.heading-sm}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "{spacing.md}"
|
||||||
|
game-rank-row:
|
||||||
|
backgroundColor: "{colors.surface-indigo}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.body}"
|
||||||
|
rounded: "{rounded.md}"
|
||||||
|
padding: "{spacing.sm} {spacing.md}"
|
||||||
|
faq-accordion:
|
||||||
|
backgroundColor: "{colors.surface-indigo}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.link-lg}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "{spacing.xl}"
|
||||||
|
badge:
|
||||||
|
backgroundColor: "{colors.magenta}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.link-sm}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "{spacing.xxs} {spacing.sm}"
|
||||||
|
footer:
|
||||||
|
backgroundColor: "{colors.canvas}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.link}"
|
||||||
|
padding: "{spacing.section}"
|
||||||
|
|
||||||
|
# ─── Examples (illustrative) — auto-derived; resolve any TO_FILL markers below ───
|
||||||
|
ex-pricing-tier:
|
||||||
|
description: "Default Pricing tier card. Re-uses feature-card chrome with brand canvas-soft surface."
|
||||||
|
backgroundColor: "{colors.canvas}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
borderColor: "{colors.hairline}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "{spacing.xl}"
|
||||||
|
ex-pricing-tier-featured:
|
||||||
|
description: "Featured/highlighted tier — polarity-flipped surface (dark fill + light text in light mode, light fill + dark text in dark mode)."
|
||||||
|
backgroundColor: "{colors.ink}"
|
||||||
|
textColor: "{colors.on-primary}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "{spacing.xl}"
|
||||||
|
ex-product-selector:
|
||||||
|
description: "What's Included summary card — re-purposed for SaaS / B2B verticals (NOT a literal product gallery)."
|
||||||
|
backgroundColor: "{colors.canvas}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "{spacing.xl}"
|
||||||
|
ex-cart-drawer:
|
||||||
|
description: "Subscription summary — re-purposed for SaaS / B2B (line items per add-on, not literal cart)."
|
||||||
|
backgroundColor: "{colors.canvas}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "{spacing.xl}"
|
||||||
|
item-divider: "{colors.hairline}"
|
||||||
|
ex-app-shell-row:
|
||||||
|
description: "Sidebar nav row inside the App Shell example. Active state uses brand primary as the indicator."
|
||||||
|
backgroundColor: "{colors.canvas}"
|
||||||
|
activeIndicator: "{colors.primary}"
|
||||||
|
rounded: "{rounded.md}"
|
||||||
|
padding: "{spacing.sm} {spacing.md}"
|
||||||
|
ex-data-table-cell:
|
||||||
|
description: "Default data-table th + td chrome. Header uses small link-caps typography; body uses body."
|
||||||
|
headerBackground: "{colors.surface-indigo}"
|
||||||
|
headerTypography: "{typography.link-sm}"
|
||||||
|
bodyTypography: "{typography.body}"
|
||||||
|
cellPadding: "{spacing.sm} {spacing.md}"
|
||||||
|
rowBorder: "{colors.hairline}"
|
||||||
|
ex-auth-form-card:
|
||||||
|
description: "Sign-in / sign-up card. Re-uses feature-card chrome with text-input primitives inside."
|
||||||
|
backgroundColor: "{colors.canvas}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "{spacing.xl}"
|
||||||
|
ex-modal-card:
|
||||||
|
description: "Modal dialog surface — same chrome as feature-card with elevated shadow."
|
||||||
|
backgroundColor: "{colors.canvas}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "{spacing.xl}"
|
||||||
|
ex-empty-state-card:
|
||||||
|
description: "Empty-state illustration frame."
|
||||||
|
backgroundColor: "{colors.canvas}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "{spacing.section}"
|
||||||
|
captionTypography: "{typography.body}"
|
||||||
|
ex-toast:
|
||||||
|
description: "Toast notification surface — feature-card shape + medium shadow."
|
||||||
|
backgroundColor: "{colors.canvas}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: "{spacing.sm} {spacing.md}"
|
||||||
|
typography: "{typography.body}"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Discord's marketing design is loud on purpose. The pages live on a deep-indigo canvas (`{colors.canvas}` — #0a0d3a) that is rarely still: it is washed by an animated mesh of Blurple, violet, and vibrant magenta, then punctuated by full-bleed gradient bands and oversized rounded media. Where most product sites whisper in restrained neutrals, Discord shouts in heavy all-caps ABC Ginto Nord, stacks playful 3D character art, and lets a single electric green CTA (`{colors.green}`) pop against the cool indigo. The whole system reads like an arcade cabinet: energetic, saturated, unmistakably gaming-native.
|
||||||
|
|
||||||
|
The brand anchor is **Blurple** (`{colors.primary}` — #5865f2) — Discord's signature indigo-violet. It owns the primary CTA, the marquee and CTA bands, stat cards, and the brand mark. Around it orbit two supporting accents: the electric **green** (`{colors.green}`) used for the highest-intent "get started" actions, and a vibrant **magenta** (`{colors.magenta}` — #ec48bd) that fills the gradient feature panels and step cards. Surfaces stack in cool darks — the indigo canvas, a raised indigo panel (`{colors.surface-indigo}`), an onyx UI card (`{colors.surface-onyx}`), and pure black (`{colors.surface-black}`) showcase bands.
|
||||||
|
|
||||||
|
Geometry is soft and generous. Everyday controls round at `{rounded.sm}` (12px) and `{rounded.lg}` (16px); media tiles and feature panels bow out at `{rounded.xl}` (40px) and beyond; the most expressive shapes reach `{rounded.jumbo}` (120px) and pill caps. Nothing is sharp. The result is friendly, toy-like, and built to make software feel like play.
|
||||||
|
|
||||||
|
**Key Characteristics:**
|
||||||
|
- Deep-indigo canvas (`{colors.canvas}`) lit by an animated Blurple-to-magenta gradient mesh — never a flat or neutral background.
|
||||||
|
- One iconic brand colour: Blurple (`{colors.primary}`) owns CTAs, bands, and the brand mark; electric green (`{colors.green}`) is reserved for highest-intent actions.
|
||||||
|
- Vibrant magenta (`{colors.magenta}`) gradient feature panels and step cards carry the playful, saturated energy.
|
||||||
|
- Heavy all-caps display type in `{typography.display-xl}` (ABC Ginto Nord 800) shouting over generously rounded media.
|
||||||
|
- Soft, toy-like geometry: 12–16px on controls, 40px+ on media, up to `{rounded.jumbo}` on signature shapes.
|
||||||
|
- Page rhythm: dark-indigo hero → gradient + dark feature cards → black showcase band → Blurple marquee/CTA band → giant wordmark footer.
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
> Source pages analyzed: home, ads/quests, nitro, trending-games. Blurple, green, magenta, white display type, and the deep-indigo canvas recur on every page; nitro adds the pricing table, trending-games adds the ranked game list.
|
||||||
|
|
||||||
|
### Brand & Accent
|
||||||
|
- **Blurple** (`{colors.primary}` — #5865f2): The iconic brand colour. Primary CTA fill, marquee and CTA bands, stat cards, brand mark. The single most-used action colour.
|
||||||
|
- **Electric Green** (`{colors.green}` — #35ed7e): Reserved for the highest-intent CTA ("get started" / "download"), always paired with `{colors.ink-dark}` text.
|
||||||
|
- **Vibrant Magenta** (`{colors.magenta}` — #ec48bd): The saturated pink that fills gradient feature panels, step cards, and badges — the playful counterweight to the cool indigo.
|
||||||
|
- **Link Cyan** (`{colors.link}` — #00b0f4): Inline text-link colour on dark surfaces.
|
||||||
|
|
||||||
|
### Surface
|
||||||
|
- **Indigo Canvas** (`{colors.canvas}` — #0a0d3a): The deep-indigo page base, washed by the animated brand-gradient mesh.
|
||||||
|
- **Raised Indigo** (`{colors.surface-indigo}` — #1e2353): One step up from canvas — dark feature cards, pricing table, game-rank rows, ghost buttons.
|
||||||
|
- **Onyx** (`{colors.surface-onyx}` — #23272a): Discord's classic dark-UI surface; product-chrome cards and dividers.
|
||||||
|
- **Black** (`{colors.surface-black}` — #000000): Full-black showcase bands framing product media.
|
||||||
|
|
||||||
|
### Text
|
||||||
|
- **White** (`{colors.ink}` — #ffffff): All display and body text on the dark canvas. The dominant text colour.
|
||||||
|
- **Ink** (`{colors.ink-dark}` — #000000): Text on light fills — the white button and green CTA.
|
||||||
|
- **Muted Ink** (`{colors.muted}` — #333333): Secondary text on the occasional light surface (white product-mockup cards).
|
||||||
|
|
||||||
|
### Brand Gradient
|
||||||
|
The hero and feature panels ride an animated mesh that sweeps from `{colors.primary}` (Blurple) through a deep violet into the vibrant `{colors.magenta}`, resolving back into the `{colors.canvas}` indigo at the edges. It is the brand's defining atmospheric signature — always in motion, never a flat fill.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
### Font Family
|
||||||
|
- **ABC Ginto Nord** — the heavy display face. All marketing headlines, set in 700–800 weight, frequently all-caps. Wide, confident, slightly condensed character that reads as "gaming."
|
||||||
|
- **ABC Ginto** — the lighter companion for lead paragraphs, links, and buttons (weight 500).
|
||||||
|
- **ggsans** — Discord's in-product UI sans, used for dense body copy (16px / 400).
|
||||||
|
|
||||||
|
**Note on font substitutes:** ABC Ginto Nord and ggsans are proprietary. For an open-source rebuild, pair a heavy geometric grotesque — **Hanken Grotesk** or **Space Grotesk** at 700–800 — for display, with **Inter** or **Plus Jakarta Sans** for body and UI. Keep headlines bold and tracked tight; the loud, confident display weight is the brand's voice.
|
||||||
|
|
||||||
|
### Hierarchy
|
||||||
|
|
||||||
|
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `{typography.display-xl}` | 82px | 800 | 1.0 | 0 | Hero headline (all-caps) |
|
||||||
|
| `{typography.display-lg}` | 62px | 800 | 1.05 | 0 | Marquee band, major headline |
|
||||||
|
| `{typography.display-md}` | 56px | 700 | 1.05 | 0 | Section headline, CTA band |
|
||||||
|
| `{typography.heading-lg}` | 48px | 700 | 1.1 | 0 | Sub-section heading |
|
||||||
|
| `{typography.heading-sm}` | 22px | 700 | 1.2 | 0 | Card heading, step label |
|
||||||
|
| `{typography.body-lg}` | 20px | 500 | 1.4 | 0 | Lead paragraph |
|
||||||
|
| `{typography.link-lg}` | 18px | 500 | 1.4 | 0 | Large button label, prominent link |
|
||||||
|
| `{typography.body}` | 16px | 400 | 1.5 | 0 | Default body copy (ggsans) |
|
||||||
|
| `{typography.link}` | 16px | 500 | 1.4 | 0 | Nav link, button label |
|
||||||
|
| `{typography.link-sm}` | 14px | 500 | 1.4 | 0 | Small link, badge, fine print |
|
||||||
|
|
||||||
|
### Principles
|
||||||
|
- Headlines are short, declarative, and frequently ALL-CAPS in ABC Ginto Nord 800 — the loudest element on every page.
|
||||||
|
- Body copy drops to the lighter ABC Ginto / ggsans 400–500 so the display type stays the hero.
|
||||||
|
- The display-to-body weight jump (800 → 400/500) is dramatic on purpose; there is no timid mid-weight in between.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
### Spacing System
|
||||||
|
- **Base unit**: 8px.
|
||||||
|
- **Tokens**: `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 20px · `{spacing.xl}` 24px · `{spacing.xxl}` 32px · `{spacing.section}` 40px.
|
||||||
|
- Card interiors run `{spacing.xl}`–`{spacing.section}`; buttons pad `{spacing.sm}`–`{spacing.lg}` vertical by `{spacing.xl}` horizontal.
|
||||||
|
|
||||||
|
### Grid & Container
|
||||||
|
- Centered max-width content column (~1200px) on the full-bleed indigo canvas.
|
||||||
|
- Feature sections alternate a two-column split (text + media) with stacked full-width gradient/dark cards.
|
||||||
|
- Marquee, CTA, and showcase bands are full-bleed colour fields with their own rounded inner containers.
|
||||||
|
|
||||||
|
### Whitespace Philosophy
|
||||||
|
Sections breathe through large vertical gaps of indigo, then collide with saturated colour bands for rhythm. Inside cards, generous padding lets oversized 3D art and product mockups float with air.
|
||||||
|
|
||||||
|
### Responsive Strategy
|
||||||
|
|
||||||
|
#### Breakpoints
|
||||||
|
| Name | Width | Key Changes |
|
||||||
|
|---|---|---|
|
||||||
|
| Mobile | < 768px | Single column; nav collapses to logo + hamburger; CTAs stack full-width |
|
||||||
|
| Tablet | 768–1023px | Two-column splits begin stacking; gradient cards go full-width |
|
||||||
|
| Laptop | 1024–1279px | Container narrows; multi-column grids retained |
|
||||||
|
| Desktop | ≥ 1280px | Full multi-column grids; centered ~1200px column |
|
||||||
|
|
||||||
|
(Discord ships an unusually dense breakpoint ladder — dozens of stops between 240px and ~2000px — to keep the oversized display type and 3D art balanced at every width.)
|
||||||
|
|
||||||
|
#### Touch Targets
|
||||||
|
`{components.button-primary}` and `{components.button-green}` clear ≥44px tap height via their vertical padding. Nav links and game-rank rows meet the same minimum on mobile.
|
||||||
|
|
||||||
|
#### Collapsing Strategy
|
||||||
|
The dark top nav (logo · links · Login · Download CTA) collapses to logo + hamburger below 768px. Two-column feature rows stack media-over-text; gradient and dark cards span full width. The nitro pricing table becomes horizontally scrollable; the trending-games ranked list keeps its row layout but drops secondary columns.
|
||||||
|
|
||||||
|
#### Image Behavior
|
||||||
|
Product mockups and 3D character art sit inside rounded media frames (`{rounded.lg}`–`{rounded.xl}`) or bleed past card edges as decorative props. Media scales fluidly within its container and keeps its corner radius at every width.
|
||||||
|
|
||||||
|
## Elevation & Depth
|
||||||
|
|
||||||
|
| Level | Treatment | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 — Flat | No shadow; separation by colour field + large radius | Most cards, colour bands |
|
||||||
|
| 1 — Soft float | `0 3px 68px rgba(69,42,124,0.1)` — wide, violet-tinted, very diffuse | Floating media cards, elevated mockups |
|
||||||
|
|
||||||
|
Discord leans on **colour, gradient, and radius** for depth far more than on shadow. The one extracted shadow is a wide, violet-tinted diffuse glow that lifts product media off the indigo canvas without a hard edge.
|
||||||
|
|
||||||
|
### Decorative Depth
|
||||||
|
- The animated Blurple-to-magenta gradient mesh creates depth by motion and hue rather than shadow.
|
||||||
|
- 3D character art and product props overlap card edges to build foreground/background layering.
|
||||||
|
- Full-bleed colour bands (Blurple, black) push depth by contrast against the indigo scroll.
|
||||||
|
|
||||||
|
## Shapes
|
||||||
|
|
||||||
|
### Border Radius Scale
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `{rounded.xs}` | 6px | Small ghost buttons, compact chips |
|
||||||
|
| `{rounded.sm}` | 12px | Primary / green CTA buttons, links, table cells |
|
||||||
|
| `{rounded.md}` | 14px | Game-rank rows, mid controls |
|
||||||
|
| `{rounded.lg}` | 16px | White / ghost buttons, cards, media frames |
|
||||||
|
| `{rounded.xl}` | 40px | Gradient feature panels, large media tiles |
|
||||||
|
| `{rounded.pill}` | 50px | Pill caps, badges, avatar chips |
|
||||||
|
| `{rounded.jumbo}` | 120px | Signature oversized rounded shape cards |
|
||||||
|
| `{rounded.full}` | 9999px | Circular avatars and icon buttons |
|
||||||
|
|
||||||
|
### Photography Geometry
|
||||||
|
Media is presented at soft-cornered rectangles (`{rounded.lg}`–`{rounded.xl}`), never hard-edged. The hero media block uses a directional bottom-only radius (88px bottom corners) for a swooping base. Avatars and circular icon controls are fully round.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
> No hover states documented. Component specs cover Default and Active/Pressed only; variants are separate `components:` entries.
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
**`button-primary`** — the Blurple pill CTA
|
||||||
|
- Background `{colors.primary}`, text `{colors.on-primary}`, type `{typography.link-lg}`, rounded `{rounded.sm}`, padding `{spacing.lg} {spacing.xl}`. The everyday action button across hero and feature sections.
|
||||||
|
|
||||||
|
**`button-green`** — the electric-green high-intent CTA ("Get Started" / "Download")
|
||||||
|
- Background `{colors.green}`, text `{colors.ink-dark}`, type `{typography.link-lg}`, rounded `{rounded.sm}`, padding `{spacing.sm} {spacing.xl}`. The highest-visibility action; black label for contrast.
|
||||||
|
|
||||||
|
**`button-white`** — white solid button
|
||||||
|
- Background `{colors.ink}`, text `{colors.ink-dark}`, type `{typography.link}`, rounded `{rounded.lg}`, padding `{spacing.xs} {spacing.md}`.
|
||||||
|
|
||||||
|
**`button-ghost`** — translucent indigo button on dark surfaces
|
||||||
|
- Background `{colors.surface-indigo}`, text `{colors.ink}`, type `{typography.link}`, rounded `{rounded.lg}`, padding `{spacing.md}`.
|
||||||
|
|
||||||
|
**`button-ghost-sm`** — compact ghost button (quests CTA row)
|
||||||
|
- Background `{colors.surface-indigo}`, text `{colors.ink}`, type `{typography.link-sm}`, rounded `{rounded.xs}`, padding `{spacing.sm} {spacing.xxl}`.
|
||||||
|
|
||||||
|
### Cards & Containers
|
||||||
|
|
||||||
|
**`hero`** — dark-indigo hero
|
||||||
|
- Indigo `{colors.canvas}` field with the animated gradient mesh, white `{colors.ink}` all-caps headline at `{typography.display-xl}`, lead paragraph, and a CTA pair. The hero media block carries a swooping bottom-only radius.
|
||||||
|
|
||||||
|
**`feature-card-gradient`** — vibrant magenta gradient feature panel
|
||||||
|
- Background `{colors.magenta}` gradient, white text, `{rounded.xl}` (40px), padding `{spacing.section}`. Frames a product mockup or 3D prop.
|
||||||
|
|
||||||
|
**`feature-card-dark`** — raised dark feature card
|
||||||
|
- Background `{colors.surface-indigo}`, white text, `{rounded.xl}`, padding `{spacing.xxl}`. Holds product screenshots / chat mockups.
|
||||||
|
|
||||||
|
**`showcase-band-black`** — full-black product showcase band
|
||||||
|
- Background `{colors.surface-black}`, white text, `{rounded.xl}`, padding `{spacing.section}`. Frames a hero product demo.
|
||||||
|
|
||||||
|
**`stat-card`** — big-number stat card (quests)
|
||||||
|
- Background `{colors.primary}` (Blurple), white text, headline at `{typography.display-md}`, `{rounded.xl}`, padding `{spacing.xxl}`.
|
||||||
|
|
||||||
|
**`step-card`** — numbered step panel (1/2/3 process)
|
||||||
|
- Background `{colors.magenta}` gradient, white text, label at `{typography.heading-sm}`, `{rounded.lg}`, padding `{spacing.xl}`.
|
||||||
|
|
||||||
|
**`cta-band`** — full-bleed Blurple CTA band
|
||||||
|
- Background `{colors.primary}`, white headline at `{typography.display-md}`, `{rounded.xl}`, padding `{spacing.section}`, with a `button-white` or `button-green` CTA.
|
||||||
|
|
||||||
|
**`marquee-band`** — scrolling all-caps marquee ("PLAY · CHAT · HANG OUT")
|
||||||
|
- Background `{colors.primary}`, white display text at `{typography.display-lg}`, padding `{spacing.lg}`.
|
||||||
|
|
||||||
|
**`faq-accordion`** — collapsible FAQ row (nitro)
|
||||||
|
- Background `{colors.surface-indigo}`, white text, question at `{typography.link-lg}`, `{rounded.lg}`, padding `{spacing.xl}`.
|
||||||
|
|
||||||
|
### Inputs & Forms
|
||||||
|
|
||||||
|
> Discord's marketing pages surface no standalone text inputs; forms route to the app. The kit-mirror `ex-*` form surfaces below model inputs against the brand's `{rounded.lg}` surfaces and `{colors.surface-indigo}` fills.
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
**`nav-bar`** — dark top navigation
|
||||||
|
- Indigo `{colors.canvas}` bar, white `{colors.ink}` links at `{typography.link}`, padding `{spacing.md} {spacing.xl}`. Slots: Discord logo · text links · "Login" · a Blurple/green Download CTA. Collapses to logo + hamburger on mobile.
|
||||||
|
|
||||||
|
**`footer`** — dark link footer
|
||||||
|
- Indigo `{colors.canvas}`, white links at `{typography.link}`, padding `{spacing.section}`, organized into multi-column link groups above a giant "Discord" wordmark.
|
||||||
|
|
||||||
|
### Signature Components
|
||||||
|
|
||||||
|
**`pricing-table`** — nitro plan comparison table
|
||||||
|
- Raised-indigo `{colors.surface-indigo}` surface, body text at `{typography.body}`, `{rounded.lg}`, padding `{spacing.xl}`. Two plan columns (Nitro Basic / Nitro) with the popular tier carrying a `{colors.magenta}` badge, each row ending in a `button-primary` "Subscribe".
|
||||||
|
|
||||||
|
**`game-rank-feature`** — top-ranked game card (trending-games #1/#2/#3)
|
||||||
|
- Raised-indigo `{colors.surface-indigo}` card, large media, rank number + title at `{typography.heading-sm}`, `{rounded.lg}`, padding `{spacing.md}`.
|
||||||
|
|
||||||
|
**`game-rank-row`** — ranked game list row
|
||||||
|
- Raised-indigo `{colors.surface-indigo}`, body text at `{typography.body}`, `{rounded.md}`, padding `{spacing.sm} {spacing.md}`. Rank · icon · title · metadata columns.
|
||||||
|
|
||||||
|
**`badge`** — small rounded tag / category chip
|
||||||
|
- Background `{colors.magenta}`, white text at `{typography.link-sm}`, `{rounded.lg}`, padding `{spacing.xxs} {spacing.sm}`.
|
||||||
|
|
||||||
|
### Examples (illustrative)
|
||||||
|
|
||||||
|
> Auto-derived kit-mirror demonstration surfaces (`scripts/derive-examples-block.mjs`). Each `ex-*` entry references brand-native primitives so downstream consumers (`/preview-design`, `/generate-kit`) re-skin the same 10 surfaces consistently. `TO_FILL` markers indicate missing primitives — resolve in the LLM judgment pass.
|
||||||
|
|
||||||
|
**`ex-pricing-tier`** — Default Pricing tier card. Re-uses feature-card chrome with brand canvas-soft surface.
|
||||||
|
- Properties: `backgroundColor`, `textColor`, `borderColor`, `rounded`, `padding`
|
||||||
|
|
||||||
|
**`ex-pricing-tier-featured`** — Featured/highlighted tier — polarity-flipped surface (dark fill + light text in light mode, light fill + dark text in dark mode).
|
||||||
|
- Properties: `backgroundColor`, `textColor`, `rounded`, `padding`
|
||||||
|
|
||||||
|
**`ex-product-selector`** — What's Included summary card — re-purposed for SaaS / B2B verticals (NOT a literal product gallery).
|
||||||
|
- Properties: `backgroundColor`, `rounded`, `padding`
|
||||||
|
|
||||||
|
**`ex-cart-drawer`** — Subscription summary — re-purposed for SaaS / B2B (line items per add-on, not literal cart).
|
||||||
|
- Properties: `backgroundColor`, `rounded`, `padding`, `item-divider`
|
||||||
|
|
||||||
|
**`ex-app-shell-row`** — Sidebar nav row inside the App Shell example. Active state uses brand primary as the indicator.
|
||||||
|
- Properties: `backgroundColor`, `activeIndicator`, `rounded`, `padding`
|
||||||
|
|
||||||
|
**`ex-data-table-cell`** — Default data-table th + td chrome. Header uses mono-caps eyebrow typography; body uses body-sm.
|
||||||
|
- Properties: `headerBackground`, `headerTypography`, `bodyTypography`, `cellPadding`, `rowBorder`
|
||||||
|
|
||||||
|
**`ex-auth-form-card`** — Sign-in / sign-up card. Re-uses feature-card chrome with text-input primitives inside.
|
||||||
|
- Properties: `backgroundColor`, `rounded`, `padding`
|
||||||
|
|
||||||
|
**`ex-modal-card`** — Modal dialog surface — same chrome as feature-card with elevated shadow.
|
||||||
|
- Properties: `backgroundColor`, `rounded`, `padding`
|
||||||
|
|
||||||
|
**`ex-empty-state-card`** — Empty-state illustration frame.
|
||||||
|
- Properties: `backgroundColor`, `rounded`, `padding`, `captionTypography`
|
||||||
|
|
||||||
|
**`ex-toast`** — Toast notification surface — feature-card shape + medium shadow.
|
||||||
|
- Properties: `backgroundColor`, `rounded`, `padding`, `typography`
|
||||||
|
|
||||||
|
|
||||||
|
## Do's and Don'ts
|
||||||
|
|
||||||
|
### Do
|
||||||
|
- Lead with the deep-indigo canvas (`{colors.canvas}`) and let the animated Blurple-to-magenta gradient carry atmosphere.
|
||||||
|
- Reserve `{colors.green}` for the single highest-intent CTA on a page; use `{colors.primary}` Blurple for everything else action-related.
|
||||||
|
- Shout with `{typography.display-xl}` ABC Ginto Nord in all-caps for headlines; drop hard to `{typography.body}` for copy.
|
||||||
|
- Round generously — `{rounded.sm}`–`{rounded.lg}` on controls, `{rounded.xl}`+ on media and feature panels.
|
||||||
|
- Frame product mockups inside `{colors.magenta}` gradient panels or `{colors.surface-indigo}` dark cards.
|
||||||
|
- Let 3D character art and props overlap card edges to build playful depth.
|
||||||
|
|
||||||
|
### Don't
|
||||||
|
- Don't flatten the canvas to a neutral grey or pure black — the indigo + gradient mesh is the brand.
|
||||||
|
- Don't use `{colors.green}` as a general accent; it is the high-intent CTA only.
|
||||||
|
- Don't set headlines in a timid mid-weight — display type is 700–800 ABC Ginto Nord or it loses the brand voice.
|
||||||
|
- Don't square off media or cards; the soft `{rounded.xl}`+ geometry is core to the playful tone.
|
||||||
|
- Don't lean on drop shadows for depth; depth comes from colour, gradient, and overlapping art.
|
||||||
|
- Don't introduce a fourth loud accent — Blurple, green, and magenta are the full chord.
|
||||||
+1
-2
@@ -5,8 +5,7 @@ COPY package*.json ./
|
|||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG VITE_API_BASE_URL=""
|
COPY .env.example .env
|
||||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@600;700;800&family=Inter:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
<title>My Workspace</title>
|
<title>My Workspace</title>
|
||||||
<script>
|
<script>
|
||||||
;(function () {
|
;(function () {
|
||||||
|
|||||||
-12
@@ -5,18 +5,6 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://backend:8080;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /openapi/ {
|
|
||||||
proxy_pass http://backend:8080;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<router-view />
|
<router-view />
|
||||||
<Toast position="bottom-right" />
|
<Toast position="bottom-right" />
|
||||||
<ConfirmDialog />
|
<ConfirmDialog style="width: min(500px, 92vw)" />
|
||||||
</template>
|
</template>
|
||||||
Vendored
+9
-3
@@ -13,13 +13,15 @@ declare module 'vue' {
|
|||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
Avatar: typeof import('primevue/avatar')['default']
|
Avatar: typeof import('primevue/avatar')['default']
|
||||||
Button: typeof import('primevue/button')['default']
|
Button: typeof import('primevue/button')['default']
|
||||||
Card: typeof import('primevue/card')['default']
|
|
||||||
Checkbox: typeof import('primevue/checkbox')['default']
|
Checkbox: typeof import('primevue/checkbox')['default']
|
||||||
Column: typeof import('primevue/column')['default']
|
Column: typeof import('primevue/column')['default']
|
||||||
ConfirmDialog: typeof import('primevue/confirmdialog')['default']
|
ConfirmDialog: typeof import('primevue/confirmdialog')['default']
|
||||||
DataTable: typeof import('primevue/datatable')['default']
|
DataTable: typeof import('primevue/datatable')['default']
|
||||||
DatePicker: typeof import('primevue/datepicker')['default']
|
DatePicker: typeof import('primevue/datepicker')['default']
|
||||||
Dialog: typeof import('primevue/dialog')['default']
|
Dialog: typeof import('primevue/dialog')['default']
|
||||||
|
IconField: typeof import('primevue/iconfield')['default']
|
||||||
|
InputIcon: typeof import('primevue/inputicon')['default']
|
||||||
|
InputNumber: typeof import('primevue/inputnumber')['default']
|
||||||
InputText: typeof import('primevue/inputtext')['default']
|
InputText: typeof import('primevue/inputtext')['default']
|
||||||
Menu: typeof import('primevue/menu')['default']
|
Menu: typeof import('primevue/menu')['default']
|
||||||
Message: typeof import('primevue/message')['default']
|
Message: typeof import('primevue/message')['default']
|
||||||
@@ -28,11 +30,15 @@ declare module 'vue' {
|
|||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
Select: typeof import('primevue/select')['default']
|
Select: typeof import('primevue/select')['default']
|
||||||
Splitter: typeof import('primevue/splitter')['default']
|
Tab: typeof import('primevue/tab')['default']
|
||||||
SplitterPanel: typeof import('primevue/splitterpanel')['default']
|
TabList: typeof import('primevue/tablist')['default']
|
||||||
|
TabPanel: typeof import('primevue/tabpanel')['default']
|
||||||
|
TabPanels: typeof import('primevue/tabpanels')['default']
|
||||||
|
Tabs: typeof import('primevue/tabs')['default']
|
||||||
Tag: typeof import('primevue/tag')['default']
|
Tag: typeof import('primevue/tag')['default']
|
||||||
Textarea: typeof import('primevue/textarea')['default']
|
Textarea: typeof import('primevue/textarea')['default']
|
||||||
Toast: typeof import('primevue/toast')['default']
|
Toast: typeof import('primevue/toast')['default']
|
||||||
ToggleSwitch: typeof import('primevue/toggleswitch')['default']
|
ToggleSwitch: typeof import('primevue/toggleswitch')['default']
|
||||||
|
TreeTable: typeof import('primevue/treetable')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<DataTable
|
||||||
|
v-bind="$attrs"
|
||||||
|
scrollable
|
||||||
|
scrollHeight="flex"
|
||||||
|
:rowsPerPageOptions="rowsPerPageOptions"
|
||||||
|
class="min-h-0 flex-1"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DataTable>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
rowsPerPageOptions?: number[]
|
||||||
|
}>(),
|
||||||
|
{ rowsPerPageOptions: () => [10, 20, 50] },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="doc" class="flex h-full flex-col">
|
|
||||||
<div class="flex items-start gap-2 border-b border-slate-200 px-4 py-3 dark:border-slate-700 lg:gap-3 lg:px-5">
|
|
||||||
<Button
|
|
||||||
icon="pi pi-arrow-left"
|
|
||||||
rounded
|
|
||||||
text
|
|
||||||
severity="secondary"
|
|
||||||
class="lg:hidden"
|
|
||||||
aria-label="Back to documents"
|
|
||||||
@click="$emit('back')"
|
|
||||||
/>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="truncate text-lg font-semibold">{{ doc.title }}</div>
|
|
||||||
<div class="flex items-center gap-1 text-[0.8rem] text-slate-500 dark:text-slate-400">
|
|
||||||
<i class="pi pi-history"></i> Updated {{ formatDate(doc.updatedAt) }}
|
|
||||||
<span v-if="saveState === 'saved'" class="text-[0.75rem] text-emerald-500">Saved</span>
|
|
||||||
<span v-else-if="saveState === 'saving'" class="text-[0.75rem] text-amber-500">Saving…</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="editor-actions flex gap-2">
|
|
||||||
<Button v-if="canEdit" icon="pi pi-pencil" severity="secondary" outlined size="small" label="Rename" @click="$emit('rename')" />
|
|
||||||
<Button v-if="canEdit" icon="pi pi-folder-open" severity="secondary" outlined size="small" label="Move" @click="$emit('move')" />
|
|
||||||
<Button v-if="canDelete" icon="pi pi-trash" severity="danger" outlined size="small" label="Delete" @click="$emit('delete')" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<RichTextEditor
|
|
||||||
:model-value="modelValue"
|
|
||||||
class="flex-1 overflow-auto px-5 py-4"
|
|
||||||
@update:model-value="$emit('update:modelValue', $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex h-full flex-col items-center justify-center gap-2">
|
|
||||||
<i class="pi pi-file dark:text-slate-600" style="font-size: 2.5rem; color: #cbd5e1"></i>
|
|
||||||
<p class="text-slate-500 dark:text-slate-400">Select a document from the tree to start editing</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import RichTextEditor from './RichTextEditor.vue'
|
|
||||||
import type { DocumentItem } from '../types'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
doc: DocumentItem | null
|
|
||||||
modelValue: string
|
|
||||||
saveState: 'idle' | 'saving' | 'saved'
|
|
||||||
canEdit: boolean
|
|
||||||
canDelete: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: string]
|
|
||||||
rename: []
|
|
||||||
move: []
|
|
||||||
delete: []
|
|
||||||
back: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function formatDate(value: string) {
|
|
||||||
return new Date(value).toLocaleString()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.editor-actions :deep(.p-button-label) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 py-1 hover:bg-slate-100 dark:hover:bg-slate-800"
|
|
||||||
:class="{ 'bg-blue-100 dark:bg-blue-900/50': isSelected }"
|
|
||||||
@click="onRowClick"
|
|
||||||
>
|
|
||||||
<span class="tree-chevron" @click.stop="onToggle">
|
|
||||||
<i :class="isFolder && expanded ? 'pi pi-chevron-down' : 'pi pi-chevron-right'" class="text-[0.7rem] text-slate-500 dark:text-slate-400"></i>
|
|
||||||
</span>
|
|
||||||
<i
|
|
||||||
:class="isFolder ? (expanded ? 'pi pi-folder-open' : 'pi pi-folder') : 'pi pi-file'"
|
|
||||||
:style="{ color: isFolder ? '#3b82f6' : '#94a3b8' }"
|
|
||||||
></i>
|
|
||||||
<span>{{ node.title }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="expanded && node.children.length" class="ml-[18px] border-l border-slate-200 pl-2 dark:border-slate-700">
|
|
||||||
<TreeNode
|
|
||||||
v-for="child in node.children"
|
|
||||||
:key="child.id"
|
|
||||||
:node="child"
|
|
||||||
:selected-id="selectedId"
|
|
||||||
@select="$emit('select', $event)"
|
|
||||||
@toggle="$emit('toggle', $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { DocumentNode } from '../types'
|
|
||||||
|
|
||||||
defineOptions({ name: 'TreeNode' })
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
node: DocumentNode
|
|
||||||
selectedId: string | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
select: [id: string]
|
|
||||||
toggle: [id: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const expanded = ref(false)
|
|
||||||
|
|
||||||
const isFolder = computed(() => props.node.type === 'Folder')
|
|
||||||
const isSelected = computed(() => props.node.id === props.selectedId)
|
|
||||||
|
|
||||||
function onRowClick() {
|
|
||||||
if (isFolder.value) {
|
|
||||||
expanded.value = !expanded.value
|
|
||||||
emit('toggle', props.node.id)
|
|
||||||
} else {
|
|
||||||
emit('select', props.node.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onToggle() {
|
|
||||||
if (isFolder.value) {
|
|
||||||
expanded.value = !expanded.value
|
|
||||||
emit('toggle', props.node.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,44 +1,85 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col overflow-hidden p-3">
|
<div class="panel flex h-full flex-col overflow-hidden">
|
||||||
<div>
|
<div class="flex flex-col gap-3 border-b p-3 sm:flex-row sm:items-center" style="border-color: var(--hairline)">
|
||||||
|
<IconField class="min-w-0 flex-1">
|
||||||
|
<InputIcon class="pi pi-search" />
|
||||||
<InputText
|
<InputText
|
||||||
:model-value="searchTerm"
|
:model-value="searchTerm"
|
||||||
placeholder="Search documents..."
|
placeholder="Search documents"
|
||||||
class="w-full"
|
class="search-input w-full"
|
||||||
@update:model-value="onSearch"
|
@update:model-value="onSearch"
|
||||||
/>
|
/>
|
||||||
<div v-if="canCreate" class="mt-2 flex gap-2">
|
</IconField>
|
||||||
<Button label="Doc" icon="pi pi-plus" severity="secondary" size="small" @click="$emit('create', 'Document')" />
|
<div v-if="canCreate" class="flex shrink-0 gap-2">
|
||||||
<Button label="Folder" icon="pi pi-plus" severity="secondary" size="small" @click="$emit('create', 'Folder')" />
|
<Button label="Document" icon="pi pi-plus" size="small" @click="$emit('create', 'Document')" />
|
||||||
|
<Button label="Folder" icon="pi pi-folder-open" severity="secondary" outlined size="small" @click="$emit('create', 'Folder')" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 text-[0.8rem] text-slate-500 dark:text-slate-400">
|
</div>
|
||||||
|
|
||||||
|
<p v-if="canCreate" class="muted-note border-b px-3 py-2" style="border-color: var(--hairline)">
|
||||||
{{ creatingLabel }}
|
{{ creatingLabel }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-auto">
|
||||||
|
<TreeTable
|
||||||
|
v-model:selectionKeys="selectionKeys"
|
||||||
|
:value="nodes"
|
||||||
|
:loading="loading"
|
||||||
|
selectionMode="single"
|
||||||
|
@node-select="onNodeSelect"
|
||||||
|
>
|
||||||
|
<Column field="title" header="Name" expander style="min-width: 240px">
|
||||||
|
<template #body="{ node }">
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<i
|
||||||
|
class="pi"
|
||||||
|
:class="node.data.type === 'Folder' ? 'pi-folder text-fuchsia-500' : 'pi-file'"
|
||||||
|
:style="node.data.type === 'Folder' ? undefined : { color: 'var(--ink-muted)' }"
|
||||||
|
></i>
|
||||||
|
<span :class="node.key === selectedId ? 'font-semibold' : ''">{{ node.data.title }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Owner" style="width: 160px" class="hidden lg:table-cell">
|
||||||
|
<template #body="{ node }">
|
||||||
|
<span class="muted-note">{{ resolveUserName(node.data.createdBy) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Last edited by" style="width: 160px" class="hidden xl:table-cell">
|
||||||
|
<template #body="{ node }">
|
||||||
|
<span class="muted-note">{{ node.data.updatedBy ? resolveUserName(node.data.updatedBy) : '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Updated" style="width: 130px" class="hidden sm:table-cell">
|
||||||
|
<template #body="{ node }">
|
||||||
|
<span class="muted-note">{{ formatDate(node.data.updatedAt) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<template #empty>
|
||||||
|
<div class="grid place-items-center gap-2 px-4 py-14 text-center">
|
||||||
|
<i class="pi pi-file-edit text-2xl" style="color: var(--ink-muted)"></i>
|
||||||
|
<p class="font-semibold" style="color: var(--ink)">No documents yet</p>
|
||||||
|
<p class="muted-note max-w-[280px]">
|
||||||
|
{{ canCreate ? 'Create a document to start writing, or a folder to group them.' : 'Ask a project owner for document access.' }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
<div class="mt-2 flex-1 overflow-auto">
|
</TreeTable>
|
||||||
<TreeNode
|
|
||||||
v-for="root in tree"
|
|
||||||
:key="root.id"
|
|
||||||
:node="root"
|
|
||||||
:selected-id="selectedId"
|
|
||||||
@select="$emit('select', $event)"
|
|
||||||
/>
|
|
||||||
<div v-if="!tree.length && !loading" class="p-4 text-center text-slate-500 dark:text-slate-400">No documents yet</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import TreeNode from './DocumentTreeNode.vue'
|
import type { DocumentNode, DocumentType, ProjectMember } from '../types'
|
||||||
import type { DocumentNode, DocumentType } from '../types'
|
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
tree: DocumentNode[]
|
tree: DocumentNode[]
|
||||||
loading: boolean
|
loading: boolean
|
||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
searchTerm: string
|
searchTerm: string
|
||||||
creatingLabel: string
|
creatingLabel: string
|
||||||
canCreate: boolean
|
canCreate: boolean
|
||||||
|
members: ProjectMember[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -50,4 +91,30 @@ const emit = defineEmits<{
|
|||||||
function onSearch(value: unknown) {
|
function onSearch(value: unknown) {
|
||||||
emit('update:searchTerm', typeof value === 'string' ? value.trim() : '')
|
emit('update:searchTerm', typeof value === 'string' ? value.trim() : '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DocumentTreeTableNode {
|
||||||
|
key: string
|
||||||
|
data: DocumentNode
|
||||||
|
children: DocumentTreeTableNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTreeTableNode(node: DocumentNode): DocumentTreeTableNode {
|
||||||
|
return { key: node.id, data: node, children: node.children.map(toTreeTableNode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = computed(() => props.tree.map(toTreeTableNode))
|
||||||
|
const selectionKeys = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
function resolveUserName(userId: string) {
|
||||||
|
return props.members.find((m) => m.userId === userId)?.displayName ?? '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(v?: string | null) {
|
||||||
|
const date = new Date(v ?? '')
|
||||||
|
return Number.isNaN(date.valueOf()) ? '—' : date.toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNodeSelect(node: { key?: string; data?: DocumentNode }) {
|
||||||
|
if (node.key && node.data?.type === 'Document') emit('select', node.key)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:visible="visible"
|
||||||
|
:modal="true"
|
||||||
|
:dismissable-mask="false"
|
||||||
|
:pt="{ root: { class: 'doc-dialog' }, content: { class: 'doc-dialog-content' } }"
|
||||||
|
@update:visible="onClose"
|
||||||
|
>
|
||||||
|
<template #container>
|
||||||
|
<header class="flex shrink-0 items-start gap-3 border-b px-5 py-3" style="border-color: var(--hairline)">
|
||||||
|
<i
|
||||||
|
class="mt-1 pi"
|
||||||
|
:class="doc?.type === 'Folder' ? 'pi-folder text-fuchsia-500' : 'pi-file-edit'"
|
||||||
|
:style="doc?.type === 'Folder' ? undefined : { color: 'var(--primary)' }"
|
||||||
|
></i>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h2 class="truncate font-display text-[19px] font-extrabold leading-tight" style="color: var(--ink)">
|
||||||
|
{{ doc?.title }}
|
||||||
|
</h2>
|
||||||
|
<p class="muted-note mt-0.5">
|
||||||
|
Updated {{ formatDate(doc?.updatedAt) }}
|
||||||
|
<span v-if="saveState === 'saved'" class="ml-2 font-semibold text-green-600 dark:text-green-400">Saved</span>
|
||||||
|
<span v-else-if="saveState === 'saving'" class="ml-2 font-semibold" style="color: var(--ink-muted)">Saving…</span>
|
||||||
|
<span v-else-if="editing" class="ml-2 font-semibold" style="color: var(--magenta)">Editing</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<template v-if="editing">
|
||||||
|
<Button label="Cancel" severity="secondary" text size="small" @click="$emit('cancel-edit')" />
|
||||||
|
<Button
|
||||||
|
label="Save"
|
||||||
|
icon="pi pi-check"
|
||||||
|
size="small"
|
||||||
|
:loading="saveState === 'saving'"
|
||||||
|
:disabled="saveState === 'saving'"
|
||||||
|
@click="$emit('save')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Button v-if="canEdit" icon="pi pi-pencil" label="Edit" size="small" @click="$emit('edit')" />
|
||||||
|
<Button v-if="canEdit" icon="pi pi-folder-open" text severity="secondary" size="small" aria-label="Move to folder" @click="$emit('move')" />
|
||||||
|
<Button v-if="canDelete" icon="pi pi-trash" text severity="danger" size="small" aria-label="Delete" @click="$emit('delete')" />
|
||||||
|
<Button
|
||||||
|
v-if="canEdit"
|
||||||
|
icon="pi pi-ellipsis-h"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
aria-label="More actions"
|
||||||
|
@click="toggleMenu"
|
||||||
|
/>
|
||||||
|
<Menu ref="menu" :model="menuItems" popup />
|
||||||
|
</template>
|
||||||
|
<Button icon="pi pi-times" text severity="secondary" size="small" aria-label="Close" @click="onClose(false)" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-hidden" style="background: var(--panel)">
|
||||||
|
<RichTextEditor
|
||||||
|
v-if="editing"
|
||||||
|
class="h-full"
|
||||||
|
:model-value="modelValue"
|
||||||
|
@update:model-value="onContentChange"
|
||||||
|
/>
|
||||||
|
<div v-else-if="doc?.type === 'Document'" class="h-full overflow-auto p-4">
|
||||||
|
<article class="ck-content document-content" v-html="displayHtml"></article>
|
||||||
|
</div>
|
||||||
|
<div v-else class="grid h-full place-items-center">
|
||||||
|
<p class="muted-note">Folder — open a document inside it to read or edit.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import RichTextEditor from './RichTextEditor.vue'
|
||||||
|
import type { DocumentItem } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
doc: DocumentItem | null
|
||||||
|
modelValue: string
|
||||||
|
editing: boolean
|
||||||
|
saveState: 'idle' | 'saving' | 'saved'
|
||||||
|
canEdit: boolean
|
||||||
|
canDelete: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [value: boolean]
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
edit: []
|
||||||
|
'cancel-edit': []
|
||||||
|
save: []
|
||||||
|
rename: []
|
||||||
|
move: []
|
||||||
|
delete: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const menu = ref()
|
||||||
|
|
||||||
|
function toggleMenu(event: Event) {
|
||||||
|
menu.value?.toggle(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems = computed(() => {
|
||||||
|
const items: Record<string, unknown>[] = []
|
||||||
|
if (props.canEdit) {
|
||||||
|
items.push({ label: 'Rename', icon: 'pi pi-pencil', command: () => emit('rename') })
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
function onClose(value: unknown) {
|
||||||
|
emit('update:visible', typeof value === 'boolean' ? value : false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContentChange(value: string) {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayHtml = computed(() => props.doc?.content || '<p></p>')
|
||||||
|
|
||||||
|
function formatDate(value?: string) {
|
||||||
|
return value ? new Date(value).toLocaleString() : '—'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Near-fullscreen writing surface — the editor is the point of this screen */
|
||||||
|
.doc-dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 96vw;
|
||||||
|
max-width: 1440px;
|
||||||
|
height: 94dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
box-shadow: rgba(10, 13, 58, 0.28) 0 18px 60px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.document-content {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="editor-shell">
|
||||||
<Ckeditor
|
<Ckeditor
|
||||||
:editor="Editor"
|
:editor="Editor"
|
||||||
:model-value="modelValue ?? ''"
|
:model-value="modelValue ?? ''"
|
||||||
:config="editorConfig"
|
:config="editorConfig"
|
||||||
@update:model-value="onUpdate"
|
@update:model-value="onUpdate"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -102,3 +104,16 @@ const editorConfig = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.editor-shell {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.editor-shell :deep(.ck.ck-editor) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+108
-40
@@ -1,27 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex h-dvh flex-col lg:grid"
|
class="flex h-dvh flex-col lg:grid"
|
||||||
:class="sidebarOpen ? 'lg:grid-cols-[250px_1fr]' : 'lg:grid-cols-1'"
|
:class="sidebarOpen ? 'lg:grid-cols-[236px_1fr]' : 'lg:grid-cols-1'"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="sidebarOpen"
|
v-if="sidebarOpen"
|
||||||
class="fixed inset-0 z-30 bg-black/40 lg:hidden"
|
class="fixed inset-0 z-30 bg-slate-950/60 backdrop-blur-sm lg:hidden"
|
||||||
@click="sidebarOpen = false"
|
@click="sidebarOpen = false"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
class="fixed inset-y-0 left-0 z-40 flex w-[250px] transform flex-col gap-2 bg-slate-800 p-4 text-slate-200 transition-transform duration-200 lg:static"
|
class="fixed inset-y-0 left-0 z-40 flex w-[236px] transform flex-col border-r px-3 py-4 transition-transform duration-200 lg:static"
|
||||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:hidden'"
|
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:hidden'"
|
||||||
|
style="background: var(--panel); border-color: var(--hairline)"
|
||||||
>
|
>
|
||||||
<div class="mb-4 flex items-center justify-between text-xl font-bold text-white">
|
<div class="mb-5 flex items-center justify-between px-2">
|
||||||
<div class="flex items-center gap-2">
|
<router-link to="/dashboard" class="flex items-center gap-2.5">
|
||||||
<i class="pi pi-briefcase"></i>
|
<span
|
||||||
MWS
|
class="grid h-8 w-8 place-items-center rounded-[10px] font-display text-[15px] font-extrabold text-white"
|
||||||
</div>
|
style="background: var(--primary)"
|
||||||
<button class="lg:hidden" aria-label="Close menu" @click="sidebarOpen = false">
|
>M</span
|
||||||
<i class="pi pi-times text-lg"></i>
|
>
|
||||||
|
<span class="font-display text-[15px] font-extrabold tracking-[-0.01em]" style="color: var(--ink)">
|
||||||
|
Workspace
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
|
<button
|
||||||
|
class="text-slate-400 transition-colors hover:text-slate-900 dark:hover:text-white lg:hidden"
|
||||||
|
aria-label="Close menu"
|
||||||
|
@click="sidebarOpen = false"
|
||||||
|
>
|
||||||
|
<i class="pi pi-times text-base"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex flex-col gap-1">
|
|
||||||
|
<nav class="flex flex-col gap-0.5">
|
||||||
<router-link
|
<router-link
|
||||||
v-for="item in visibleNavItems"
|
v-for="item in visibleNavItems"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
@@ -35,45 +48,76 @@
|
|||||||
<i class="pi pi-cog"></i> Settings
|
<i class="pi pi-cog"></i> Settings
|
||||||
</router-link>
|
</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<div class="flex items-center gap-2 text-slate-300">
|
|
||||||
<Avatar :label="initials" style="background: #3b82f6; color: #fff" size="normal" />
|
|
||||||
<span>{{ auth.user?.displayName ?? auth.user?.username }}</span>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<div class="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-2.5 dark:border-slate-800 dark:bg-slate-900 lg:px-6">
|
<header
|
||||||
<div class="flex items-center gap-2">
|
class="flex items-center justify-between gap-3 border-b px-4 py-2.5 lg:px-6"
|
||||||
|
style="background: var(--panel); border-color: var(--hairline)"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-bars"
|
icon="pi pi-bars"
|
||||||
rounded
|
rounded
|
||||||
text
|
text
|
||||||
|
severity="secondary"
|
||||||
:aria-label="sidebarOpen ? 'Close menu' : 'Open menu'"
|
:aria-label="sidebarOpen ? 'Close menu' : 'Open menu'"
|
||||||
@click="sidebarOpen = !sidebarOpen"
|
@click="sidebarOpen = !sidebarOpen"
|
||||||
/>
|
/>
|
||||||
<span class="truncate text-slate-500 dark:text-slate-400">{{ currentProject?.name ?? 'My Workspace' }}</span>
|
<nav class="flex min-w-0 items-center gap-1.5 text-[13px]" aria-label="Breadcrumb">
|
||||||
|
<span class="shrink-0 font-medium" style="color: var(--ink-muted)">
|
||||||
|
{{ pageTitle }}
|
||||||
|
</span>
|
||||||
|
<template v-if="currentProject">
|
||||||
|
<span style="color: var(--ink-muted)">/</span>
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'project-overview', params: { id: currentProject.id } }"
|
||||||
|
class="truncate font-medium transition-colors hover:text-slate-900 dark:hover:text-white"
|
||||||
|
style="color: var(--ink-muted)"
|
||||||
|
>
|
||||||
|
{{ currentProject.name }}
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<span class="hidden shrink-0 text-[13px] font-semibold sm:block" style="color: var(--ink)">
|
||||||
|
Welcome back, {{ auth.user?.displayName ?? auth.user?.username }}
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
||||||
rounded
|
rounded
|
||||||
text
|
text
|
||||||
|
severity="secondary"
|
||||||
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
|
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||||
@click="theme.toggle"
|
@click="theme.toggle"
|
||||||
/>
|
/>
|
||||||
<Menu ref="menu" :model="menuItems" popup />
|
<button
|
||||||
<Button
|
class="flex w-auto items-center gap-2.5 rounded-xl border px-2.5 py-1.5 text-left transition-colors hover:bg-slate-50 dark:hover:bg-white/5"
|
||||||
icon="pi pi-ellipsis-v"
|
style="border-color: var(--hairline)"
|
||||||
rounded
|
aria-haspopup="true"
|
||||||
text
|
|
||||||
aria-label="Options"
|
|
||||||
@click="toggleMenu"
|
@click="toggleMenu"
|
||||||
/>
|
>
|
||||||
|
<Avatar :label="initials" :style="{ background: 'var(--primary)', color: '#fff' }" size="normal" />
|
||||||
|
<span class="hidden min-w-0 sm:block">
|
||||||
|
<span class="block truncate text-[13px] font-semibold" style="color: var(--ink)">
|
||||||
|
{{ auth.user?.displayName ?? auth.user?.username }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-[11px]" style="color: var(--ink-muted)">
|
||||||
|
@{{ auth.user?.username }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Menu ref="menu" :model="menuItems" popup />
|
||||||
<main class="flex-1 overflow-auto p-4 lg:p-6">
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 overflow-auto">
|
||||||
|
<div class="mx-auto h-full w-full max-w-[1280px] p-4 sm:p-6 lg:px-8 lg:py-7">
|
||||||
<router-view />
|
<router-view />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,11 +132,11 @@ import type { Project } from '../types'
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const menu = ref()
|
const menu = ref()
|
||||||
const sidebarOpen = ref(false)
|
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
|
||||||
const desktopMq = window.matchMedia('(min-width: 1024px)')
|
const desktopMq = window.matchMedia('(min-width: 1024px)')
|
||||||
const isDesktop = ref(desktopMq.matches)
|
const isDesktop = ref(desktopMq.matches)
|
||||||
|
const sidebarOpen = ref(isDesktop.value)
|
||||||
desktopMq.addEventListener('change', (e) => (isDesktop.value = e.matches))
|
desktopMq.addEventListener('change', (e) => (isDesktop.value = e.matches))
|
||||||
|
|
||||||
function handleNavClick() {
|
function handleNavClick() {
|
||||||
@@ -102,11 +146,12 @@ function handleNavClick() {
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ key: 'dashboard', label: 'Dashboard', icon: 'pi pi-home', to: '/dashboard' },
|
{ key: 'dashboard', label: 'Dashboard', icon: 'pi pi-home', to: '/dashboard' },
|
||||||
{ key: 'projects', label: 'Projects', icon: 'pi pi-folder-open', to: '/projects' },
|
{ key: 'projects', label: 'Projects', icon: 'pi pi-folder-open', to: '/projects' },
|
||||||
{ key: 'accounts', label: 'Accounts', icon: 'pi pi-users', to: '/accounts' },
|
{ key: 'users', label: 'Users', icon: 'pi pi-users', to: '/users' },
|
||||||
{ key: 'roles', label: 'Roles', icon: 'pi pi-shield', to: '/roles' },
|
|
||||||
]
|
]
|
||||||
const visibleNavItems = computed(() => navItems.filter((item) => auth.canView(item.key)))
|
const visibleNavItems = computed(() => navItems.filter((item) => auth.canView(item.key)))
|
||||||
|
|
||||||
|
const pageTitle = computed(() => (route.meta.title as string | undefined) ?? 'Projects')
|
||||||
|
|
||||||
const currentProject = ref<Project | null>(null)
|
const currentProject = ref<Project | null>(null)
|
||||||
|
|
||||||
async function loadCurrentProject(id: string | string[]) {
|
async function loadCurrentProject(id: string | string[]) {
|
||||||
@@ -142,6 +187,7 @@ const menuItems = computed(() => [
|
|||||||
{
|
{
|
||||||
label: auth.user?.displayName ?? auth.user?.username,
|
label: auth.user?.displayName ?? auth.user?.username,
|
||||||
items: [
|
items: [
|
||||||
|
{ label: 'Profile', icon: 'pi pi-user', command: () => router.push('/profile') },
|
||||||
{
|
{
|
||||||
label: 'Logout',
|
label: 'Logout',
|
||||||
icon: 'pi pi-sign-out',
|
icon: 'pi pi-sign-out',
|
||||||
@@ -154,6 +200,8 @@ const menuItems = computed(() => [
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
function toggleMenu(event: Event) {
|
function toggleMenu(event: Event) {
|
||||||
menu.value?.toggle(event)
|
menu.value?.toggle(event)
|
||||||
}
|
}
|
||||||
@@ -161,22 +209,42 @@ function toggleMenu(event: Event) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.nav-link {
|
.nav-link {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.625rem;
|
gap: 0.65rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 12px;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.875rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #cbd5e1;
|
color: var(--ink-muted);
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.nav-link i {
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
background-color: #334155;
|
background-color: var(--primary-soft);
|
||||||
color: #fff;
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
.nav-link.router-link-active {
|
.nav-link.router-link-active {
|
||||||
background-color: #3b82f6;
|
background-color: var(--primary-soft);
|
||||||
color: #fff;
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.app-dark .nav-link.router-link-active {
|
||||||
|
color: #9ba6f8;
|
||||||
|
}
|
||||||
|
/* Active marker: a magenta rail, the one place the second accent shows in the shell */
|
||||||
|
.nav-link.router-link-active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 20%;
|
||||||
|
height: 60%;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--magenta);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,24 +1,48 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="project-layout">
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
<div class="mb-5 flex gap-1 overflow-x-auto border-b border-slate-200 dark:border-slate-700">
|
<nav class="mb-5 flex shrink-0 gap-1 overflow-x-auto border-b pb-px" style="border-color: var(--hairline)">
|
||||||
<router-link
|
<router-link class="tab tab-exact" :to="{ name: 'project-overview' }">Overview</router-link>
|
||||||
class="-mb-px whitespace-nowrap border-b-2 border-transparent px-4 py-2.5 font-medium text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 [&.router-link-exact-active]:border-blue-500 [&.router-link-exact-active]:text-blue-500"
|
<router-link class="tab" :to="{ name: 'documents' }">Documents</router-link>
|
||||||
:to="{ name: 'project-overview' }"
|
<router-link class="tab" :to="{ name: 'tasks' }">Tasks</router-link>
|
||||||
>Overview</router-link>
|
<router-link class="tab" :to="{ name: 'members' }">Members</router-link>
|
||||||
<router-link
|
</nav>
|
||||||
class="-mb-px whitespace-nowrap border-b-2 border-transparent px-4 py-2.5 font-medium text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 [&.router-link-active]:border-blue-500 [&.router-link-active]:text-blue-500"
|
|
||||||
:to="{ name: 'documents' }"
|
|
||||||
>Documents</router-link>
|
|
||||||
<router-link
|
|
||||||
class="-mb-px whitespace-nowrap border-b-2 border-transparent px-4 py-2.5 font-medium text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 [&.router-link-active]:border-blue-500 [&.router-link-active]:text-blue-500"
|
|
||||||
:to="{ name: 'tasks' }"
|
|
||||||
>Tasks</router-link>
|
|
||||||
<router-link
|
|
||||||
class="-mb-px whitespace-nowrap border-b-2 border-transparent px-4 py-2.5 font-medium text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 [&.router-link-active]:border-blue-500 [&.router-link-active]:text-blue-500"
|
|
||||||
:to="{ name: 'members' }"
|
|
||||||
>Members</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tab {
|
||||||
|
position: relative;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.5rem 0.9rem 0.7rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.tab.router-link-active:not(.tab-exact),
|
||||||
|
.tab-exact.router-link-exact-active {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.app-dark .tab.router-link-active:not(.tab-exact),
|
||||||
|
.app-dark .tab-exact.router-link-exact-active {
|
||||||
|
color: #9ba6f8;
|
||||||
|
}
|
||||||
|
.tab.router-link-active:not(.tab-exact)::after,
|
||||||
|
.tab-exact.router-link-exact-active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0.4rem;
|
||||||
|
bottom: -1px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@ import { createPinia } from 'pinia'
|
|||||||
import PrimeVue from 'primevue/config'
|
import PrimeVue from 'primevue/config'
|
||||||
import ToastService from 'primevue/toastservice'
|
import ToastService from 'primevue/toastservice'
|
||||||
import ConfirmationService from 'primevue/confirmationservice'
|
import ConfirmationService from 'primevue/confirmationservice'
|
||||||
import Aura from '@primevue/themes/aura'
|
import KrakenPreset from './theme'
|
||||||
|
|
||||||
import 'primeicons/primeicons.css'
|
import 'primeicons/primeicons.css'
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ app.use(createPinia())
|
|||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(PrimeVue, {
|
app.use(PrimeVue, {
|
||||||
theme: {
|
theme: {
|
||||||
preset: Aura,
|
preset: KrakenPreset,
|
||||||
options: { darkModeSelector: '.app-dark' },
|
options: { darkModeSelector: '.app-dark' },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
+10
-7
@@ -5,6 +5,7 @@ declare module 'vue-router' {
|
|||||||
interface RouteMeta {
|
interface RouteMeta {
|
||||||
public?: boolean
|
public?: boolean
|
||||||
screenKey?: string
|
screenKey?: string
|
||||||
|
title?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,10 +23,9 @@ const router = createRouter({
|
|||||||
component: () => import('../layouts/MainLayout.vue'),
|
component: () => import('../layouts/MainLayout.vue'),
|
||||||
children: [
|
children: [
|
||||||
{ path: '', redirect: '/projects' },
|
{ path: '', redirect: '/projects' },
|
||||||
{ path: 'dashboard', name: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { screenKey: 'dashboard' } },
|
{ path: 'dashboard', name: 'dashboard', component: () => import('../views/dashboard/DashboardView.vue'), meta: { screenKey: 'dashboard', title: 'Dashboard' } },
|
||||||
{ path: 'projects', name: 'projects', component: () => import('../views/projects/ProjectsListView.vue'), meta: { screenKey: 'projects' } },
|
{ path: 'projects', name: 'projects', component: () => import('../views/projects/ProjectsListView.vue'), meta: { screenKey: 'projects', title: 'Projects' } },
|
||||||
{ path: 'accounts', name: 'accounts', component: () => import('../views/AccountsView.vue'), meta: { screenKey: 'accounts' } },
|
{ path: 'users', name: 'users', component: () => import('../views/users/UsersView.vue'), meta: { screenKey: 'users', title: 'Users' } },
|
||||||
{ path: 'roles', name: 'roles', component: () => import('../views/RolesView.vue'), meta: { screenKey: 'roles' } },
|
|
||||||
{
|
{
|
||||||
path: 'projects/:id',
|
path: 'projects/:id',
|
||||||
component: () => import('../layouts/ProjectLayout.vue'),
|
component: () => import('../layouts/ProjectLayout.vue'),
|
||||||
@@ -38,7 +38,8 @@ const router = createRouter({
|
|||||||
{ path: 'members', name: 'members', component: () => import('../views/projects/MembersView.vue') },
|
{ path: 'members', name: 'members', component: () => import('../views/projects/MembersView.vue') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: 'settings', name: 'settings', component: () => import('../views/SettingsView.vue') },
|
{ path: 'settings', name: 'settings', component: () => import('../views/settings/SettingsView.vue'), meta: { title: 'Settings' } },
|
||||||
|
{ path: 'profile', name: 'profile', component: () => import('../views/profile/ProfileView.vue'), meta: { title: 'Profile' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/projects' },
|
{ path: '/:pathMatch(.*)*', redirect: '/projects' },
|
||||||
@@ -51,13 +52,15 @@ router.beforeEach(async (to) => {
|
|||||||
return { name: 'login', query: { redirect: to.fullPath } }
|
return { name: 'login', query: { redirect: to.fullPath } }
|
||||||
}
|
}
|
||||||
if (to.name === 'login' && auth.isAuthenticated) {
|
if (to.name === 'login' && auth.isAuthenticated) {
|
||||||
return { path: '/projects' }
|
return { path: '/dashboard' }
|
||||||
}
|
}
|
||||||
if (auth.isAuthenticated) {
|
if (auth.isAuthenticated) {
|
||||||
await auth.ensureMenu()
|
await auth.ensureMenu()
|
||||||
}
|
}
|
||||||
if (to.meta.screenKey && !auth.canView(to.meta.screenKey)) {
|
if (to.meta.screenKey && !auth.canView(to.meta.screenKey)) {
|
||||||
return { path: '/projects' }
|
if (to.path !== '/dashboard') {
|
||||||
|
return { path: '/dashboard' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+108
-28
@@ -1,5 +1,19 @@
|
|||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
import type { LoginResponse, Project, ProjectMember, MemberRole, Account, MenuItem, Role, SaveRoleRequest } from '../types'
|
import type {
|
||||||
|
LoginResponse,
|
||||||
|
Project,
|
||||||
|
ProjectMember,
|
||||||
|
MemberRole,
|
||||||
|
UserListItem,
|
||||||
|
MenuItem,
|
||||||
|
Role,
|
||||||
|
SaveRoleRequest,
|
||||||
|
MasterDataItem,
|
||||||
|
SaveMasterDataRequest,
|
||||||
|
PagedResult,
|
||||||
|
UserRoleDetail,
|
||||||
|
User,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
export async function login(username: string, password: string): Promise<LoginResponse> {
|
export async function login(username: string, password: string): Promise<LoginResponse> {
|
||||||
const { data } = await api.post<LoginResponse>('/api/auth/login', { username, password })
|
const { data } = await api.post<LoginResponse>('/api/auth/login', { username, password })
|
||||||
@@ -7,36 +21,65 @@ export async function login(username: string, password: string): Promise<LoginRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getMenu(): Promise<MenuItem[]> {
|
export async function getMenu(): Promise<MenuItem[]> {
|
||||||
const { data } = await api.get<MenuItem[]>('/api/menu')
|
const { data } = await api.get<MenuItem[]>('/api/settings/permission/menu')
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRoles(): Promise<Role[]> {
|
export async function getRoles(page = 1, pageSize = 20): Promise<PagedResult<Role>> {
|
||||||
const { data } = await api.get<Role[]>('/api/roles')
|
const { data } = await api.get<PagedResult<Role>>('/api/settings/permission', { params: { page, pageSize } })
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRole(id: string): Promise<Role> {
|
export async function getRole(id: string): Promise<Role> {
|
||||||
const { data } = await api.get<Role>(`/api/roles/${id}`)
|
const { data } = await api.get<Role>(`/api/settings/permission/${id}`)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRole(payload: SaveRoleRequest): Promise<Role> {
|
export async function createRole(payload: SaveRoleRequest): Promise<Role> {
|
||||||
const { data } = await api.post<Role>('/api/roles', payload)
|
const { data } = await api.post<Role>('/api/settings/permission', payload)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRole(id: string, payload: SaveRoleRequest): Promise<Role> {
|
export async function updateRole(id: string, payload: SaveRoleRequest): Promise<Role> {
|
||||||
const { data } = await api.put<Role>(`/api/roles/${id}`, payload)
|
const { data } = await api.put<Role>(`/api/settings/permission/${id}`, payload)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRole(id: string): Promise<void> {
|
export async function deleteRole(id: string): Promise<void> {
|
||||||
await api.delete(`/api/roles/${id}`)
|
await api.delete(`/api/settings/permission/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProjects(): Promise<Project[]> {
|
export async function getMasterDataList(group?: string, page = 1, pageSize = 20): Promise<PagedResult<MasterDataItem>> {
|
||||||
const { data } = await api.get<Project[]>('/api/projects')
|
const { data } = await api.get<PagedResult<MasterDataItem>>('/api/masterdata', { params: { group, page, pageSize } })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMasterDataByGroup(group: string): Promise<MasterDataItem[]> {
|
||||||
|
const { data } = await api.get<MasterDataItem[]>(`/api/masterdata/groups/${group}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMasterDataOptions(group: string): Promise<{ label: string; value: string }[]> {
|
||||||
|
const items = await getMasterDataByGroup(group)
|
||||||
|
return items.map((i) => ({ label: i.label, value: i.value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMasterData(payload: SaveMasterDataRequest): Promise<MasterDataItem> {
|
||||||
|
const { data } = await api.post<MasterDataItem>('/api/masterdata', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMasterData(id: string, payload: SaveMasterDataRequest): Promise<MasterDataItem> {
|
||||||
|
const { data } = await api.put<MasterDataItem>(`/api/masterdata/${id}`, payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMasterData(id: string): Promise<void> {
|
||||||
|
await api.delete(`/api/masterdata/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjects(page = 1, pageSize = 20): Promise<PagedResult<Project>> {
|
||||||
|
const { data } = await api.get<PagedResult<Project>>('/api/projects', { params: { page, pageSize } })
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,8 +115,8 @@ export async function deleteProject(id: string): Promise<void> {
|
|||||||
await api.delete(`/api/projects/${id}`)
|
await api.delete(`/api/projects/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMembers(projectId: string): Promise<ProjectMember[]> {
|
export async function getMembers(projectId: string, page = 1, pageSize = 20): Promise<PagedResult<ProjectMember>> {
|
||||||
const { data } = await api.get<ProjectMember[]>(`/api/projects/${projectId}/members`)
|
const { data } = await api.get<PagedResult<ProjectMember>>(`/api/projects/${projectId}/members`, { params: { page, pageSize } })
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,38 +133,75 @@ export async function removeMember(projectId: string, userId: string): Promise<v
|
|||||||
await api.delete(`/api/projects/${projectId}/members/${userId}`)
|
await api.delete(`/api/projects/${projectId}/members/${userId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUsers(q?: string) {
|
export async function updateMemberDocumentPermissions(
|
||||||
const { data } = await api.get('/api/users', { params: { q } })
|
projectId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: {
|
||||||
|
canViewDocuments: boolean
|
||||||
|
canCreateDocuments: boolean
|
||||||
|
canEditDocuments: boolean
|
||||||
|
canDeleteDocuments: boolean
|
||||||
|
},
|
||||||
|
): Promise<ProjectMember> {
|
||||||
|
const { data } = await api.put<ProjectMember>(`/api/projects/${projectId}/members/${userId}/document-permissions`, payload)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAccounts(q?: string): Promise<Account[]> {
|
export async function getProfile(): Promise<User> {
|
||||||
const { data } = await api.get<Account[]>('/api/accounts', { params: { q } })
|
const { data } = await api.get<User>('/api/auth/me')
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAccount(payload: {
|
export async function getUsers(q?: string): Promise<User[]> {
|
||||||
|
const { data } = await api.get<User[]>('/api/users/list', { params: { q } })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUsersPaged(q?: string, page = 1, pageSize = 20): Promise<PagedResult<UserListItem>> {
|
||||||
|
const { data } = await api.get<PagedResult<UserListItem>>('/api/users', { params: { q, page, pageSize } })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(payload: {
|
||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
password: string
|
password: string
|
||||||
roleId: string
|
roleId?: string
|
||||||
}): Promise<Account> {
|
}): Promise<UserListItem> {
|
||||||
const { data } = await api.post<Account>('/api/accounts', payload)
|
const { data } = await api.post<UserListItem>('/api/users', payload)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAccount(
|
export async function updateUser(
|
||||||
id: string,
|
id: string,
|
||||||
payload: { displayName: string; roleId: string; isActive: boolean },
|
payload: { displayName: string; roleId?: string; isActive: boolean },
|
||||||
): Promise<Account> {
|
): Promise<UserListItem> {
|
||||||
const { data } = await api.put<Account>(`/api/accounts/${id}`, payload)
|
const { data } = await api.put<UserListItem>(`/api/users/${id}`, payload)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAccount(id: string): Promise<void> {
|
export async function deleteUser(id: string): Promise<void> {
|
||||||
await api.delete(`/api/accounts/${id}`)
|
await api.delete(`/api/users/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetAccountPassword(id: string, newPassword: string): Promise<void> {
|
export async function resetUserPassword(id: string, newPassword: string): Promise<void> {
|
||||||
await api.post(`/api/accounts/${id}/reset-password`, { newPassword })
|
await api.post(`/api/users/${id}/reset-password`, { newPassword })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserRoles(userId: string): Promise<UserRoleDetail> {
|
||||||
|
const { data } = await api.get<UserRoleDetail>(`/api/users/${userId}/roles`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignUserRole(userId: string, roleId: string): Promise<void> {
|
||||||
|
await api.post(`/api/users/${userId}/roles`, { roleId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unassignUserRole(userId: string, roleId: string): Promise<void> {
|
||||||
|
await api.delete(`/api/users/${userId}/roles/${roleId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserPermissions(userId: string): Promise<MenuItem[]> {
|
||||||
|
const { data } = await api.get<MenuItem[]>(`/api/users/${userId}/permissions`)
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
import type { DocumentItem, DocumentNode, DocumentType, Task, TaskPriority, TaskStatus } from '../types'
|
import type { DocumentItem, DocumentNode, DocumentType, PagedResult, Task, TaskPriority, TaskStatus } from '../types'
|
||||||
|
|
||||||
export async function getDocumentTree(projectId: string): Promise<DocumentNode[]> {
|
export async function getDocumentTree(projectId: string): Promise<DocumentNode[]> {
|
||||||
const { data } = await api.get<DocumentNode[]>(`/api/projects/${projectId}/documents`)
|
const { data } = await api.get<DocumentNode[]>(`/api/projects/${projectId}/documents`)
|
||||||
@@ -44,8 +44,10 @@ export async function searchDocuments(q: string): Promise<DocumentNode[]> {
|
|||||||
export async function getTasks(
|
export async function getTasks(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
filters?: { status?: string; priority?: string; assigneeId?: string },
|
filters?: { status?: string; priority?: string; assigneeId?: string },
|
||||||
): Promise<Task[]> {
|
page = 1,
|
||||||
const { data } = await api.get<Task[]>(`/api/projects/${projectId}/tasks`, { params: filters })
|
pageSize = 20,
|
||||||
|
): Promise<PagedResult<Task>> {
|
||||||
|
const { data } = await api.get<PagedResult<Task>>(`/api/projects/${projectId}/tasks`, { params: { ...filters, page, pageSize } })
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -1,5 +1,5 @@
|
|||||||
import type { MenuItem, User } from '../types'
|
import type { MenuItem, User } from '../types'
|
||||||
import { login as apiLogin, getMenu } from '../services/backend'
|
import { login as apiLogin, getMenu, getProfile } from '../services/backend'
|
||||||
|
|
||||||
function loadUser(): User | null {
|
function loadUser(): User | null {
|
||||||
try {
|
try {
|
||||||
@@ -41,6 +41,12 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
this.menu = await getMenu()
|
this.menu = await getMenu()
|
||||||
this.menuLoaded = true
|
this.menuLoaded = true
|
||||||
},
|
},
|
||||||
|
async fetchProfile() {
|
||||||
|
if (this.token) {
|
||||||
|
this.user = await getProfile()
|
||||||
|
localStorage.setItem('mws_user', JSON.stringify(this.user))
|
||||||
|
}
|
||||||
|
},
|
||||||
async ensureMenu() {
|
async ensureMenu() {
|
||||||
if (!this.menuLoaded) await this.loadMenu()
|
if (!this.menuLoaded) await this.loadMenu()
|
||||||
},
|
},
|
||||||
|
|||||||
+221
-32
@@ -2,29 +2,166 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:where(.app-dark, .app-dark *));
|
@custom-variant dark (&:where(.app-dark, .app-dark *));
|
||||||
|
|
||||||
@layer base {
|
@theme {
|
||||||
body {
|
/* Display = heavy geometric grotesque (DESIGN.md substitute for ABC Ginto Nord) */
|
||||||
font-family: var(--font-family, Inter, 'Segoe UI', Roboto, Arial, sans-serif);
|
--font-sans: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
background: #f5f7fa;
|
--font-display: 'Hanken Grotesk', 'Inter', Helvetica, Arial, sans-serif;
|
||||||
color: #1e293b;
|
|
||||||
|
/* Blurple — brand primary, remaps every indigo-* utility */
|
||||||
|
--color-indigo-50: #eef0fe;
|
||||||
|
--color-indigo-100: #dfe3fd;
|
||||||
|
--color-indigo-200: #c3cafb;
|
||||||
|
--color-indigo-300: #9ba6f8;
|
||||||
|
--color-indigo-400: #7b88f5;
|
||||||
|
--color-indigo-500: #5865f2;
|
||||||
|
--color-indigo-600: #4551e0;
|
||||||
|
--color-indigo-700: #3742b8;
|
||||||
|
--color-indigo-800: #29328c;
|
||||||
|
--color-indigo-900: #1e2353;
|
||||||
|
--color-indigo-950: #0a0d3a;
|
||||||
|
|
||||||
|
/* Legacy purple-* aliases → Blurple, so untouched views follow the brand */
|
||||||
|
--color-purple-50: #eef0fe;
|
||||||
|
--color-purple-100: #dfe3fd;
|
||||||
|
--color-purple-200: #c3cafb;
|
||||||
|
--color-purple-300: #9ba6f8;
|
||||||
|
--color-purple-400: #7b88f5;
|
||||||
|
--color-purple-500: #5865f2;
|
||||||
|
--color-purple-600: #4551e0;
|
||||||
|
--color-purple-700: #3742b8;
|
||||||
|
--color-purple-800: #29328c;
|
||||||
|
--color-purple-900: #1e2353;
|
||||||
|
--color-purple-950: #0a0d3a;
|
||||||
|
|
||||||
|
/* Magenta — the playful counterweight (badges, folder marks, focus art) */
|
||||||
|
--color-fuchsia-400: #f26bcb;
|
||||||
|
--color-fuchsia-500: #ec48bd;
|
||||||
|
--color-fuchsia-600: #d32ba1;
|
||||||
|
|
||||||
|
/* Electric green — highest-intent only */
|
||||||
|
--color-green-50: #e8fdf1;
|
||||||
|
--color-green-100: #c7f9dd;
|
||||||
|
--color-green-200: #8df3bb;
|
||||||
|
--color-green-300: #56ef9c;
|
||||||
|
--color-green-400: #35ed7e;
|
||||||
|
--color-green-500: #1cc963;
|
||||||
|
--color-green-600: #14a151;
|
||||||
|
--color-green-700: #0f7c3f;
|
||||||
|
--color-green-800: #0b562d;
|
||||||
|
--color-green-900: #073a1f;
|
||||||
|
--color-green-950: #042313;
|
||||||
|
|
||||||
|
/* Cool indigo-tinted neutrals — remaps every slate-* utility */
|
||||||
|
--color-slate-50: #f6f7fb;
|
||||||
|
--color-slate-100: #eef0f7;
|
||||||
|
--color-slate-200: #dde0ed;
|
||||||
|
--color-slate-300: #c1c6dd;
|
||||||
|
--color-slate-400: #8e95b5;
|
||||||
|
--color-slate-500: #666d92;
|
||||||
|
--color-slate-600: #4a5177;
|
||||||
|
--color-slate-700: #2d3358;
|
||||||
|
--color-slate-800: #1e2353;
|
||||||
|
--color-slate-900: #141840;
|
||||||
|
--color-slate-950: #0a0d3a;
|
||||||
|
|
||||||
|
--shadow-subtle: rgba(69, 42, 124, 0.1) 0px 3px 34px;
|
||||||
|
--shadow-micro: rgba(20, 24, 64, 0.06) 0px 1px 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-dark body {
|
:root {
|
||||||
background: #0f172a;
|
--font-family: var(--font-sans);
|
||||||
color: #e2e8f0;
|
--ink: #141840;
|
||||||
|
--ink-muted: #666d92;
|
||||||
|
--primary: #5865f2;
|
||||||
|
--magenta: #ec48bd;
|
||||||
|
--canvas: #f6f7fb;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--hairline: #dde0ed;
|
||||||
|
--primary-soft: rgba(88, 101, 242, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dark {
|
||||||
|
--ink: #ffffff;
|
||||||
|
--ink-muted: #8e95b5;
|
||||||
|
--canvas: #0a0d3a;
|
||||||
|
--panel: #141840;
|
||||||
|
--hairline: #2d3358;
|
||||||
|
--primary-soft: rgba(123, 136, 245, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
background: var(--canvas);
|
||||||
|
color: var(--ink);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
height: 100vh;
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #3b82f6;
|
color: var(--primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CKEditor: fill its container, no fixed height */
|
||||||
|
.ck.ck-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ck.ck-editor__main {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.ck-editor__editable {
|
.ck-editor__editable {
|
||||||
min-height: 400px;
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ck.ck-editor__editable_inline {
|
||||||
|
width: 100% !important;
|
||||||
|
padding: 20px !important;
|
||||||
|
border: 1px solid var(--hairline) !important;
|
||||||
|
border-radius: 0 0 12px 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ck.ck-toolbar {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ck.ck-editor__editable_inline.ck-focused {
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No max-width here: CKEditor puts this class on the live editable too — capping
|
||||||
|
it would re-center and narrow the edit area. Read-view width lives on .document-content. */
|
||||||
|
.ck-content {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ck-content h2 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ck-content h3,
|
||||||
|
.ck-content h4 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ck-content ol,
|
.ck-content ol,
|
||||||
@@ -37,26 +174,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-dark .ck.ck-editor {
|
.app-dark .ck.ck-editor {
|
||||||
--ck-color-base-background: #0f172a;
|
--ck-color-base-background: #0a0d3a;
|
||||||
--ck-color-base-border: #334155;
|
--ck-color-base-border: #2d3358;
|
||||||
--ck-color-base-text: #e2e8f0;
|
--ck-color-base-text: #ffffff;
|
||||||
--ck-color-text: #e2e8f0;
|
--ck-color-text: #ffffff;
|
||||||
--ck-color-focus-border: #3b82f6;
|
--ck-color-focus-border: #5865f2;
|
||||||
--ck-color-toolbar-background: #1e293b;
|
--ck-color-toolbar-background: #141840;
|
||||||
--ck-color-toolbar-border: #334155;
|
--ck-color-toolbar-border: #2d3358;
|
||||||
--ck-color-dropdown-panel-background: #1e293b;
|
--ck-color-dropdown-panel-background: #141840;
|
||||||
--ck-color-panel-background: #1e293b;
|
--ck-color-panel-background: #141840;
|
||||||
--ck-color-panel-border: #334155;
|
--ck-color-panel-border: #2d3358;
|
||||||
--ck-color-button-default-hover-background: #334155;
|
--ck-color-button-default-hover-background: #2d3358;
|
||||||
--ck-color-button-default-active-background: #334155;
|
--ck-color-button-default-active-background: #2d3358;
|
||||||
--ck-color-button-on-background: #334155;
|
--ck-color-button-on-background: #2d3358;
|
||||||
--ck-color-input-background: #0f172a;
|
--ck-color-input-background: #0a0d3a;
|
||||||
--ck-color-input-border: #334155;
|
--ck-color-input-border: #2d3358;
|
||||||
--ck-color-input-text: #e2e8f0;
|
--ck-color-input-text: #ffffff;
|
||||||
--ck-color-tooltip-background: #334155;
|
--ck-color-tooltip-background: #2d3358;
|
||||||
--ck-color-tooltip-text: #e2e8f0;
|
--ck-color-tooltip-text: #ffffff;
|
||||||
--ck-color-table-border: #475569;
|
--ck-color-table-border: #4a5177;
|
||||||
--ck-color-link-default: #60a5fa;
|
--ck-color-link-default: #9ba6f8;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +203,58 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field > label {
|
.field > label {
|
||||||
@apply mb-1.5 block text-sm font-medium;
|
@apply mb-1.5 block text-[13px] font-semibold uppercase tracking-[0.06em];
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(28px, 3vw, 38px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-note {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eyebrow: small caps label above a title */
|
||||||
|
.eyebrow {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Surface card used outside PrimeVue <Card> */
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input.p-inputtext,
|
||||||
|
.search-input.p-select {
|
||||||
|
border-radius: 12px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
import { definePreset } from '@primevue/themes'
|
||||||
|
import Aura from '@primevue/themes/aura'
|
||||||
|
|
||||||
|
// Blurple — DESIGN.md brand primary (#5865f2)
|
||||||
|
const blurple = {
|
||||||
|
50: '#eef0fe',
|
||||||
|
100: '#dfe3fd',
|
||||||
|
200: '#c3cafb',
|
||||||
|
300: '#9ba6f8',
|
||||||
|
400: '#7b88f5',
|
||||||
|
500: '#5865f2',
|
||||||
|
600: '#4551e0',
|
||||||
|
700: '#3742b8',
|
||||||
|
800: '#29328c',
|
||||||
|
900: '#1e2353',
|
||||||
|
950: '#0a0d3a',
|
||||||
|
}
|
||||||
|
|
||||||
|
const green = {
|
||||||
|
50: '#e8fdf1',
|
||||||
|
100: '#c7f9dd',
|
||||||
|
200: '#8df3bb',
|
||||||
|
300: '#56ef9c',
|
||||||
|
400: '#35ed7e',
|
||||||
|
500: '#1cc963',
|
||||||
|
600: '#14a151',
|
||||||
|
700: '#0f7c3f',
|
||||||
|
800: '#0b562d',
|
||||||
|
900: '#073a1f',
|
||||||
|
950: '#042313',
|
||||||
|
}
|
||||||
|
|
||||||
|
const lightSurface = {
|
||||||
|
0: '#ffffff',
|
||||||
|
50: '#f6f7fb',
|
||||||
|
100: '#eef0f7',
|
||||||
|
200: '#dde0ed',
|
||||||
|
300: '#c1c6dd',
|
||||||
|
400: '#8e95b5',
|
||||||
|
500: '#666d92',
|
||||||
|
600: '#4a5177',
|
||||||
|
700: '#2d3358',
|
||||||
|
800: '#1e2353',
|
||||||
|
900: '#141840',
|
||||||
|
950: '#0a0d3a',
|
||||||
|
}
|
||||||
|
|
||||||
|
const darkSurface = {
|
||||||
|
0: '#ffffff',
|
||||||
|
50: '#f6f7fb',
|
||||||
|
100: '#dde0ed',
|
||||||
|
200: '#c1c6dd',
|
||||||
|
300: '#8e95b5',
|
||||||
|
400: '#666d92',
|
||||||
|
500: '#4a5177',
|
||||||
|
600: '#2d3358',
|
||||||
|
700: '#242a4d',
|
||||||
|
800: '#1e2353',
|
||||||
|
900: '#141840',
|
||||||
|
950: '#0a0d3a',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePreset(Aura, {
|
||||||
|
primitive: {
|
||||||
|
borderRadius: {
|
||||||
|
none: '0',
|
||||||
|
xs: '6px',
|
||||||
|
sm: '10px',
|
||||||
|
md: '12px',
|
||||||
|
lg: '16px',
|
||||||
|
xl: '20px',
|
||||||
|
},
|
||||||
|
blurple,
|
||||||
|
green,
|
||||||
|
},
|
||||||
|
semantic: {
|
||||||
|
primary: blurple,
|
||||||
|
success: green,
|
||||||
|
focusRing: {
|
||||||
|
width: '2px',
|
||||||
|
style: 'solid',
|
||||||
|
color: '{primary.500}',
|
||||||
|
offset: '2px',
|
||||||
|
shadow: 'none',
|
||||||
|
},
|
||||||
|
colorScheme: {
|
||||||
|
light: {
|
||||||
|
surface: lightSurface,
|
||||||
|
primary: {
|
||||||
|
color: '{primary.500}',
|
||||||
|
contrastColor: '#ffffff',
|
||||||
|
hoverColor: '{primary.600}',
|
||||||
|
activeColor: '{primary.700}',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: '{surface.900}',
|
||||||
|
hoverColor: '{surface.950}',
|
||||||
|
mutedColor: '{surface.500}',
|
||||||
|
hoverMutedColor: '{surface.600}',
|
||||||
|
},
|
||||||
|
formField: {
|
||||||
|
borderColor: '{surface.200}',
|
||||||
|
hoverBorderColor: '{surface.300}',
|
||||||
|
focusBorderColor: '{primary.500}',
|
||||||
|
color: '{surface.900}',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
background: '#ffffff',
|
||||||
|
borderColor: '{surface.200}',
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
modal: {
|
||||||
|
background: '#ffffff',
|
||||||
|
borderColor: '{surface.200}',
|
||||||
|
color: '{text.color}',
|
||||||
|
shadow: 'rgba(69, 42, 124, 0.14) 0px 12px 48px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
surface: darkSurface,
|
||||||
|
text: {
|
||||||
|
color: '#ffffff',
|
||||||
|
hoverColor: '#ffffff',
|
||||||
|
mutedColor: '{surface.300}',
|
||||||
|
hoverMutedColor: '{surface.200}',
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
color: '{primary.400}',
|
||||||
|
contrastColor: '#0a0d3a',
|
||||||
|
hoverColor: '{primary.300}',
|
||||||
|
activeColor: '{primary.200}',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
background: '#141840',
|
||||||
|
borderColor: '{surface.600}',
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
modal: {
|
||||||
|
background: '#141840',
|
||||||
|
borderColor: '{surface.600}',
|
||||||
|
color: '#ffffff',
|
||||||
|
shadow: 'rgba(0, 0, 0, 0.5) 0px 12px 48px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
button: {
|
||||||
|
root: {
|
||||||
|
borderRadius: '12px',
|
||||||
|
paddingX: '1.05rem',
|
||||||
|
label: { fontWeight: '600' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
root: {
|
||||||
|
background: '{content.background}',
|
||||||
|
borderRadius: '16px',
|
||||||
|
border: '1px solid {content.border.color}',
|
||||||
|
shadow: 'none',
|
||||||
|
},
|
||||||
|
body: { gap: '0.75rem' },
|
||||||
|
title: { fontSize: '0.95rem', fontWeight: '700' },
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
root: { borderRadius: '16px' },
|
||||||
|
header: { padding: '1.25rem 1.5rem 0.75rem' },
|
||||||
|
content: { padding: '0 1.5rem 0.5rem' },
|
||||||
|
footer: { padding: '0.75rem 1.5rem 1.25rem' },
|
||||||
|
title: { fontWeight: '700', fontSize: '1.05rem' },
|
||||||
|
},
|
||||||
|
datatable: {
|
||||||
|
header: {
|
||||||
|
background: '{content.background}',
|
||||||
|
borderColor: '{content.border.color}',
|
||||||
|
color: '{text.muted.color}',
|
||||||
|
},
|
||||||
|
headerCell: {
|
||||||
|
background: 'transparent',
|
||||||
|
borderColor: '{content.border.color}',
|
||||||
|
color: '{text.muted.color}',
|
||||||
|
fontWeight: '600',
|
||||||
|
padding: '0.7rem 1rem',
|
||||||
|
},
|
||||||
|
bodyCell: { padding: '0.8rem 1rem' },
|
||||||
|
row: { borderColor: '{content.border.color}' },
|
||||||
|
},
|
||||||
|
treetable: {
|
||||||
|
headerCell: {
|
||||||
|
background: 'transparent',
|
||||||
|
borderColor: '{content.border.color}',
|
||||||
|
color: '{text.muted.color}',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
bodyCell: { padding: '0.55rem 0.85rem' },
|
||||||
|
row: { borderColor: '{content.border.color}' },
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
root: { borderRadius: '999px', fontWeight: '600', padding: '0.2rem 0.6rem' },
|
||||||
|
},
|
||||||
|
inputtext: {
|
||||||
|
root: { borderRadius: '12px' },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
root: { borderRadius: '12px' },
|
||||||
|
},
|
||||||
|
textarea: {
|
||||||
|
root: { borderRadius: '12px' },
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
root: { borderRadius: '16px' },
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
root: { borderRadius: '14px' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
+44
-1
@@ -1,3 +1,10 @@
|
|||||||
|
export interface PagedResult<T> {
|
||||||
|
items: T[]
|
||||||
|
totalCount: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
@@ -6,7 +13,7 @@ export interface User {
|
|||||||
roleName: string
|
roleName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Account {
|
export interface UserListItem {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
@@ -51,6 +58,23 @@ export interface SaveRoleRequest {
|
|||||||
permissions: PermissionEntry[]
|
permissions: PermissionEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MasterDataItem {
|
||||||
|
id: string
|
||||||
|
group: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
sortOrder: number
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveMasterDataRequest {
|
||||||
|
group: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
sortOrder: number
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type ProjectStatus = 'Active' | 'Archived'
|
export type ProjectStatus = 'Active' | 'Archived'
|
||||||
export type MemberRole = 'Owner' | 'Member'
|
export type MemberRole = 'Owner' | 'Member'
|
||||||
export type DocumentType = 'Folder' | 'Document'
|
export type DocumentType = 'Folder' | 'Document'
|
||||||
@@ -62,7 +86,11 @@ export interface Project {
|
|||||||
name: string
|
name: string
|
||||||
description: string | null
|
description: string | null
|
||||||
status: ProjectStatus
|
status: ProjectStatus
|
||||||
|
createdBy: string
|
||||||
|
createdByName: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
updatedBy: string | null
|
||||||
|
updatedByName: string | null
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +99,10 @@ export interface ProjectMember {
|
|||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
role: MemberRole
|
role: MemberRole
|
||||||
|
canViewDocuments: boolean
|
||||||
|
canCreateDocuments: boolean
|
||||||
|
canEditDocuments: boolean
|
||||||
|
canDeleteDocuments: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectOverview {
|
export interface ProjectOverview {
|
||||||
@@ -100,7 +132,10 @@ export interface DocumentNode {
|
|||||||
parentId: string | null
|
parentId: string | null
|
||||||
title: string
|
title: string
|
||||||
type: DocumentType
|
type: DocumentType
|
||||||
|
createdAt: string
|
||||||
|
createdBy: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
updatedBy: string | null
|
||||||
children: DocumentNode[]
|
children: DocumentNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +166,14 @@ export interface Task {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserRoleDetail {
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
assignedRoles: Role[]
|
||||||
|
unassignedRoles: Role[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskCounts {
|
export interface TaskCounts {
|
||||||
[status: string]: number
|
[status: string]: number
|
||||||
}
|
}
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="mb-6">
|
|
||||||
<h1 class="m-0 text-2xl font-semibold">Dashboard</h1>
|
|
||||||
<p class="mt-1 text-slate-500 dark:text-slate-400">
|
|
||||||
Welcome back, {{ auth.user?.displayName ?? auth.user?.username }}.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-6 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
|
|
||||||
<Card class="[&_.p-card-body]:pt-3">
|
|
||||||
<template #content>
|
|
||||||
<div class="text-sm text-slate-500 dark:text-slate-400">Projects</div>
|
|
||||||
<div class="text-[1.8rem] font-bold">{{ projects.length }}</div>
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
<Card class="[&_.p-card-body]:pt-3">
|
|
||||||
<template #content>
|
|
||||||
<div class="text-sm text-slate-500 dark:text-slate-400">Active</div>
|
|
||||||
<div class="text-[1.8rem] font-bold">{{ activeCount }}</div>
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
<Card class="[&_.p-card-body]:pt-3">
|
|
||||||
<template #content>
|
|
||||||
<div class="text-sm text-slate-500 dark:text-slate-400">Archived</div>
|
|
||||||
<div class="text-[1.8rem] font-bold">{{ archivedCount }}</div>
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card class="mb-6">
|
|
||||||
<template #title>Recent Projects</template>
|
|
||||||
<template #content>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<DataTable :value="recentProjects" :loading="loading" emptyMessage="No projects yet">
|
|
||||||
<Column field="name" header="Name">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<router-link
|
|
||||||
:to="{ name: 'project-overview', params: { id: data.id } }"
|
|
||||||
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
|
|
||||||
>
|
|
||||||
{{ data.name }}
|
|
||||||
</router-link>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column field="status" header="Status" style="width: 140px">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warning' : 'success'" />
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column header="Updated" style="width: 160px">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<Button icon="pi pi-plus" label="New Project" :to="{ name: 'projects' }" />
|
|
||||||
<Button icon="pi pi-cog" label="Settings" outlined :to="{ name: 'settings' }" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { getProjects } from '../services/backend'
|
|
||||||
import { errorMessage } from '../services/api'
|
|
||||||
import { useAuthStore } from '../stores/auth'
|
|
||||||
import type { Project } from '../types'
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
const projects = ref<Project[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
const activeCount = computed(() => projects.value.filter((p) => p.status === 'Active').length)
|
|
||||||
const archivedCount = computed(() => projects.value.filter((p) => p.status === 'Archived').length)
|
|
||||||
const recentProjects = computed(() => projects.value.slice(0, 8))
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
projects.value = await getProjects()
|
|
||||||
} catch (e) {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value: string) {
|
|
||||||
return new Date(value).toLocaleDateString()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<h1 class="m-0 text-2xl font-semibold">Roles</h1>
|
|
||||||
<Button v-if="auth.can('roles', 'create')" label="New Role" icon="pi pi-plus" @click="openCreate" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<DataTable :value="roles" :loading="loading" emptyMessage="No roles" class="min-w-[480px]">
|
|
||||||
<Column field="name" header="Name" style="width: 40%" />
|
|
||||||
<Column header="Type" style="width: 20%">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<Tag :value="data.isSystem ? 'System' : 'Custom'" :severity="data.isSystem ? 'warn' : 'secondary'" />
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column header="" style="width: 40%">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<div class="flex justify-end gap-1">
|
|
||||||
<Button v-if="auth.can('roles', 'edit')" icon="pi pi-pencil" text @click="openEdit(data)" />
|
|
||||||
<Button
|
|
||||||
v-if="auth.can('roles', 'delete')"
|
|
||||||
icon="pi pi-trash"
|
|
||||||
text
|
|
||||||
severity="danger"
|
|
||||||
:disabled="data.isSystem"
|
|
||||||
@click="confirmDelete(data)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog v-model:visible="formDialog" :header="editTarget ? 'Edit Role' : 'New Role'" :modal="true" style="width: min(640px, 92vw)">
|
|
||||||
<div class="field">
|
|
||||||
<label for="role-name">Name</label>
|
|
||||||
<InputText id="role-name" v-model.trim="form.name" class="w-full" autofocus />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Permissions</label>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full min-w-[420px] border-collapse text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b border-slate-200 dark:border-slate-700">
|
|
||||||
<th class="py-2 text-left font-medium">Screen</th>
|
|
||||||
<th class="w-16 text-center font-medium">View</th>
|
|
||||||
<th class="w-16 text-center font-medium">Create</th>
|
|
||||||
<th class="w-16 text-center font-medium">Edit</th>
|
|
||||||
<th class="w-16 text-center font-medium">Delete</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="row in form.permissions" :key="row.screen" class="border-b border-slate-100 dark:border-slate-800">
|
|
||||||
<td class="py-2">{{ screenLabel(row.screen) }}</td>
|
|
||||||
<td class="text-center"><Checkbox v-model="row.canView" binary /></td>
|
|
||||||
<td class="text-center"><Checkbox v-model="row.canCreate" binary /></td>
|
|
||||||
<td class="text-center"><Checkbox v-model="row.canEdit" binary /></td>
|
|
||||||
<td class="text-center"><Checkbox v-model="row.canDelete" binary /></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<Button label="Cancel" severity="secondary" text @click="formDialog = false" />
|
|
||||||
<Button label="Save" :loading="saving" @click="onSave" />
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { getRoles, createRole, updateRole, deleteRole } from '../services/backend'
|
|
||||||
import { errorMessage } from '../services/api'
|
|
||||||
import { useAuthStore } from '../stores/auth'
|
|
||||||
import type { PermissionEntry, Role } from '../types'
|
|
||||||
|
|
||||||
const toast = useToast()
|
|
||||||
const confirm = useConfirm()
|
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
const roles = ref<Role[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
const formDialog = ref(false)
|
|
||||||
const editTarget = ref<Role | null>(null)
|
|
||||||
const form = ref<{ name: string; permissions: PermissionEntry[] }>({ name: '', permissions: [] })
|
|
||||||
|
|
||||||
function emptyPermissions(): PermissionEntry[] {
|
|
||||||
return auth.menu.map((m) => ({ screen: m.key, canView: false, canCreate: false, canEdit: false, canDelete: false }))
|
|
||||||
}
|
|
||||||
|
|
||||||
function screenLabel(key: string) {
|
|
||||||
return auth.menu.find((m) => m.key === key)?.label ?? key
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRoles() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
roles.value = await getRoles()
|
|
||||||
} catch (e) {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreate() {
|
|
||||||
editTarget.value = null
|
|
||||||
form.value = { name: '', permissions: emptyPermissions() }
|
|
||||||
formDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(role: Role) {
|
|
||||||
editTarget.value = role
|
|
||||||
const permissions = emptyPermissions().map((row) => {
|
|
||||||
const existing = role.permissions.find((p) => p.screen === row.screen)
|
|
||||||
return existing ? { ...existing } : row
|
|
||||||
})
|
|
||||||
form.value = { name: role.name, permissions }
|
|
||||||
formDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSave() {
|
|
||||||
if (!form.value.name) {
|
|
||||||
toast.add({ severity: 'warn', summary: 'Name is required', life: 3000 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
if (editTarget.value) {
|
|
||||||
await updateRole(editTarget.value.id, form.value)
|
|
||||||
} else {
|
|
||||||
await createRole(form.value)
|
|
||||||
}
|
|
||||||
formDialog.value = false
|
|
||||||
toast.add({ severity: 'success', summary: 'Role saved', life: 3000 })
|
|
||||||
await loadRoles()
|
|
||||||
} catch (e) {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDelete(role: Role) {
|
|
||||||
confirm.require({
|
|
||||||
message: `Delete role "${role.name}"?`,
|
|
||||||
header: 'Delete',
|
|
||||||
accept: async () => {
|
|
||||||
try {
|
|
||||||
await deleteRole(role.id)
|
|
||||||
toast.add({ severity: 'success', summary: 'Role deleted', life: 2000 })
|
|
||||||
await loadRoles()
|
|
||||||
} catch (e) {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadRoles)
|
|
||||||
</script>
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="max-w-xl">
|
|
||||||
<div class="mb-6">
|
|
||||||
<h1 class="m-0 text-2xl font-semibold">Settings</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card class="mb-4">
|
|
||||||
<template #title>Profile</template>
|
|
||||||
<template #content>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<Avatar :label="initials" style="background: #3b82f6; color: #fff" size="large" />
|
|
||||||
<div>
|
|
||||||
<div class="font-semibold">{{ auth.user?.displayName }}</div>
|
|
||||||
<div class="text-sm text-slate-500 dark:text-slate-400">@{{ auth.user?.username }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="mb-4">
|
|
||||||
<template #title>Appearance</template>
|
|
||||||
<template #content>
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div class="font-medium">Dark mode</div>
|
|
||||||
<div class="text-sm text-slate-500 dark:text-slate-400">Switch between light and dark theme</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
|
||||||
:label="theme.isDark.value ? 'Light' : 'Dark'"
|
|
||||||
severity="secondary"
|
|
||||||
outlined
|
|
||||||
@click="theme.toggle"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<template #title>Account</template>
|
|
||||||
<template #content>
|
|
||||||
<Button icon="pi pi-sign-out" label="Logout" severity="danger" outlined @click="onLogout" />
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useAuthStore } from '../stores/auth'
|
|
||||||
import { useTheme } from '../composables/useTheme'
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
|
||||||
const theme = useTheme()
|
|
||||||
|
|
||||||
const initials = computed(() => {
|
|
||||||
const name = auth.user?.displayName ?? auth.user?.username ?? '?'
|
|
||||||
return name.slice(0, 2).toUpperCase()
|
|
||||||
})
|
|
||||||
|
|
||||||
function onLogout() {
|
|
||||||
auth.logout()
|
|
||||||
window.location.href = '/login'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,41 +1,63 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-screen items-center justify-center bg-slate-100 p-4 dark:bg-slate-900">
|
<div class="grid min-h-dvh lg:grid-cols-[1.05fr_1fr]">
|
||||||
<Button
|
<Button
|
||||||
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
||||||
rounded
|
rounded
|
||||||
text
|
text
|
||||||
style="position: fixed; right: 1rem; top: 1rem; z-index: 10"
|
severity="secondary"
|
||||||
|
class="!fixed !right-4 !top-4 z-10"
|
||||||
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
|
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||||
@click="theme.toggle"
|
@click="theme.toggle"
|
||||||
/>
|
/>
|
||||||
<Card class="w-full max-w-[380px]">
|
|
||||||
<template #title>
|
<!-- Signature: the indigo canvas with the brand gradient mesh -->
|
||||||
<div class="flex items-center gap-2">
|
<section class="brand-panel relative hidden overflow-hidden p-12 lg:flex lg:flex-col lg:justify-between">
|
||||||
<i class="pi pi-briefcase" style="color: #3b82f6"></i>
|
<span class="font-display text-[15px] font-extrabold tracking-[-0.01em] text-white">Workspace</span>
|
||||||
MWS — My Workspace
|
<div class="relative z-10 max-w-[440px]">
|
||||||
|
<h1 class="font-display text-[clamp(40px,4.4vw,62px)] font-extrabold uppercase leading-[0.98] text-white">
|
||||||
|
Projects,<br />docs, tasks.<br />One place.
|
||||||
|
</h1>
|
||||||
|
<p class="mt-5 text-[17px] leading-relaxed text-white/70">
|
||||||
|
Write documents, track work, and keep your team in sync — without switching tools.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<div class="relative z-10 flex gap-6 text-white/60">
|
||||||
<template #content>
|
<span class="text-[13px]">Documents</span>
|
||||||
|
<span class="text-[13px]">Tasks</span>
|
||||||
|
<span class="text-[13px]">Members</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex items-center justify-center p-6" style="background: var(--canvas)">
|
||||||
|
<div class="w-full max-w-[360px]">
|
||||||
|
<div class="mb-7">
|
||||||
|
<span class="eyebrow">My Workspace</span>
|
||||||
|
<h2 class="page-title mt-2">Sign in</h2>
|
||||||
|
<p class="page-subtitle mt-1.5">Use the account your workspace owner set up for you.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="submit">
|
<form @submit.prevent="submit">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<InputText id="username" v-model.trim="username" class="w-full" autocomplete="username" />
|
<InputText id="username" v-model.trim="username" class="w-full" autocomplete="username" autofocus />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<InputText
|
<Password
|
||||||
id="password"
|
id="password"
|
||||||
v-model="password"
|
v-model="password"
|
||||||
type="password"
|
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
inputClass="w-full"
|
||||||
|
toggleMask
|
||||||
|
:feedback="false"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Message v-if="error" severity="error" variant="simple" class="mb-2 w-full">{{ error }}</Message>
|
<Message v-if="error" severity="error" variant="simple" class="mb-3 w-full">{{ error }}</Message>
|
||||||
<Button type="submit" label="Sign in" class="w-full" :loading="loading" />
|
<Button type="submit" label="Sign in" class="w-full" :loading="loading" />
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -57,7 +79,7 @@ const error = ref('')
|
|||||||
async function submit() {
|
async function submit() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
if (!username.value || !password.value) {
|
if (!username.value || !password.value) {
|
||||||
error.value = 'Username and password are required'
|
error.value = 'Enter your username and password'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -72,3 +94,13 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Blurple → magenta mesh over the deep-indigo canvas (DESIGN.md brand gradient) */
|
||||||
|
.brand-panel {
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 90% at 12% 8%, rgba(88, 101, 242, 0.85) 0%, transparent 58%),
|
||||||
|
radial-gradient(95% 85% at 88% 92%, rgba(236, 72, 189, 0.6) 0%, transparent 62%),
|
||||||
|
#0a0d3a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mb-6 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div v-for="stat in stats" :key="stat.label" class="panel px-5 py-4">
|
||||||
|
<div class="muted-note">{{ stat.label }}</div>
|
||||||
|
<div class="mt-1 font-display text-[34px] font-extrabold leading-none" style="color: var(--ink)">
|
||||||
|
{{ stat.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel mb-6 overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between border-b px-5 py-3.5" style="border-color: var(--hairline)">
|
||||||
|
<h2 class="font-display text-[15px] font-bold" style="color: var(--ink)">Recent projects</h2>
|
||||||
|
<Button label="All projects" icon="pi pi-arrow-right" iconPos="right" text size="small" @click="router.push({ name: 'projects' })" />
|
||||||
|
</div>
|
||||||
|
<DataTable :value="recentProjects" :loading="loading" class="min-w-0">
|
||||||
|
<template #empty>
|
||||||
|
<div class="grid place-items-center gap-2 px-4 py-12 text-center">
|
||||||
|
<p class="font-semibold" style="color: var(--ink)">No projects yet</p>
|
||||||
|
<p class="muted-note">Create a project to start collecting documents and tasks.</p>
|
||||||
|
<Button label="New project" icon="pi pi-plus" size="small" class="mt-1" @click="router.push({ name: 'projects' })" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<Column field="name" header="Name">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'project-overview', params: { id: data.id } }"
|
||||||
|
class="font-semibold hover:underline"
|
||||||
|
>
|
||||||
|
{{ data.name }}
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="status" header="Status" style="width: 140px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warn' : 'success'" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Updated" style="width: 150px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getProjects } from '../../services/backend'
|
||||||
|
import { errorMessage } from '../../services/api'
|
||||||
|
import type { Project } from '../../types'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const projects = ref<Project[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const stats = computed(() => [
|
||||||
|
{ label: 'Projects', value: projects.value.length },
|
||||||
|
{ label: 'Active', value: projects.value.filter((p) => p.status === 'Active').length },
|
||||||
|
{ label: 'Archived', value: projects.value.filter((p) => p.status === 'Archived').length },
|
||||||
|
])
|
||||||
|
const recentProjects = computed(() => projects.value.slice(0, 8))
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getProjects(1, 100)
|
||||||
|
projects.value = res.items
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Date(value).toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -1,60 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<Splitter v-if="isDesktop" class="h-[calc(100vh-170px)]">
|
<div class="h-full min-h-[420px]">
|
||||||
<SplitterPanel :size="30" :minSize="20">
|
|
||||||
<DocumentTreePanel
|
<DocumentTreePanel
|
||||||
:tree="tree"
|
:tree="tree"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:selected-id="selectedId"
|
:selected-id="selectedId"
|
||||||
v-model:search-term="searchTerm"
|
v-model:search-term="searchTerm"
|
||||||
:creating-label="creatingLabel"
|
:creating-label="creatingLabel"
|
||||||
:can-create="auth.can('documents', 'create')"
|
:can-create="canCreate"
|
||||||
|
:members="members"
|
||||||
@select="selectDocument"
|
@select="selectDocument"
|
||||||
@create="openCreate"
|
@create="openCreate"
|
||||||
/>
|
/>
|
||||||
</SplitterPanel>
|
|
||||||
|
|
||||||
<SplitterPanel>
|
|
||||||
<DocumentEditorPanel
|
|
||||||
:doc="doc"
|
|
||||||
:model-value="contentModel"
|
|
||||||
:save-state="saveState"
|
|
||||||
:can-edit="auth.can('documents', 'edit')"
|
|
||||||
:can-delete="auth.can('documents', 'delete')"
|
|
||||||
@update:model-value="onContentChange"
|
|
||||||
@rename="openRename"
|
|
||||||
@move="openMove"
|
|
||||||
@delete="confirmDelete"
|
|
||||||
/>
|
|
||||||
</SplitterPanel>
|
|
||||||
</Splitter>
|
|
||||||
|
|
||||||
<div v-else>
|
|
||||||
<DocumentTreePanel
|
|
||||||
v-if="!doc"
|
|
||||||
:tree="tree"
|
|
||||||
:loading="loading"
|
|
||||||
:selected-id="selectedId"
|
|
||||||
v-model:search-term="searchTerm"
|
|
||||||
:creating-label="creatingLabel"
|
|
||||||
:can-create="auth.can('documents', 'create')"
|
|
||||||
@select="selectDocument"
|
|
||||||
@create="openCreate"
|
|
||||||
/>
|
|
||||||
<DocumentEditorPanel
|
|
||||||
v-else
|
|
||||||
:doc="doc"
|
|
||||||
:model-value="contentModel"
|
|
||||||
:save-state="saveState"
|
|
||||||
:can-edit="auth.can('documents', 'edit')"
|
|
||||||
:can-delete="auth.can('documents', 'delete')"
|
|
||||||
@update:model-value="onContentChange"
|
|
||||||
@rename="openRename"
|
|
||||||
@move="openMove"
|
|
||||||
@delete="confirmDelete"
|
|
||||||
@back="closeDocument"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DocumentViewerModal
|
||||||
|
:visible="viewerVisible"
|
||||||
|
:doc="doc"
|
||||||
|
:model-value="contentModel"
|
||||||
|
:editing="editing"
|
||||||
|
:save-state="saveState"
|
||||||
|
:can-edit="canEdit"
|
||||||
|
:can-delete="canDelete"
|
||||||
|
@update:visible="onViewerClose"
|
||||||
|
@update:model-value="onContentChange"
|
||||||
|
@edit="startEdit"
|
||||||
|
@cancel-edit="cancelEdit"
|
||||||
|
@save="onSave"
|
||||||
|
@rename="openRename"
|
||||||
|
@move="openMove"
|
||||||
|
@delete="confirmDelete"
|
||||||
|
/>
|
||||||
|
|
||||||
<Dialog v-model:visible="createDialog" :header="`New ${createType}`" :modal="true" style="width: min(420px, 92vw)">
|
<Dialog v-model:visible="createDialog" :header="`New ${createType}`" :modal="true" style="width: min(420px, 92vw)">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Title</label>
|
<label>Title</label>
|
||||||
@@ -95,7 +71,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DocumentTreePanel from '../../components/DocumentTreePanel.vue'
|
import DocumentTreePanel from '../../components/DocumentTreePanel.vue'
|
||||||
import DocumentEditorPanel from '../../components/DocumentEditorPanel.vue'
|
import DocumentViewerModal from '../../components/DocumentViewerModal.vue'
|
||||||
import {
|
import {
|
||||||
getDocumentTree,
|
getDocumentTree,
|
||||||
getDocument,
|
getDocument,
|
||||||
@@ -105,9 +81,10 @@ import {
|
|||||||
deleteDocument,
|
deleteDocument,
|
||||||
searchDocuments,
|
searchDocuments,
|
||||||
} from '../../services/modules'
|
} from '../../services/modules'
|
||||||
|
import { getMembers } from '../../services/backend'
|
||||||
import { errorMessage } from '../../services/api'
|
import { errorMessage } from '../../services/api'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import type { DocumentItem, DocumentNode, DocumentType } from '../../types'
|
import type { DocumentItem, DocumentNode, DocumentType, ProjectMember } from '../../types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -121,6 +98,10 @@ const selectedId = ref<string | null>(null)
|
|||||||
const doc = ref<DocumentItem | null>(null)
|
const doc = ref<DocumentItem | null>(null)
|
||||||
const contentModel = ref('')
|
const contentModel = ref('')
|
||||||
const saveState = ref<'idle' | 'saving' | 'saved'>('idle')
|
const saveState = ref<'idle' | 'saving' | 'saved'>('idle')
|
||||||
|
const editing = ref(false)
|
||||||
|
const viewerVisible = ref(false)
|
||||||
|
|
||||||
|
const members = ref<ProjectMember[]>([])
|
||||||
|
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const searchMode = ref(false)
|
const searchMode = ref(false)
|
||||||
@@ -133,14 +114,15 @@ const renameTitle = ref('')
|
|||||||
const moveDialog = ref(false)
|
const moveDialog = ref(false)
|
||||||
const moveTarget = ref<string | null>(null)
|
const moveTarget = ref<string | null>(null)
|
||||||
|
|
||||||
const isDesktop = ref(false)
|
|
||||||
let mediaQuery: MediaQueryList | null = null
|
|
||||||
|
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
const currentMember = computed(() => members.value.find((m) => m.userId === auth.user?.id))
|
||||||
|
const canCreate = computed(() => currentMember.value?.canCreateDocuments ?? false)
|
||||||
|
const canEdit = computed(() => currentMember.value?.canEditDocuments ?? false)
|
||||||
|
const canDelete = computed(() => currentMember.value?.canDeleteDocuments ?? false)
|
||||||
|
|
||||||
const creatingLabel = computed(() =>
|
const creatingLabel = computed(() =>
|
||||||
doc.value?.type === 'Folder' ? `New items go inside: ${doc.value.title}` : 'New items are created at root',
|
doc.value?.type === 'Folder' ? `New items go inside "${doc.value.title}"` : 'New items are created at the root',
|
||||||
)
|
)
|
||||||
|
|
||||||
const folderOptions = computed(() => {
|
const folderOptions = computed(() => {
|
||||||
@@ -163,8 +145,15 @@ async function loadTree() {
|
|||||||
tree.value = searchMode.value ? await searchDocuments(searchTerm.value) : await getDocumentTree(projectId)
|
tree.value = searchMode.value ? await searchDocuments(searchTerm.value) : await getDocumentTree(projectId)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
} finally {
|
}
|
||||||
loading.value = false
|
}
|
||||||
|
|
||||||
|
async function loadMembers() {
|
||||||
|
try {
|
||||||
|
const res = await getMembers(projectId, 1, 100)
|
||||||
|
members.value = res.items
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,19 +167,55 @@ watch(searchTerm, () => {
|
|||||||
|
|
||||||
async function selectDocument(id: string) {
|
async function selectDocument(id: string) {
|
||||||
selectedId.value = id
|
selectedId.value = id
|
||||||
clearTimeout(saveTimer)
|
|
||||||
try {
|
try {
|
||||||
doc.value = await getDocument(id)
|
doc.value = await getDocument(id)
|
||||||
contentModel.value = doc.value.content ?? ''
|
contentModel.value = doc.value.content ?? ''
|
||||||
|
editing.value = false
|
||||||
saveState.value = 'idle'
|
saveState.value = 'idle'
|
||||||
|
viewerVisible.value = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDocument() {
|
function onViewerClose(value: boolean) {
|
||||||
doc.value = null
|
viewerVisible.value = value
|
||||||
selectedId.value = null
|
if (!value) {
|
||||||
|
editing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
editing.value = true
|
||||||
|
saveState.value = 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editing.value = false
|
||||||
|
contentModel.value = doc.value?.content ?? ''
|
||||||
|
saveState.value = 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContentChange(value: string) {
|
||||||
|
contentModel.value = value
|
||||||
|
saveState.value = 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
if (!doc.value) return
|
||||||
|
saveState.value = 'saving'
|
||||||
|
try {
|
||||||
|
const updated = await updateDocument(doc.value.id, { title: doc.value.title, content: contentModel.value })
|
||||||
|
doc.value = updated
|
||||||
|
contentModel.value = updated.content ?? ''
|
||||||
|
editing.value = false
|
||||||
|
saveState.value = 'saved'
|
||||||
|
toast.add({ severity: 'success', summary: 'Saved', life: 2000 })
|
||||||
|
await loadTree()
|
||||||
|
} catch (e) {
|
||||||
|
saveState.value = 'idle'
|
||||||
|
toast.add({ severity: 'error', summary: 'Save failed', detail: errorMessage(e), life: 4000 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate(type: DocumentType) {
|
function openCreate(type: DocumentType) {
|
||||||
@@ -264,11 +289,14 @@ function confirmDelete() {
|
|||||||
confirm.require({
|
confirm.require({
|
||||||
message: `Delete "${doc.value.title}"?`,
|
message: `Delete "${doc.value.title}"?`,
|
||||||
header: 'Delete',
|
header: 'Delete',
|
||||||
|
acceptProps: { severity: 'danger' },
|
||||||
|
rejectProps: { severity: 'secondary', outlined: true },
|
||||||
accept: async () => {
|
accept: async () => {
|
||||||
try {
|
try {
|
||||||
await deleteDocument(doc.value!.id)
|
await deleteDocument(doc.value!.id)
|
||||||
doc.value = null
|
doc.value = null
|
||||||
selectedId.value = null
|
selectedId.value = null
|
||||||
|
viewerVisible.value = false
|
||||||
toast.add({ severity: 'success', summary: 'Deleted', life: 2000 })
|
toast.add({ severity: 'success', summary: 'Deleted', life: 2000 })
|
||||||
await loadTree()
|
await loadTree()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -278,36 +306,10 @@ function confirmDelete() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function onContentChange(value: string) {
|
|
||||||
if (!doc.value) return
|
|
||||||
contentModel.value = value
|
|
||||||
saveState.value = 'saving'
|
|
||||||
clearTimeout(saveTimer)
|
|
||||||
saveTimer = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const updated = await updateDocument(doc.value!.id, { title: doc.value!.title, content: contentModel.value })
|
|
||||||
doc.value = updated
|
|
||||||
saveState.value = 'saved'
|
|
||||||
} catch (e) {
|
|
||||||
saveState.value = 'idle'
|
|
||||||
toast.add({ severity: 'error', summary: 'Save failed', detail: errorMessage(e), life: 5000 })
|
|
||||||
}
|
|
||||||
}, 800)
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncDesktop() {
|
|
||||||
isDesktop.value = mediaQuery?.matches ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
mediaQuery = window.matchMedia('(min-width: 1024px)')
|
|
||||||
syncDesktop()
|
|
||||||
mediaQuery.addEventListener('change', syncDesktop)
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
void loadTree()
|
Promise.all([loadTree(), loadMembers()]).finally(() => {
|
||||||
|
loading.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
mediaQuery?.removeEventListener('change', syncDesktop)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="page-title m-0">Profile</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel max-w-[480px] p-5">
|
||||||
|
<div class="flex items-center gap-3 mb-5">
|
||||||
|
<Avatar :label="initials" size="xlarge" :style="{ background: 'var(--primary)', color: '#fff' }" />
|
||||||
|
<div>
|
||||||
|
<div class="text-[15px] font-semibold" style="color: var(--ink)">
|
||||||
|
{{ auth.user?.displayName ?? auth.user?.username }}
|
||||||
|
</div>
|
||||||
|
<div class="text-[13px]" style="color: var(--ink-muted)">@{{ auth.user?.username }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="onSave">
|
||||||
|
<div class="field">
|
||||||
|
<label for="prof-username">Username</label>
|
||||||
|
<InputText id="prof-username" :model-value="auth.user?.username" disabled class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="prof-displayname">Display name</label>
|
||||||
|
<InputText id="prof-displayname" v-model.trim="displayName" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="prof-role">Role</label>
|
||||||
|
<InputText id="prof-role" :model-value="auth.user?.roleName" disabled class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="prof-status">Status</label>
|
||||||
|
<InputText id="prof-status" value="Active" disabled class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 flex justify-end">
|
||||||
|
<Button type="submit" label="Save" :loading="saving" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import { updateUser } from '../../services/backend'
|
||||||
|
import { errorMessage } from '../../services/api'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
auth.fetchProfile()
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayName = ref(auth.user?.displayName ?? '')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => auth.user?.displayName,
|
||||||
|
(val) => {
|
||||||
|
if (val !== undefined) displayName.value = val
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const initials = computed(() => {
|
||||||
|
const name = displayName.value || auth.user?.username || '?'
|
||||||
|
return name.slice(0, 2).toUpperCase()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
if (!auth.user?.id) return
|
||||||
|
if (!displayName.value) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Display name required', life: 3000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const updated = await updateUser(auth.user.id, {
|
||||||
|
displayName: displayName.value,
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
if (auth.user) {
|
||||||
|
auth.user.displayName = updated.displayName
|
||||||
|
localStorage.setItem('mws_user', JSON.stringify(auth.user))
|
||||||
|
}
|
||||||
|
toast.add({ severity: 'success', summary: 'Profile updated', life: 3000 })
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,39 +1,82 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex h-full flex-col">
|
||||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<InputText v-model.trim="userSearch" placeholder="Search users..." class="w-full sm:w-[320px]" @input="debouncedUsers" />
|
<div class="sm:w-[320px]">
|
||||||
|
<IconField>
|
||||||
|
<InputIcon class="pi pi-search" />
|
||||||
|
<InputText v-model.trim="userSearch" placeholder="Search users..." class="search-input w-full" @input="debouncedUsers" />
|
||||||
|
</IconField>
|
||||||
|
</div>
|
||||||
<Button v-if="isOwner" label="Add Member" icon="pi pi-plus" @click="addDialog = true" />
|
<Button v-if="isOwner" label="Add Member" icon="pi pi-plus" @click="addDialog = true" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<DataTable :value="members" :loading="loading" emptyMessage="No members" class="min-w-[480px]">
|
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
|
||||||
|
<AppDataTable
|
||||||
|
:value="members"
|
||||||
|
:loading="loading"
|
||||||
|
:lazy="true"
|
||||||
|
:paginator="true"
|
||||||
|
:rows="pageSize"
|
||||||
|
:totalRecords="totalCount"
|
||||||
|
:first="first"
|
||||||
|
@page="onPageChange"
|
||||||
|
emptyMessage="No members yet"
|
||||||
|
scrollable
|
||||||
|
scrollHeight="flex"
|
||||||
|
class="min-h-0 flex-1 min-w-[480px]"
|
||||||
|
>
|
||||||
<Column header="User" style="width: 50%">
|
<Column header="User" style="width: 50%">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Avatar :label="(data.displayName || data.username).slice(0, 2).toUpperCase()"
|
<Avatar :label="(data.displayName || data.username).slice(0, 2).toUpperCase()"
|
||||||
style="background: #3b82f6; color: #fff" />
|
style="background: var(--primary); color: #fff" />
|
||||||
<span>{{ data.displayName }}</span>
|
<span>{{ data.displayName }}</span>
|
||||||
<span class="text-slate-500 dark:text-slate-400">@{{ data.username }}</span>
|
<span class="muted-note">@{{ data.username }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="role" header="Role" style="width: 20%">
|
<Column field="role" header="Role" style="width: 16%">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Tag :value="data.role" :severity="data.role === 'Owner' ? 'warn' : 'secondary'" />
|
<Tag :value="data.role" :severity="data.role === 'Owner' ? 'primary' : 'secondary'" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Document Access" style="width: 34%">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div v-if="data.role === 'Owner'" class="flex flex-wrap gap-1">
|
||||||
|
<Tag value="Full Access" severity="primary" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-wrap gap-1">
|
||||||
|
<Tag v-if="data.canViewDocuments" value="View" severity="secondary" />
|
||||||
|
<Tag v-if="data.canCreateDocuments" value="Create" severity="secondary" />
|
||||||
|
<Tag v-if="data.canEditDocuments" value="Edit" severity="secondary" />
|
||||||
|
<Tag v-if="data.canDeleteDocuments" value="Delete" severity="secondary" />
|
||||||
|
<Tag v-if="!data.canViewDocuments" value="No access" severity="danger" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column header="" style="width: 10%">
|
<Column header="" style="width: 10%">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
|
<div v-if="isOwner && data.role !== 'Owner'" class="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-sliders-h"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
:aria-label="`Set document permissions for ${data.displayName}`"
|
||||||
|
@click="openPermissions(data)"
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
v-if="isOwner && data.role !== 'Owner'"
|
|
||||||
icon="pi pi-trash"
|
icon="pi pi-trash"
|
||||||
text
|
text
|
||||||
severity="danger"
|
severity="danger"
|
||||||
|
:aria-label="`Remove ${data.displayName}`"
|
||||||
@click="onRemove(data.userId)"
|
@click="onRemove(data.userId)"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</AppDataTable>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog v-model:visible="addDialog" header="Add Member" :modal="true" style="width: min(460px, 92vw)">
|
<Dialog v-model:visible="addDialog" header="Add Member" :modal="true" style="width: min(460px, 92vw)">
|
||||||
@@ -53,14 +96,40 @@
|
|||||||
<Button label="Add" :loading="adding" @click="onAdd" />
|
<Button label="Add" :loading="adding" @click="onAdd" />
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog v-model:visible="permDialog" header="Document Access" :modal="true" style="width: min(440px, 92vw)">
|
||||||
|
<div class="muted-note mb-3">
|
||||||
|
Permissions for <span class="font-medium" style="color: var(--ink)">{{ permTarget?.displayName }}</span>
|
||||||
|
@{{ permTarget?.username }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<label
|
||||||
|
v-for="opt in permOptions"
|
||||||
|
:key="opt.key"
|
||||||
|
class="flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2" style="border-color: var(--hairline)"
|
||||||
|
>
|
||||||
|
<Checkbox v-model="permForm[opt.key]" binary />
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium">{{ opt.label }}</div>
|
||||||
|
<div class="muted-note">{{ opt.hint }}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Cancel" severity="secondary" text @click="permDialog = false" />
|
||||||
|
<Button label="Save" :loading="savingPerms" @click="savePermissions" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getMembers, addMember, removeMember, getUsers } from '../../services/backend'
|
import { getMembers, addMember, removeMember, getUsers, updateMemberDocumentPermissions, getMasterDataOptions } from '../../services/backend'
|
||||||
import { errorMessage } from '../../services/api'
|
import { errorMessage } from '../../services/api'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import AppDataTable from '../../components/AppDataTable.vue'
|
||||||
import type { ProjectMember } from '../../types'
|
import type { ProjectMember } from '../../types'
|
||||||
|
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -71,17 +140,30 @@ const members = ref<ProjectMember[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const isOwner = ref(false)
|
const isOwner = ref(false)
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const first = computed(() => (page.value - 1) * pageSize.value)
|
||||||
|
|
||||||
const addDialog = ref(false)
|
const addDialog = ref(false)
|
||||||
const userSearch = ref('')
|
const userSearch = ref('')
|
||||||
const users = ref<{ label: string; value: string }[]>([])
|
const users = ref<{ label: string; value: string }[]>([])
|
||||||
const selectedUserId = ref<string | null>(null)
|
const selectedUserId = ref<string | null>(null)
|
||||||
const newRole = ref<'Owner' | 'Member'>('Member')
|
const newRole = ref<'Owner' | 'Member'>('Member')
|
||||||
const roleOptions = [
|
const roleOptions = ref<{ label: string; value: string }[]>([])
|
||||||
{ label: 'Member', value: 'Member' },
|
|
||||||
{ label: 'Owner', value: 'Owner' },
|
|
||||||
]
|
|
||||||
const adding = ref(false)
|
const adding = ref(false)
|
||||||
|
|
||||||
|
const permDialog = ref(false)
|
||||||
|
const permTarget = ref<ProjectMember | null>(null)
|
||||||
|
const permForm = reactive({ canViewDocuments: false, canCreateDocuments: false, canEditDocuments: false, canDeleteDocuments: false })
|
||||||
|
const savingPerms = ref(false)
|
||||||
|
const permOptions = [
|
||||||
|
{ key: 'canViewDocuments' as const, label: 'View', hint: 'See documents in the project' },
|
||||||
|
{ key: 'canCreateDocuments' as const, label: 'Create', hint: 'Create new documents and folders' },
|
||||||
|
{ key: 'canEditDocuments' as const, label: 'Edit', hint: 'Edit content, rename and move' },
|
||||||
|
{ key: 'canDeleteDocuments' as const, label: 'Delete', hint: 'Delete documents and folders' },
|
||||||
|
]
|
||||||
|
|
||||||
const userOptions = computed(() => users.value.filter((u) => !members.value.some((m) => m.userId === u.value)))
|
const userOptions = computed(() => users.value.filter((u) => !members.value.some((m) => m.userId === u.value)))
|
||||||
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
@@ -89,7 +171,9 @@ let timer: ReturnType<typeof setTimeout> | undefined
|
|||||||
async function loadMembers() {
|
async function loadMembers() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
members.value = await getMembers(projectId)
|
const res = await getMembers(projectId, page.value, pageSize.value)
|
||||||
|
members.value = res.items
|
||||||
|
totalCount.value = res.totalCount
|
||||||
isOwner.value = members.value.some((m) => m.userId === auth.user?.id && m.role === 'Owner')
|
isOwner.value = members.value.some((m) => m.userId === auth.user?.id && m.role === 'Owner')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
@@ -98,6 +182,12 @@ async function loadMembers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onPageChange(event: DataTablePageEvent) {
|
||||||
|
page.value = event.page + 1
|
||||||
|
pageSize.value = event.rows
|
||||||
|
void loadMembers()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
users.value = (await getUsers(userSearch.value || undefined)).map((u: { id: string; username: string; displayName: string }) => ({
|
users.value = (await getUsers(userSearch.value || undefined)).map((u: { id: string; username: string; displayName: string }) => ({
|
||||||
label: `${u.displayName} (@${u.username})`,
|
label: `${u.displayName} (@${u.username})`,
|
||||||
@@ -139,7 +229,32 @@ async function onRemove(userId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPermissions(member: ProjectMember) {
|
||||||
|
permTarget.value = member
|
||||||
|
permForm.canViewDocuments = member.canViewDocuments
|
||||||
|
permForm.canCreateDocuments = member.canCreateDocuments
|
||||||
|
permForm.canEditDocuments = member.canEditDocuments
|
||||||
|
permForm.canDeleteDocuments = member.canDeleteDocuments
|
||||||
|
permDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePermissions() {
|
||||||
|
if (!permTarget.value) return
|
||||||
|
savingPerms.value = true
|
||||||
|
try {
|
||||||
|
await updateMemberDocumentPermissions(projectId, permTarget.value.userId, { ...permForm })
|
||||||
|
permDialog.value = false
|
||||||
|
toast.add({ severity: 'success', summary: 'Permissions updated', life: 3000 })
|
||||||
|
await loadMembers()
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
} finally {
|
||||||
|
savingPerms.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
roleOptions.value = await getMasterDataOptions('member_role')
|
||||||
await loadMembers()
|
await loadMembers()
|
||||||
await loadUsers()
|
await loadUsers()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,38 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="overview">
|
<div v-if="overview">
|
||||||
<div class="mb-4 flex items-center gap-3">
|
<div class="mb-6 flex items-center gap-3">
|
||||||
<h1 class="m-0 text-2xl font-bold">{{ overview.project.name }}</h1>
|
<h1 class="page-title m-0">{{ overview.project.name }}</h1>
|
||||||
<Tag v-if="overview.project.status === 'Archived'" value="Archived" severity="warning" />
|
<Tag v-if="overview.project.status === 'Archived'" value="Archived" severity="warn" />
|
||||||
</div>
|
|
||||||
<p v-if="overview.project.description" class="-mt-3 mb-4 text-slate-500 dark:text-slate-400">{{ overview.project.description }}</p>
|
|
||||||
|
|
||||||
<div class="mb-3 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
|
|
||||||
<Card class="[&_.p-card-body]:pt-3">
|
|
||||||
<template #content>
|
|
||||||
<div class="text-sm text-slate-500 dark:text-slate-400">Members</div>
|
|
||||||
<div class="text-[1.8rem] font-bold">{{ overview.memberCount }}</div>
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
<Card class="[&_.p-card-body]:pt-3">
|
|
||||||
<template #content>
|
|
||||||
<div class="text-sm text-slate-500 dark:text-slate-400">Documents</div>
|
|
||||||
<div class="text-[1.8rem] font-bold">{{ overview.documentCount }}</div>
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
<Card v-for="(count, status) in taskCounts" :key="status" class="[&_.p-card-body]:pt-3">
|
|
||||||
<template #content>
|
|
||||||
<div class="text-sm text-slate-500 dark:text-slate-400">{{ statusLabel(status) }}</div>
|
|
||||||
<div class="text-[1.8rem] font-bold">{{ count }}</div>
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3 flex flex-col gap-3 lg:flex-row">
|
<div class="mb-4 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
|
||||||
<Card class="min-w-0 flex-1">
|
<div class="panel px-4 py-3">
|
||||||
<template #title>Recent Tasks</template>
|
<div class="muted-note mb-1">Members</div>
|
||||||
<template #content>
|
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ overview.memberCount }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel px-4 py-3">
|
||||||
|
<div class="muted-note mb-1">Documents</div>
|
||||||
|
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ overview.documentCount }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="(count, status) in taskCounts" :key="status" class="panel px-4 py-3">
|
||||||
|
<div class="muted-note mb-1">{{ statusLabel(status) }}</div>
|
||||||
|
<div class="font-display text-[28px] font-extrabold" style="color: var(--ink)">{{ count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 flex flex-col gap-3 lg:flex-row">
|
||||||
|
<div class="panel min-w-0 flex-1 overflow-hidden">
|
||||||
|
<div class="border-b px-4 py-3 font-semibold" style="border-color: var(--hairline); color: var(--ink)">Recent Tasks</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<DataTable :value="overview.recentTasks" emptyMessage="No tasks">
|
<DataTable :value="overview.recentTasks" emptyMessage="No tasks yet">
|
||||||
<Column field="title" header="Title" />
|
<Column field="title" header="Title" />
|
||||||
<Column field="status" header="Status" style="width: 120px">
|
<Column field="status" header="Status" style="width: 120px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
@@ -41,23 +33,20 @@
|
|||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
<div class="panel min-w-0 flex-1 overflow-hidden">
|
||||||
<Card class="min-w-0 flex-1">
|
<div class="border-b px-4 py-3 font-semibold" style="border-color: var(--hairline); color: var(--ink)">Recent Documents</div>
|
||||||
<template #title>Recent Documents</template>
|
|
||||||
<template #content>
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<DataTable :value="overview.recentDocuments" emptyMessage="No documents">
|
<DataTable :value="overview.recentDocuments" emptyMessage="No documents yet">
|
||||||
<Column field="title" header="Title" />
|
<Column field="title" header="Title" />
|
||||||
<Column header="Updated" style="width: 150px">
|
<Column header="Updated" style="width: 150px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
|
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
@@ -66,7 +55,7 @@
|
|||||||
v-if="auth.can('projects', 'delete') && overview.project.status !== 'Archived'"
|
v-if="auth.can('projects', 'delete') && overview.project.status !== 'Archived'"
|
||||||
icon="pi pi-archive"
|
icon="pi pi-archive"
|
||||||
label="Archive project"
|
label="Archive project"
|
||||||
severity="warning"
|
severity="warn"
|
||||||
outlined
|
outlined
|
||||||
@click="confirmArchive"
|
@click="confirmArchive"
|
||||||
/>
|
/>
|
||||||
@@ -167,6 +156,8 @@ function confirmArchive() {
|
|||||||
icon: 'pi pi-exclamation-triangle',
|
icon: 'pi pi-exclamation-triangle',
|
||||||
acceptLabel: 'Archive',
|
acceptLabel: 'Archive',
|
||||||
rejectLabel: 'Cancel',
|
rejectLabel: 'Cancel',
|
||||||
|
acceptProps: { severity: 'danger' },
|
||||||
|
rejectProps: { severity: 'secondary', outlined: true },
|
||||||
accept: async () => {
|
accept: async () => {
|
||||||
try {
|
try {
|
||||||
await deleteProject(overview.value!.project.id)
|
await deleteProject(overview.value!.project.id)
|
||||||
|
|||||||
@@ -1,47 +1,86 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex h-full flex-col">
|
||||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<header class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h1 class="m-0 text-2xl font-semibold">Projects</h1>
|
<h1 class="page-title m-0">Projects</h1>
|
||||||
<Button v-if="auth.can('projects', 'create')" label="New Project" icon="pi pi-plus" @click="createDialog = true" />
|
<Button v-if="auth.can('projects', 'create')" label="New Project" icon="pi pi-plus" @click="createDialog = true" />
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-4 sm:w-[320px]">
|
||||||
|
<IconField>
|
||||||
|
<InputIcon class="pi pi-search" />
|
||||||
<InputText
|
<InputText
|
||||||
v-model.trim="searchTerm"
|
v-model.trim="searchTerm"
|
||||||
placeholder="Search projects..."
|
placeholder="Search projects..."
|
||||||
icon="pi pi-search"
|
class="search-input w-full"
|
||||||
class="w-full sm:w-[320px]"
|
|
||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
|
</IconField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<DataTable :value="projects" :loading="loading" v-model:selection="selectedProject" selectionMode="single"
|
<AppDataTable
|
||||||
dataKey="id" @row-select="onRowSelect" emptyMessage="No projects found" class="min-w-[600px]">
|
:value="projects"
|
||||||
<Column field="name" header="Name" style="width: 30%">
|
:loading="loading"
|
||||||
|
:lazy="true"
|
||||||
|
:paginator="true"
|
||||||
|
:rows="pageSize"
|
||||||
|
:totalRecords="totalCount"
|
||||||
|
:first="first"
|
||||||
|
@page="onPageChange"
|
||||||
|
v-model:selection="selectedProject"
|
||||||
|
selectionMode="single"
|
||||||
|
dataKey="id"
|
||||||
|
@row-select="onRowSelect"
|
||||||
|
scrollable
|
||||||
|
scrollHeight="flex"
|
||||||
|
class="min-h-0 flex-1 min-w-[900px]"
|
||||||
|
>
|
||||||
|
<template #empty>
|
||||||
|
<div class="grid place-items-center gap-2 px-4 py-14 text-center">
|
||||||
|
<i class="pi pi-folder-open text-2xl" style="color: var(--ink-muted)"></i>
|
||||||
|
<p class="font-semibold" style="color: var(--ink)">No projects found</p>
|
||||||
|
<p class="muted-note">Try a different search, or create your first project.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<Column field="name" header="Name" style="width: 22%">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<i class="pi pi-folder" style="color: #3b82f6"></i>
|
<i class="pi pi-folder text-fuchsia-500"></i>
|
||||||
<span style="font-weight: 600">{{ data.name }}</span>
|
<span class="font-medium">{{ data.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="description" header="Description" style="width: 40%">
|
<Column field="description" header="Description" style="width: 22%">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<span class="text-slate-500 dark:text-slate-400">{{ data.description }}</span>
|
<span class="muted-note">{{ data.description || '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="status" header="Status" style="width: 15%">
|
<Column field="status" header="Status" style="width: 10%">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warning' : 'success'" />
|
<Tag :value="data.status" :severity="data.status === 'Archived' ? 'warn' : 'success'" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column header="Updated" style="width: 15%">
|
<Column header="Created" style="width: 12%">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
|
<span class="muted-note">{{ formatDate(data.createdAt) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
<Column header="Created by" style="width: 12%">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="muted-note">{{ data.createdByName || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Updated" style="width: 12%">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Updated by" style="width: 12%">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="muted-note">{{ data.updatedByName || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</AppDataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog v-model:visible="createDialog" header="New Project" :modal="true" style="width: min(480px, 92vw)">
|
<Dialog v-model:visible="createDialog" header="New Project" :modal="true" style="width: min(480px, 92vw)">
|
||||||
@@ -65,7 +104,9 @@
|
|||||||
import { createProject, getProjects, searchProjects } from '../../services/backend'
|
import { createProject, getProjects, searchProjects } from '../../services/backend'
|
||||||
import { errorMessage } from '../../services/api'
|
import { errorMessage } from '../../services/api'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import AppDataTable from '../../components/AppDataTable.vue'
|
||||||
import type { Project } from '../../types'
|
import type { Project } from '../../types'
|
||||||
|
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -76,6 +117,11 @@ const loading = ref(false)
|
|||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const selectedProject = ref<Project | null>(null)
|
const selectedProject = ref<Project | null>(null)
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const first = computed(() => (page.value - 1) * pageSize.value)
|
||||||
|
|
||||||
const createDialog = ref(false)
|
const createDialog = ref(false)
|
||||||
const newName = ref('')
|
const newName = ref('')
|
||||||
const newDescription = ref('')
|
const newDescription = ref('')
|
||||||
@@ -86,9 +132,14 @@ let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|||||||
async function loadProjects() {
|
async function loadProjects() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
projects.value = searchTerm.value
|
if (searchTerm.value) {
|
||||||
? await searchProjects(searchTerm.value)
|
projects.value = await searchProjects(searchTerm.value)
|
||||||
: await getProjects()
|
totalCount.value = projects.value.length
|
||||||
|
} else {
|
||||||
|
const res = await getProjects(page.value, pageSize.value)
|
||||||
|
projects.value = res.items
|
||||||
|
totalCount.value = res.totalCount
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -96,9 +147,18 @@ async function loadProjects() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onPageChange(event: DataTablePageEvent) {
|
||||||
|
page.value = event.page + 1
|
||||||
|
pageSize.value = event.rows
|
||||||
|
void loadProjects()
|
||||||
|
}
|
||||||
|
|
||||||
function debouncedSearch() {
|
function debouncedSearch() {
|
||||||
clearTimeout(searchTimer)
|
clearTimeout(searchTimer)
|
||||||
searchTimer = setTimeout(loadProjects, 300)
|
searchTimer = setTimeout(() => {
|
||||||
|
page.value = 1
|
||||||
|
void loadProjects()
|
||||||
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRowSelect() {
|
function onRowSelect() {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<Tabs value="masterdata" class="flex min-h-0 flex-1 flex-col">
|
||||||
|
<TabList>
|
||||||
|
<Tab v-if="auth.canView('masterdata')" value="masterdata">Master Data</Tab>
|
||||||
|
<Tab v-if="auth.canView('permissions')" value="roles">Role</Tab>
|
||||||
|
<Tab v-if="auth.canView('permissions')" value="permissions">Permission</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanels class="min-h-0 flex-1">
|
||||||
|
<TabPanel value="masterdata" class="flex h-full min-h-0 flex-col">
|
||||||
|
<MasterDataView v-if="auth.canView('masterdata')" />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value="roles" class="flex h-full min-h-0 flex-col">
|
||||||
|
<RolesView v-if="auth.canView('permissions')" />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value="permissions" class="flex h-full min-h-0 flex-col">
|
||||||
|
<PermissionsView v-if="auth.canView('permissions')" />
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import MasterDataView from './masterdata/MasterDataView.vue'
|
||||||
|
import RolesView from './roles/RolesView.vue'
|
||||||
|
import PermissionsView from './permissions/PermissionsView.vue'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<h1 class="page-title m-0">Master Data</h1>
|
||||||
|
<Button v-if="auth.can('masterdata', 'create')" label="New Entry" icon="pi pi-plus" @click="openCreate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
|
||||||
|
<AppDataTable
|
||||||
|
:value="entries"
|
||||||
|
:loading="loading"
|
||||||
|
:lazy="true"
|
||||||
|
:paginator="true"
|
||||||
|
:rows="pageSize"
|
||||||
|
:totalRecords="totalCount"
|
||||||
|
:first="first"
|
||||||
|
@page="onPageChange"
|
||||||
|
emptyMessage="No master data yet"
|
||||||
|
class="min-h-0 flex-1 min-w-[640px]"
|
||||||
|
>
|
||||||
|
<Column field="group" header="Group" style="width: 20%" />
|
||||||
|
<Column field="label" header="Label" style="width: 25%" />
|
||||||
|
<Column field="value" header="Value" style="width: 20%" />
|
||||||
|
<Column field="sortOrder" header="Sort" style="width: 10%" />
|
||||||
|
<Column header="Status" style="width: 15%">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<ToggleSwitch
|
||||||
|
v-model="data.isActive"
|
||||||
|
:disabled="!auth.can('masterdata', 'edit')"
|
||||||
|
:aria-label="data.isActive ? 'Deactivate entry' : 'Activate entry'"
|
||||||
|
@change="onToggleActive(data)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="" style="width: 10%">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex justify-end gap-1">
|
||||||
|
<Button v-if="auth.can('masterdata', 'edit')" icon="pi pi-pencil" text @click="openEdit(data)" />
|
||||||
|
<Button
|
||||||
|
v-if="auth.can('masterdata', 'delete')"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
text
|
||||||
|
severity="danger"
|
||||||
|
@click="confirmDelete(data)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</AppDataTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog v-model:visible="formDialog" :header="editTarget ? 'Edit Entry' : 'New Entry'" :modal="true" style="width: min(480px, 92vw)">
|
||||||
|
<div class="field">
|
||||||
|
<label for="md-group">Group</label>
|
||||||
|
<InputText id="md-group" v-model.trim="form.group" class="w-full" autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="md-label">Label</label>
|
||||||
|
<InputText id="md-label" v-model.trim="form.label" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="md-value">Value</label>
|
||||||
|
<InputText id="md-value" v-model.trim="form.value" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="md-sort">Sort order</label>
|
||||||
|
<InputNumber id="md-sort" v-model="form.sortOrder" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field flex items-center gap-2">
|
||||||
|
<ToggleSwitch v-model="form.isActive" inputId="md-active" />
|
||||||
|
<label for="md-active">Active</label>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Cancel" severity="secondary" text @click="formDialog = false" />
|
||||||
|
<Button label="Save" :loading="saving" @click="onSave" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getMasterDataList, createMasterData, updateMasterData, deleteMasterData } from '../../../services/backend'
|
||||||
|
import { errorMessage } from '../../../services/api'
|
||||||
|
import { useAuthStore } from '../../../stores/auth'
|
||||||
|
import AppDataTable from '../../../components/AppDataTable.vue'
|
||||||
|
import type { MasterDataItem, SaveMasterDataRequest } from '../../../types'
|
||||||
|
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const entries = ref<MasterDataItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const first = computed(() => (page.value - 1) * pageSize.value)
|
||||||
|
|
||||||
|
const formDialog = ref(false)
|
||||||
|
const editTarget = ref<MasterDataItem | null>(null)
|
||||||
|
const form = ref<SaveMasterDataRequest>({ group: '', label: '', value: '', sortOrder: 0, isActive: true })
|
||||||
|
|
||||||
|
async function loadEntries() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getMasterDataList(undefined, page.value, pageSize.value)
|
||||||
|
entries.value = res.items
|
||||||
|
totalCount.value = res.totalCount
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange(event: DataTablePageEvent) {
|
||||||
|
page.value = event.page + 1
|
||||||
|
pageSize.value = event.rows
|
||||||
|
void loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editTarget.value = null
|
||||||
|
form.value = { group: '', label: '', value: '', sortOrder: 0, isActive: true }
|
||||||
|
formDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(entry: MasterDataItem) {
|
||||||
|
editTarget.value = entry
|
||||||
|
form.value = { group: entry.group, label: entry.label, value: entry.value, sortOrder: entry.sortOrder, isActive: entry.isActive }
|
||||||
|
formDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggleActive(entry: MasterDataItem) {
|
||||||
|
const prev = entry.isActive
|
||||||
|
try {
|
||||||
|
await updateMasterData(entry.id, {
|
||||||
|
group: entry.group,
|
||||||
|
label: entry.label,
|
||||||
|
value: entry.value,
|
||||||
|
sortOrder: entry.sortOrder,
|
||||||
|
isActive: entry.isActive,
|
||||||
|
})
|
||||||
|
toast.add({ severity: 'success', summary: entry.isActive ? 'Entry activated' : 'Entry deactivated', life: 2000 })
|
||||||
|
} catch (e) {
|
||||||
|
entry.isActive = prev
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
if (!form.value.group || !form.value.label || !form.value.value) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Group, label and value are required', life: 3000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editTarget.value) {
|
||||||
|
await updateMasterData(editTarget.value.id, form.value)
|
||||||
|
} else {
|
||||||
|
await createMasterData(form.value)
|
||||||
|
}
|
||||||
|
formDialog.value = false
|
||||||
|
toast.add({ severity: 'success', summary: 'Entry saved', life: 3000 })
|
||||||
|
await loadEntries()
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(entry: MasterDataItem) {
|
||||||
|
confirm.require({
|
||||||
|
message: `Delete entry "${entry.label}"?`,
|
||||||
|
header: 'Delete',
|
||||||
|
acceptProps: { severity: 'danger' },
|
||||||
|
rejectProps: { severity: 'secondary', outlined: true },
|
||||||
|
accept: async () => {
|
||||||
|
try {
|
||||||
|
await deleteMasterData(entry.id)
|
||||||
|
toast.add({ severity: 'success', summary: 'Entry deleted', life: 2000 })
|
||||||
|
await loadEntries()
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadEntries)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-12">
|
||||||
|
<!-- Left: Role List -->
|
||||||
|
<div class="panel flex flex-col p-4 md:col-span-4" style="height: 620px;">
|
||||||
|
<div class="mb-3">
|
||||||
|
<IconField>
|
||||||
|
<InputIcon class="pi pi-search text-xs" />
|
||||||
|
<InputText
|
||||||
|
v-model.trim="roleSearchTerm"
|
||||||
|
placeholder="Search roles..."
|
||||||
|
class="search-input w-full text-sm"
|
||||||
|
/>
|
||||||
|
</IconField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingRoles" class="flex flex-1 items-center justify-center">
|
||||||
|
<ProgressSpinner style="width: 32px; height: 32px;" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="filteredRoles.length === 0" class="muted-note p-4 text-center">
|
||||||
|
No roles found
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex-1 overflow-y-auto pr-1 space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="r in filteredRoles"
|
||||||
|
:key="r.id"
|
||||||
|
class="flex cursor-pointer items-center justify-between rounded-xl p-3 transition-colors"
|
||||||
|
:class="selectedRoleId === r.id
|
||||||
|
? 'bg-indigo-500/10 text-indigo-500 dark:bg-indigo-500/20 font-semibold'
|
||||||
|
: 'hover:bg-slate-100 dark:hover:bg-slate-800/60'"
|
||||||
|
@click="selectRole(r.id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span class="text-sm leading-tight">{{ r.name }}</span>
|
||||||
|
<Tag :value="r.isSystem ? 'System' : 'Custom'" :severity="r.isSystem ? 'warn' : 'secondary'" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
<i v-if="selectedRoleId === r.id" class="pi pi-chevron-right text-xs"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Role Screen Permissions Matrix -->
|
||||||
|
<div class="panel md:col-span-8 flex flex-col overflow-hidden p-4" style="height: 620px;">
|
||||||
|
<div v-if="!selectedRoleId" class="flex flex-1 items-center justify-center muted-note">
|
||||||
|
Select a role from the left list to view/edit permissions
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<div class="eyebrow">Screen Permissions ({{ selectedRole?.name }})</div>
|
||||||
|
<Button
|
||||||
|
label="Save Changes"
|
||||||
|
icon="pi pi-check"
|
||||||
|
size="small"
|
||||||
|
:loading="savingRolePermissions"
|
||||||
|
@click="handleSaveRolePermissions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="loadingRoleDetail" class="flex flex-1 items-center justify-center">
|
||||||
|
<ProgressSpinner style="width: 32px; height: 32px;" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex-1 overflow-x-auto">
|
||||||
|
<table class="w-full border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b" style="border-color: var(--hairline)">
|
||||||
|
<th class="py-2.5 text-left font-medium">Screen</th>
|
||||||
|
<th class="w-20 text-center font-medium">View</th>
|
||||||
|
<th class="w-20 text-center font-medium">Create</th>
|
||||||
|
<th class="w-20 text-center font-medium">Edit</th>
|
||||||
|
<th class="w-20 text-center font-medium">Delete</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in rolePermissions" :key="item.screen" class="border-b" style="border-color: var(--hairline)">
|
||||||
|
<td class="py-3 font-medium">{{ screenLabel(item.screen) }}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<Checkbox v-model="item.canView" :binary="true" />
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<Checkbox v-model="item.canCreate" :binary="true" />
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<Checkbox v-model="item.canEdit" :binary="true" />
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<Checkbox v-model="item.canDelete" :binary="true" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getRoles, getRole, updateRole } from '../../../services/backend'
|
||||||
|
import { errorMessage } from '../../../services/api'
|
||||||
|
import type { Role, PermissionEntry } from '../../../types'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const roles = ref<Role[]>([])
|
||||||
|
const loadingRoles = ref(false)
|
||||||
|
const roleSearchTerm = ref('')
|
||||||
|
const selectedRoleId = ref<string | null>(null)
|
||||||
|
const selectedRole = ref<Role | null>(null)
|
||||||
|
const rolePermissions = ref<PermissionEntry[]>([])
|
||||||
|
const loadingRoleDetail = ref(false)
|
||||||
|
const savingRolePermissions = ref(false)
|
||||||
|
|
||||||
|
const ALL_SCREENS = [
|
||||||
|
{ key: 'dashboard', label: 'Dashboard' },
|
||||||
|
{ key: 'projects', label: 'Projects' },
|
||||||
|
{ key: 'documents', label: 'Documents' },
|
||||||
|
{ key: 'tasks', label: 'Tasks' },
|
||||||
|
{ key: 'users', label: 'Users' },
|
||||||
|
{ key: 'permissions', label: 'Permissions' },
|
||||||
|
{ key: 'masterdata', label: 'Master Data' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const filteredRoles = computed(() => {
|
||||||
|
if (!roleSearchTerm.value) return roles.value
|
||||||
|
const term = roleSearchTerm.value.toLowerCase()
|
||||||
|
return roles.value.filter((r) => r.name.toLowerCase().includes(term))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchRoles() {
|
||||||
|
loadingRoles.value = true
|
||||||
|
try {
|
||||||
|
const res = await getRoles(1, 100)
|
||||||
|
roles.value = res.items
|
||||||
|
if (roles.value.length > 0 && !selectedRoleId.value) {
|
||||||
|
selectRole(roles.value[0].id)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
} finally {
|
||||||
|
loadingRoles.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectRole(roleId: string) {
|
||||||
|
selectedRoleId.value = roleId
|
||||||
|
loadingRoleDetail.value = true
|
||||||
|
try {
|
||||||
|
const data = await getRole(roleId)
|
||||||
|
selectedRole.value = data
|
||||||
|
rolePermissions.value = ALL_SCREENS.map((s) => {
|
||||||
|
const existing = data.permissions.find((p) => p.screen === s.key)
|
||||||
|
return {
|
||||||
|
screen: s.key,
|
||||||
|
canView: existing?.canView ?? false,
|
||||||
|
canCreate: existing?.canCreate ?? false,
|
||||||
|
canEdit: existing?.canEdit ?? false,
|
||||||
|
canDelete: existing?.canDelete ?? false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
} finally {
|
||||||
|
loadingRoleDetail.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenLabel(key: string): string {
|
||||||
|
return ALL_SCREENS.find((s) => s.key === key)?.label ?? key
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveRolePermissions() {
|
||||||
|
if (!selectedRoleId.value || !selectedRole.value) return
|
||||||
|
savingRolePermissions.value = true
|
||||||
|
try {
|
||||||
|
await updateRole(selectedRoleId.value, {
|
||||||
|
name: selectedRole.value.name,
|
||||||
|
permissions: rolePermissions.value,
|
||||||
|
})
|
||||||
|
toast.add({ severity: 'success', summary: 'Permissions saved', life: 2000 })
|
||||||
|
await fetchRoles()
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
} finally {
|
||||||
|
savingRolePermissions.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void fetchRoles()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-12">
|
||||||
|
<!-- Left: User List -->
|
||||||
|
<div class="panel flex flex-col p-4 md:col-span-4" style="height: 620px;">
|
||||||
|
<div class="mb-3">
|
||||||
|
<IconField>
|
||||||
|
<InputIcon class="pi pi-search text-xs" />
|
||||||
|
<InputText
|
||||||
|
v-model.trim="userSearchTerm"
|
||||||
|
placeholder="Search users..."
|
||||||
|
class="search-input w-full text-sm"
|
||||||
|
@input="onUserSearch"
|
||||||
|
/>
|
||||||
|
</IconField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingUsers" class="flex flex-1 items-center justify-center">
|
||||||
|
<ProgressSpinner style="width: 32px; height: 32px;" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="users.length === 0" class="muted-note p-4 text-center">
|
||||||
|
No users found
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex-1 overflow-y-auto pr-1">
|
||||||
|
<div
|
||||||
|
v-for="u in users"
|
||||||
|
:key="u.id"
|
||||||
|
class="flex cursor-pointer items-center justify-between rounded-xl p-3 transition-colors mb-1"
|
||||||
|
:class="selectedUserId === u.id
|
||||||
|
? 'bg-indigo-500/10 text-indigo-500 dark:bg-indigo-500/20 font-semibold'
|
||||||
|
: 'hover:bg-slate-100 dark:hover:bg-slate-800/60'"
|
||||||
|
@click="selectUser(u.id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<Avatar
|
||||||
|
:label="(u.displayName || u.username).slice(0, 2).toUpperCase()"
|
||||||
|
style="background: var(--primary); color: #fff"
|
||||||
|
shape="circle"
|
||||||
|
size="normal"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm leading-tight">{{ u.displayName }}</div>
|
||||||
|
<div class="muted-note text-xs">@{{ u.username }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i v-if="selectedUserId === u.id" class="pi pi-chevron-right text-xs"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Top & Bottom: Roles (Unassigned / Assigned) -->
|
||||||
|
<div class="flex flex-col gap-4 md:col-span-8">
|
||||||
|
<div v-if="!selectedUserId" class="panel flex items-center justify-center p-8 muted-note" style="height: 620px;">
|
||||||
|
Select a user from the left list to manage roles
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<!-- Top Right: Unassigned Roles -->
|
||||||
|
<div class="panel flex flex-col p-4" style="height: 300px;">
|
||||||
|
<div class="eyebrow mb-3 flex items-center gap-2">
|
||||||
|
<i class="pi pi-plus-circle text-indigo-500 text-sm"></i> Unassigned Roles
|
||||||
|
</div>
|
||||||
|
<div v-if="loadingUserRoles" class="flex flex-1 items-center justify-center">
|
||||||
|
<ProgressSpinner style="width: 32px; height: 32px;" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="unassignedRoles.length === 0" class="flex flex-1 items-center justify-center muted-note">
|
||||||
|
All available roles assigned
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex-1 overflow-y-auto space-y-2 pr-1">
|
||||||
|
<div
|
||||||
|
v-for="r in unassignedRoles"
|
||||||
|
:key="r.id"
|
||||||
|
class="flex items-center justify-between rounded-xl border p-3"
|
||||||
|
style="border-color: var(--hairline)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="font-medium text-sm">{{ r.name }}</span>
|
||||||
|
<Tag :value="r.isSystem ? 'System' : 'Custom'" :severity="r.isSystem ? 'warn' : 'secondary'" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
label="Assign"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
size="small"
|
||||||
|
:loading="assigningRoleId === r.id"
|
||||||
|
@click="handleAssignRole(r.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Right: Assigned Roles -->
|
||||||
|
<div class="panel flex flex-col p-4" style="height: 304px;">
|
||||||
|
<div class="eyebrow mb-3 flex items-center gap-2">
|
||||||
|
<i class="pi pi-check-circle text-green-500 text-sm"></i> Assigned Roles
|
||||||
|
</div>
|
||||||
|
<div v-if="loadingUserRoles" class="flex flex-1 items-center justify-center">
|
||||||
|
<ProgressSpinner style="width: 32px; height: 32px;" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="assignedRoles.length === 0" class="flex flex-1 items-center justify-center muted-note">
|
||||||
|
No roles assigned yet
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex-1 overflow-y-auto space-y-2 pr-1">
|
||||||
|
<div
|
||||||
|
v-for="r in assignedRoles"
|
||||||
|
:key="r.id"
|
||||||
|
class="flex items-center justify-between rounded-xl border p-3"
|
||||||
|
style="border-color: var(--hairline)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="font-medium text-sm">{{ r.name }}</span>
|
||||||
|
<Tag :value="r.isSystem ? 'System' : 'Custom'" :severity="r.isSystem ? 'warn' : 'secondary'" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
label="Unassign"
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
:loading="unassigningRoleId === r.id"
|
||||||
|
@click="handleUnassignRole(r.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getUsers, getUserRoles, assignUserRole, unassignUserRole } from '../../../services/backend'
|
||||||
|
import { errorMessage } from '../../../services/api'
|
||||||
|
import type { User, Role } from '../../../types'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const users = ref<User[]>([])
|
||||||
|
const loadingUsers = ref(false)
|
||||||
|
const userSearchTerm = ref('')
|
||||||
|
|
||||||
|
const selectedUserId = ref<string | null>(null)
|
||||||
|
const assignedRoles = ref<Role[]>([])
|
||||||
|
const unassignedRoles = ref<Role[]>([])
|
||||||
|
const loadingUserRoles = ref(false)
|
||||||
|
const assigningRoleId = ref<string | null>(null)
|
||||||
|
const unassigningRoleId = ref<string | null>(null)
|
||||||
|
|
||||||
|
let userSearchTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
async function fetchUsers(q?: string) {
|
||||||
|
loadingUsers.value = true
|
||||||
|
try {
|
||||||
|
users.value = await getUsers(q)
|
||||||
|
if (users.value.length > 0 && !selectedUserId.value) {
|
||||||
|
selectUser(users.value[0].id)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
} finally {
|
||||||
|
loadingUsers.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUserSearch() {
|
||||||
|
clearTimeout(userSearchTimer)
|
||||||
|
userSearchTimer = setTimeout(() => void fetchUsers(userSearchTerm.value), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectUser(userId: string) {
|
||||||
|
selectedUserId.value = userId
|
||||||
|
loadingUserRoles.value = true
|
||||||
|
try {
|
||||||
|
const data = await getUserRoles(userId)
|
||||||
|
assignedRoles.value = data.assignedRoles
|
||||||
|
unassignedRoles.value = data.unassignedRoles
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
} finally {
|
||||||
|
loadingUserRoles.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAssignRole(roleId: string) {
|
||||||
|
if (!selectedUserId.value) return
|
||||||
|
assigningRoleId.value = roleId
|
||||||
|
try {
|
||||||
|
await assignUserRole(selectedUserId.value, roleId)
|
||||||
|
toast.add({ severity: 'success', summary: 'Role assigned', life: 2000 })
|
||||||
|
await selectUser(selectedUserId.value)
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
} finally {
|
||||||
|
assigningRoleId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnassignRole(roleId: string) {
|
||||||
|
if (!selectedUserId.value) return
|
||||||
|
unassigningRoleId.value = roleId
|
||||||
|
try {
|
||||||
|
await unassignUserRole(selectedUserId.value, roleId)
|
||||||
|
toast.add({ severity: 'success', summary: 'Role unassigned', life: 2000 })
|
||||||
|
await selectUser(selectedUserId.value)
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
} finally {
|
||||||
|
unassigningRoleId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void fetchUsers()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { createTask, updateTask, deleteTask } from '../../services/modules'
|
import { createTask, updateTask, deleteTask } from '../../services/modules'
|
||||||
|
import { getMasterDataOptions } from '../../services/backend'
|
||||||
import { errorMessage } from '../../services/api'
|
import { errorMessage } from '../../services/api'
|
||||||
import type { Task, TaskPriority, TaskStatus } from '../../types'
|
import type { Task, TaskPriority, TaskStatus } from '../../types'
|
||||||
|
|
||||||
@@ -88,18 +89,17 @@ const toast = useToast()
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const savingError = ref('')
|
const savingError = ref('')
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = ref<{ label: string; value: TaskStatus }[]>([])
|
||||||
{ label: 'To Do', value: 'Todo' as TaskStatus },
|
const priorityOptions = ref<{ label: string; value: TaskPriority }[]>([])
|
||||||
{ label: 'In Progress', value: 'InProgress' as TaskStatus },
|
|
||||||
{ label: 'Done', value: 'Done' as TaskStatus },
|
|
||||||
{ label: 'Cancelled', value: 'Cancelled' as TaskStatus },
|
|
||||||
]
|
|
||||||
|
|
||||||
const priorityOptions = [
|
onMounted(async () => {
|
||||||
{ label: 'Low', value: 'Low' as TaskPriority },
|
const [status, priority] = await Promise.all([
|
||||||
{ label: 'Medium', value: 'Medium' as TaskPriority },
|
getMasterDataOptions('task_status'),
|
||||||
{ label: 'High', value: 'High' as TaskPriority },
|
getMasterDataOptions('task_priority'),
|
||||||
]
|
])
|
||||||
|
statusOptions.value = status as { label: string; value: TaskStatus }[]
|
||||||
|
priorityOptions.value = priority as { label: string; value: TaskPriority }[]
|
||||||
|
})
|
||||||
|
|
||||||
const assigneeOptions = computed(() => props.members.map((m) => ({ label: m.displayName, value: m.userId })))
|
const assigneeOptions = computed(() => props.members.map((m) => ({ label: m.displayName, value: m.userId })))
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="col in columns"
|
v-for="col in columns"
|
||||||
:key="col.status"
|
:key="col.status"
|
||||||
class="min-w-[260px] flex-1 rounded-lg bg-slate-100 p-2 dark:bg-slate-800"
|
class="min-w-[260px] flex-1 rounded-2xl p-2" style="background: var(--canvas)"
|
||||||
@dragover.prevent="dragOverStatus = col.status"
|
@dragover.prevent="dragOverStatus = col.status"
|
||||||
@dragleave="dragOverStatus = null"
|
@dragleave="dragOverStatus = null"
|
||||||
@drop.prevent="onDrop(col.status)"
|
@drop.prevent="onDrop(col.status)"
|
||||||
@@ -30,7 +30,8 @@
|
|||||||
<div
|
<div
|
||||||
v-for="task in tasksIn(col.status)"
|
v-for="task in tasksIn(col.status)"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
class="mb-2 cursor-pointer rounded-lg border border-slate-200 bg-white p-3 hover:border-blue-500 dark:border-slate-700 dark:bg-slate-900"
|
class="task-card mb-2 cursor-pointer rounded-xl border p-3"
|
||||||
|
:style="{ background: 'var(--panel)', borderColor: 'var(--hairline)' }"
|
||||||
:class="{ 'opacity-40': draggingId === task.id }"
|
:class="{ 'opacity-40': draggingId === task.id }"
|
||||||
:draggable="auth.can('tasks', 'edit')"
|
:draggable="auth.can('tasks', 'edit')"
|
||||||
@dragstart="onDragStart(task)"
|
@dragstart="onDragStart(task)"
|
||||||
@@ -38,11 +39,11 @@
|
|||||||
@click="openEdit(task)"
|
@click="openEdit(task)"
|
||||||
>
|
>
|
||||||
<div style="font-weight: 500">{{ task.title }}</div>
|
<div style="font-weight: 500">{{ task.title }}</div>
|
||||||
<div v-if="task.description" class="mt-1 truncate text-[0.8rem] text-slate-500 dark:text-slate-400">{{ task.description }}</div>
|
<div v-if="task.description" class="muted-note mt-1 truncate">{{ task.description }}</div>
|
||||||
<div class="mt-2 flex items-center gap-2 text-[0.8rem]">
|
<div class="mt-2 flex items-center gap-2 text-[0.8rem]">
|
||||||
<Tag :value="task.priority" :severity="prioritySeverity(task.priority)" />
|
<Tag :value="task.priority" :severity="prioritySeverity(task.priority)" />
|
||||||
<span class="text-slate-500 dark:text-slate-400">{{ task.assigneeName ?? 'Unassigned' }}</span>
|
<span class="muted-note">{{ task.assigneeName ?? 'Unassigned' }}</span>
|
||||||
<span v-if="task.dueDate" class="text-slate-500 dark:text-slate-400">{{ formatDate(task.dueDate) }}</span>
|
<span v-if="task.dueDate" class="muted-note">{{ formatDate(task.dueDate) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,7 +97,8 @@ function tasksIn(status: TaskStatus) {
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
tasks.value = await getTasks(projectId)
|
const res = await getTasks(projectId, undefined, 1, 100)
|
||||||
|
tasks.value = res.items
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
}
|
}
|
||||||
@@ -104,7 +106,8 @@ async function load() {
|
|||||||
|
|
||||||
async function loadMembers() {
|
async function loadMembers() {
|
||||||
try {
|
try {
|
||||||
members.value = (await getMembers(projectId)).map((m) => ({
|
const res = await getMembers(projectId, 1, 100)
|
||||||
|
members.value = res.items.map((m) => ({
|
||||||
userId: m.userId,
|
userId: m.userId,
|
||||||
displayName: m.displayName,
|
displayName: m.displayName,
|
||||||
}))
|
}))
|
||||||
@@ -182,3 +185,9 @@ onMounted(async () => {
|
|||||||
await load()
|
await load()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.task-card:hover {
|
||||||
|
border-color: var(--primary) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex h-full flex-col">
|
||||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Select
|
<Select
|
||||||
@@ -39,13 +39,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<DataTable :value="filteredTasks" :loading="loading" dataKey="id" emptyMessage="No tasks"
|
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
|
||||||
@row-click="openEdit" class="min-w-[700px]">
|
<AppDataTable
|
||||||
|
:value="tasks"
|
||||||
|
:loading="loading"
|
||||||
|
:lazy="true"
|
||||||
|
:paginator="true"
|
||||||
|
:rows="pageSize"
|
||||||
|
:totalRecords="totalCount"
|
||||||
|
:first="first"
|
||||||
|
@page="onPageChange"
|
||||||
|
dataKey="id"
|
||||||
|
emptyMessage="No tasks yet"
|
||||||
|
@row-click="openEdit"
|
||||||
|
scrollable
|
||||||
|
scrollHeight="flex"
|
||||||
|
class="min-h-0 flex-1 min-w-[700px]"
|
||||||
|
>
|
||||||
<Column field="title" header="Title" style="width: 35%">
|
<Column field="title" header="Title" style="width: 35%">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div style="font-weight: 500">{{ data.title }}</div>
|
<div style="font-weight: 500">{{ data.title }}</div>
|
||||||
<div v-if="data.description" class="max-w-[400px] truncate text-[0.8rem] text-slate-500 dark:text-slate-400">{{ data.description }}</div>
|
<div v-if="data.description" class="muted-note max-w-[400px] truncate">{{ data.description }}</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="status" header="Status" style="width: 12%">
|
<Column field="status" header="Status" style="width: 12%">
|
||||||
@@ -70,10 +85,11 @@
|
|||||||
</Column>
|
</Column>
|
||||||
<Column header="Updated" style="width: 12%">
|
<Column header="Updated" style="width: 12%">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.updatedAt) }}</span>
|
<span class="muted-note">{{ formatDate(data.updatedAt) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</AppDataTable>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TaskDetailDialog
|
<TaskDetailDialog
|
||||||
@@ -92,10 +108,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import TaskDetailDialog from './TaskDetailDialog.vue'
|
import TaskDetailDialog from './TaskDetailDialog.vue'
|
||||||
import { getTasks } from '../../services/modules'
|
import { getTasks } from '../../services/modules'
|
||||||
import { getMembers } from '../../services/backend'
|
import { getMembers, getMasterDataOptions } from '../../services/backend'
|
||||||
import { errorMessage } from '../../services/api'
|
import { errorMessage } from '../../services/api'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import AppDataTable from '../../components/AppDataTable.vue'
|
||||||
import type { Task, TaskPriority, TaskStatus } from '../../types'
|
import type { Task, TaskPriority, TaskStatus } from '../../types'
|
||||||
|
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -106,6 +124,11 @@ const tasks = ref<Task[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const members = ref<{ userId: string; displayName: string }[]>([])
|
const members = ref<{ userId: string; displayName: string }[]>([])
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const first = computed(() => (page.value - 1) * pageSize.value)
|
||||||
|
|
||||||
const filterStatus = ref<string | null>(null)
|
const filterStatus = ref<string | null>(null)
|
||||||
const filterPriority = ref<string | null>(null)
|
const filterPriority = ref<string | null>(null)
|
||||||
const filterAssignee = ref<string | null>(null)
|
const filterAssignee = ref<string | null>(null)
|
||||||
@@ -115,36 +138,24 @@ const editingTask = ref<Task | null>(null)
|
|||||||
|
|
||||||
const boardMode = computed(() => route.name === 'tasks-board')
|
const boardMode = computed(() => route.name === 'tasks-board')
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = ref<{ label: string; value: string }[]>([])
|
||||||
{ label: 'Todo', value: 'Todo' },
|
const priorityOptions = ref<{ label: string; value: string }[]>([])
|
||||||
{ label: 'In Progress', value: 'InProgress' },
|
|
||||||
{ label: 'Done', value: 'Done' },
|
|
||||||
{ label: 'Cancelled', value: 'Cancelled' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const priorityOptions = [
|
|
||||||
{ label: 'Low', value: 'Low' },
|
|
||||||
{ label: 'Medium', value: 'Medium' },
|
|
||||||
{ label: 'High', value: 'High' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const assigneeOptions = computed(() =>
|
const assigneeOptions = computed(() =>
|
||||||
members.value.map((m) => ({ label: m.displayName, value: m.userId })),
|
members.value.map((m) => ({ label: m.displayName, value: m.userId })),
|
||||||
)
|
)
|
||||||
|
|
||||||
const filteredTasks = computed(() => {
|
|
||||||
return tasks.value.filter((t) => {
|
|
||||||
if (filterStatus.value && t.status !== filterStatus.value) return false
|
|
||||||
if (filterPriority.value && t.priority !== filterPriority.value) return false
|
|
||||||
if (filterAssignee.value && t.assigneeId !== filterAssignee.value) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
tasks.value = await getTasks(projectId)
|
const filters = {
|
||||||
|
status: filterStatus.value || undefined,
|
||||||
|
priority: filterPriority.value || undefined,
|
||||||
|
assigneeId: filterAssignee.value || undefined,
|
||||||
|
}
|
||||||
|
const res = await getTasks(projectId, filters, page.value, pageSize.value)
|
||||||
|
tasks.value = res.items
|
||||||
|
totalCount.value = res.totalCount
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -152,9 +163,16 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onPageChange(event: DataTablePageEvent) {
|
||||||
|
page.value = event.page + 1
|
||||||
|
pageSize.value = event.rows
|
||||||
|
void load()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMembers() {
|
async function loadMembers() {
|
||||||
try {
|
try {
|
||||||
members.value = (await getMembers(projectId)).map((m) => ({
|
const res = await getMembers(projectId, 1, 100)
|
||||||
|
members.value = res.items.map((m) => ({
|
||||||
userId: m.userId,
|
userId: m.userId,
|
||||||
displayName: m.displayName,
|
displayName: m.displayName,
|
||||||
}))
|
}))
|
||||||
@@ -163,9 +181,9 @@ async function loadMembers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(filterStatus, load)
|
watch(filterStatus, () => { page.value = 1; void load() })
|
||||||
watch(filterPriority, load)
|
watch(filterPriority, () => { page.value = 1; void load() })
|
||||||
watch(filterAssignee, load)
|
watch(filterAssignee, () => { page.value = 1; void load() })
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editingTask.value = null
|
editingTask.value = null
|
||||||
@@ -208,6 +226,12 @@ function formatDate(v: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
const [status, priority] = await Promise.all([
|
||||||
|
getMasterDataOptions('task_status'),
|
||||||
|
getMasterDataOptions('task_priority'),
|
||||||
|
])
|
||||||
|
statusOptions.value = status
|
||||||
|
priorityOptions.value = priority
|
||||||
await loadMembers()
|
await loadMembers()
|
||||||
await load()
|
await load()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,48 +1,65 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex h-full flex-col">
|
||||||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h1 class="m-0 text-2xl font-semibold">Accounts</h1>
|
<h1 class="page-title m-0">Users</h1>
|
||||||
<Button v-if="auth.can('accounts', 'create')" label="New Account" icon="pi pi-plus" @click="openCreate" />
|
<Button v-if="auth.can('users', 'create')" label="New User" icon="pi pi-plus" @click="openCreate" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-4 sm:w-[320px]">
|
||||||
<InputText v-model.trim="searchTerm" placeholder="Search accounts..." class="w-full sm:w-[320px]" @input="debouncedSearch" />
|
<IconField>
|
||||||
|
<InputIcon class="pi pi-search" />
|
||||||
|
<InputText v-model.trim="searchTerm" placeholder="Search users..." class="search-input w-full" @input="debouncedSearch" />
|
||||||
|
</IconField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<DataTable :value="accounts" :loading="loading" emptyMessage="No accounts" class="min-w-[640px]">
|
<div class="flex min-h-0 flex-1 flex-col overflow-x-auto">
|
||||||
|
<AppDataTable
|
||||||
|
:value="users"
|
||||||
|
:loading="loading"
|
||||||
|
:lazy="true"
|
||||||
|
:paginator="true"
|
||||||
|
:rows="pageSize"
|
||||||
|
:totalRecords="totalCount"
|
||||||
|
:first="first"
|
||||||
|
@page="onPageChange"
|
||||||
|
emptyMessage="No users yet"
|
||||||
|
scrollable
|
||||||
|
scrollHeight="flex"
|
||||||
|
class="min-h-0 flex-1 min-w-[640px]"
|
||||||
|
>
|
||||||
<Column header="User" style="width: 35%">
|
<Column header="User" style="width: 35%">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Avatar :label="(data.displayName || data.username).slice(0, 2).toUpperCase()"
|
<Avatar :label="(data.displayName || data.username).slice(0, 2).toUpperCase()"
|
||||||
style="background: #3b82f6; color: #fff" />
|
style="background: var(--primary); color: #fff" />
|
||||||
<span>{{ data.displayName }}</span>
|
<span>{{ data.displayName }}</span>
|
||||||
<span class="text-slate-500 dark:text-slate-400">@{{ data.username }}</span>
|
<span class="muted-note">@{{ data.username }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="roleName" header="Role" style="width: 15%">
|
<Column field="isActive" header="Status" style="width: 25%">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Tag :value="data.roleName" severity="secondary" />
|
<ToggleSwitch
|
||||||
|
v-model="data.isActive"
|
||||||
|
:disabled="!auth.can('users', 'edit')"
|
||||||
|
:aria-label="data.isActive ? 'Deactivate user' : 'Activate user'"
|
||||||
|
@change="onToggleActive(data)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="isActive" header="Status" style="width: 15%">
|
<Column header="Created" style="width: 20%">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Tag :value="data.isActive ? 'Active' : 'Disabled'" :severity="data.isActive ? 'success' : 'danger'" />
|
<span class="muted-note">{{ formatDate(data.createdAt) }}</span>
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column header="Created" style="width: 15%">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<span class="text-slate-500 dark:text-slate-400">{{ formatDate(data.createdAt) }}</span>
|
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column header="" style="width: 20%">
|
<Column header="" style="width: 20%">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
<Button v-if="auth.can('accounts', 'edit')" icon="pi pi-key" text severity="secondary" aria-label="Reset password" @click="openReset(data)" />
|
<Button v-if="auth.can('users', 'edit')" icon="pi pi-key" text severity="secondary" aria-label="Reset password" @click="openReset(data)" />
|
||||||
<Button v-if="auth.can('accounts', 'edit')" icon="pi pi-pencil" text @click="openEdit(data)" />
|
<Button v-if="auth.can('users', 'edit')" icon="pi pi-pencil" text @click="openEdit(data)" />
|
||||||
<Button
|
<Button
|
||||||
v-if="auth.can('accounts', 'delete') && data.id !== auth.user?.id"
|
v-if="auth.can('users', 'delete') && data.id !== auth.user?.id"
|
||||||
icon="pi pi-trash"
|
icon="pi pi-trash"
|
||||||
text
|
text
|
||||||
severity="danger"
|
severity="danger"
|
||||||
@@ -51,10 +68,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</AppDataTable>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog v-model:visible="createDialog" header="New Account" :modal="true" style="width: min(460px, 92vw)">
|
<Dialog v-model:visible="createDialog" header="New User" :modal="true" style="width: min(460px, 92vw)">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="acc-username">Username</label>
|
<label for="acc-username">Username</label>
|
||||||
<InputText id="acc-username" v-model.trim="createForm.username" class="w-full" autofocus />
|
<InputText id="acc-username" v-model.trim="createForm.username" class="w-full" autofocus />
|
||||||
@@ -67,25 +85,17 @@
|
|||||||
<label for="acc-pass">Password</label>
|
<label for="acc-pass">Password</label>
|
||||||
<Password id="acc-pass" v-model="createForm.password" class="w-full" inputClass="w-full" toggleMask :feedback="false" />
|
<Password id="acc-pass" v-model="createForm.password" class="w-full" inputClass="w-full" toggleMask :feedback="false" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
|
||||||
<label for="acc-role">Role</label>
|
|
||||||
<Select id="acc-role" v-model="createForm.roleId" :options="roleOptions" optionLabel="label" optionValue="value" class="w-full" />
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button label="Cancel" severity="secondary" text @click="createDialog = false" />
|
<Button label="Cancel" severity="secondary" text @click="createDialog = false" />
|
||||||
<Button label="Create" :loading="saving" @click="onCreate" />
|
<Button label="Create" :loading="saving" @click="onCreate" />
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog v-model:visible="editDialog" header="Edit Account" :modal="true" style="width: min(460px, 92vw)">
|
<Dialog v-model:visible="editDialog" header="Edit User" :modal="true" style="width: min(460px, 92vw)">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="edit-name">Display name</label>
|
<label for="edit-name">Display name</label>
|
||||||
<InputText id="edit-name" v-model.trim="editForm.displayName" class="w-full" autofocus />
|
<InputText id="edit-name" v-model.trim="editForm.displayName" class="w-full" autofocus />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
|
||||||
<label for="edit-role">Role</label>
|
|
||||||
<Select id="edit-role" v-model="editForm.roleId" :options="roleOptions" optionLabel="label" optionValue="value" class="w-full" />
|
|
||||||
</div>
|
|
||||||
<div class="field flex items-center gap-2">
|
<div class="field flex items-center gap-2">
|
||||||
<ToggleSwitch v-model="editForm.isActive" inputId="edit-active" />
|
<ToggleSwitch v-model="editForm.isActive" inputId="edit-active" />
|
||||||
<label for="edit-active">Active</label>
|
<label for="edit-active">Active</label>
|
||||||
@@ -110,39 +120,46 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getAccounts, createAccount, updateAccount, deleteAccount, resetAccountPassword, getRoles } from '../services/backend'
|
import { getUsersPaged, createUser, updateUser, deleteUser, resetUserPassword } from '../../services/backend'
|
||||||
import { errorMessage } from '../services/api'
|
import { errorMessage } from '../../services/api'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import type { Account } from '../types'
|
import AppDataTable from '../../components/AppDataTable.vue'
|
||||||
|
import type { UserListItem } from '../../types'
|
||||||
|
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const accounts = ref<Account[]>([])
|
const users = ref<UserListItem[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
|
|
||||||
const roleOptions = ref<{ label: string; value: string }[]>([])
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const first = computed(() => (page.value - 1) * pageSize.value)
|
||||||
|
|
||||||
const createDialog = ref(false)
|
const createDialog = ref(false)
|
||||||
const createForm = ref({ username: '', displayName: '', password: '', roleId: '' })
|
const createForm = ref({ username: '', displayName: '', password: '' })
|
||||||
|
|
||||||
const editDialog = ref(false)
|
const editDialog = ref(false)
|
||||||
const editTarget = ref<Account | null>(null)
|
const editTarget = ref<UserListItem | null>(null)
|
||||||
const editForm = ref({ displayName: '', roleId: '', isActive: true })
|
const editForm = ref({ displayName: '', isActive: true })
|
||||||
|
|
||||||
const resetDialog = ref(false)
|
const resetDialog = ref(false)
|
||||||
const resetTarget = ref<Account | null>(null)
|
const resetTarget = ref<UserListItem | null>(null)
|
||||||
const resetPassword = ref('')
|
const resetPassword = ref('')
|
||||||
|
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
async function loadAccounts() {
|
async function loadUsers() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
accounts.value = await getAccounts(searchTerm.value || undefined)
|
const res = await getUsersPaged(searchTerm.value || undefined, page.value, pageSize.value)
|
||||||
|
users.value = res.items
|
||||||
|
totalCount.value = res.totalCount
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -150,21 +167,22 @@ async function loadAccounts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRoles() {
|
function onPageChange(event: DataTablePageEvent) {
|
||||||
try {
|
page.value = event.page + 1
|
||||||
roleOptions.value = (await getRoles()).map((r) => ({ label: r.name, value: r.id }))
|
pageSize.value = event.rows
|
||||||
} catch (e) {
|
void loadUsers()
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function debouncedSearch() {
|
function debouncedSearch() {
|
||||||
clearTimeout(searchTimer)
|
clearTimeout(searchTimer)
|
||||||
searchTimer = setTimeout(loadAccounts, 300)
|
searchTimer = setTimeout(() => {
|
||||||
|
page.value = 1
|
||||||
|
void loadUsers()
|
||||||
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
createForm.value = { username: '', displayName: '', password: '', roleId: roleOptions.value[0]?.value ?? '' }
|
createForm.value = { username: '', displayName: '', password: '' }
|
||||||
createDialog.value = true
|
createDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,10 +193,10 @@ async function onCreate() {
|
|||||||
}
|
}
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await createAccount(createForm.value)
|
await createUser(createForm.value)
|
||||||
createDialog.value = false
|
createDialog.value = false
|
||||||
toast.add({ severity: 'success', summary: 'Account created', life: 3000 })
|
toast.add({ severity: 'success', summary: 'User created', life: 3000 })
|
||||||
await loadAccounts()
|
await loadUsers()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -186,9 +204,9 @@ async function onCreate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(account: Account) {
|
function openEdit(user: UserListItem) {
|
||||||
editTarget.value = account
|
editTarget.value = user
|
||||||
editForm.value = { displayName: account.displayName, roleId: account.roleId, isActive: account.isActive }
|
editForm.value = { displayName: user.displayName, isActive: user.isActive }
|
||||||
editDialog.value = true
|
editDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,10 +214,10 @@ async function onEdit() {
|
|||||||
if (!editTarget.value) return
|
if (!editTarget.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await updateAccount(editTarget.value.id, editForm.value)
|
await updateUser(editTarget.value.id, editForm.value)
|
||||||
editDialog.value = false
|
editDialog.value = false
|
||||||
toast.add({ severity: 'success', summary: 'Account updated', life: 3000 })
|
toast.add({ severity: 'success', summary: 'User updated', life: 3000 })
|
||||||
await loadAccounts()
|
await loadUsers()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -207,8 +225,8 @@ async function onEdit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openReset(account: Account) {
|
function openReset(user: UserListItem) {
|
||||||
resetTarget.value = account
|
resetTarget.value = user
|
||||||
resetPassword.value = ''
|
resetPassword.value = ''
|
||||||
resetDialog.value = true
|
resetDialog.value = true
|
||||||
}
|
}
|
||||||
@@ -220,7 +238,7 @@ async function onReset() {
|
|||||||
}
|
}
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await resetAccountPassword(resetTarget.value.id, resetPassword.value)
|
await resetUserPassword(resetTarget.value.id, resetPassword.value)
|
||||||
resetDialog.value = false
|
resetDialog.value = false
|
||||||
toast.add({ severity: 'success', summary: 'Password reset', life: 3000 })
|
toast.add({ severity: 'success', summary: 'Password reset', life: 3000 })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -230,15 +248,17 @@ async function onReset() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(account: Account) {
|
function confirmDelete(user: UserListItem) {
|
||||||
confirm.require({
|
confirm.require({
|
||||||
message: `Delete account "${account.displayName}"?`,
|
message: `Delete user "${user.displayName}"?`,
|
||||||
header: 'Delete',
|
header: 'Delete',
|
||||||
|
acceptProps: { severity: 'danger' },
|
||||||
|
rejectProps: { severity: 'secondary', outlined: true },
|
||||||
accept: async () => {
|
accept: async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAccount(account.id)
|
await deleteUser(user.id)
|
||||||
toast.add({ severity: 'success', summary: 'Account deleted', life: 2000 })
|
toast.add({ severity: 'success', summary: 'User deleted', life: 2000 })
|
||||||
await loadAccounts()
|
await loadUsers()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
}
|
}
|
||||||
@@ -250,7 +270,18 @@ function formatDate(value: string) {
|
|||||||
return new Date(value).toLocaleDateString()
|
return new Date(value).toLocaleDateString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onToggleActive(user: UserListItem) {
|
||||||
|
const prev = user.isActive
|
||||||
|
try {
|
||||||
|
await updateUser(user.id, { displayName: user.displayName, isActive: user.isActive })
|
||||||
|
toast.add({ severity: 'success', summary: user.isActive ? 'User activated' : 'User disabled', life: 2000 })
|
||||||
|
} catch (e) {
|
||||||
|
user.isActive = prev
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadAccounts(), loadRoles()])
|
await loadUsers()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
Reference in New Issue
Block a user