From 4bad2cd675b843f3dce557b14ec6f2c13cec0d37 Mon Sep 17 00:00:00 2001 From: Neethika Date: Mon, 13 Apr 2026 01:13:05 -0400 Subject: [PATCH] feat(workspace): refine workspace and decision flows (ref Sentinent-AI/Sentinent#24) --- src/app/app.routes.ts | 3 +- .../decision-form/decision-form.component.css | 481 +++++++++++++++-- .../decision-form.component.html | 175 ++++-- .../decision-form/decision-form.component.ts | 335 ++++++++++-- .../decision-list.component.html | 1 - .../components/workspace/edit-workspace.css | 499 ++++++++++++++---- .../components/workspace/edit-workspace.html | 114 +++- .../components/workspace/edit-workspace.ts | 120 ++++- .../workspace-details/workspace-details.css | 321 +++++++++-- .../workspace-details/workspace-details.html | 63 ++- .../workspace-details/workspace-details.ts | 19 + src/app/services/decision.service.ts | 52 +- src/app/services/workspace.ts | 35 +- 13 files changed, 1889 insertions(+), 329 deletions(-) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 9a76a31..298b112 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -61,7 +61,8 @@ export const routes: Routes = [ }, { path: 'decisions/:decisionId/edit', - loadComponent: () => import('./components/decision-form/decision-form.component').then(m => m.DecisionFormComponent) + redirectTo: 'decisions', + pathMatch: 'full' }, { path: '', redirectTo: 'decisions', pathMatch: 'full' } ] diff --git a/src/app/components/decision-form/decision-form.component.css b/src/app/components/decision-form/decision-form.component.css index d4a3e61..19a29d9 100644 --- a/src/app/components/decision-form/decision-form.component.css +++ b/src/app/components/decision-form/decision-form.component.css @@ -1,90 +1,487 @@ -.form-container { - max-width: 600px; - margin: 8px auto 20px; - padding: 20px; - border: 1px solid #ddd; - border-radius: 12px; - background-color: #fff; +.decision-shell { + display: grid; + grid-template-columns: minmax(360px, 1.15fr) minmax(300px, 0.85fr); + gap: 18px; +} + +.decision-card, +.preview-panel { + border-radius: 18px; + border: 1px solid rgba(0, 0, 0, 0.14); + position: relative; + overflow: hidden; + animation: rise-in 0.4s ease; +} + +.decision-card { + background: rgba(255, 255, 255, 0.94); + padding: 24px; +} + +.decision-card::before, +.preview-panel::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background-image: radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.56) 1px, transparent 0); + background-size: 18px 18px; + opacity: 0.09; +} + +.decision-header, +.progress-block, +.decision-form, +.loading-state { + position: relative; + z-index: 1; +} + +.eyebrow { + margin: 0 0 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.13em; + text-transform: uppercase; + color: rgba(0, 0, 0, 0.56); } -.form-header { +.header-row { display: flex; justify-content: space-between; align-items: center; - gap: 12px; - margin-bottom: 16px; + gap: 10px; } -.form-header h2 { +.header-row h2 { margin: 0; + font-family: var(--font-display); + font-size: clamp(28px, 3.8vw, 38px); + line-height: 1.05; + letter-spacing: -0.03em; +} + +.header-copy { + margin: 10px 0 0; + color: rgba(0, 0, 0, 0.65); + line-height: 1.55; +} + +.btn-ghost { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid rgba(0, 0, 0, 0.2); + background: #fff; + color: #111; + text-decoration: none; + font-size: 13px; + font-weight: 600; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.btn-ghost:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1); +} + +.loading-state { + min-height: 260px; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + color: #222; + font-weight: 600; +} + +.spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(0, 0, 0, 0.18); + border-top-color: #000; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.progress-block { + margin-top: 14px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 12px; + background: #fff; + padding: 12px 14px; +} + +.progress-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 700; + margin-bottom: 8px; +} + +.progress-track { + height: 8px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.08); + overflow: hidden; +} + +.progress-fill { + height: 100%; + width: 0; + border-radius: inherit; + background: linear-gradient(90deg, #000000 0%, #444 100%); + transition: width 0.3s ease; +} + +.decision-form { + margin-top: 16px; + display: grid; + gap: 14px; } .form-group { - margin-bottom: 15px; + display: grid; + gap: 8px; } -.form-group label { - display: block; - margin-bottom: 5px; - font-weight: bold; +.label-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +label { + font-size: 13px; + font-weight: 700; +} + +.counter { + font-size: 11px; + color: rgba(0, 0, 0, 0.52); + font-weight: 600; } .form-control { width: 100%; - padding: 10px 11px; - border: 1px solid #ced4da; - border-radius: 8px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid rgba(0, 0, 0, 0.18); + background: #fff; + color: #111; + font-size: 14px; + line-height: 1.45; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; box-sizing: border-box; - /* Ensures padding doesn't increase width */ +} + +.form-control::placeholder { + color: rgba(0, 0, 0, 0.45); +} + +.form-control:hover { + border-color: rgba(0, 0, 0, 0.3); +} + +.form-control:focus, +.form-control.active { + outline: none; + border-color: #000; + box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.07); + transform: translateY(-1px); } .form-control.is-invalid { - border-color: #dc3545; + border-color: #b4233c; } .invalid-feedback { - color: #dc3545; + margin-top: -2px; + color: #b4233c; font-size: 12px; - margin-top: 5px; + font-weight: 600; +} + +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + border: 1px solid rgba(0, 0, 0, 0.18); + border-radius: 999px; + min-height: 30px; + padding: 0 12px; + background: #fff; + color: #111; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease; +} + +.chip-wide { + border-radius: 12px; + text-align: left; + padding: 8px 10px; +} + +.chip:hover:not(:disabled) { + background: #000; + color: #fff; + transform: translateY(-1px); +} + +.chip:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.status-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.status-chip { + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 12px; + padding: 10px; + text-align: left; + background: #fff; + color: #111; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease; +} + +.status-chip span { + display: block; + font-size: 13px; + font-weight: 700; +} + +.status-chip small { + display: block; + margin-top: 3px; + font-size: 11px; + color: rgba(0, 0, 0, 0.58); +} + +.status-chip.active { + background: #000; + color: #fff; + border-color: #000; +} + +.status-chip.active small { + color: rgba(255, 255, 255, 0.76); +} + +.status-chip:hover:not(:disabled) { + transform: translateY(-1px); +} + +.status-chip:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.error-msg { + margin: 0; + border: 1px solid rgba(176, 0, 32, 0.35); + border-radius: 10px; + background: rgba(255, 229, 234, 0.8); + color: #7f0015; + font-size: 13px; + font-weight: 600; + padding: 10px 12px; } .form-actions { display: flex; justify-content: flex-end; gap: 10px; - margin-top: 20px; + margin-top: 4px; +} + +.btn-primary, +.btn-secondary { + min-height: 40px; + border-radius: 10px; + padding: 0 14px; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; } .btn-primary { - background-color: #111; + border: none; + background: #000; color: #fff; - padding: 8px 14px; - border: 1px solid #111; - border-radius: 8px; + gap: 8px; cursor: pointer; + font-family: var(--font-display); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.28); } .btn-primary:disabled { - background-color: #888; - border-color: #888; + opacity: 0.6; cursor: not-allowed; } .btn-secondary { - background-color: #fff; + background: #fff; color: #111; - padding: 8px 14px; - border: 1px solid #cfcfcf; - border-radius: 8px; - cursor: pointer; + border: 1px solid rgba(0, 0, 0, 0.2); } -.btn-ghost { - background: #fff; - color: #111; - padding: 8px 12px; - text-decoration: none; - border-radius: 8px; - border: 1px solid #cfcfcf; +.preview-panel { + background: + radial-gradient(circle at 80% 16%, rgba(255, 255, 255, 0.12), transparent 34%), + linear-gradient(170deg, #060606 0%, #191919 100%); + color: #f7f7f7; + padding: 22px; +} + +.preview-eyebrow { + margin: 0; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.14em; + font-weight: 700; + color: rgba(255, 255, 255, 0.64); +} + +.preview-panel h3 { + margin: 14px 0 12px; + font-family: var(--font-display); + font-size: clamp(24px, 3.3vw, 34px); + line-height: 1.08; + letter-spacing: -0.03em; +} + +.preview-description { + margin: 0; + color: rgba(255, 255, 255, 0.84); + line-height: 1.6; + min-height: 74px; +} + +.preview-stats { + margin-top: 18px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.stat-tile { + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 10px; + padding: 10px 8px; + background: rgba(255, 255, 255, 0.06); +} + +.stat-label { + display: block; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: rgba(255, 255, 255, 0.65); +} + +.stat-value { + display: block; + margin-top: 5px; font-size: 13px; + font-weight: 700; +} + +.preview-stream { + margin-top: 16px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 12px; + padding: 14px; + background: rgba(255, 255, 255, 0.05); +} + +.stream-title { + margin: 0 0 10px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.11em; + font-weight: 700; + color: rgba(255, 255, 255, 0.72); +} + +.preview-stream ul { + margin: 0; + padding-left: 17px; + display: grid; + gap: 8px; + color: rgba(255, 255, 255, 0.85); + line-height: 1.45; +} + +.preview-stream li.complete { + color: #ffffff; + font-weight: 600; +} + +@media (max-width: 1024px) { + .decision-shell { + grid-template-columns: 1fr; + } + + .decision-card, + .preview-panel { + padding: 18px; + } +} + +@media (max-width: 640px) { + .status-row { + grid-template-columns: 1fr; + } + + .preview-stats { + grid-template-columns: 1fr; + } + + .form-actions { + flex-direction: column-reverse; + align-items: stretch; + } +} + +@keyframes rise-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } } diff --git a/src/app/components/decision-form/decision-form.component.html b/src/app/components/decision-form/decision-form.component.html index 19b3cac..2562c0d 100644 --- a/src/app/components/decision-form/decision-form.component.html +++ b/src/app/components/decision-form/decision-form.component.html @@ -1,44 +1,151 @@ -
-
-

{{ isEditMode ? 'Edit Decision' : 'Create New Decision' }}

- Back to Dashboard -
- -
-
- - -
- Title is required. +
+
+
+

Decision Flow

+
+

{{ isEditMode ? 'Edit Decision' : 'Create Decision' }}

+ Back
+

Capture clear rationale, status, and timing so your team can act quickly.

-
- - +
+ + Loading decision...
-
- - +
+
+ Decision Readiness + {{ completionPercent }}% +
+
+
+
- -
- - + + +
+
+ + {{ titleLength }}/{{ titleMaxLength }} +
+ +
+ +
+
+ Title is required. +
+
+ +
+
+ + {{ descriptionLength }}/{{ descriptionMaxLength }} +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ + +
+ +

{{ submitError }}

+ +
+ Cancel + +
+ +
+ +
diff --git a/src/app/components/decision-form/decision-form.component.ts b/src/app/components/decision-form/decision-form.component.ts index 8b5e875..fa1f20d 100644 --- a/src/app/components/decision-form/decision-form.component.ts +++ b/src/app/components/decision-form/decision-form.component.ts @@ -1,9 +1,10 @@ -import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { DecisionService } from '../../services/decision.service'; +import { Subject, Subscription, takeUntil, timeout } from 'rxjs'; import { Decision } from '../../models/decision.model'; +import { DecisionService } from '../../services/decision.service'; @Component({ selector: 'app-decision-form', @@ -12,94 +13,336 @@ import { Decision } from '../../models/decision.model'; templateUrl: './decision-form.component.html', styleUrls: ['./decision-form.component.css'] }) -export class DecisionFormComponent implements OnInit { +export class DecisionFormComponent implements OnInit, OnDestroy { + private static readonly LOAD_GUARD_TIMEOUT_MS = 12000; + decisionForm: FormGroup; isEditMode = false; decisionId: string | null = null; isLoading = false; + isSubmitting = false; workspaceId: string | null = null; + submitError = ''; + activeField: 'title' | 'description' | 'dueDate' | '' = ''; + readonly titleMaxLength = 90; + readonly descriptionMaxLength = 360; + readonly requestTimeoutMs = 10000; + readonly titleSuggestions = [ + 'Finalize Q2 onboarding workflow', + 'Approve incident response runbook', + 'Set customer escalation policy' + ]; + readonly descriptionSuggestions = [ + 'Align teams on the final approach and decision timeline.', + 'Capture tradeoffs, ownership, and rollout steps clearly.', + 'Document decision context for future audits and reviews.' + ]; + readonly statusOptions: Array<{ value: Decision['status']; label: string; hint: string }> = [ + { value: 'DRAFT', label: 'Draft', hint: 'Still shaping direction' }, + { value: 'OPEN', label: 'Open', hint: 'Needs decision or review' }, + { value: 'CLOSED', label: 'Closed', hint: 'Finalized and recorded' } + ]; + + private readonly destroy$ = new Subject(); + private loadSubscription?: Subscription; + private loadGuardTimeoutId?: ReturnType; constructor( - private fb: FormBuilder, - private decisionService: DecisionService, - private route: ActivatedRoute, - private router: Router + private readonly fb: FormBuilder, + private readonly decisionService: DecisionService, + private readonly route: ActivatedRoute, + private readonly router: Router ) { this.decisionForm = this.fb.group({ - title: ['', Validators.required], - description: [''], + title: ['', [Validators.required, Validators.maxLength(this.titleMaxLength)]], + description: ['', [Validators.maxLength(this.descriptionMaxLength)]], status: ['DRAFT', Validators.required], dueDate: [''] }); } - ngOnInit(): void { - this.workspaceId = this.getWorkspaceIdFromRoute(); - - // Get decisionId from current route - this.route.paramMap.subscribe(params => { - this.decisionId = params.get('decisionId'); - if (this.decisionId) { - this.isEditMode = true; - this.loadDecision(this.decisionId); - } + get titleLength(): number { + return (this.decisionForm.get('title')?.value ?? '').trim().length; + } + + get descriptionLength(): number { + return (this.decisionForm.get('description')?.value ?? '').trim().length; + } + + get trimmedTitle(): string { + return (this.decisionForm.get('title')?.value ?? '').trim(); + } + + get trimmedDescription(): string { + return (this.decisionForm.get('description')?.value ?? '').trim(); + } + + get selectedStatus(): Decision['status'] { + return (this.decisionForm.get('status')?.value ?? 'DRAFT') as Decision['status']; + } + + get dueDateText(): string { + const raw = this.decisionForm.get('dueDate')?.value; + if (!raw) { + return 'No due date set'; + } + + const parsed = new Date(raw); + if (Number.isNaN(parsed.getTime())) { + return 'No due date set'; + } + + return parsed.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric' }); } - loadDecision(id: string): void { + get completionPercent(): number { + let progress = 20; + if (this.trimmedTitle.length > 0) { + progress += 45; + } + if (this.trimmedDescription.length >= 20) { + progress += 20; + } else if (this.trimmedDescription.length > 0) { + progress += 10; + } + if (this.decisionForm.get('dueDate')?.value) { + progress += 15; + } + return Math.min(progress, 100); + } + + ngOnInit(): void { + this.workspaceId = this.resolveWorkspaceId(); if (!this.workspaceId) { - return; + this.submitError = 'Workspace context is missing. Please open this page from a workspace.'; } - this.isLoading = true; - this.decisionService.getDecision(this.workspaceId, id).subscribe(decision => { - this.isLoading = false; - if (decision) { - this.decisionForm.patchValue({ - title: decision.title, - description: decision.description, - status: decision.status, - dueDate: decision.dueDate ? new Date(decision.dueDate).toISOString().split('T')[0] : '' - }); + this.route.paramMap.pipe(takeUntil(this.destroy$)).subscribe(params => { + const nextDecisionId = params.get('decisionId'); + if (nextDecisionId) { + this.enterEditMode(nextDecisionId); } else { - this.router.navigate(['../'], { relativeTo: this.route }); + this.enterCreateMode(); } }); } + ngOnDestroy(): void { + this.clearActiveLoad(); + this.destroy$.next(); + this.destroy$.complete(); + } + + applyTitleSuggestion(title: string): void { + if (this.isSubmitting || this.isLoading) { + return; + } + this.decisionForm.patchValue({ title }); + this.submitError = ''; + } + + applyDescriptionSuggestion(description: string): void { + if (this.isSubmitting || this.isLoading) { + return; + } + this.decisionForm.patchValue({ description }); + this.submitError = ''; + } + + selectStatus(status: Decision['status']): void { + if (this.isSubmitting || this.isLoading) { + return; + } + this.decisionForm.patchValue({ status }); + } + onSubmit(): void { + this.workspaceId = this.workspaceId ?? this.resolveWorkspaceId(); if (this.decisionForm.invalid) { + this.decisionForm.markAllAsTouched(); + return; + } + if (!this.workspaceId) { + this.submitError = 'Workspace context is missing. Please reopen this page from a workspace.'; return; } - const formValue = this.decisionForm.value; + const payload = this.decisionForm.value as Partial; + this.submitError = ''; + this.isSubmitting = true; if (this.isEditMode && this.decisionId) { - if (!this.workspaceId) { - return; + this.decisionService.updateDecision(this.workspaceId, this.decisionId, payload).pipe( + timeout(this.requestTimeoutMs), + takeUntil(this.destroy$), + ).subscribe({ + next: (updated) => { + this.isSubmitting = false; + if (!updated) { + this.submitError = 'Decision not found.'; + return; + } + this.navigateToDecisionList(); + }, + error: (error) => { + this.isSubmitting = false; + this.submitError = this.resolveSubmitError(error, 'Unable to update decision. Please try again.'); + } + }); + return; + } + + this.decisionService.createDecision({ ...payload, workspaceId: this.workspaceId }).pipe( + timeout(this.requestTimeoutMs), + takeUntil(this.destroy$), + ).subscribe({ + next: () => { + this.isSubmitting = false; + this.navigateToDecisionList(); + }, + error: (error) => { + this.isSubmitting = false; + this.submitError = this.resolveSubmitError(error, 'Unable to create decision. Please try again.'); } + }); + } - this.decisionService.updateDecision(this.workspaceId, this.decisionId, formValue).subscribe(() => { - this.router.navigate(['../../'], { relativeTo: this.route }); - }); - } else { - if (!this.workspaceId) { - return; + private enterCreateMode(): void { + this.isEditMode = false; + this.decisionId = null; + this.isLoading = false; + this.submitError = ''; + this.decisionForm.reset({ + title: '', + description: '', + status: 'DRAFT', + dueDate: '' + }, { emitEvent: false }); + } + + private enterEditMode(id: string): void { + if (!/^\d+$/.test(id)) { + this.enterCreateMode(); + this.submitError = 'Invalid decision link.'; + return; + } + + this.isEditMode = true; + this.decisionId = id; + this.loadDecision(id); + } + + private loadDecision(id: string): void { + this.workspaceId = this.workspaceId ?? this.resolveWorkspaceId(); + if (!this.workspaceId) { + this.submitError = 'Workspace context is missing. Please reopen this decision from the workspace page.'; + return; + } + + this.clearActiveLoad(); + this.isLoading = true; + this.submitError = ''; + this.startLoadGuardTimer(); + + this.loadSubscription = this.decisionService.getDecision(this.workspaceId, id).pipe( + timeout(this.requestTimeoutMs), + takeUntil(this.destroy$), + ).subscribe({ + next: (decision) => { + this.clearActiveLoad(); + this.isLoading = false; + if (!decision) { + this.submitError = 'Decision not found.'; + return; + } + + this.decisionForm.patchValue({ + title: decision.title ?? '', + description: decision.description ?? '', + status: decision.status ?? 'DRAFT', + dueDate: this.toDateInputValue(decision.dueDate) + }); + }, + error: (error) => { + this.clearActiveLoad(); + this.isLoading = false; + this.submitError = this.resolveSubmitError(error, 'Unable to load decision. Please try again.'); } + }); + } - this.decisionService.createDecision({ ...formValue, workspaceId: this.workspaceId }).subscribe(() => { - this.router.navigate(['../'], { relativeTo: this.route }); - }); + private navigateToDecisionList(): void { + if (!this.workspaceId) { + return; } + void this.router.navigate(['/workspaces', this.workspaceId, 'decisions']); } - private getWorkspaceIdFromRoute(): string | null { + private resolveWorkspaceId(): string | null { for (const route of this.route.pathFromRoot) { const id = route.snapshot.paramMap.get('id'); if (id) { return id; } } + + const urlMatch = this.router.url.match(/\/workspaces\/([^/]+)/); + if (urlMatch?.[1]) { + return urlMatch[1]; + } + return null; } + + private toDateInputValue(date?: Date): string { + if (!date) { + return ''; + } + const parsed = date instanceof Date ? date : new Date(date); + if (Number.isNaN(parsed.getTime())) { + return ''; + } + return parsed.toISOString().split('T')[0]; + } + + private resolveSubmitError(error: unknown, fallback: string): string { + if (error && typeof error === 'object' && 'message' in error && typeof (error as { message?: unknown }).message === 'string') { + const message = String((error as { message: string }).message).trim(); + if (message.length > 0) { + if (message === 'Timeout has occurred') { + return 'Request timed out. Please check backend connectivity and try again.'; + } + return message; + } + } + return fallback; + } + + private startLoadGuardTimer(): void { + this.clearLoadGuardTimer(); + this.loadGuardTimeoutId = setTimeout(() => { + this.loadSubscription?.unsubscribe(); + this.loadSubscription = undefined; + this.isLoading = false; + this.submitError = 'Loading decision is taking longer than expected. Please try again.'; + }, DecisionFormComponent.LOAD_GUARD_TIMEOUT_MS); + } + + private clearLoadGuardTimer(): void { + if (this.loadGuardTimeoutId === undefined) { + return; + } + clearTimeout(this.loadGuardTimeoutId); + this.loadGuardTimeoutId = undefined; + } + + private clearActiveLoad(): void { + this.loadSubscription?.unsubscribe(); + this.loadSubscription = undefined; + this.clearLoadGuardTimer(); + } } diff --git a/src/app/components/decision-list/decision-list.component.html b/src/app/components/decision-list/decision-list.component.html index 9561bfe..72f8661 100644 --- a/src/app/components/decision-list/decision-list.component.html +++ b/src/app/components/decision-list/decision-list.component.html @@ -25,7 +25,6 @@

{{ decision.title }}

- Edit
diff --git a/src/app/components/workspace/edit-workspace.css b/src/app/components/workspace/edit-workspace.css index 6e817af..8a4012b 100644 --- a/src/app/components/workspace/edit-workspace.css +++ b/src/app/components/workspace/edit-workspace.css @@ -1,9 +1,12 @@ .page-container { min-height: 100vh; - background-color: #ffffff; + background: + radial-gradient(circle at 18% 12%, rgba(0, 0, 0, 0.04), transparent 32%), + linear-gradient(160deg, #f7f7f7 0%, #ffffff 52%, #f2f2f2 100%); display: flex; flex-direction: column; font-family: var(--font-sans); + color: #050505; } .top-nav { @@ -11,8 +14,9 @@ justify-content: space-between; align-items: center; padding: 16px 32px; - background: #ffffff; - border-bottom: 1px solid #e5e5e5; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + background: rgba(255, 255, 255, 0.84); + backdrop-filter: blur(5px); position: sticky; top: 0; z-index: 10; @@ -22,187 +26,484 @@ display: flex; align-items: center; gap: 12px; - color: #000000; } .brand-icon { - display: flex; - align-items: center; - justify-content: center; + display: grid; + place-items: center; width: 32px; height: 32px; - background: #000000; - color: #ffffff; border-radius: 8px; + color: #ffffff; + background: #000000; } .brand-name { + font-family: var(--font-display); font-size: 18px; font-weight: 700; - letter-spacing: -0.5px; + letter-spacing: -0.04em; } .back-nav-btn { - color: #000000; - text-decoration: none; - font-size: 14px; - font-weight: 500; - padding: 8px 16px; - border-radius: 6px; - transition: all 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid rgba(0, 0, 0, 0.2); + color: #0a0a0a; background: #ffffff; - border: 1px solid #e5e5e5; + text-decoration: none; + font-size: 13px; + font-weight: 600; + transition: transform 0.2s ease, box-shadow 0.2s ease; } .back-nav-btn:hover { - background: #f3f3f3; - border-color: #cccccc; + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12); } -.form-wrapper { +.workspace-shell { flex: 1; - display: flex; - align-items: flex-start; - justify-content: center; - padding: 64px 20px; + width: min(1180px, 100% - 40px); + margin: 34px auto 48px; + display: grid; + grid-template-columns: minmax(360px, 1.1fr) minmax(320px, 0.9fr); + gap: 22px; +} + +.form-card, +.preview-panel { + border-radius: 22px; + border: 1px solid rgba(0, 0, 0, 0.14); + position: relative; + overflow: hidden; + animation: rise-in 0.45s ease; } .form-card { - width: 100%; - max-width: 480px; - background: #ffffff; - border-radius: 12px; - border: 1px solid #e5e5e5; - padding: 40px; + background: rgba(255, 255, 255, 0.92); + padding: 34px; +} + +.form-card::before, +.preview-panel::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background-image: radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.6) 1px, transparent 0); + background-size: 18px 18px; + opacity: 0.08; } .form-header { - margin-bottom: 32px; - text-align: center; + position: relative; + z-index: 1; + margin-bottom: 18px; } -h2 { - margin: 0 0 8px 0; - color: #000000; - font-size: 28px; +.eyebrow { + margin: 0 0 10px; + font-size: 11px; font-weight: 700; - letter-spacing: -0.5px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: rgba(0, 0, 0, 0.58); } -.form-header p { +h1 { margin: 0; - color: #666666; - font-size: 15px; + font-family: var(--font-display); + font-size: clamp(30px, 5vw, 42px); + line-height: 1; + letter-spacing: -0.03em; } -form { +.header-copy { + margin: 12px 0 0; + color: rgba(0, 0, 0, 0.64); + line-height: 1.6; +} + +.loading-state { + position: relative; + z-index: 1; display: flex; - flex-direction: column; - gap: 24px; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 280px; + color: #222; + font-weight: 600; } -.form-group { +.spinner { + width: 20px; + height: 20px; + border: 2px solid rgba(0, 0, 0, 0.18); + border-top-color: #000000; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.progress-block { + position: relative; + z-index: 1; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 14px; + padding: 12px 14px; + background: #ffffff; + margin-bottom: 18px; +} + +.progress-meta { display: flex; - flex-direction: column; + justify-content: space-between; + align-items: center; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + margin-bottom: 8px; +} + +.progress-track { + height: 8px; + background: rgba(0, 0, 0, 0.08); + border-radius: 999px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #000000 0%, #454545 100%); + border-radius: inherit; + width: 0; + transition: width 0.3s ease; +} + +.workspace-form { + position: relative; + z-index: 1; + display: grid; + gap: 16px; +} + +.form-group { + display: grid; gap: 8px; } +.label-row { + display: flex; + justify-content: space-between; + align-items: center; +} + label { - font-size: 14px; + font-size: 13px; + font-weight: 700; +} + +.counter { + font-size: 11px; font-weight: 600; - color: #000000; + color: rgba(0, 0, 0, 0.52); } .premium-input { width: 100%; - padding: 12px 16px; - border: 1px solid #e5e5e5; - border-radius: 8px; + padding: 13px 14px; + border-radius: 12px; + border: 1px solid rgba(0, 0, 0, 0.18); + background: rgba(255, 255, 255, 0.95); + color: #111111; font-size: 15px; - color: #000000; - transition: all 0.2s ease; - box-sizing: border-box; - background: #ffffff; - font-family: inherit; + line-height: 1.45; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; + resize: vertical; +} + +.premium-input::placeholder { + color: rgba(0, 0, 0, 0.4); } .premium-input:hover { - border-color: #cccccc; + border-color: rgba(0, 0, 0, 0.32); } -.premium-input:focus { +.premium-input:focus, +.premium-input.active { outline: none; border-color: #000000; - border-width: 2px; - padding: 11px 15px; + box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.07); + transform: translateY(-1px); } -.premium-input::placeholder { - color: #999999; +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; } -textarea.premium-input { - resize: vertical; - min-height: 100px; +.chip { + border: 1px solid rgba(0, 0, 0, 0.18); + background: #ffffff; + color: #111111; + min-height: 30px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + padding: 0 12px; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease; +} + +.chip-wide { + border-radius: 12px; + text-align: left; + padding: 8px 10px; +} + +.chip:hover:not(:disabled) { + background: #000000; + color: #ffffff; + transform: translateY(-1px); +} + +.chip:disabled { + opacity: 0.5; + cursor: not-allowed; } .form-actions { - margin-top: 8px; + margin-top: 4px; } .premium-btn { width: 100%; + min-height: 48px; + border: none; + border-radius: 12px; background: #000000; color: #ffffff; - border: none; - padding: 14px 24px; - border-radius: 8px; - font-size: 16px; - font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + font-family: var(--font-display); + font-size: 15px; + font-weight: 700; + letter-spacing: 0.01em; cursor: pointer; - transition: background 0.2s ease; + transition: transform 0.2s ease, box-shadow 0.2s ease; } .premium-btn:hover:not(:disabled) { - background: #333333; + transform: translateY(-1px); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.3); +} + +.premium-btn:hover:not(:disabled) span[aria-hidden="true"], +.premium-btn:hover:not(:disabled) span:last-child { + transform: translateX(2px); } .premium-btn:disabled { - opacity: 0.6; + opacity: 0.62; cursor: not-allowed; } +.premium-btn span:last-child { + transition: transform 0.2s ease; +} + .error-msg { - margin-top: 24px; - padding: 12px 16px; - background: #ffffff; - border-radius: 8px; - color: #cf222e; - font-size: 14px; - font-weight: 500; - text-align: center; - border: 1px solid #cf222e; + margin: 14px 0 0; + position: relative; + z-index: 1; + border-radius: 12px; + border: 1px solid rgba(176, 0, 32, 0.4); + background: rgba(255, 225, 231, 0.75); + color: #7f0015; + font-size: 13px; + font-weight: 600; + padding: 10px 12px; } -.loading-state { - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - padding: 40px 0; - color: #000000; - font-weight: 500; +.error-state { + position: relative; + z-index: 1; + display: grid; + gap: 10px; } -.spinner { - width: 20px; - height: 20px; - border: 2px solid #e5e5e5; - border-top-color: #000000; - border-radius: 50%; - animation: spin 0.8s linear infinite; +.error-state .error-msg { + margin: 14px 0 0; +} + +.retry-btn { + justify-self: start; + min-height: 34px; + border-radius: 999px; + border: 1px solid rgba(0, 0, 0, 0.2); + background: #fff; + color: #111; + padding: 0 14px; + font-size: 12px; + font-weight: 700; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.retry-btn:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.14); +} + +.preview-panel { + background: + radial-gradient(circle at 80% 14%, rgba(255, 255, 255, 0.12), transparent 32%), + linear-gradient(170deg, #050505 0%, #171717 100%); + color: #f6f6f6; + padding: 30px; +} + +.preview-eyebrow { + margin: 0; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.15em; + color: rgba(255, 255, 255, 0.64); +} + +.preview-panel h2 { + margin: 14px 0 12px; + font-family: var(--font-display); + font-size: clamp(26px, 4vw, 40px); + line-height: 1.04; + letter-spacing: -0.03em; +} + +.preview-description { + margin: 0; + color: rgba(255, 255, 255, 0.82); + line-height: 1.65; + min-height: 76px; +} + +.preview-stats { + margin-top: 22px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.stat-tile { + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 12px; + padding: 12px 10px; + background: rgba(255, 255, 255, 0.05); +} + +.stat-label { + display: block; + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.62); +} + +.stat-value { + display: block; + margin-top: 6px; + font-size: 15px; + font-weight: 700; +} + +.preview-stream { + margin-top: 20px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 14px; + padding: 16px; + background: rgba(255, 255, 255, 0.04); +} + +.stream-title { + margin: 0 0 10px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.74); +} + +.preview-stream ul { + margin: 0; + padding-left: 18px; + display: grid; + gap: 10px; + color: rgba(255, 255, 255, 0.88); + line-height: 1.5; +} + +@media (max-width: 1024px) { + .top-nav { + padding: 14px 16px; + } + + .workspace-shell { + width: min(920px, 100% - 24px); + margin: 20px auto 32px; + grid-template-columns: 1fr; + } + + .form-card, + .preview-panel { + padding: 24px; + } + + .preview-stats { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .back-nav-btn { + min-height: 34px; + padding: 0 12px; + font-size: 12px; + } + + .form-card, + .preview-panel { + border-radius: 16px; + padding: 18px; + } + + .preview-stats { + grid-template-columns: 1fr; + } +} + +@keyframes rise-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } diff --git a/src/app/components/workspace/edit-workspace.html b/src/app/components/workspace/edit-workspace.html index dea5561..9af8980 100644 --- a/src/app/components/workspace/edit-workspace.html +++ b/src/app/components/workspace/edit-workspace.html @@ -12,11 +12,12 @@ Back to Dashboard -
-
+
+
-

Edit Workspace

-

Update the details of your workspace below.

+

Workspace Update

+

Edit Workspace

+

Refine workspace details to keep your team context clear and aligned.

@@ -24,42 +25,131 @@

Edit Workspace

Loading workspace...
-
+
+
+ Readiness + {{ completionPercent }}% +
+
+
+
+
+ +
- +
+ + {{ name.trim().length }}/{{ nameMaxLength }} +
+
+ +
- +
+ + {{ description.trim().length }}/{{ descriptionMaxLength }} +
+
+ +
-

{{ error }}

-
+
+

{{ error }}

+ +
+ + +
diff --git a/src/app/components/workspace/edit-workspace.ts b/src/app/components/workspace/edit-workspace.ts index 26f1341..a107ba4 100644 --- a/src/app/components/workspace/edit-workspace.ts +++ b/src/app/components/workspace/edit-workspace.ts @@ -1,7 +1,8 @@ -import { ChangeDetectorRef, Component, OnInit, inject } from '@angular/core'; +import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { Subscription } from 'rxjs'; import { WorkspaceService } from '../../services/workspace'; @Component({ @@ -11,7 +12,9 @@ import { WorkspaceService } from '../../services/workspace'; templateUrl: './edit-workspace.html', styleUrls: ['./edit-workspace.css'] }) -export class EditWorkspaceComponent implements OnInit { +export class EditWorkspaceComponent implements OnInit, OnDestroy { + private static readonly WORKSPACE_LOAD_TIMEOUT_MS = 8000; + workspaceId: string | null = null; name = ''; description = ''; @@ -19,26 +22,98 @@ export class EditWorkspaceComponent implements OnInit { isSubmitting = false; isLoading = true; hasWorkspace = false; + activeField: 'name' | 'description' | '' = ''; + readonly nameMaxLength = 60; + readonly descriptionMaxLength = 180; + readonly nameSuggestions = ['Product Ops', 'Engineering Hub', 'Customer Success']; + readonly descriptionSuggestions = [ + 'Track incidents, escalations, and release updates in one place.', + 'Centralize signals and decisions for weekly planning and follow-ups.', + 'Collect cross-team notifications and keep execution aligned.' + ]; private workspaceService = inject(WorkspaceService); private router = inject(Router); private route = inject(ActivatedRoute); private cdr = inject(ChangeDetectorRef); + private loadSubscription?: Subscription; + private loadTimeoutId?: ReturnType; + + get trimmedName(): string { + return this.name.trim(); + } + + get trimmedDescription(): string { + return this.description.trim(); + } + + get completionPercent(): number { + let progress = 20; + if (this.trimmedName.length > 0) { + progress += 45; + } + if (this.trimmedDescription.length >= 20) { + progress += 35; + } else if (this.trimmedDescription.length > 0) { + progress += 20; + } + return Math.min(progress, 100); + } + + applyNameSuggestion(name: string) { + if (this.isSubmitting || this.isLoading) { + return; + } + this.name = name; + this.error = ''; + } + + applyDescriptionSuggestion(description: string) { + if (this.isSubmitting || this.isLoading) { + return; + } + this.description = description; + this.error = ''; + } ngOnInit(): void { this.route.paramMap.subscribe(params => { this.workspaceId = params.get('id'); - if (this.workspaceId) { + if (this.workspaceId && /^\d+$/.test(this.workspaceId)) { this.loadWorkspace(this.workspaceId); } else { - this.router.navigate(['/dashboard']); + this.isLoading = false; + this.hasWorkspace = false; + this.error = 'Invalid workspace URL.'; + this.syncView(); } }); } + ngOnDestroy(): void { + this.clearActiveLoad(); + } + loadWorkspace(id: string): void { - this.workspaceService.getWorkspace(id).subscribe({ + this.clearActiveLoad(); + this.isLoading = true; + this.hasWorkspace = false; + this.error = ''; + + this.loadTimeoutId = setTimeout(() => { + this.clearLoadTimeout(); + this.loadSubscription?.unsubscribe(); + this.loadSubscription = undefined; + this.isLoading = false; + this.hasWorkspace = false; + this.error = 'Loading workspace is taking longer than expected. Please try again.'; + this.syncView(); + }, EditWorkspaceComponent.WORKSPACE_LOAD_TIMEOUT_MS); + + this.loadSubscription = this.workspaceService.getWorkspace(id).subscribe({ next: workspace => { + this.clearLoadTimeout(); + this.loadSubscription = undefined; this.isLoading = false; if (workspace) { this.hasWorkspace = true; @@ -48,13 +123,15 @@ export class EditWorkspaceComponent implements OnInit { this.hasWorkspace = false; this.error = 'Workspace not found'; } - this.cdr.detectChanges(); + this.syncView(); }, - error: () => { + error: (error: Error) => { + this.clearLoadTimeout(); + this.loadSubscription = undefined; this.isLoading = false; this.hasWorkspace = false; - this.error = 'Unable to load workspace. Please try again.'; - this.cdr.detectChanges(); + this.error = error.message || 'Unable to load workspace. Please try again.'; + this.syncView(); } }); } @@ -84,4 +161,29 @@ export class EditWorkspaceComponent implements OnInit { } }); } + + retryLoad(): void { + if (!this.workspaceId || !/^\d+$/.test(this.workspaceId)) { + return; + } + this.loadWorkspace(this.workspaceId); + } + + private clearActiveLoad(): void { + this.loadSubscription?.unsubscribe(); + this.loadSubscription = undefined; + this.clearLoadTimeout(); + } + + private clearLoadTimeout(): void { + if (this.loadTimeoutId === undefined) { + return; + } + clearTimeout(this.loadTimeoutId); + this.loadTimeoutId = undefined; + } + + private syncView(): void { + this.cdr.detectChanges(); + } } diff --git a/src/app/components/workspace/workspace-details/workspace-details.css b/src/app/components/workspace/workspace-details/workspace-details.css index 6c98056..24ecf98 100644 --- a/src/app/components/workspace/workspace-details/workspace-details.css +++ b/src/app/components/workspace/workspace-details/workspace-details.css @@ -1,72 +1,303 @@ -.workspace-details-container { - padding: 28px 24px; - max-width: 1100px; - margin: 0 auto; +.workspace-page { + min-height: 100vh; + padding: 28px 22px 36px; + background: + radial-gradient(circle at 12% 8%, rgba(0, 0, 0, 0.05), transparent 34%), + linear-gradient(160deg, #f7f7f7 0%, #ffffff 52%, #f2f2f2 100%); + color: #0a0a0a; + font-family: var(--font-sans); } -header { - margin-bottom: 20px; - border-bottom: 1px solid #dedede; - padding-bottom: 18px; +.workspace-hero { + max-width: 1160px; + margin: 0 auto 18px; + border-radius: 22px; + border: 1px solid rgba(0, 0, 0, 0.16); + background: + radial-gradient(circle at 82% 10%, rgba(255, 255, 255, 0.1), transparent 34%), + linear-gradient(168deg, #040404 0%, #151515 100%); + color: #f9f9f9; + padding: 24px; + overflow: hidden; + position: relative; } -.header-top { - margin-bottom: 12px; +.workspace-hero::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background-image: radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.15) 1px, transparent 0); + background-size: 22px 22px; + opacity: 0.2; +} + +.hero-head, +.hero-grid { + position: relative; + z-index: 1; +} + +.hero-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 20px; } .back-btn { - display: inline-flex; - align-items: center; - text-decoration: none; - color: #fff; - background: #0a0a0a; - border: 1px solid #0a0a0a; - border-radius: 8px; - padding: 7px 12px; - font-size: 13px; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + padding: 0 14px; + text-decoration: none; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + border: 1px solid rgba(255, 255, 255, 0.28); + color: #f9f9f9; + background: rgba(255, 255, 255, 0.06); + transition: transform 0.2s ease, background 0.2s ease; } .back-btn:hover { - background: #000; + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.16); +} + +.hero-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + padding: 0 14px; + border-radius: 999px; + text-decoration: none; + font-size: 13px; + font-weight: 600; + border: 1px solid #ffffff; + color: #0a0a0a; + background: #ffffff; + transition: transform 0.2s ease; +} + +.action-btn.subtle { + border-color: rgba(255, 255, 255, 0.28); + color: #f9f9f9; + background: rgba(255, 255, 255, 0.08); +} + +.action-btn:hover { + transform: translateY(-1px); +} + +.hero-grid { + display: grid; + grid-template-columns: minmax(320px, 1.2fr) minmax(260px, 0.8fr); + gap: 16px; +} + +.workspace-main { + display: flex; + gap: 14px; + align-items: flex-start; +} + +.workspace-badge { + width: 56px; + height: 56px; + border-radius: 14px; + display: grid; + place-items: center; + font-family: var(--font-display); + font-weight: 700; + font-size: 18px; + background: #ffffff; + color: #080808; + flex-shrink: 0; +} + +.eyebrow { + margin: 0 0 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.13em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.68); +} + +h1 { + margin: 0; + font-family: var(--font-display); + font-size: clamp(30px, 4vw, 42px); + line-height: 1.02; + letter-spacing: -0.03em; +} + +.description { + margin: 12px 0 0; + color: rgba(255, 255, 255, 0.84); + line-height: 1.6; + max-width: 700px; } -header h1 { - margin: 0 0 8px; - font-size: 34px; +.workspace-metrics { + display: grid; + gap: 10px; } -header p { - color: #555; - margin: 0; +.metric-card { + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 12px; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.06); +} + +.metric-label { + display: block; + font-size: 11px; + letter-spacing: 0.11em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.66); +} + +.metric-value { + display: block; + margin-top: 6px; + font-size: 15px; + font-weight: 700; } .workspace-nav { - display: flex; - gap: 8px; - border-bottom: 1px solid #dedede; - padding-bottom: 10px; - margin-bottom: 24px; + max-width: 1160px; + margin: 0 auto 20px; + display: flex; + gap: 8px; + padding: 6px; + border-radius: 14px; + border: 1px solid rgba(0, 0, 0, 0.12); + background: rgba(255, 255, 255, 0.86); + backdrop-filter: blur(5px); } .workspace-nav a { - padding: 8px 14px; - text-decoration: none; - color: #444; - border: 1px solid transparent; - border-radius: 8px; - font-size: 14px; - line-height: 1.1; + flex: 0 0 auto; + min-height: 36px; + padding: 0 14px; + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; + border-radius: 10px; + color: #2a2a2a; + font-size: 14px; + font-weight: 600; + border: 1px solid transparent; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.workspace-nav a:hover { + border-color: rgba(0, 0, 0, 0.2); + background: rgba(255, 255, 255, 0.7); } .workspace-nav a.active { - color: #fff; - background: #0a0a0a; - border-color: #0a0a0a; - font-weight: 600; + color: #ffffff; + background: #050505; + border-color: #050505; +} + +.content-shell { + max-width: 1160px; + margin: 0 auto; + border-radius: 16px; + border: 1px solid rgba(0, 0, 0, 0.12); + background: rgba(255, 255, 255, 0.92); + padding: 18px; } .loading { - text-align: center; - padding: 40px; - color: #666; + min-height: 70vh; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + color: #333; + font-weight: 600; +} + +.spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(0, 0, 0, 0.2); + border-top-color: #000000; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@media (max-width: 980px) { + .workspace-page { + padding: 16px 12px 24px; + } + + .workspace-hero { + border-radius: 18px; + padding: 18px; + } + + .hero-head { + flex-direction: column; + align-items: flex-start; + } + + .hero-grid { + grid-template-columns: 1fr; + } + + .workspace-main { + align-items: center; + } + + .workspace-nav { + overflow-x: auto; + white-space: nowrap; + } + + .content-shell { + padding: 14px; + } +} + +@media (max-width: 640px) { + .workspace-main { + align-items: flex-start; + } + + .workspace-badge { + width: 46px; + height: 46px; + font-size: 15px; + border-radius: 10px; + } + + .action-btn, + .back-btn { + min-height: 34px; + font-size: 12px; + padding: 0 10px; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } } diff --git a/src/app/components/workspace/workspace-details/workspace-details.html b/src/app/components/workspace/workspace-details/workspace-details.html index 5e5c26e..ce0e7fb 100644 --- a/src/app/components/workspace/workspace-details/workspace-details.html +++ b/src/app/components/workspace/workspace-details/workspace-details.html @@ -1,23 +1,54 @@ -
-
- -

{{ workspace.name }}

-

{{ workspace.description || 'No description yet.' }}

-
+
+
+ - +
+
+
{{ getWorkspaceInitials(workspace.name) }}
+
+

Workspace

+

{{ workspace.name }}

+

{{ workspace.description || 'No description yet. Add one to give this workspace stronger context.' }}

+
+
-
- +
+
+ Health + {{ getWorkspaceHealth(workspace.description || '') }} +
+
+ Created + {{ workspace.createdDate | date:'mediumDate' }} +
+
+ Owner + User #{{ workspace.ownerId }} +
+
+
+ + + +
+ +
-
Loading workspace...
+
+ + Loading workspace... +
diff --git a/src/app/components/workspace/workspace-details/workspace-details.ts b/src/app/components/workspace/workspace-details/workspace-details.ts index 6863e45..4da2451 100644 --- a/src/app/components/workspace/workspace-details/workspace-details.ts +++ b/src/app/components/workspace/workspace-details/workspace-details.ts @@ -28,4 +28,23 @@ export class WorkspaceDetailsComponent implements OnInit { } }); } + + getWorkspaceInitials(name: string): string { + return name + .trim() + .split(/\s+/) + .slice(0, 2) + .map(part => part.charAt(0).toUpperCase()) + .join('') || 'WS'; + } + + getWorkspaceHealth(description: string): string { + if (description.trim().length >= 20) { + return 'Configured'; + } + if (description.trim().length > 0) { + return 'In Progress'; + } + return 'Needs Detail'; + } } diff --git a/src/app/services/decision.service.ts b/src/app/services/decision.service.ts index a22e3aa..989905d 100644 --- a/src/app/services/decision.service.ts +++ b/src/app/services/decision.service.ts @@ -1,19 +1,24 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { catchError, map, Observable, of, throwError } from 'rxjs'; +import { catchError, map, Observable, of, throwError, timeout } from 'rxjs'; import { Decision } from '../models/decision.model'; import { toError } from './http-error'; interface DecisionResponse { - id: number; - workspace_id: number; - user_id: number; - title: string; + id?: number | string; + workspace_id?: number | string; + workspaceId?: number | string; + user_id?: number | string; + userId?: number | string; + title?: string; description?: string; status: 'DRAFT' | 'OPEN' | 'CLOSED'; due_date?: string | null; - created_at: string; - updated_at: string; + dueDate?: string | null; + created_at?: string; + createdAt?: string; + updated_at?: string; + updatedAt?: string; } @Injectable({ @@ -25,6 +30,7 @@ export class DecisionService { getDecisions(workspaceId: string): Observable { return this.http.get(`${this.apiUrl}/${workspaceId}/decisions`).pipe( + timeout(5000), map((decisions) => decisions.map((decision) => this.mapDecision(decision))), catchError((error) => throwError(() => toError(error, 'Unable to load decisions.'))), ); @@ -32,6 +38,7 @@ export class DecisionService { getDecision(workspaceId: string, id: string): Observable { return this.http.get(`${this.apiUrl}/${workspaceId}/decisions/${id}`).pipe( + timeout(5000), map((decision) => this.mapDecision(decision)), catchError((error) => { if (error.status === 404) { @@ -50,6 +57,7 @@ export class DecisionService { return this.http .post(`${this.apiUrl}/${decision.workspaceId}/decisions`, this.toPayload(decision)) .pipe( + timeout(5000), map((created) => this.mapDecision(created)), catchError((error) => throwError(() => toError(error, 'Unable to create decision.'))), ); @@ -59,6 +67,7 @@ export class DecisionService { return this.http .patch(`${this.apiUrl}/${workspaceId}/decisions/${id}`, this.toPayload(updates)) .pipe( + timeout(5000), map((decision) => this.mapDecision(decision)), catchError((error) => { if (error.status === 404) { @@ -71,6 +80,7 @@ export class DecisionService { deleteDecision(workspaceId: string, id: string): Observable { return this.http.delete(`${this.apiUrl}/${workspaceId}/decisions/${id}`).pipe( + timeout(5000), catchError((error) => throwError(() => toError(error, 'Unable to delete decision.'))), ); } @@ -85,17 +95,29 @@ export class DecisionService { } private mapDecision(decision: DecisionResponse): Decision { + const dueDateRaw = decision.due_date ?? decision.dueDate; + const createdAtRaw = decision.created_at ?? decision.createdAt ?? ''; + const updatedAtRaw = decision.updated_at ?? decision.updatedAt ?? ''; + const workspaceId = decision.workspace_id ?? decision.workspaceId ?? ''; + const userId = decision.user_id ?? decision.userId ?? ''; + const status = decision.status ?? 'DRAFT'; + return { - id: String(decision.id), - workspaceId: String(decision.workspace_id), - userId: String(decision.user_id), - title: decision.title, + id: String(decision.id ?? ''), + workspaceId: String(workspaceId), + userId: String(userId), + title: decision.title ?? '', description: decision.description ?? '', - status: decision.status, - dueDate: decision.due_date ? new Date(decision.due_date) : undefined, - createdAt: new Date(decision.created_at), - updatedAt: new Date(decision.updated_at), + status, + dueDate: dueDateRaw ? this.toDate(dueDateRaw) : undefined, + createdAt: this.toDate(createdAtRaw), + updatedAt: this.toDate(updatedAtRaw), isDeleted: false, }; } + + private toDate(value: string): Date { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? new Date(0) : parsed; + } } diff --git a/src/app/services/workspace.ts b/src/app/services/workspace.ts index 769b0c3..c8be555 100644 --- a/src/app/services/workspace.ts +++ b/src/app/services/workspace.ts @@ -1,15 +1,17 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { catchError, map, Observable, of, throwError } from 'rxjs'; +import { catchError, map, Observable, of, throwError, timeout } from 'rxjs'; import { Workspace } from '../models/workspace'; import { toError } from './http-error'; interface WorkspaceResponse { - id: number; - name: string; + id?: number | string; description?: string; - owner_id: number; - created_at: string; + owner_id?: number | string; + ownerId?: number | string; + created_at?: string; + createdAt?: string; + name?: string; } @Injectable({ @@ -21,12 +23,15 @@ export class WorkspaceService { getWorkspaces(): Observable { return this.http.get(this.apiUrl).pipe( + timeout(5000), map((workspaces) => workspaces.map((workspace) => this.mapWorkspace(workspace))), + catchError((error) => throwError(() => toError(error, 'Unable to load workspaces.'))), ); } getWorkspace(id: string): Observable { return this.http.get(`${this.apiUrl}/${id}`).pipe( + timeout(5000), map((workspace) => this.mapWorkspace(workspace)), catchError((error) => { if (error.status === 404) { @@ -41,6 +46,7 @@ export class WorkspaceService { return this.http .post(this.apiUrl, { name, description }) .pipe( + timeout(5000), map((workspace) => this.mapWorkspace(workspace)), catchError((error) => throwError(() => toError(error, 'Unable to create workspace.'))), ); @@ -50,6 +56,7 @@ export class WorkspaceService { return this.http .patch(`${this.apiUrl}/${id}`, { name, description }) .pipe( + timeout(5000), map((workspace) => this.mapWorkspace(workspace)), catchError((error) => { if (error.status === 404) { @@ -62,18 +69,28 @@ export class WorkspaceService { deleteWorkspace(id: string): Observable { return this.http.delete(`${this.apiUrl}/${id}`).pipe( + timeout(5000), map(() => true), catchError((error) => throwError(() => toError(error, 'Unable to delete workspace.'))), ); } private mapWorkspace(workspace: WorkspaceResponse): Workspace { + const id = workspace.id ?? ''; + const ownerId = workspace.owner_id ?? workspace.ownerId ?? ''; + const createdAtRaw = workspace.created_at ?? workspace.createdAt ?? ''; + return { - id: String(workspace.id), - name: workspace.name, + id: String(id), + name: workspace.name ?? '', description: workspace.description ?? '', - createdDate: new Date(workspace.created_at), - ownerId: String(workspace.owner_id), + createdDate: this.toDate(createdAtRaw), + ownerId: String(ownerId), }; } + + private toDate(value: string): Date { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? new Date(0) : parsed; + } }