mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
feat(contacts): integrate contacts into Todo and Calendar apps
- Add ContactSelector, ContactBadge, ContactAvatar to shared-ui - Add ContactsClient API service to shared-auth - Add ContactReference, ContactSummary types to shared-types - Todo: Add assignee and involvedContacts to tasks with UI in TaskEditModal - Todo: Display contacts in TaskItem and KanbanTaskCard - Calendar: Add AttendeeSelector with RSVP status support - Calendar: Integrate attendees in EventForm - Calendar: Add task drag-drop to calendar views (Day/Week/MultiDay) - Contacts: Add ContactTasks component to show related tasks - Backend: Add findByContact endpoint to Todo task service - UI polish: glassmorphism styling, keyboard navigation, auto-focus 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
307f1ae22e
commit
0ecbf69ebc
50 changed files with 5791 additions and 53 deletions
|
|
@ -31,6 +31,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@calendar/shared": "workspace:*",
|
"@calendar/shared": "workspace:*",
|
||||||
"@manacore/shared-auth": "workspace:*",
|
"@manacore/shared-auth": "workspace:*",
|
||||||
|
"@manacore/shared-types": "workspace:*",
|
||||||
"@manacore/shared-auth-ui": "workspace:*",
|
"@manacore/shared-auth-ui": "workspace:*",
|
||||||
"@manacore/shared-branding": "workspace:*",
|
"@manacore/shared-branding": "workspace:*",
|
||||||
"@manacore/shared-feedback-service": "workspace:*",
|
"@manacore/shared-feedback-service": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@
|
||||||
import { eventsStore } from '$lib/stores/events.svelte';
|
import { eventsStore } from '$lib/stores/events.svelte';
|
||||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||||
import { todosStore } from '$lib/stores/todos.svelte';
|
import { todosStore, type Task } from '$lib/stores/todos.svelte';
|
||||||
import TodoRow from './TodoRow.svelte';
|
import TodoRow from './TodoRow.svelte';
|
||||||
|
import TaskBlock from './TaskBlock.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import {
|
import {
|
||||||
format,
|
format,
|
||||||
|
|
@ -16,12 +17,15 @@
|
||||||
setMinutes,
|
setMinutes,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { de } from 'date-fns/locale';
|
import { de } from 'date-fns/locale';
|
||||||
|
import type { CalendarEvent } from '../../../../../../packages/shared/src/types/event';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||||
|
onEventClick?: (event: CalendarEvent) => void;
|
||||||
|
onTaskClick?: (task: Task) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onQuickCreate }: Props = $props();
|
let { onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const HOUR_HEIGHT = 60; // pixels per hour
|
const HOUR_HEIGHT = 60; // pixels per hour
|
||||||
|
|
@ -107,6 +111,21 @@
|
||||||
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
||||||
let hasMoved = $state(false);
|
let hasMoved = $state(false);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Task Drag & Drop State
|
||||||
|
// ============================================================================
|
||||||
|
let isTaskDragging = $state(false);
|
||||||
|
let draggedTask = $state<Task | null>(null);
|
||||||
|
let taskDragPreviewTop = $state(0);
|
||||||
|
let taskDragPreviewHeight = $state(0);
|
||||||
|
|
||||||
|
// Task Resize State
|
||||||
|
let isTaskResizing = $state(false);
|
||||||
|
let resizeTask = $state<Task | null>(null);
|
||||||
|
let taskResizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||||
|
let taskResizePreviewTop = $state(0);
|
||||||
|
let taskResizePreviewHeight = $state(0);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Helper Functions
|
// Helper Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -321,13 +340,238 @@
|
||||||
document.removeEventListener('pointerup', handleDragEnd);
|
document.removeEventListener('pointerup', handleDragEnd);
|
||||||
document.removeEventListener('pointermove', handleResizeMove);
|
document.removeEventListener('pointermove', handleResizeMove);
|
||||||
document.removeEventListener('pointerup', handleResizeEnd);
|
document.removeEventListener('pointerup', handleResizeEnd);
|
||||||
|
// Task cleanup
|
||||||
|
isTaskDragging = false;
|
||||||
|
draggedTask = null;
|
||||||
|
isTaskResizing = false;
|
||||||
|
resizeTask = null;
|
||||||
|
document.removeEventListener('pointermove', handleTaskDragMove);
|
||||||
|
document.removeEventListener('pointerup', handleTaskDragEnd);
|
||||||
|
document.removeEventListener('pointermove', handleTaskResizeMove);
|
||||||
|
document.removeEventListener('pointerup', handleTaskResizeEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Task Drag & Drop
|
||||||
|
// ============================================================================
|
||||||
|
function handleTaskDragStart(task: Task, e: PointerEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isTaskDragging = true;
|
||||||
|
draggedTask = task;
|
||||||
|
hasMoved = false;
|
||||||
|
|
||||||
|
if (task.scheduledStartTime) {
|
||||||
|
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||||
|
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||||
|
taskDragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = task.estimatedDuration || 30;
|
||||||
|
taskDragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', handleTaskDragMove);
|
||||||
|
document.addEventListener('pointerup', handleTaskDragEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTaskDragMove(e: PointerEvent) {
|
||||||
|
if (!isTaskDragging || !draggedTask || !dayColumnRef) return;
|
||||||
|
hasMoved = true;
|
||||||
|
|
||||||
|
const rect = dayColumnRef.getBoundingClientRect();
|
||||||
|
const relativeY = e.clientY - rect.top;
|
||||||
|
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||||
|
|
||||||
|
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||||
|
const rawMinutes = percentY * minutesPerPercent;
|
||||||
|
const snappedMinutes = Math.round(rawMinutes / 15) * 15;
|
||||||
|
taskDragPreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTaskDragEnd() {
|
||||||
|
document.removeEventListener('pointermove', handleTaskDragMove);
|
||||||
|
document.removeEventListener('pointerup', handleTaskDragEnd);
|
||||||
|
|
||||||
|
if (!isTaskDragging || !draggedTask || !hasMoved) {
|
||||||
|
isTaskDragging = false;
|
||||||
|
draggedTask = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutesFromStart = (taskDragPreviewTop / 100) * (totalVisibleHours * 60);
|
||||||
|
const totalMinutes = firstVisibleHour * 60 + minutesFromStart;
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = Math.round(totalMinutes % 60);
|
||||||
|
|
||||||
|
const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||||
|
const duration = draggedTask.estimatedDuration || 30;
|
||||||
|
const endTotalMinutes = totalMinutes + duration;
|
||||||
|
const endHours = Math.floor(endTotalMinutes / 60);
|
||||||
|
const endMins = Math.round(endTotalMinutes % 60);
|
||||||
|
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
await todosStore.updateTodo(draggedTask.id, {
|
||||||
|
scheduledStartTime: newStartTime,
|
||||||
|
scheduledEndTime: newEndTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
isTaskDragging = false;
|
||||||
|
draggedTask = null;
|
||||||
|
hasMoved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Task Resize
|
||||||
|
// ============================================================================
|
||||||
|
function handleTaskResizeStart(task: Task, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
isTaskResizing = true;
|
||||||
|
resizeTask = task;
|
||||||
|
taskResizeEdge = edge;
|
||||||
|
hasMoved = false;
|
||||||
|
|
||||||
|
if (task.scheduledStartTime) {
|
||||||
|
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||||
|
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||||
|
taskResizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = task.estimatedDuration || 30;
|
||||||
|
taskResizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', handleTaskResizeMove);
|
||||||
|
document.addEventListener('pointerup', handleTaskResizeEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTaskResizeMove(e: PointerEvent) {
|
||||||
|
if (!isTaskResizing || !resizeTask || !dayColumnRef) return;
|
||||||
|
hasMoved = true;
|
||||||
|
|
||||||
|
const rect = dayColumnRef.getBoundingClientRect();
|
||||||
|
const relativeY = e.clientY - rect.top;
|
||||||
|
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||||
|
|
||||||
|
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||||
|
|
||||||
|
if (taskResizeEdge === 'top') {
|
||||||
|
const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight;
|
||||||
|
const rawMinutes = percentY * minutesPerPercent;
|
||||||
|
const snappedMinutes = Math.round(rawMinutes / 15) * 15;
|
||||||
|
taskResizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
taskResizePreviewHeight = Math.max(2, originalEndPercent - taskResizePreviewTop);
|
||||||
|
} else {
|
||||||
|
const rawMinutes = percentY * minutesPerPercent;
|
||||||
|
const snappedMinutes = Math.round(rawMinutes / 15) * 15;
|
||||||
|
const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTaskResizeEnd() {
|
||||||
|
document.removeEventListener('pointermove', handleTaskResizeMove);
|
||||||
|
document.removeEventListener('pointerup', handleTaskResizeEnd);
|
||||||
|
|
||||||
|
if (!isTaskResizing || !resizeTask || !hasMoved) {
|
||||||
|
isTaskResizing = false;
|
||||||
|
resizeTask = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMinutes =
|
||||||
|
(taskResizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
|
||||||
|
const endMinutes =
|
||||||
|
((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (totalVisibleHours * 60) +
|
||||||
|
firstVisibleHour * 60;
|
||||||
|
|
||||||
|
const startHours = Math.floor(startMinutes / 60);
|
||||||
|
const startMins = Math.round(startMinutes % 60);
|
||||||
|
const endHours = Math.floor(endMinutes / 60);
|
||||||
|
const endMins = Math.round(endMinutes % 60);
|
||||||
|
|
||||||
|
const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`;
|
||||||
|
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||||
|
const newDuration = Math.round(endMinutes - startMinutes);
|
||||||
|
|
||||||
|
await todosStore.updateTodo(resizeTask.id, {
|
||||||
|
scheduledStartTime: newStartTime,
|
||||||
|
scheduledEndTime: newEndTime,
|
||||||
|
estimatedDuration: newDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
isTaskResizing = false;
|
||||||
|
resizeTask = null;
|
||||||
|
hasMoved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sidebar Task Drop
|
||||||
|
// ============================================================================
|
||||||
|
let isSidebarDropTarget = $state(false);
|
||||||
|
|
||||||
|
function handleSidebarDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!e.dataTransfer) return;
|
||||||
|
const types = e.dataTransfer.types;
|
||||||
|
if (!types.includes('application/json')) return;
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
isSidebarDropTarget = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSidebarDragLeave(e: DragEvent) {
|
||||||
|
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||||
|
if (!relatedTarget?.closest('.day-column')) {
|
||||||
|
isSidebarDropTarget = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSidebarDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isSidebarDropTarget = false;
|
||||||
|
|
||||||
|
if (!e.dataTransfer || !dayColumnRef) return;
|
||||||
|
|
||||||
|
const jsonData = e.dataTransfer.getData('application/json');
|
||||||
|
if (!jsonData) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonData);
|
||||||
|
if (data.type !== 'sidebar-task') return;
|
||||||
|
|
||||||
|
const rect = dayColumnRef.getBoundingClientRect();
|
||||||
|
const relativeY = e.clientY - rect.top;
|
||||||
|
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||||
|
|
||||||
|
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||||
|
const rawMinutes = percentY * minutesPerPercent;
|
||||||
|
const snappedMinutes = Math.round(rawMinutes / 15) * 15;
|
||||||
|
const totalMinutes = firstVisibleHour * 60 + snappedMinutes;
|
||||||
|
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
const startTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const duration = data.estimatedDuration || 30;
|
||||||
|
const endMinutes = totalMinutes + duration;
|
||||||
|
const endHours = Math.floor(endMinutes / 60);
|
||||||
|
const endMins = endMinutes % 60;
|
||||||
|
const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
await todosStore.updateTodo(data.taskId, {
|
||||||
|
scheduledDate: format(viewStore.currentDate, 'yyyy-MM-dd'),
|
||||||
|
scheduledStartTime: startTime,
|
||||||
|
scheduledEndTime: endTime,
|
||||||
|
estimatedDuration: duration,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse drop data:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Keyboard Handling
|
// Keyboard Handling
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape' && (isDragging || isResizing)) {
|
if (e.key === 'Escape' && (isDragging || isResizing || isTaskDragging || isTaskResizing)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
|
|
@ -358,7 +602,36 @@
|
||||||
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
|
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEventClick(event: any, e: MouseEvent) {
|
/**
|
||||||
|
* Get style for a scheduled task (time-blocking)
|
||||||
|
*/
|
||||||
|
function getTaskStyle(task: Task): string {
|
||||||
|
if (!task.scheduledStartTime) return '';
|
||||||
|
|
||||||
|
const [startHour, startMin] = task.scheduledStartTime.split(':').map(Number);
|
||||||
|
const startMinutes = startHour * 60 + startMin;
|
||||||
|
|
||||||
|
let duration = task.estimatedDuration || 30;
|
||||||
|
if (task.scheduledEndTime) {
|
||||||
|
const [endHour, endMin] = task.scheduledEndTime.split(':').map(Number);
|
||||||
|
const endMinutes = endHour * 60 + endMin;
|
||||||
|
duration = endMinutes - startMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = minutesToPercent(startMinutes);
|
||||||
|
const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 1.5);
|
||||||
|
|
||||||
|
return `top: ${top}%; height: ${height}%;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scheduled tasks for current day
|
||||||
|
*/
|
||||||
|
function getScheduledTasks(): Task[] {
|
||||||
|
return todosStore.getScheduledTasksForDay(viewStore.currentDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
|
||||||
// Don't navigate if dragging or resizing, or if we moved
|
// Don't navigate if dragging or resizing, or if we moved
|
||||||
if (isDragging || isResizing || hasMoved) {
|
if (isDragging || isResizing || hasMoved) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -427,7 +700,16 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="day-column" class:today={isToday(viewStore.currentDate)} bind:this={dayColumnRef}>
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="day-column"
|
||||||
|
class:today={isToday(viewStore.currentDate)}
|
||||||
|
class:drop-target={isSidebarDropTarget}
|
||||||
|
bind:this={dayColumnRef}
|
||||||
|
ondragover={handleSidebarDragOver}
|
||||||
|
ondragleave={handleSidebarDragLeave}
|
||||||
|
ondrop={handleSidebarDrop}
|
||||||
|
>
|
||||||
{#each hours as hour}
|
{#each hours as hour}
|
||||||
<button
|
<button
|
||||||
class="hour-slot"
|
class="hour-slot"
|
||||||
|
|
@ -503,6 +785,25 @@
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<!-- Scheduled Tasks (Time-Blocking) -->
|
||||||
|
{#each getScheduledTasks() as task (task.id)}
|
||||||
|
{@const isTaskBeingDragged = isTaskDragging && draggedTask?.id === task.id}
|
||||||
|
{@const isTaskBeingResized = isTaskResizing && resizeTask?.id === task.id}
|
||||||
|
<TaskBlock
|
||||||
|
{task}
|
||||||
|
style={isTaskBeingDragged
|
||||||
|
? `top: ${taskDragPreviewTop}%; height: ${taskDragPreviewHeight}%;`
|
||||||
|
: isTaskBeingResized
|
||||||
|
? `top: ${taskResizePreviewTop}%; height: ${taskResizePreviewHeight}%;`
|
||||||
|
: getTaskStyle(task)}
|
||||||
|
{onTaskClick}
|
||||||
|
onDragStart={handleTaskDragStart}
|
||||||
|
onResizeStart={handleTaskResizeStart}
|
||||||
|
isDragging={isTaskBeingDragged}
|
||||||
|
isResizing={isTaskBeingResized}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
|
||||||
<!-- Current time indicator -->
|
<!-- Current time indicator -->
|
||||||
{#if isToday(viewStore.currentDate)}
|
{#if isToday(viewStore.currentDate)}
|
||||||
<div class="time-indicator" style="top: {currentTimePosition}%"></div>
|
<div class="time-indicator" style="top: {currentTimePosition}%"></div>
|
||||||
|
|
@ -624,6 +925,12 @@
|
||||||
background: hsl(var(--color-primary) / 0.05);
|
background: hsl(var(--color-primary) / 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.day-column.drop-target {
|
||||||
|
background: hsl(var(--color-primary) / 0.15);
|
||||||
|
outline: 2px dashed hsl(var(--color-primary));
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
.event-card {
|
.event-card {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 4px;
|
left: 4px;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
import { eventsStore } from '$lib/stores/events.svelte';
|
import { eventsStore } from '$lib/stores/events.svelte';
|
||||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||||
|
import { todosStore, type Task } from '$lib/stores/todos.svelte';
|
||||||
|
import TaskBlock from './TaskBlock.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import {
|
import {
|
||||||
format,
|
format,
|
||||||
|
|
@ -27,8 +29,10 @@
|
||||||
interface Props {
|
interface Props {
|
||||||
dayCount: 5 | 10 | 14;
|
dayCount: 5 | 10 | 14;
|
||||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||||
|
onEventClick?: (event: CalendarEvent) => void;
|
||||||
|
onTaskClick?: (task: Task) => void;
|
||||||
}
|
}
|
||||||
let { dayCount, onQuickCreate }: Props = $props();
|
let { dayCount, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||||
|
|
||||||
// Get date-fns locale based on current app locale
|
// Get date-fns locale based on current app locale
|
||||||
const dateLocales = { de, en: enUS, fr, es, it };
|
const dateLocales = { de, en: enUS, fr, es, it };
|
||||||
|
|
@ -111,6 +115,20 @@
|
||||||
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
||||||
let hasMoved = $state(false);
|
let hasMoved = $state(false);
|
||||||
|
|
||||||
|
// Task Drag & Drop State
|
||||||
|
let isTaskDragging = $state(false);
|
||||||
|
let draggedTask = $state<Task | null>(null);
|
||||||
|
let taskDragTargetDay = $state<Date | null>(null);
|
||||||
|
let taskDragPreviewTop = $state(0);
|
||||||
|
let taskDragPreviewHeight = $state(0);
|
||||||
|
|
||||||
|
// Task Resize State
|
||||||
|
let isTaskResizing = $state(false);
|
||||||
|
let resizeTask = $state<Task | null>(null);
|
||||||
|
let taskResizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||||
|
let taskResizePreviewTop = $state(0);
|
||||||
|
let taskResizePreviewHeight = $state(0);
|
||||||
|
|
||||||
// Reference to the days container for position calculations
|
// Reference to the days container for position calculations
|
||||||
let daysContainerEl: HTMLDivElement;
|
let daysContainerEl: HTMLDivElement;
|
||||||
|
|
||||||
|
|
@ -156,6 +174,35 @@
|
||||||
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
|
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get style for a scheduled task (time-blocking)
|
||||||
|
*/
|
||||||
|
function getTaskStyle(task: Task): string {
|
||||||
|
if (!task.scheduledStartTime) return '';
|
||||||
|
|
||||||
|
const [startHour, startMin] = task.scheduledStartTime.split(':').map(Number);
|
||||||
|
const startMinutes = startHour * 60 + startMin;
|
||||||
|
|
||||||
|
let duration = task.estimatedDuration || 30;
|
||||||
|
if (task.scheduledEndTime) {
|
||||||
|
const [endHour, endMin] = task.scheduledEndTime.split(':').map(Number);
|
||||||
|
const endMinutes = endHour * 60 + endMin;
|
||||||
|
duration = endMinutes - startMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = minutesToPercent(startMinutes);
|
||||||
|
const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 2);
|
||||||
|
|
||||||
|
return `top: ${top}%; height: ${height}%;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scheduled tasks for a specific day
|
||||||
|
*/
|
||||||
|
function getScheduledTasksForDay(day: Date): Task[] {
|
||||||
|
return todosStore.getScheduledTasksForDay(day);
|
||||||
|
}
|
||||||
|
|
||||||
function formatEventTime(date: Date | string): string {
|
function formatEventTime(date: Date | string): string {
|
||||||
const d = typeof date === 'string' ? parseISO(date) : date;
|
const d = typeof date === 'string' ? parseISO(date) : date;
|
||||||
return settingsStore.formatTime(d);
|
return settingsStore.formatTime(d);
|
||||||
|
|
@ -437,15 +484,258 @@
|
||||||
hasMoved = false;
|
hasMoved = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Task Drag & Drop ==========
|
||||||
|
|
||||||
|
function handleTaskDragStart(task: Task, e: PointerEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isTaskDragging = true;
|
||||||
|
draggedTask = task;
|
||||||
|
hasMoved = false;
|
||||||
|
|
||||||
|
if (task.scheduledStartTime) {
|
||||||
|
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||||
|
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||||
|
taskDragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = task.estimatedDuration || 30;
|
||||||
|
taskDragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', handleTaskDragMove);
|
||||||
|
document.addEventListener('pointerup', handleTaskDragEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTaskDragMove(e: PointerEvent) {
|
||||||
|
if (!isTaskDragging || !draggedTask) return;
|
||||||
|
hasMoved = true;
|
||||||
|
|
||||||
|
const daysEl = daysContainerEl;
|
||||||
|
if (!daysEl) return;
|
||||||
|
|
||||||
|
const dayColumns = daysEl.querySelectorAll('.day-column');
|
||||||
|
for (let i = 0; i < dayColumns.length; i++) {
|
||||||
|
const col = dayColumns[i];
|
||||||
|
const rect = col.getBoundingClientRect();
|
||||||
|
if (e.clientX >= rect.left && e.clientX <= rect.right) {
|
||||||
|
taskDragTargetDay = days[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetColumn = daysEl.querySelector('.day-column');
|
||||||
|
if (!targetColumn) return;
|
||||||
|
const rect = targetColumn.getBoundingClientRect();
|
||||||
|
const relativeY = e.clientY - rect.top;
|
||||||
|
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||||
|
|
||||||
|
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||||
|
const rawMinutes = percentY * minutesPerPercent;
|
||||||
|
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||||
|
taskDragPreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTaskDragEnd() {
|
||||||
|
document.removeEventListener('pointermove', handleTaskDragMove);
|
||||||
|
document.removeEventListener('pointerup', handleTaskDragEnd);
|
||||||
|
|
||||||
|
if (!isTaskDragging || !draggedTask || !hasMoved) {
|
||||||
|
isTaskDragging = false;
|
||||||
|
draggedTask = null;
|
||||||
|
taskDragTargetDay = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutesFromStart = (taskDragPreviewTop / 100) * (totalVisibleHours * 60);
|
||||||
|
const totalMinutes = firstVisibleHour * 60 + minutesFromStart;
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = Math.round(totalMinutes % 60);
|
||||||
|
|
||||||
|
const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||||
|
const duration = draggedTask.estimatedDuration || 30;
|
||||||
|
const endTotalMinutes = totalMinutes + duration;
|
||||||
|
const endHours = Math.floor(endTotalMinutes / 60);
|
||||||
|
const endMins = Math.round(endTotalMinutes % 60);
|
||||||
|
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
await todosStore.updateTodo(draggedTask.id, {
|
||||||
|
scheduledDate: taskDragTargetDay ? format(taskDragTargetDay, 'yyyy-MM-dd') : undefined,
|
||||||
|
scheduledStartTime: newStartTime,
|
||||||
|
scheduledEndTime: newEndTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
isTaskDragging = false;
|
||||||
|
draggedTask = null;
|
||||||
|
taskDragTargetDay = null;
|
||||||
|
hasMoved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Task Resize ==========
|
||||||
|
|
||||||
|
function handleTaskResizeStart(task: Task, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
isTaskResizing = true;
|
||||||
|
resizeTask = task;
|
||||||
|
taskResizeEdge = edge;
|
||||||
|
hasMoved = false;
|
||||||
|
|
||||||
|
if (task.scheduledStartTime) {
|
||||||
|
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||||
|
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||||
|
taskResizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = task.estimatedDuration || 30;
|
||||||
|
taskResizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', handleTaskResizeMove);
|
||||||
|
document.addEventListener('pointerup', handleTaskResizeEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTaskResizeMove(e: PointerEvent) {
|
||||||
|
if (!isTaskResizing || !resizeTask) return;
|
||||||
|
hasMoved = true;
|
||||||
|
|
||||||
|
const daysEl = daysContainerEl;
|
||||||
|
if (!daysEl) return;
|
||||||
|
|
||||||
|
const targetColumn = daysEl.querySelector('.day-column');
|
||||||
|
if (!targetColumn) return;
|
||||||
|
|
||||||
|
const rect = targetColumn.getBoundingClientRect();
|
||||||
|
const relativeY = e.clientY - rect.top;
|
||||||
|
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||||
|
|
||||||
|
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||||
|
|
||||||
|
if (taskResizeEdge === 'top') {
|
||||||
|
const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight;
|
||||||
|
const rawMinutes = percentY * minutesPerPercent;
|
||||||
|
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||||
|
taskResizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
taskResizePreviewHeight = Math.max(2, originalEndPercent - taskResizePreviewTop);
|
||||||
|
} else {
|
||||||
|
const rawMinutes = percentY * minutesPerPercent;
|
||||||
|
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||||
|
const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTaskResizeEnd() {
|
||||||
|
document.removeEventListener('pointermove', handleTaskResizeMove);
|
||||||
|
document.removeEventListener('pointerup', handleTaskResizeEnd);
|
||||||
|
|
||||||
|
if (!isTaskResizing || !resizeTask || !hasMoved) {
|
||||||
|
isTaskResizing = false;
|
||||||
|
resizeTask = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMinutes =
|
||||||
|
(taskResizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
|
||||||
|
const endMinutes =
|
||||||
|
((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (totalVisibleHours * 60) +
|
||||||
|
firstVisibleHour * 60;
|
||||||
|
|
||||||
|
const startHours = Math.floor(startMinutes / 60);
|
||||||
|
const startMins = Math.round(startMinutes % 60);
|
||||||
|
const endHours = Math.floor(endMinutes / 60);
|
||||||
|
const endMins = Math.round(endMinutes % 60);
|
||||||
|
|
||||||
|
const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`;
|
||||||
|
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||||
|
const newDuration = Math.round(endMinutes - startMinutes);
|
||||||
|
|
||||||
|
await todosStore.updateTodo(resizeTask.id, {
|
||||||
|
scheduledStartTime: newStartTime,
|
||||||
|
scheduledEndTime: newEndTime,
|
||||||
|
estimatedDuration: newDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
isTaskResizing = false;
|
||||||
|
resizeTask = null;
|
||||||
|
hasMoved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Sidebar Task Drop ==========
|
||||||
|
let sidebarDropTarget = $state<{ day: Date; y: number } | null>(null);
|
||||||
|
|
||||||
|
function handleSidebarDragOver(e: DragEvent, day: Date) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!e.dataTransfer) return;
|
||||||
|
const types = e.dataTransfer.types;
|
||||||
|
if (!types.includes('application/json')) return;
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
sidebarDropTarget = { day, y: e.clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSidebarDragLeave(e: DragEvent) {
|
||||||
|
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||||
|
if (!relatedTarget?.closest('.day-column')) {
|
||||||
|
sidebarDropTarget = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSidebarDrop(e: DragEvent, day: Date) {
|
||||||
|
e.preventDefault();
|
||||||
|
sidebarDropTarget = null;
|
||||||
|
|
||||||
|
if (!e.dataTransfer) return;
|
||||||
|
|
||||||
|
const jsonData = e.dataTransfer.getData('application/json');
|
||||||
|
if (!jsonData) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonData);
|
||||||
|
if (data.type !== 'sidebar-task') return;
|
||||||
|
|
||||||
|
const dayColumn = (e.target as HTMLElement).closest('.day-column');
|
||||||
|
if (!dayColumn) return;
|
||||||
|
|
||||||
|
const rect = dayColumn.getBoundingClientRect();
|
||||||
|
const relativeY = e.clientY - rect.top;
|
||||||
|
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||||
|
|
||||||
|
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||||
|
const rawMinutes = percentY * minutesPerPercent;
|
||||||
|
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||||
|
const totalMinutes = firstVisibleHour * 60 + snappedMinutes;
|
||||||
|
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
const startTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const duration = data.estimatedDuration || 30;
|
||||||
|
const endMinutes = totalMinutes + duration;
|
||||||
|
const endHours = Math.floor(endMinutes / 60);
|
||||||
|
const endMins = endMinutes % 60;
|
||||||
|
const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
await todosStore.updateTodo(data.taskId, {
|
||||||
|
scheduledDate: format(day, 'yyyy-MM-dd'),
|
||||||
|
scheduledStartTime: startTime,
|
||||||
|
scheduledEndTime: endTime,
|
||||||
|
estimatedDuration: duration,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse drop data:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Keyboard Handling ==========
|
// ========== Keyboard Handling ==========
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape' && (isDragging || isResizing)) {
|
if (e.key === 'Escape' && (isDragging || isResizing || isTaskDragging || isTaskResizing)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
document.removeEventListener('pointermove', handleDragMove);
|
document.removeEventListener('pointermove', handleDragMove);
|
||||||
document.removeEventListener('pointerup', handleDragEnd);
|
document.removeEventListener('pointerup', handleDragEnd);
|
||||||
document.removeEventListener('pointermove', handleResizeMove);
|
document.removeEventListener('pointermove', handleResizeMove);
|
||||||
document.removeEventListener('pointerup', handleResizeEnd);
|
document.removeEventListener('pointerup', handleResizeEnd);
|
||||||
|
document.removeEventListener('pointermove', handleTaskDragMove);
|
||||||
|
document.removeEventListener('pointerup', handleTaskDragEnd);
|
||||||
|
document.removeEventListener('pointermove', handleTaskResizeMove);
|
||||||
|
document.removeEventListener('pointerup', handleTaskResizeEnd);
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
draggedEvent = null;
|
draggedEvent = null;
|
||||||
dragTargetDay = null;
|
dragTargetDay = null;
|
||||||
|
|
@ -453,6 +743,11 @@
|
||||||
resizeEvent = null;
|
resizeEvent = null;
|
||||||
resizeOriginalStart = null;
|
resizeOriginalStart = null;
|
||||||
resizeOriginalEnd = null;
|
resizeOriginalEnd = null;
|
||||||
|
isTaskDragging = false;
|
||||||
|
draggedTask = null;
|
||||||
|
taskDragTargetDay = null;
|
||||||
|
isTaskResizing = false;
|
||||||
|
resizeTask = null;
|
||||||
hasMoved = false;
|
hasMoved = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -516,7 +811,15 @@
|
||||||
<!-- Day columns -->
|
<!-- Day columns -->
|
||||||
<div class="days-container" bind:this={daysContainerEl}>
|
<div class="days-container" bind:this={daysContainerEl}>
|
||||||
{#each days as day}
|
{#each days as day}
|
||||||
<div class="day-column" class:today={isToday(day)}>
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="day-column"
|
||||||
|
class:today={isToday(day)}
|
||||||
|
class:drop-target={sidebarDropTarget && isSameDay(day, sidebarDropTarget.day)}
|
||||||
|
ondragover={(e) => handleSidebarDragOver(e, day)}
|
||||||
|
ondragleave={handleSidebarDragLeave}
|
||||||
|
ondrop={(e) => handleSidebarDrop(e, day)}
|
||||||
|
>
|
||||||
{#each hours as hour}
|
{#each hours as hour}
|
||||||
<button
|
<button
|
||||||
class="hour-slot"
|
class="hour-slot"
|
||||||
|
|
@ -542,13 +845,16 @@
|
||||||
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
||||||
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
|
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
|
||||||
{@const isDraft = eventsStore.isDraftEvent(event.id)}
|
{@const isDraft = eventsStore.isDraftEvent(event.id)}
|
||||||
|
{@const isCrossDayDrag =
|
||||||
|
isBeingDragged && dragTargetDay && !isSameDay(day, dragTargetDay)}
|
||||||
<div
|
<div
|
||||||
class="event-card"
|
class="event-card"
|
||||||
class:dragging={isBeingDragged}
|
class:dragging={isBeingDragged && !isCrossDayDrag}
|
||||||
|
class:dragging-source={isCrossDayDrag}
|
||||||
class:resizing={isBeingResized}
|
class:resizing={isBeingResized}
|
||||||
class:draft={isDraft}
|
class:draft={isDraft}
|
||||||
data-event-id={event.id}
|
data-event-id={event.id}
|
||||||
style={isBeingDragged
|
style={isBeingDragged && !isCrossDayDrag
|
||||||
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||||
: isBeingResized
|
: isBeingResized
|
||||||
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||||
|
|
@ -587,10 +893,43 @@
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Drag preview ghost (for cross-day dragging) -->
|
<!-- Scheduled Tasks (Time-Blocking) -->
|
||||||
{#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some((e) => e.id === draggedEvent.id)}
|
{#each getScheduledTasksForDay(day) as task (task.id)}
|
||||||
|
{@const isTaskBeingDragged = isTaskDragging && draggedTask?.id === task.id}
|
||||||
|
{@const isTaskBeingResized = isTaskResizing && resizeTask?.id === task.id}
|
||||||
|
{@const isTaskCrossDayDrag =
|
||||||
|
isTaskBeingDragged &&
|
||||||
|
taskDragTargetDay !== null &&
|
||||||
|
!isSameDay(day, taskDragTargetDay)}
|
||||||
|
<TaskBlock
|
||||||
|
{task}
|
||||||
|
style={isTaskBeingDragged && !isTaskCrossDayDrag
|
||||||
|
? `top: ${taskDragPreviewTop}%; height: ${taskDragPreviewHeight}%;`
|
||||||
|
: isTaskBeingResized
|
||||||
|
? `top: ${taskResizePreviewTop}%; height: ${taskResizePreviewHeight}%;`
|
||||||
|
: getTaskStyle(task)}
|
||||||
|
{onTaskClick}
|
||||||
|
onDragStart={handleTaskDragStart}
|
||||||
|
onResizeStart={handleTaskResizeStart}
|
||||||
|
isDragging={isTaskBeingDragged && !isTaskCrossDayDrag}
|
||||||
|
isResizing={isTaskBeingResized}
|
||||||
|
isDraggingSource={isTaskCrossDayDrag}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Task Drag preview (solid) for cross-day dragging - shows where task will be -->
|
||||||
|
{#if isTaskDragging && draggedTask && taskDragTargetDay && isSameDay(day, taskDragTargetDay) && !getScheduledTasksForDay(day).some((t) => t.id === draggedTask!.id)}
|
||||||
|
<TaskBlock
|
||||||
|
task={draggedTask}
|
||||||
|
style="top: {taskDragPreviewTop}%; height: {taskDragPreviewHeight}%;"
|
||||||
|
isDragging={true}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Drag preview (solid) for cross-day dragging - shows where event will be -->
|
||||||
|
{#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some((e) => e.id === draggedEvent!.id)}
|
||||||
<div
|
<div
|
||||||
class="event-card drag-ghost"
|
class="event-card drag-preview"
|
||||||
style="top: {dragPreviewTop}%; height: {dragPreviewHeight}%; background-color: {calendarsStore.getColor(
|
style="top: {dragPreviewTop}%; height: {dragPreviewHeight}%; background-color: {calendarsStore.getColor(
|
||||||
draggedEvent.calendarId
|
draggedEvent.calendarId
|
||||||
)};"
|
)};"
|
||||||
|
|
@ -874,10 +1213,51 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-card.drag-ghost {
|
/* Ghost style for source position during cross-day drag */
|
||||||
opacity: 0.6;
|
.event-card.dragging-source {
|
||||||
|
opacity: 0.4;
|
||||||
|
background: transparent !important;
|
||||||
|
border: 2px dashed hsl(var(--color-border));
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
border: 2px dashed white;
|
}
|
||||||
|
|
||||||
|
.event-card.dragging-source .event-title,
|
||||||
|
.event-card.dragging-source .event-time {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Solid preview at target position during cross-day drag */
|
||||||
|
.event-card.drag-preview {
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task drag ghost */
|
||||||
|
.task-drag-ghost {
|
||||||
|
position: absolute;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: hsl(var(--color-surface) / 0.8);
|
||||||
|
border: 2px dashed hsl(var(--color-primary));
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
opacity: 0.7;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 50;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-drag-ghost .task-title {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar task drop target */
|
||||||
|
.day-column.drop-target {
|
||||||
|
background: hsl(var(--color-primary) / 0.15);
|
||||||
|
outline: 2px dashed hsl(var(--color-primary));
|
||||||
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact .event-card,
|
.compact .event-card,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,297 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Task } from '$lib/api/todos';
|
||||||
|
import { todosStore } from '$lib/stores/todos.svelte';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { CheckSquare, Square } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
task: Task;
|
||||||
|
style: string;
|
||||||
|
onTaskClick?: (task: Task) => void;
|
||||||
|
onDragStart?: (task: Task, e: PointerEvent) => void;
|
||||||
|
onResizeStart?: (task: Task, edge: 'top' | 'bottom', e: PointerEvent) => void;
|
||||||
|
isDragging?: boolean;
|
||||||
|
isResizing?: boolean;
|
||||||
|
isDraggingSource?: boolean; // True when this is the source of a cross-day drag (shows as ghost)
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
task,
|
||||||
|
style,
|
||||||
|
onTaskClick,
|
||||||
|
onDragStart,
|
||||||
|
onResizeStart,
|
||||||
|
isDragging = false,
|
||||||
|
isResizing = false,
|
||||||
|
isDraggingSource = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Priority colors
|
||||||
|
const PRIORITY_COLORS: Record<string, string> = {
|
||||||
|
urgent: 'hsl(0, 72%, 51%)', // red
|
||||||
|
high: 'hsl(25, 95%, 53%)', // orange
|
||||||
|
medium: 'hsl(48, 96%, 53%)', // yellow
|
||||||
|
low: 'hsl(142, 71%, 45%)', // green
|
||||||
|
};
|
||||||
|
|
||||||
|
let priorityColor = $derived(PRIORITY_COLORS[task.priority] || PRIORITY_COLORS.medium);
|
||||||
|
|
||||||
|
async function toggleComplete(e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
await todosStore.toggleComplete(task.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
// Don't trigger click if we just finished dragging
|
||||||
|
if (isDragging || isResizing) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onTaskClick?.(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onTaskClick?.(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(e: PointerEvent) {
|
||||||
|
// Don't allow dragging completed tasks
|
||||||
|
if (task.isCompleted) return;
|
||||||
|
// Don't start drag from checkbox
|
||||||
|
if ((e.target as HTMLElement).closest('.task-checkbox')) return;
|
||||||
|
// Don't start drag from resize handles
|
||||||
|
if ((e.target as HTMLElement).closest('.resize-handle')) return;
|
||||||
|
|
||||||
|
onDragStart?.(task, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResizeTop(e: PointerEvent) {
|
||||||
|
if (task.isCompleted) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
onResizeStart?.(task, 'top', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResizeBottom(e: PointerEvent) {
|
||||||
|
if (task.isCompleted) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
onResizeStart?.(task, 'bottom', e);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="task-block"
|
||||||
|
class:completed={task.isCompleted}
|
||||||
|
class:dragging={isDragging}
|
||||||
|
class:resizing={isResizing}
|
||||||
|
class:dragging-source={isDraggingSource}
|
||||||
|
{style}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="{$_('todo.task')}: {task.title}"
|
||||||
|
onclick={handleClick}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
onpointerdown={handlePointerDown}
|
||||||
|
>
|
||||||
|
<!-- Top resize handle (only for non-completed tasks) -->
|
||||||
|
{#if onResizeStart && !task.isCompleted}
|
||||||
|
<div
|
||||||
|
class="resize-handle top"
|
||||||
|
onpointerdown={handleResizeTop}
|
||||||
|
role="slider"
|
||||||
|
aria-label={$_('event.changeStartTime')}
|
||||||
|
aria-valuenow={0}
|
||||||
|
tabindex="-1"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="task-priority-indicator" style="background-color: {priorityColor}"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="task-checkbox"
|
||||||
|
onclick={toggleComplete}
|
||||||
|
aria-label={task.isCompleted ? $_('todo.markIncomplete') : $_('todo.markComplete')}
|
||||||
|
>
|
||||||
|
{#if task.isCompleted}
|
||||||
|
<CheckSquare size={14} />
|
||||||
|
{:else}
|
||||||
|
<Square size={14} />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="task-content">
|
||||||
|
<span class="task-time">
|
||||||
|
{task.scheduledStartTime || ''}
|
||||||
|
{#if task.scheduledEndTime}
|
||||||
|
- {task.scheduledEndTime}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="task-title">{task.title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom resize handle (only for non-completed tasks) -->
|
||||||
|
{#if onResizeStart && !task.isCompleted}
|
||||||
|
<div
|
||||||
|
class="resize-handle bottom"
|
||||||
|
onpointerdown={handleResizeBottom}
|
||||||
|
role="slider"
|
||||||
|
aria-label={$_('event.changeEndTime')}
|
||||||
|
aria-valuenow={0}
|
||||||
|
tabindex="-1"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.task-block {
|
||||||
|
position: absolute;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
background: hsl(var(--color-surface));
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: left;
|
||||||
|
cursor: grab;
|
||||||
|
z-index: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
transition:
|
||||||
|
box-shadow 0.15s ease,
|
||||||
|
opacity 0.15s ease;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-block:hover {
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||||
|
border-color: hsl(var(--color-primary) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-block.completed {
|
||||||
|
background: hsl(var(--color-muted) / 0.3);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-block.completed .task-title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-block.completed .task-checkbox {
|
||||||
|
color: hsl(var(--color-success, 142 71% 45%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-block.completed .task-priority-indicator {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-block.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
opacity: 0.9;
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-block.resizing {
|
||||||
|
opacity: 0.85;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
outline: 2px dashed hsl(var(--color-primary) / 0.6);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ghost style for source position during cross-day drag */
|
||||||
|
.task-block.dragging-source {
|
||||||
|
opacity: 0.5;
|
||||||
|
background: transparent;
|
||||||
|
border: 2px dashed hsl(var(--color-border));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-block.dragging-source .task-title,
|
||||||
|
.task-block.dragging-source .task-time,
|
||||||
|
.task-block.dragging-source .task-checkbox {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-priority-indicator {
|
||||||
|
width: 3px;
|
||||||
|
min-height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-checkbox {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-checkbox:hover {
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-time {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resize handles */
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 8px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.top {
|
||||||
|
top: 0;
|
||||||
|
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle.bottom {
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-block:hover .resize-handle {
|
||||||
|
opacity: 1;
|
||||||
|
background: hsl(var(--color-primary) / 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -27,6 +27,8 @@
|
||||||
// Fetch todos on mount
|
// Fetch todos on mount
|
||||||
await todosStore.fetchTodayTodos();
|
await todosStore.fetchTodayTodos();
|
||||||
await todosStore.fetchUpcomingTodos();
|
await todosStore.fetchUpcomingTodos();
|
||||||
|
// Also fetch scheduled todos (including completed) for calendar display
|
||||||
|
await todosStore.fetchScheduledTodos();
|
||||||
});
|
});
|
||||||
|
|
||||||
function toggleExpanded() {
|
function toggleExpanded() {
|
||||||
|
|
@ -114,6 +116,7 @@
|
||||||
{task}
|
{task}
|
||||||
variant="compact"
|
variant="compact"
|
||||||
showProject={false}
|
showProject={false}
|
||||||
|
draggable={!task.isCompleted}
|
||||||
onclick={() => handleTaskClick(task)}
|
onclick={() => handleTaskClick(task)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@
|
||||||
import { eventsStore } from '$lib/stores/events.svelte';
|
import { eventsStore } from '$lib/stores/events.svelte';
|
||||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||||
import { todosStore } from '$lib/stores/todos.svelte';
|
import { todosStore, type Task } from '$lib/stores/todos.svelte';
|
||||||
import TodoRow from './TodoRow.svelte';
|
import TodoRow from './TodoRow.svelte';
|
||||||
|
import TaskBlock from './TaskBlock.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import {
|
import {
|
||||||
format,
|
format,
|
||||||
|
|
@ -25,9 +26,11 @@
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||||
|
onEventClick?: (event: CalendarEvent) => void;
|
||||||
|
onTaskClick?: (task: Task) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onQuickCreate }: Props = $props();
|
let { onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const HOUR_HEIGHT = 60; // px - should match CSS --hour-height
|
const HOUR_HEIGHT = 60; // px - should match CSS --hour-height
|
||||||
|
|
@ -112,6 +115,20 @@
|
||||||
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
||||||
let hasMoved = $state(false);
|
let hasMoved = $state(false);
|
||||||
|
|
||||||
|
// Task Drag & Drop State
|
||||||
|
let isTaskDragging = $state(false);
|
||||||
|
let draggedTask = $state<Task | null>(null);
|
||||||
|
let taskDragTargetDay = $state<Date | null>(null);
|
||||||
|
let taskDragPreviewTop = $state(0);
|
||||||
|
let taskDragPreviewHeight = $state(0);
|
||||||
|
|
||||||
|
// Task Resize State
|
||||||
|
let isTaskResizing = $state(false);
|
||||||
|
let resizeTask = $state<Task | null>(null);
|
||||||
|
let taskResizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||||
|
let taskResizePreviewTop = $state(0);
|
||||||
|
let taskResizePreviewHeight = $state(0);
|
||||||
|
|
||||||
// Reference to the days container for position calculations
|
// Reference to the days container for position calculations
|
||||||
let daysContainerEl: HTMLDivElement;
|
let daysContainerEl: HTMLDivElement;
|
||||||
|
|
||||||
|
|
@ -157,6 +174,37 @@
|
||||||
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
|
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get style for a scheduled task (time-blocking)
|
||||||
|
*/
|
||||||
|
function getTaskStyle(task: Task): string {
|
||||||
|
if (!task.scheduledStartTime) return '';
|
||||||
|
|
||||||
|
// Parse HH:mm time
|
||||||
|
const [startHour, startMin] = task.scheduledStartTime.split(':').map(Number);
|
||||||
|
const startMinutes = startHour * 60 + startMin;
|
||||||
|
|
||||||
|
// Calculate duration - use estimatedDuration or scheduledEndTime or default 30 min
|
||||||
|
let duration = task.estimatedDuration || 30;
|
||||||
|
if (task.scheduledEndTime) {
|
||||||
|
const [endHour, endMin] = task.scheduledEndTime.split(':').map(Number);
|
||||||
|
const endMinutes = endHour * 60 + endMin;
|
||||||
|
duration = endMinutes - startMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = minutesToPercent(startMinutes);
|
||||||
|
const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 2);
|
||||||
|
|
||||||
|
return `top: ${top}%; height: ${height}%;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scheduled tasks for a specific day
|
||||||
|
*/
|
||||||
|
function getScheduledTasksForDay(day: Date): Task[] {
|
||||||
|
return todosStore.getScheduledTasksForDay(day);
|
||||||
|
}
|
||||||
|
|
||||||
function formatEventTime(date: Date | string): string {
|
function formatEventTime(date: Date | string): string {
|
||||||
const d = typeof date === 'string' ? parseISO(date) : date;
|
const d = typeof date === 'string' ? parseISO(date) : date;
|
||||||
return settingsStore.formatTime(d);
|
return settingsStore.formatTime(d);
|
||||||
|
|
@ -439,6 +487,263 @@
|
||||||
hasMoved = false;
|
hasMoved = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Task Drag & Drop ==========
|
||||||
|
|
||||||
|
function handleTaskDragStart(task: Task, e: PointerEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isTaskDragging = true;
|
||||||
|
draggedTask = task;
|
||||||
|
hasMoved = false;
|
||||||
|
|
||||||
|
// Initialize preview position
|
||||||
|
if (task.scheduledStartTime) {
|
||||||
|
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||||
|
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||||
|
taskDragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = task.estimatedDuration || 30;
|
||||||
|
taskDragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', handleTaskDragMove);
|
||||||
|
document.addEventListener('pointerup', handleTaskDragEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTaskDragMove(e: PointerEvent) {
|
||||||
|
if (!isTaskDragging || !draggedTask) return;
|
||||||
|
hasMoved = true;
|
||||||
|
|
||||||
|
// Find which day column we're over
|
||||||
|
const daysEl = daysContainerEl;
|
||||||
|
if (!daysEl) return;
|
||||||
|
|
||||||
|
const dayColumns = daysEl.querySelectorAll('.day-column');
|
||||||
|
for (let i = 0; i < dayColumns.length; i++) {
|
||||||
|
const col = dayColumns[i];
|
||||||
|
const rect = col.getBoundingClientRect();
|
||||||
|
if (e.clientX >= rect.left && e.clientX <= rect.right) {
|
||||||
|
taskDragTargetDay = days[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate vertical position
|
||||||
|
const targetColumn = daysEl.querySelector('.day-column');
|
||||||
|
if (!targetColumn) return;
|
||||||
|
const rect = targetColumn.getBoundingClientRect();
|
||||||
|
const relativeY = e.clientY - rect.top;
|
||||||
|
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||||
|
|
||||||
|
// Snap to 15-minute intervals
|
||||||
|
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||||
|
const rawMinutes = percentY * minutesPerPercent;
|
||||||
|
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||||
|
taskDragPreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTaskDragEnd(e: PointerEvent) {
|
||||||
|
document.removeEventListener('pointermove', handleTaskDragMove);
|
||||||
|
document.removeEventListener('pointerup', handleTaskDragEnd);
|
||||||
|
|
||||||
|
if (!isTaskDragging || !draggedTask || !hasMoved) {
|
||||||
|
isTaskDragging = false;
|
||||||
|
draggedTask = null;
|
||||||
|
taskDragTargetDay = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new time from position
|
||||||
|
const minutesFromStart = (taskDragPreviewTop / 100) * (totalVisibleHours * 60);
|
||||||
|
const totalMinutes = firstVisibleHour * 60 + minutesFromStart;
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = Math.round(totalMinutes % 60);
|
||||||
|
|
||||||
|
const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Calculate end time based on duration
|
||||||
|
const duration = draggedTask.estimatedDuration || 30;
|
||||||
|
const endTotalMinutes = totalMinutes + duration;
|
||||||
|
const endHours = Math.floor(endTotalMinutes / 60);
|
||||||
|
const endMins = Math.round(endTotalMinutes % 60);
|
||||||
|
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
await todosStore.updateTodo(draggedTask.id, {
|
||||||
|
scheduledDate: taskDragTargetDay ? format(taskDragTargetDay, 'yyyy-MM-dd') : undefined,
|
||||||
|
scheduledStartTime: newStartTime,
|
||||||
|
scheduledEndTime: newEndTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
isTaskDragging = false;
|
||||||
|
draggedTask = null;
|
||||||
|
taskDragTargetDay = null;
|
||||||
|
hasMoved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Task Resize ==========
|
||||||
|
|
||||||
|
function handleTaskResizeStart(task: Task, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
isTaskResizing = true;
|
||||||
|
resizeTask = task;
|
||||||
|
taskResizeEdge = edge;
|
||||||
|
hasMoved = false;
|
||||||
|
|
||||||
|
// Initialize preview position
|
||||||
|
if (task.scheduledStartTime) {
|
||||||
|
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||||
|
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||||
|
taskResizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = task.estimatedDuration || 30;
|
||||||
|
taskResizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', handleTaskResizeMove);
|
||||||
|
document.addEventListener('pointerup', handleTaskResizeEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTaskResizeMove(e: PointerEvent) {
|
||||||
|
if (!isTaskResizing || !resizeTask) return;
|
||||||
|
hasMoved = true;
|
||||||
|
|
||||||
|
const daysEl = daysContainerEl;
|
||||||
|
if (!daysEl) return;
|
||||||
|
|
||||||
|
const targetColumn = daysEl.querySelector('.day-column');
|
||||||
|
if (!targetColumn) return;
|
||||||
|
|
||||||
|
const rect = targetColumn.getBoundingClientRect();
|
||||||
|
const relativeY = e.clientY - rect.top;
|
||||||
|
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||||
|
|
||||||
|
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||||
|
|
||||||
|
if (taskResizeEdge === 'top') {
|
||||||
|
// Adjust start time, keep end fixed
|
||||||
|
const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight;
|
||||||
|
const rawMinutes = percentY * minutesPerPercent;
|
||||||
|
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||||
|
taskResizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
taskResizePreviewHeight = Math.max(2, originalEndPercent - taskResizePreviewTop);
|
||||||
|
} else {
|
||||||
|
// Adjust end time, keep start fixed
|
||||||
|
const rawMinutes = percentY * minutesPerPercent;
|
||||||
|
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||||
|
const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTaskResizeEnd(e: PointerEvent) {
|
||||||
|
document.removeEventListener('pointermove', handleTaskResizeMove);
|
||||||
|
document.removeEventListener('pointerup', handleTaskResizeEnd);
|
||||||
|
|
||||||
|
if (!isTaskResizing || !resizeTask || !hasMoved) {
|
||||||
|
isTaskResizing = false;
|
||||||
|
resizeTask = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new times from position
|
||||||
|
const startMinutes =
|
||||||
|
(taskResizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
|
||||||
|
const endMinutes =
|
||||||
|
((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (totalVisibleHours * 60) +
|
||||||
|
firstVisibleHour * 60;
|
||||||
|
|
||||||
|
const startHours = Math.floor(startMinutes / 60);
|
||||||
|
const startMins = Math.round(startMinutes % 60);
|
||||||
|
const endHours = Math.floor(endMinutes / 60);
|
||||||
|
const endMins = Math.round(endMinutes % 60);
|
||||||
|
|
||||||
|
const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`;
|
||||||
|
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||||
|
const newDuration = Math.round(endMinutes - startMinutes);
|
||||||
|
|
||||||
|
await todosStore.updateTodo(resizeTask.id, {
|
||||||
|
scheduledStartTime: newStartTime,
|
||||||
|
scheduledEndTime: newEndTime,
|
||||||
|
estimatedDuration: newDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
isTaskResizing = false;
|
||||||
|
resizeTask = null;
|
||||||
|
hasMoved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Sidebar Task Drop ==========
|
||||||
|
let sidebarDropTarget = $state<{ day: Date; y: number } | null>(null);
|
||||||
|
|
||||||
|
function handleSidebarDragOver(e: DragEvent, day: Date) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!e.dataTransfer) return;
|
||||||
|
|
||||||
|
// Check if this is a sidebar task drag
|
||||||
|
const types = e.dataTransfer.types;
|
||||||
|
if (!types.includes('application/json')) return;
|
||||||
|
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
sidebarDropTarget = { day, y: e.clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSidebarDragLeave(e: DragEvent) {
|
||||||
|
// Only clear if leaving the column entirely
|
||||||
|
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||||
|
if (!relatedTarget?.closest('.day-column')) {
|
||||||
|
sidebarDropTarget = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSidebarDrop(e: DragEvent, day: Date) {
|
||||||
|
e.preventDefault();
|
||||||
|
sidebarDropTarget = null;
|
||||||
|
|
||||||
|
if (!e.dataTransfer) return;
|
||||||
|
|
||||||
|
const jsonData = e.dataTransfer.getData('application/json');
|
||||||
|
if (!jsonData) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonData);
|
||||||
|
if (data.type !== 'sidebar-task') return;
|
||||||
|
|
||||||
|
// Calculate drop time from Y position
|
||||||
|
const dayColumn = (e.target as HTMLElement).closest('.day-column');
|
||||||
|
if (!dayColumn) return;
|
||||||
|
|
||||||
|
const rect = dayColumn.getBoundingClientRect();
|
||||||
|
const relativeY = e.clientY - rect.top;
|
||||||
|
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||||
|
|
||||||
|
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||||
|
const rawMinutes = percentY * minutesPerPercent;
|
||||||
|
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||||
|
const totalMinutes = firstVisibleHour * 60 + snappedMinutes;
|
||||||
|
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
const startTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Calculate end time
|
||||||
|
const duration = data.estimatedDuration || 30;
|
||||||
|
const endMinutes = totalMinutes + duration;
|
||||||
|
const endHours = Math.floor(endMinutes / 60);
|
||||||
|
const endMins = endMinutes % 60;
|
||||||
|
const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Update the task with scheduled time
|
||||||
|
await todosStore.updateTodo(data.taskId, {
|
||||||
|
scheduledDate: format(day, 'yyyy-MM-dd'),
|
||||||
|
scheduledStartTime: startTime,
|
||||||
|
scheduledEndTime: endTime,
|
||||||
|
estimatedDuration: duration,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse drop data:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Keyboard Handling ==========
|
// ========== Keyboard Handling ==========
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
|
@ -459,6 +764,20 @@
|
||||||
resizeOriginalEnd = null;
|
resizeOriginalEnd = null;
|
||||||
hasMoved = false;
|
hasMoved = false;
|
||||||
}
|
}
|
||||||
|
// Cancel task drag/resize
|
||||||
|
if (isTaskDragging || isTaskResizing) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.removeEventListener('pointermove', handleTaskDragMove);
|
||||||
|
document.removeEventListener('pointerup', handleTaskDragEnd);
|
||||||
|
document.removeEventListener('pointermove', handleTaskResizeMove);
|
||||||
|
document.removeEventListener('pointerup', handleTaskResizeEnd);
|
||||||
|
isTaskDragging = false;
|
||||||
|
draggedTask = null;
|
||||||
|
taskDragTargetDay = null;
|
||||||
|
isTaskResizing = false;
|
||||||
|
resizeTask = null;
|
||||||
|
hasMoved = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -538,7 +857,15 @@
|
||||||
<!-- Day columns -->
|
<!-- Day columns -->
|
||||||
<div class="days-container" bind:this={daysContainerEl}>
|
<div class="days-container" bind:this={daysContainerEl}>
|
||||||
{#each days as day, dayIndex}
|
{#each days as day, dayIndex}
|
||||||
<div class="day-column" class:today={isToday(day)}>
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="day-column"
|
||||||
|
class:today={isToday(day)}
|
||||||
|
class:drop-target={sidebarDropTarget && isSameDay(day, sidebarDropTarget.day)}
|
||||||
|
ondragover={(e) => handleSidebarDragOver(e, day)}
|
||||||
|
ondragleave={handleSidebarDragLeave}
|
||||||
|
ondrop={(e) => handleSidebarDrop(e, day)}
|
||||||
|
>
|
||||||
{#each hours as hour}
|
{#each hours as hour}
|
||||||
<button
|
<button
|
||||||
class="hour-slot"
|
class="hour-slot"
|
||||||
|
|
@ -563,13 +890,16 @@
|
||||||
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
||||||
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
|
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
|
||||||
{@const isDraft = eventsStore.isDraftEvent(event.id)}
|
{@const isDraft = eventsStore.isDraftEvent(event.id)}
|
||||||
|
{@const isCrossDayDrag =
|
||||||
|
isBeingDragged && dragTargetDay && !isSameDay(day, dragTargetDay)}
|
||||||
<div
|
<div
|
||||||
class="event-card"
|
class="event-card"
|
||||||
class:dragging={isBeingDragged}
|
class:dragging={isBeingDragged && !isCrossDayDrag}
|
||||||
|
class:dragging-source={isCrossDayDrag}
|
||||||
class:resizing={isBeingResized}
|
class:resizing={isBeingResized}
|
||||||
class:draft={isDraft}
|
class:draft={isDraft}
|
||||||
data-event-id={event.id}
|
data-event-id={event.id}
|
||||||
style={isBeingDragged
|
style={isBeingDragged && !isCrossDayDrag
|
||||||
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||||
: isBeingResized
|
: isBeingResized
|
||||||
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||||
|
|
@ -605,10 +935,43 @@
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Drag preview ghost (for cross-day dragging) -->
|
<!-- Scheduled Tasks (Time-Blocking) -->
|
||||||
{#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some((e) => e.id === draggedEvent.id)}
|
{#each getScheduledTasksForDay(day) as task (task.id)}
|
||||||
|
{@const isTaskBeingDragged = isTaskDragging && draggedTask?.id === task.id}
|
||||||
|
{@const isTaskBeingResized = isTaskResizing && resizeTask?.id === task.id}
|
||||||
|
{@const isTaskCrossDayDrag =
|
||||||
|
isTaskBeingDragged &&
|
||||||
|
taskDragTargetDay !== null &&
|
||||||
|
!isSameDay(day, taskDragTargetDay)}
|
||||||
|
<TaskBlock
|
||||||
|
{task}
|
||||||
|
style={isTaskBeingDragged && !isTaskCrossDayDrag
|
||||||
|
? `top: ${taskDragPreviewTop}%; height: ${taskDragPreviewHeight}%;`
|
||||||
|
: isTaskBeingResized
|
||||||
|
? `top: ${taskResizePreviewTop}%; height: ${taskResizePreviewHeight}%;`
|
||||||
|
: getTaskStyle(task)}
|
||||||
|
{onTaskClick}
|
||||||
|
onDragStart={handleTaskDragStart}
|
||||||
|
onResizeStart={handleTaskResizeStart}
|
||||||
|
isDragging={isTaskBeingDragged && !isTaskCrossDayDrag}
|
||||||
|
isResizing={isTaskBeingResized}
|
||||||
|
isDraggingSource={isTaskCrossDayDrag}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Task Drag preview (solid) for cross-day dragging - shows where task will be -->
|
||||||
|
{#if isTaskDragging && draggedTask && taskDragTargetDay && isSameDay(day, taskDragTargetDay) && !getScheduledTasksForDay(day).some((t) => t.id === draggedTask!.id)}
|
||||||
|
<TaskBlock
|
||||||
|
task={draggedTask}
|
||||||
|
style="top: {taskDragPreviewTop}%; height: {taskDragPreviewHeight}%;"
|
||||||
|
isDragging={true}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Drag preview (solid) for cross-day dragging - shows where event will be -->
|
||||||
|
{#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some((e) => e.id === draggedEvent!.id)}
|
||||||
<div
|
<div
|
||||||
class="event-card drag-ghost"
|
class="event-card drag-preview"
|
||||||
style="top: {dragPreviewTop}%; height: {dragPreviewHeight}%; background-color: {calendarsStore.getColor(
|
style="top: {dragPreviewTop}%; height: {dragPreviewHeight}%; background-color: {calendarsStore.getColor(
|
||||||
draggedEvent.calendarId
|
draggedEvent.calendarId
|
||||||
)};"
|
)};"
|
||||||
|
|
@ -796,6 +1159,12 @@
|
||||||
background: hsl(var(--color-primary) / 0.05);
|
background: hsl(var(--color-primary) / 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.day-column.drop-target {
|
||||||
|
background: hsl(var(--color-primary) / 0.15);
|
||||||
|
outline: 2px dashed hsl(var(--color-primary));
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
.hour-slot {
|
.hour-slot {
|
||||||
height: var(--hour-height);
|
height: var(--hour-height);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -847,10 +1216,44 @@
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-card.drag-ghost {
|
/* Ghost style for source position during cross-day drag */
|
||||||
opacity: 0.6;
|
.event-card.dragging-source {
|
||||||
|
opacity: 0.4;
|
||||||
|
background: transparent !important;
|
||||||
|
border: 2px dashed hsl(var(--color-border));
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
border: 2px dashed white;
|
}
|
||||||
|
|
||||||
|
.event-card.dragging-source .event-title,
|
||||||
|
.event-card.dragging-source .event-time {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Solid preview at target position during cross-day drag */
|
||||||
|
.event-card.drag-preview {
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task drag ghost */
|
||||||
|
.task-drag-ghost {
|
||||||
|
position: absolute;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: hsl(var(--color-surface) / 0.8);
|
||||||
|
border: 2px dashed hsl(var(--color-primary));
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
opacity: 0.7;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 50;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-drag-ghost .task-title {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-card.draft {
|
.event-card.draft {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { EventAttendee, AttendeeStatus } from '@calendar/shared';
|
||||||
|
import type { ContactSummary, ContactOrManual, ManualContactEntry } from '@manacore/shared-types';
|
||||||
|
import { ContactSelector, ContactAvatar } from '@manacore/shared-ui';
|
||||||
|
import { Check, X, HelpCircle, Clock, ChevronDown } from 'lucide-svelte';
|
||||||
|
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
attendees: EventAttendee[];
|
||||||
|
onAttendeesChange: (attendees: EventAttendee[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { attendees, onAttendeesChange, disabled = false }: Props = $props();
|
||||||
|
|
||||||
|
let contactsAvailable = $state<boolean | null>(null);
|
||||||
|
let showStatusDropdown = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Check contacts availability on mount
|
||||||
|
$effect(() => {
|
||||||
|
contactsStore.checkAvailability().then((available) => {
|
||||||
|
contactsAvailable = available;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert attendees to ContactOrManual format for the selector
|
||||||
|
const selectedContacts = $derived<ContactOrManual[]>(
|
||||||
|
attendees.map((a) => {
|
||||||
|
if (a.contactId) {
|
||||||
|
return {
|
||||||
|
contactId: a.contactId,
|
||||||
|
displayName: a.name || a.email,
|
||||||
|
email: a.email,
|
||||||
|
photoUrl: a.photoUrl,
|
||||||
|
company: a.company,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Manual entry
|
||||||
|
return {
|
||||||
|
email: a.email,
|
||||||
|
name: a.name,
|
||||||
|
isManual: true as const,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleContactsChange(contacts: ContactOrManual[]) {
|
||||||
|
const newAttendees: EventAttendee[] = contacts.map((contact) => {
|
||||||
|
if ('isManual' in contact && contact.isManual) {
|
||||||
|
// Manual entry
|
||||||
|
const manual = contact as ManualContactEntry;
|
||||||
|
// Preserve existing status if email matches
|
||||||
|
const existing = attendees.find((a) => a.email === manual.email);
|
||||||
|
return {
|
||||||
|
email: manual.email,
|
||||||
|
name: manual.name,
|
||||||
|
status: existing?.status || ('pending' as AttendeeStatus),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Contact reference
|
||||||
|
const contactRef = contact as {
|
||||||
|
contactId: string;
|
||||||
|
displayName: string;
|
||||||
|
email?: string;
|
||||||
|
photoUrl?: string;
|
||||||
|
company?: string;
|
||||||
|
};
|
||||||
|
// Preserve existing status if contactId or email matches
|
||||||
|
const existing = attendees.find(
|
||||||
|
(a) => a.contactId === contactRef.contactId || a.email === contactRef.email
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
email: contactRef.email || '',
|
||||||
|
name: contactRef.displayName,
|
||||||
|
status: existing?.status || ('pending' as AttendeeStatus),
|
||||||
|
contactId: contactRef.contactId,
|
||||||
|
photoUrl: contactRef.photoUrl,
|
||||||
|
company: contactRef.company,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onAttendeesChange(newAttendees);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch(query: string): Promise<ContactSummary[]> {
|
||||||
|
return contactsStore.searchContacts(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatusChange(email: string, status: AttendeeStatus) {
|
||||||
|
const updated = attendees.map((a) => (a.email === email ? { ...a, status } : a));
|
||||||
|
onAttendeesChange(updated);
|
||||||
|
showStatusDropdown = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveAttendee(email: string) {
|
||||||
|
onAttendeesChange(attendees.filter((a) => a.email !== email));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status?: AttendeeStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'accepted':
|
||||||
|
return 'text-green-600 bg-green-100 dark:text-green-400 dark:bg-green-900/30';
|
||||||
|
case 'declined':
|
||||||
|
return 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900/30';
|
||||||
|
case 'tentative':
|
||||||
|
return 'text-yellow-600 bg-yellow-100 dark:text-yellow-400 dark:bg-yellow-900/30';
|
||||||
|
default:
|
||||||
|
return 'text-gray-500 bg-gray-100 dark:text-gray-400 dark:bg-gray-800';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status?: AttendeeStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'accepted':
|
||||||
|
return 'Zugesagt';
|
||||||
|
case 'declined':
|
||||||
|
return 'Abgesagt';
|
||||||
|
case 'tentative':
|
||||||
|
return 'Vorbehaltlich';
|
||||||
|
default:
|
||||||
|
return 'Ausstehend';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusOptions: { value: AttendeeStatus; label: string }[] = [
|
||||||
|
{ value: 'pending', label: 'Ausstehend' },
|
||||||
|
{ value: 'accepted', label: 'Zugesagt' },
|
||||||
|
{ value: 'tentative', label: 'Vorbehaltlich' },
|
||||||
|
{ value: 'declined', label: 'Abgesagt' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="attendee-selector">
|
||||||
|
<!-- Existing Attendees with Status -->
|
||||||
|
{#if attendees.length > 0}
|
||||||
|
<div class="space-y-2 mb-4">
|
||||||
|
{#each attendees as attendee (attendee.email)}
|
||||||
|
<div class="flex items-center gap-3 p-2 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<ContactAvatar
|
||||||
|
photoUrl={attendee.photoUrl}
|
||||||
|
name={attendee.name || attendee.email}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-foreground truncate">
|
||||||
|
{attendee.name || attendee.email}
|
||||||
|
</div>
|
||||||
|
{#if attendee.name && attendee.email}
|
||||||
|
<div class="text-xs text-muted-foreground truncate">
|
||||||
|
{attendee.email}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if attendee.company}
|
||||||
|
<div class="text-xs text-muted-foreground truncate">
|
||||||
|
{attendee.company}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Dropdown -->
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() =>
|
||||||
|
(showStatusDropdown =
|
||||||
|
showStatusDropdown === attendee.email ? null : attendee.email)}
|
||||||
|
class="
|
||||||
|
flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium
|
||||||
|
{getStatusColor(attendee.status)}
|
||||||
|
hover:opacity-80 transition-opacity
|
||||||
|
"
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
|
{#if attendee.status === 'accepted'}
|
||||||
|
<Check size={12} />
|
||||||
|
{:else if attendee.status === 'declined'}
|
||||||
|
<X size={12} />
|
||||||
|
{:else if attendee.status === 'tentative'}
|
||||||
|
<HelpCircle size={12} />
|
||||||
|
{:else}
|
||||||
|
<Clock size={12} />
|
||||||
|
{/if}
|
||||||
|
<span class="hidden sm:inline">{getStatusLabel(attendee.status)}</span>
|
||||||
|
<ChevronDown size={12} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if showStatusDropdown === attendee.email}
|
||||||
|
<div
|
||||||
|
class="
|
||||||
|
absolute right-0 top-full mt-1 z-50
|
||||||
|
bg-white dark:bg-gray-800
|
||||||
|
border border-gray-200 dark:border-gray-700
|
||||||
|
rounded-lg shadow-lg
|
||||||
|
py-1 min-w-[140px]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{#each statusOptions as option (option.value)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => handleStatusChange(attendee.email, option.value)}
|
||||||
|
class="
|
||||||
|
w-full flex items-center gap-2 px-3 py-1.5
|
||||||
|
text-sm text-left
|
||||||
|
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||||
|
{attendee.status === option.value ? 'bg-gray-50 dark:bg-gray-700/50' : ''}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="{getStatusColor(option.value)} p-0.5 rounded">
|
||||||
|
{#if option.value === 'accepted'}
|
||||||
|
<Check size={12} />
|
||||||
|
{:else if option.value === 'declined'}
|
||||||
|
<X size={12} />
|
||||||
|
{:else if option.value === 'tentative'}
|
||||||
|
<HelpCircle size={12} />
|
||||||
|
{:else}
|
||||||
|
<Clock size={12} />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Remove Button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => handleRemoveAttendee(attendee.email)}
|
||||||
|
class="
|
||||||
|
p-1 rounded-md
|
||||||
|
text-gray-400 hover:text-red-500
|
||||||
|
hover:bg-red-50 dark:hover:bg-red-900/20
|
||||||
|
transition-colors
|
||||||
|
"
|
||||||
|
title="Entfernen"
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Add New Attendees -->
|
||||||
|
<ContactSelector
|
||||||
|
{selectedContacts}
|
||||||
|
onContactsChange={handleContactsChange}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
allowManualEntry={true}
|
||||||
|
placeholder="Teilnehmer hinzufügen..."
|
||||||
|
addLabel="Teilnehmer"
|
||||||
|
searchPlaceholder="Name oder E-Mail..."
|
||||||
|
isAvailable={contactsAvailable ?? false}
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.attendee-selector {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -4,12 +4,14 @@
|
||||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||||
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
|
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
|
||||||
import { TagSelector, type Tag } from '@manacore/shared-ui';
|
import { TagSelector, type Tag } from '@manacore/shared-ui';
|
||||||
|
import AttendeeSelector from './AttendeeSelector.svelte';
|
||||||
import type {
|
import type {
|
||||||
CalendarEvent,
|
CalendarEvent,
|
||||||
CreateEventInput,
|
CreateEventInput,
|
||||||
UpdateEventInput,
|
UpdateEventInput,
|
||||||
LocationDetails,
|
LocationDetails,
|
||||||
EventTag,
|
EventTag,
|
||||||
|
EventAttendee,
|
||||||
} from '@calendar/shared';
|
} from '@calendar/shared';
|
||||||
import { format, addMinutes, parseISO } from 'date-fns';
|
import { format, addMinutes, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
|
@ -49,6 +51,9 @@
|
||||||
})) || []
|
})) || []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Attendees state
|
||||||
|
let attendees = $state<EventAttendee[]>(event?.metadata?.attendees || []);
|
||||||
|
|
||||||
// Convert EventTag to Tag type for shared-ui components
|
// Convert EventTag to Tag type for shared-ui components
|
||||||
function eventTagToTag(tag: EventTag): Tag {
|
function eventTagToTag(tag: EventTag): Tag {
|
||||||
return {
|
return {
|
||||||
|
|
@ -167,6 +172,13 @@
|
||||||
delete metadata.locationDetails;
|
delete metadata.locationDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add attendees
|
||||||
|
if (attendees.length > 0) {
|
||||||
|
metadata.attendees = attendees;
|
||||||
|
} else {
|
||||||
|
delete metadata.attendees;
|
||||||
|
}
|
||||||
|
|
||||||
// Only include metadata if it has properties
|
// Only include metadata if it has properties
|
||||||
const finalMetadata = Object.keys(metadata).length > 0 ? metadata : undefined;
|
const finalMetadata = Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||||
|
|
||||||
|
|
@ -389,6 +401,15 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Teilnehmer -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-sm font-medium text-foreground">Teilnehmer</span>
|
||||||
|
<AttendeeSelector
|
||||||
|
{attendees}
|
||||||
|
onAttendeesChange={(newAttendees) => (attendees = newAttendees)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-4 border-t border-border">
|
<div class="flex justify-end gap-3 pt-4 border-t border-border">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,18 @@
|
||||||
import { toast } from '$lib/stores/toast';
|
import { toast } from '$lib/stores/toast';
|
||||||
import TodoCheckbox from './TodoCheckbox.svelte';
|
import TodoCheckbox from './TodoCheckbox.svelte';
|
||||||
import PriorityBadge from './PriorityBadge.svelte';
|
import PriorityBadge from './PriorityBadge.svelte';
|
||||||
import { X, Calendar, Clock, Folder, Tag, Trash2, CheckSquare, AlertCircle } from 'lucide-svelte';
|
import {
|
||||||
|
X,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
Folder,
|
||||||
|
Tag,
|
||||||
|
Trash2,
|
||||||
|
CheckSquare,
|
||||||
|
AlertCircle,
|
||||||
|
CalendarClock,
|
||||||
|
Timer,
|
||||||
|
} from 'lucide-svelte';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { de } from 'date-fns/locale';
|
import { de } from 'date-fns/locale';
|
||||||
|
|
||||||
|
|
@ -23,12 +34,34 @@
|
||||||
let isDeleting = $state(false);
|
let isDeleting = $state(false);
|
||||||
let isToggling = $state(false);
|
let isToggling = $state(false);
|
||||||
|
|
||||||
// Form state
|
// Form state - initialized with derived values
|
||||||
let title = $state(task.title);
|
let title = $state(initialTask.title);
|
||||||
let description = $state(task.description || '');
|
let description = $state(initialTask.description || '');
|
||||||
let dueDate = $state(task.dueDate ? formatDateForInput(task.dueDate) : '');
|
let dueDate = $state(initialTask.dueDate ? formatDateForInput(initialTask.dueDate) : '');
|
||||||
let dueTime = $state(task.dueTime || '');
|
let dueTime = $state(initialTask.dueTime || '');
|
||||||
let priority = $state<TaskPriority>(task.priority);
|
let priority = $state<TaskPriority>(initialTask.priority);
|
||||||
|
|
||||||
|
// Time-Blocking fields
|
||||||
|
let scheduledDate = $state(
|
||||||
|
initialTask.scheduledDate ? formatDateForInput(initialTask.scheduledDate) : ''
|
||||||
|
);
|
||||||
|
let scheduledStartTime = $state(initialTask.scheduledStartTime || '');
|
||||||
|
let scheduledEndTime = $state(initialTask.scheduledEndTime || '');
|
||||||
|
let estimatedDuration = $state(initialTask.estimatedDuration?.toString() || '');
|
||||||
|
|
||||||
|
// Sync form state when task changes
|
||||||
|
$effect(() => {
|
||||||
|
title = task.title;
|
||||||
|
description = task.description || '';
|
||||||
|
dueDate = task.dueDate ? formatDateForInput(task.dueDate) : '';
|
||||||
|
dueTime = task.dueTime || '';
|
||||||
|
priority = task.priority;
|
||||||
|
// Time-Blocking
|
||||||
|
scheduledDate = task.scheduledDate ? formatDateForInput(task.scheduledDate) : '';
|
||||||
|
scheduledStartTime = task.scheduledStartTime || '';
|
||||||
|
scheduledEndTime = task.scheduledEndTime || '';
|
||||||
|
estimatedDuration = task.estimatedDuration?.toString() || '';
|
||||||
|
});
|
||||||
|
|
||||||
function formatDateForInput(date: string | Date | null | undefined): string {
|
function formatDateForInput(date: string | Date | null | undefined): string {
|
||||||
if (!date) return '';
|
if (!date) return '';
|
||||||
|
|
@ -67,6 +100,11 @@
|
||||||
dueDate: dueDate || null,
|
dueDate: dueDate || null,
|
||||||
dueTime: dueTime || null,
|
dueTime: dueTime || null,
|
||||||
priority,
|
priority,
|
||||||
|
// Time-Blocking
|
||||||
|
scheduledDate: scheduledDate || null,
|
||||||
|
scheduledStartTime: scheduledStartTime || null,
|
||||||
|
scheduledEndTime: scheduledEndTime || null,
|
||||||
|
estimatedDuration: estimatedDuration ? parseInt(estimatedDuration, 10) : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await todosStore.updateTodo(task.id, updateData);
|
const result = await todosStore.updateTodo(task.id, updateData);
|
||||||
|
|
@ -106,6 +144,11 @@
|
||||||
dueDate = task.dueDate ? formatDateForInput(task.dueDate) : '';
|
dueDate = task.dueDate ? formatDateForInput(task.dueDate) : '';
|
||||||
dueTime = task.dueTime || '';
|
dueTime = task.dueTime || '';
|
||||||
priority = task.priority;
|
priority = task.priority;
|
||||||
|
// Time-Blocking
|
||||||
|
scheduledDate = task.scheduledDate ? formatDateForInput(task.scheduledDate) : '';
|
||||||
|
scheduledStartTime = task.scheduledStartTime || '';
|
||||||
|
scheduledEndTime = task.scheduledEndTime || '';
|
||||||
|
estimatedDuration = task.estimatedDuration?.toString() || '';
|
||||||
isEditing = true;
|
isEditing = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,6 +243,42 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Time-Blocking Section -->
|
||||||
|
<div class="form-section">
|
||||||
|
<span class="section-label">
|
||||||
|
<CalendarClock size={16} />
|
||||||
|
Zeitplanung (Time-Blocking)
|
||||||
|
</span>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="scheduledDate">Geplantes Datum</label>
|
||||||
|
<input id="scheduledDate" type="date" bind:value={scheduledDate} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="estimatedDuration">Dauer (Min.)</label>
|
||||||
|
<input
|
||||||
|
id="estimatedDuration"
|
||||||
|
type="number"
|
||||||
|
min="5"
|
||||||
|
max="480"
|
||||||
|
step="5"
|
||||||
|
bind:value={estimatedDuration}
|
||||||
|
placeholder="30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="scheduledStartTime">Startzeit</label>
|
||||||
|
<input id="scheduledStartTime" type="time" bind:value={scheduledStartTime} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="scheduledEndTime">Endzeit</label>
|
||||||
|
<input id="scheduledEndTime" type="time" bind:value={scheduledEndTime} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Priorität</label>
|
<label>Priorität</label>
|
||||||
<div class="priority-options">
|
<div class="priority-options">
|
||||||
|
|
@ -238,6 +317,29 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Time-Blocking Display -->
|
||||||
|
{#if task.scheduledDate}
|
||||||
|
<div class="detail-item scheduled">
|
||||||
|
<CalendarClock size={16} />
|
||||||
|
<span>
|
||||||
|
Geplant: {formatDisplayDate(task.scheduledDate)}
|
||||||
|
{#if task.scheduledStartTime}
|
||||||
|
um {task.scheduledStartTime}
|
||||||
|
{#if task.scheduledEndTime}
|
||||||
|
- {task.scheduledEndTime}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if task.estimatedDuration}
|
||||||
|
<div class="detail-item">
|
||||||
|
<Timer size={16} />
|
||||||
|
<span>Geschätzte Dauer: {task.estimatedDuration} Min.</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<AlertCircle size={16} />
|
<AlertCircle size={16} />
|
||||||
<PriorityBadge {priority} variant="pill" showLabel />
|
<PriorityBadge {priority} variant="pill" showLabel />
|
||||||
|
|
@ -423,6 +525,17 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-item.scheduled {
|
||||||
|
background: hsl(var(--color-primary) / 0.1);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border-left: 3px solid hsl(var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item.scheduled :global(svg) {
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
.labels-row {
|
.labels-row {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
@ -508,9 +621,35 @@
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: hsl(var(--color-muted) / 0.3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid hsl(var(--color-border) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label :global(svg) {
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
input[type='text'],
|
input[type='text'],
|
||||||
input[type='date'],
|
input[type='date'],
|
||||||
input[type='time'],
|
input[type='time'],
|
||||||
|
input[type='number'],
|
||||||
textarea {
|
textarea {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border: 1px solid hsl(var(--color-border));
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
showProject?: boolean;
|
showProject?: boolean;
|
||||||
showDueDate?: boolean;
|
showDueDate?: boolean;
|
||||||
showPriority?: boolean;
|
showPriority?: boolean;
|
||||||
|
draggable?: boolean;
|
||||||
onclick?: () => void;
|
onclick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
showProject = true,
|
showProject = true,
|
||||||
showDueDate = true,
|
showDueDate = true,
|
||||||
showPriority = true,
|
showPriority = true,
|
||||||
|
draggable = false,
|
||||||
onclick,
|
onclick,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
|
@ -75,6 +77,22 @@
|
||||||
onclick();
|
onclick();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDragStart(e: DragEvent) {
|
||||||
|
if (!draggable || !e.dataTransfer) return;
|
||||||
|
// Store task data for drop target
|
||||||
|
e.dataTransfer.setData(
|
||||||
|
'application/json',
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'sidebar-task',
|
||||||
|
taskId: task.id,
|
||||||
|
title: task.title,
|
||||||
|
priority: task.priority,
|
||||||
|
estimatedDuration: task.estimatedDuration || 30,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -84,9 +102,12 @@
|
||||||
class:compact={variant === 'compact'}
|
class:compact={variant === 'compact'}
|
||||||
class:minimal={variant === 'minimal'}
|
class:minimal={variant === 'minimal'}
|
||||||
class:clickable={!!onclick}
|
class:clickable={!!onclick}
|
||||||
|
class:draggable-task={draggable}
|
||||||
style="--priority-color: {priorityColor};"
|
style="--priority-color: {priorityColor};"
|
||||||
onclick={handleClick}
|
onclick={handleClick}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
|
ondragstart={handleDragStart}
|
||||||
|
draggable={draggable ? 'true' : 'false'}
|
||||||
role={onclick ? 'button' : 'listitem'}
|
role={onclick ? 'button' : 'listitem'}
|
||||||
tabindex={onclick ? 0 : -1}
|
tabindex={onclick ? 0 : -1}
|
||||||
>
|
>
|
||||||
|
|
@ -168,6 +189,15 @@
|
||||||
transform: translateX(2px);
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.todo-item.draggable-task {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.draggable-task:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
.todo-item.completed {
|
.todo-item.completed {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
apps/calendar/apps/web/src/lib/composables/index.ts
Normal file
8
apps/calendar/apps/web/src/lib/composables/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/**
|
||||||
|
* Calendar Composables
|
||||||
|
* Reusable logic extracted from components
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { useDragDrop, type DragDropConfig, type DragState } from './useDragDrop.svelte';
|
||||||
|
export { useResize, type ResizeConfig, type ResizeState } from './useResize.svelte';
|
||||||
|
export { useTaskDragDrop } from './useTaskDragDrop.svelte';
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
/**
|
||||||
|
* Composable for Task Drag & Drop in Calendar Views
|
||||||
|
* Handles dragging tasks to reschedule and resizing to change duration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Task, UpdateTaskInput } from '$lib/api/todos';
|
||||||
|
import { todosStore } from '$lib/stores/todos.svelte';
|
||||||
|
import { format, parseISO, addMinutes, differenceInMinutes, setHours, setMinutes } from 'date-fns';
|
||||||
|
|
||||||
|
const SNAP_MINUTES = 15;
|
||||||
|
|
||||||
|
interface UseTaskDragDropOptions {
|
||||||
|
/** Minimum snap interval in minutes */
|
||||||
|
snapMinutes?: number;
|
||||||
|
/** Callback when task is updated */
|
||||||
|
onTaskUpdate?: (task: Task) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTaskDragDrop(options: UseTaskDragDropOptions = {}) {
|
||||||
|
const snapMinutes = options.snapMinutes ?? SNAP_MINUTES;
|
||||||
|
|
||||||
|
// Drag state
|
||||||
|
let isDragging = $state(false);
|
||||||
|
let draggedTask = $state<Task | null>(null);
|
||||||
|
let dragStartY = $state(0);
|
||||||
|
let dragTargetDay = $state<Date | null>(null);
|
||||||
|
let dragPreviewTop = $state(0);
|
||||||
|
let dragPreviewHeight = $state(0);
|
||||||
|
|
||||||
|
// Resize state
|
||||||
|
let isResizing = $state(false);
|
||||||
|
let resizeTask = $state<Task | null>(null);
|
||||||
|
let resizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||||
|
let resizeStartY = $state(0);
|
||||||
|
let resizePreviewTop = $state(0);
|
||||||
|
let resizePreviewHeight = $state(0);
|
||||||
|
|
||||||
|
// Track if we actually moved
|
||||||
|
let hasMoved = $state(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start dragging a task
|
||||||
|
*/
|
||||||
|
function startDrag(
|
||||||
|
task: Task,
|
||||||
|
e: PointerEvent,
|
||||||
|
gridElement: HTMLElement,
|
||||||
|
firstVisibleHour: number,
|
||||||
|
totalVisibleHours: number
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging = true;
|
||||||
|
draggedTask = task;
|
||||||
|
dragStartY = e.clientY;
|
||||||
|
hasMoved = false;
|
||||||
|
|
||||||
|
// Calculate initial position
|
||||||
|
if (task.scheduledStartTime) {
|
||||||
|
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||||
|
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||||
|
dragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate height from duration
|
||||||
|
const duration = task.estimatedDuration || 30;
|
||||||
|
dragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||||
|
|
||||||
|
// Capture pointer
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle drag move
|
||||||
|
*/
|
||||||
|
function onDragMove(
|
||||||
|
e: PointerEvent,
|
||||||
|
gridElement: HTMLElement,
|
||||||
|
day: Date,
|
||||||
|
firstVisibleHour: number,
|
||||||
|
totalVisibleHours: number
|
||||||
|
) {
|
||||||
|
if (!isDragging || !draggedTask) return;
|
||||||
|
|
||||||
|
hasMoved = true;
|
||||||
|
dragTargetDay = day;
|
||||||
|
|
||||||
|
const rect = gridElement.getBoundingClientRect();
|
||||||
|
const relativeY = e.clientY - rect.top;
|
||||||
|
const percentY = (relativeY / rect.height) * 100;
|
||||||
|
|
||||||
|
// Snap to intervals
|
||||||
|
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||||
|
const rawMinutes = percentY * minutesPerPercent + firstVisibleHour * 60;
|
||||||
|
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
|
||||||
|
|
||||||
|
dragPreviewTop = ((snappedMinutes - firstVisibleHour * 60) / (totalVisibleHours * 60)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End drag and update task
|
||||||
|
*/
|
||||||
|
async function endDrag(firstVisibleHour: number, totalVisibleHours: number) {
|
||||||
|
if (!isDragging || !draggedTask || !hasMoved) {
|
||||||
|
isDragging = false;
|
||||||
|
draggedTask = null;
|
||||||
|
dragTargetDay = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new time from position
|
||||||
|
const minutesFromMidnight =
|
||||||
|
(dragPreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
|
||||||
|
const hours = Math.floor(minutesFromMidnight / 60);
|
||||||
|
const minutes = Math.round(minutesFromMidnight % 60);
|
||||||
|
|
||||||
|
const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Calculate end time based on duration
|
||||||
|
const duration = draggedTask.estimatedDuration || 30;
|
||||||
|
const endMinutes = minutesFromMidnight + duration;
|
||||||
|
const endHours = Math.floor(endMinutes / 60);
|
||||||
|
const endMins = Math.round(endMinutes % 60);
|
||||||
|
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const updateData: UpdateTaskInput = {
|
||||||
|
scheduledDate: dragTargetDay
|
||||||
|
? format(dragTargetDay, 'yyyy-MM-dd')
|
||||||
|
: draggedTask.scheduledDate,
|
||||||
|
scheduledStartTime: newStartTime,
|
||||||
|
scheduledEndTime: newEndTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await todosStore.updateTodo(draggedTask.id, updateData);
|
||||||
|
if (result.data) {
|
||||||
|
options.onTaskUpdate?.(result.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
isDragging = false;
|
||||||
|
draggedTask = null;
|
||||||
|
dragTargetDay = null;
|
||||||
|
hasMoved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start resizing a task
|
||||||
|
*/
|
||||||
|
function startResize(
|
||||||
|
task: Task,
|
||||||
|
edge: 'top' | 'bottom',
|
||||||
|
e: PointerEvent,
|
||||||
|
firstVisibleHour: number,
|
||||||
|
totalVisibleHours: number
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
isResizing = true;
|
||||||
|
resizeTask = task;
|
||||||
|
resizeEdge = edge;
|
||||||
|
resizeStartY = e.clientY;
|
||||||
|
hasMoved = false;
|
||||||
|
|
||||||
|
// Initialize preview position
|
||||||
|
if (task.scheduledStartTime) {
|
||||||
|
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||||
|
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||||
|
resizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = task.estimatedDuration || 30;
|
||||||
|
resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||||
|
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle resize move
|
||||||
|
*/
|
||||||
|
function onResizeMove(
|
||||||
|
e: PointerEvent,
|
||||||
|
gridElement: HTMLElement,
|
||||||
|
firstVisibleHour: number,
|
||||||
|
totalVisibleHours: number
|
||||||
|
) {
|
||||||
|
if (!isResizing || !resizeTask) return;
|
||||||
|
|
||||||
|
hasMoved = true;
|
||||||
|
|
||||||
|
const rect = gridElement.getBoundingClientRect();
|
||||||
|
const relativeY = e.clientY - rect.top;
|
||||||
|
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||||
|
|
||||||
|
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||||
|
|
||||||
|
if (resizeEdge === 'top') {
|
||||||
|
// Adjust start time, keep end fixed
|
||||||
|
const originalEndPercent = resizePreviewTop + resizePreviewHeight;
|
||||||
|
const rawMinutes = percentY * minutesPerPercent;
|
||||||
|
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
|
||||||
|
resizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
resizePreviewHeight = Math.max(2, originalEndPercent - resizePreviewTop);
|
||||||
|
} else {
|
||||||
|
// Adjust end time, keep start fixed
|
||||||
|
const rawMinutes = percentY * minutesPerPercent;
|
||||||
|
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
|
||||||
|
const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||||
|
resizePreviewHeight = Math.max(2, newBottom - resizePreviewTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End resize and update task
|
||||||
|
*/
|
||||||
|
async function endResize(firstVisibleHour: number, totalVisibleHours: number) {
|
||||||
|
if (!isResizing || !resizeTask || !hasMoved) {
|
||||||
|
isResizing = false;
|
||||||
|
resizeTask = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new times from position
|
||||||
|
const startMinutes =
|
||||||
|
(resizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
|
||||||
|
const endMinutes =
|
||||||
|
((resizePreviewTop + resizePreviewHeight) / 100) * (totalVisibleHours * 60) +
|
||||||
|
firstVisibleHour * 60;
|
||||||
|
|
||||||
|
const startHours = Math.floor(startMinutes / 60);
|
||||||
|
const startMins = Math.round(startMinutes % 60);
|
||||||
|
const endHours = Math.floor(endMinutes / 60);
|
||||||
|
const endMins = Math.round(endMinutes % 60);
|
||||||
|
|
||||||
|
const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`;
|
||||||
|
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||||
|
const newDuration = Math.round(endMinutes - startMinutes);
|
||||||
|
|
||||||
|
const updateData: UpdateTaskInput = {
|
||||||
|
scheduledStartTime: newStartTime,
|
||||||
|
scheduledEndTime: newEndTime,
|
||||||
|
estimatedDuration: newDuration,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await todosStore.updateTodo(resizeTask.id, updateData);
|
||||||
|
if (result.data) {
|
||||||
|
options.onTaskUpdate?.(result.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
isResizing = false;
|
||||||
|
resizeTask = null;
|
||||||
|
hasMoved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel any ongoing drag/resize
|
||||||
|
*/
|
||||||
|
function cancel() {
|
||||||
|
isDragging = false;
|
||||||
|
isResizing = false;
|
||||||
|
draggedTask = null;
|
||||||
|
resizeTask = null;
|
||||||
|
dragTargetDay = null;
|
||||||
|
hasMoved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State getters
|
||||||
|
get isDragging() {
|
||||||
|
return isDragging;
|
||||||
|
},
|
||||||
|
get draggedTask() {
|
||||||
|
return draggedTask;
|
||||||
|
},
|
||||||
|
get dragTargetDay() {
|
||||||
|
return dragTargetDay;
|
||||||
|
},
|
||||||
|
get dragPreviewTop() {
|
||||||
|
return dragPreviewTop;
|
||||||
|
},
|
||||||
|
get dragPreviewHeight() {
|
||||||
|
return dragPreviewHeight;
|
||||||
|
},
|
||||||
|
get isResizing() {
|
||||||
|
return isResizing;
|
||||||
|
},
|
||||||
|
get resizeTask() {
|
||||||
|
return resizeTask;
|
||||||
|
},
|
||||||
|
get resizePreviewTop() {
|
||||||
|
return resizePreviewTop;
|
||||||
|
},
|
||||||
|
get resizePreviewHeight() {
|
||||||
|
return resizePreviewHeight;
|
||||||
|
},
|
||||||
|
get hasMoved() {
|
||||||
|
return hasMoved;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
startDrag,
|
||||||
|
onDragMove,
|
||||||
|
endDrag,
|
||||||
|
startResize,
|
||||||
|
onResizeMove,
|
||||||
|
endResize,
|
||||||
|
cancel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -86,5 +86,30 @@
|
||||||
"search": "Suchen",
|
"search": "Suchen",
|
||||||
"error": "Fehler",
|
"error": "Fehler",
|
||||||
"success": "Erfolgreich"
|
"success": "Erfolgreich"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadEvents": "Termine konnten nicht geladen werden",
|
||||||
|
"createEvent": "Termin konnte nicht erstellt werden",
|
||||||
|
"updateEvent": "Termin konnte nicht aktualisiert werden",
|
||||||
|
"deleteEvent": "Termin konnte nicht gelöscht werden"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"eventCreated": "Termin erstellt",
|
||||||
|
"eventDeleted": "Termin gelöscht"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"urgent": "Dringend",
|
||||||
|
"high": "Wichtig",
|
||||||
|
"medium": "Normal",
|
||||||
|
"low": "Später"
|
||||||
|
},
|
||||||
|
"todo": {
|
||||||
|
"task": "Aufgabe",
|
||||||
|
"markComplete": "Als erledigt markieren",
|
||||||
|
"markIncomplete": "Als unerledigt markieren"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
"createEventOn": "Termin erstellen am {date}",
|
||||||
|
"slotTime": "{day} {time}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,5 +86,30 @@
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"success": "Success"
|
"success": "Success"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadEvents": "Failed to load events",
|
||||||
|
"createEvent": "Failed to create event",
|
||||||
|
"updateEvent": "Failed to update event",
|
||||||
|
"deleteEvent": "Failed to delete event"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"eventCreated": "Event created",
|
||||||
|
"eventDeleted": "Event deleted"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"urgent": "Urgent",
|
||||||
|
"high": "High",
|
||||||
|
"medium": "Normal",
|
||||||
|
"low": "Low"
|
||||||
|
},
|
||||||
|
"todo": {
|
||||||
|
"task": "Task",
|
||||||
|
"markComplete": "Mark as complete",
|
||||||
|
"markIncomplete": "Mark as incomplete"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
"createEventOn": "Create event on {date}",
|
||||||
|
"slotTime": "{day} {time}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
175
apps/calendar/apps/web/src/lib/stores/contacts.svelte.ts
Normal file
175
apps/calendar/apps/web/src/lib/stores/contacts.svelte.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
/**
|
||||||
|
* Contacts Store for Calendar App
|
||||||
|
*
|
||||||
|
* Provides access to contacts from the Contacts app for event attendee management.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { createContactsClient, type ContactsClient } from '@manacore/shared-auth';
|
||||||
|
import type { ContactSummary } from '@manacore/shared-types';
|
||||||
|
import { authStore } from './auth.svelte';
|
||||||
|
|
||||||
|
// State
|
||||||
|
let client: ContactsClient | null = null;
|
||||||
|
let isAvailable = $state<boolean | null>(null);
|
||||||
|
let isChecking = $state(false);
|
||||||
|
let lastCheck = $state<number>(0);
|
||||||
|
|
||||||
|
// Cache for recent search results
|
||||||
|
let searchCache = $state<Map<string, { results: ContactSummary[]; timestamp: number }>>(new Map());
|
||||||
|
const CACHE_TTL = 60000; // 1 minute
|
||||||
|
|
||||||
|
// Get contacts API URL dynamically
|
||||||
|
function getContactsApiUrl(): string {
|
||||||
|
if (browser && typeof window !== 'undefined') {
|
||||||
|
const injectedUrl = (window as unknown as { __PUBLIC_CONTACTS_API_URL__?: string })
|
||||||
|
.__PUBLIC_CONTACTS_API_URL__;
|
||||||
|
return injectedUrl || 'http://localhost:3015/api/v1';
|
||||||
|
}
|
||||||
|
return 'http://localhost:3015/api/v1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize client lazily
|
||||||
|
function getClient(): ContactsClient {
|
||||||
|
if (!client) {
|
||||||
|
client = createContactsClient({
|
||||||
|
apiUrl: getContactsApiUrl(),
|
||||||
|
getAuthToken: async () => authStore.getAccessToken(),
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contactsStore = {
|
||||||
|
// Getters
|
||||||
|
get isAvailable() {
|
||||||
|
return isAvailable;
|
||||||
|
},
|
||||||
|
get isChecking() {
|
||||||
|
return isChecking;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the Contacts API is available
|
||||||
|
* Caches result for 30 seconds
|
||||||
|
*/
|
||||||
|
async checkAvailability(): Promise<boolean> {
|
||||||
|
const now = Date.now();
|
||||||
|
// Skip if checked recently
|
||||||
|
if (lastCheck && now - lastCheck < 30000 && isAvailable !== null) {
|
||||||
|
return isAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
isChecking = true;
|
||||||
|
try {
|
||||||
|
const available = await getClient().isAvailable();
|
||||||
|
isAvailable = available;
|
||||||
|
lastCheck = now;
|
||||||
|
return available;
|
||||||
|
} catch {
|
||||||
|
isAvailable = false;
|
||||||
|
lastCheck = now;
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isChecking = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search contacts by query string
|
||||||
|
*/
|
||||||
|
async searchContacts(query: string): Promise<ContactSummary[]> {
|
||||||
|
// Check cache first
|
||||||
|
const cacheKey = query.toLowerCase().trim();
|
||||||
|
const cached = searchCache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
return cached.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check availability
|
||||||
|
if (isAvailable === null) {
|
||||||
|
await this.checkAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAvailable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await getClient().searchContacts({
|
||||||
|
query,
|
||||||
|
limit: 20,
|
||||||
|
excludeArchived: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache results
|
||||||
|
searchCache.set(cacheKey, {
|
||||||
|
results,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[contactsStore] Search failed:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single contact by ID
|
||||||
|
*/
|
||||||
|
async getContact(id: string): Promise<ContactSummary | null> {
|
||||||
|
if (isAvailable === null) {
|
||||||
|
await this.checkAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAvailable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await getClient().getContact(id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[contactsStore] Failed to get contact ${id}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get multiple contacts by IDs
|
||||||
|
*/
|
||||||
|
async getContacts(ids: string[]): Promise<ContactSummary[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
|
if (isAvailable === null) {
|
||||||
|
await this.checkAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAvailable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await getClient().getContacts(ids);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[contactsStore] Failed to get contacts:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the search cache
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
searchCache.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset availability check (force recheck on next call)
|
||||||
|
*/
|
||||||
|
resetAvailability() {
|
||||||
|
isAvailable = null;
|
||||||
|
lastCheck = 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -74,6 +74,50 @@ export const todosStore = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scheduled tasks for a specific day (by scheduledDate - for time-blocking)
|
||||||
|
* Note: Includes completed tasks so they remain visible in the calendar
|
||||||
|
*/
|
||||||
|
getScheduledTasksForDay(date: Date): Task[] {
|
||||||
|
const currentTodos = todos ?? [];
|
||||||
|
if (!Array.isArray(currentTodos)) return [];
|
||||||
|
|
||||||
|
return currentTodos.filter((task) => {
|
||||||
|
if (!task.scheduledDate) return false;
|
||||||
|
const scheduledDate =
|
||||||
|
typeof task.scheduledDate === 'string' ? parseISO(task.scheduledDate) : task.scheduledDate;
|
||||||
|
return isSameDay(scheduledDate, date);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scheduled tasks within a date range (for time-blocking)
|
||||||
|
* Note: Includes completed tasks so they remain visible in the calendar
|
||||||
|
*/
|
||||||
|
getScheduledTasksInRange(start: Date, end: Date): Task[] {
|
||||||
|
const currentTodos = todos ?? [];
|
||||||
|
if (!Array.isArray(currentTodos)) return [];
|
||||||
|
|
||||||
|
return currentTodos.filter((task) => {
|
||||||
|
if (!task.scheduledDate) return false;
|
||||||
|
const scheduledDate =
|
||||||
|
typeof task.scheduledDate === 'string' ? parseISO(task.scheduledDate) : task.scheduledDate;
|
||||||
|
return isWithinInterval(scheduledDate, { start, end });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unscheduled tasks (no scheduledDate - for sidebar drag source)
|
||||||
|
*/
|
||||||
|
get unscheduledForTimeBlocking(): Task[] {
|
||||||
|
const currentTodos = todos ?? [];
|
||||||
|
if (!Array.isArray(currentTodos)) return [];
|
||||||
|
|
||||||
|
return currentTodos
|
||||||
|
.filter((task) => !task.isCompleted && !task.scheduledDate)
|
||||||
|
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get todos within a date range
|
* Get todos within a date range
|
||||||
*/
|
*/
|
||||||
|
|
@ -202,14 +246,13 @@ export const todosStore = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch todos for a date range
|
* Fetch todos for a date range
|
||||||
|
* Note: Fetches both completed and uncompleted tasks so scheduled tasks remain visible
|
||||||
*/
|
*/
|
||||||
async fetchTodos(startDate?: Date, endDate?: Date) {
|
async fetchTodos(startDate?: Date, endDate?: Date) {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
const query: TaskQuery = {
|
const query: TaskQuery = {};
|
||||||
isCompleted: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (startDate) {
|
if (startDate) {
|
||||||
query.dueDateFrom = format(startDate, 'yyyy-MM-dd');
|
query.dueDateFrom = format(startDate, 'yyyy-MM-dd');
|
||||||
|
|
@ -236,7 +279,7 @@ export const todosStore = {
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch today's todos (shortcut)
|
* Fetch today's todos (shortcut) - only uncompleted tasks
|
||||||
*/
|
*/
|
||||||
async fetchTodayTodos() {
|
async fetchTodayTodos() {
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
@ -260,6 +303,40 @@ export const todosStore = {
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all scheduled todos (including completed ones)
|
||||||
|
* Used for calendar time-blocking to keep completed tasks visible
|
||||||
|
*/
|
||||||
|
async fetchScheduledTodos() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
// Fetch all tasks without isCompleted filter - API will return all
|
||||||
|
const result = await api.getTasks({});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
error = result.error.message;
|
||||||
|
serviceAvailable = false;
|
||||||
|
} else {
|
||||||
|
// Only keep tasks that have a scheduledDate (for time-blocking)
|
||||||
|
// Merge with existing todos (avoid duplicates)
|
||||||
|
const allTasks = result.data || [];
|
||||||
|
const scheduledTasks = allTasks.filter((t) => t.scheduledDate);
|
||||||
|
const existingIds = new Set(todos.map((t) => t.id));
|
||||||
|
const uniqueNew = scheduledTasks.filter((t) => !existingIds.has(t.id));
|
||||||
|
// Also update existing scheduled tasks (in case isCompleted changed)
|
||||||
|
todos = todos.map((existing) => {
|
||||||
|
const updated = scheduledTasks.find((t) => t.id === existing.id);
|
||||||
|
return updated || existing;
|
||||||
|
});
|
||||||
|
todos = [...todos, ...uniqueNew];
|
||||||
|
serviceAvailable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch upcoming todos (shortcut)
|
* Fetch upcoming todos (shortcut)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
|
/**
|
||||||
|
* Event attendee RSVP status
|
||||||
|
*/
|
||||||
|
export type AttendeeStatus = 'accepted' | 'declined' | 'tentative' | 'pending';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event attendee information
|
* Event attendee information
|
||||||
*/
|
*/
|
||||||
export interface EventAttendee {
|
export interface EventAttendee {
|
||||||
email: string;
|
email: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
status?: 'accepted' | 'declined' | 'tentative' | 'pending';
|
status?: AttendeeStatus;
|
||||||
|
/** Contact reference for linked contacts */
|
||||||
|
contactId?: string;
|
||||||
|
/** Cached photo URL from contact */
|
||||||
|
photoUrl?: string;
|
||||||
|
/** Cached company from contact */
|
||||||
|
company?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
232
apps/contacts/apps/web/src/lib/api/todos.ts
Normal file
232
apps/contacts/apps/web/src/lib/api/todos.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
/**
|
||||||
|
* Cross-App API Client for Todo Backend
|
||||||
|
* Allows Contacts app to fetch tasks related to a contact
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
|
||||||
|
// Types mirrored from @todo/shared
|
||||||
|
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||||
|
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
|
export interface Subtask {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
isCompleted: boolean;
|
||||||
|
completedAt?: string | null;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Label {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
color: string;
|
||||||
|
icon?: string | null;
|
||||||
|
order: number;
|
||||||
|
isArchived: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactReference {
|
||||||
|
contactId: string;
|
||||||
|
displayName: string;
|
||||||
|
email?: string;
|
||||||
|
photoUrl?: string;
|
||||||
|
company?: string;
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskMetadata {
|
||||||
|
notes?: string;
|
||||||
|
attachments?: string[];
|
||||||
|
linkedCalendarEventId?: string | null;
|
||||||
|
storyPoints?: number | null;
|
||||||
|
effectiveDuration?: {
|
||||||
|
value: number;
|
||||||
|
unit: 'minutes' | 'hours' | 'days';
|
||||||
|
} | null;
|
||||||
|
funRating?: number | null;
|
||||||
|
assignee?: ContactReference | null;
|
||||||
|
involvedContacts?: ContactReference[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
projectId?: string | null;
|
||||||
|
userId: string;
|
||||||
|
parentTaskId?: string | null;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
dueTime?: string | null;
|
||||||
|
startDate?: string | null;
|
||||||
|
scheduledDate?: string | null;
|
||||||
|
scheduledStartTime?: string | null;
|
||||||
|
scheduledEndTime?: string | null;
|
||||||
|
estimatedDuration?: number | null;
|
||||||
|
priority: TaskPriority;
|
||||||
|
status: TaskStatus;
|
||||||
|
isCompleted: boolean;
|
||||||
|
completedAt?: string | null;
|
||||||
|
order: number;
|
||||||
|
columnId?: string | null;
|
||||||
|
columnOrder?: number;
|
||||||
|
recurrenceRule?: string | null;
|
||||||
|
recurrenceEndDate?: string | null;
|
||||||
|
lastOccurrence?: string | null;
|
||||||
|
subtasks?: Subtask[] | null;
|
||||||
|
metadata?: TaskMetadata | null;
|
||||||
|
labels?: Label[];
|
||||||
|
project?: Project | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Configuration
|
||||||
|
function getTodoApiBase(): string {
|
||||||
|
if (browser && typeof window !== 'undefined') {
|
||||||
|
const injectedUrl = (window as unknown as { __PUBLIC_TODO_BACKEND_URL__?: string })
|
||||||
|
.__PUBLIC_TODO_BACKEND_URL__;
|
||||||
|
return injectedUrl || 'http://localhost:3018';
|
||||||
|
}
|
||||||
|
return 'http://localhost:3018';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiResult<T> {
|
||||||
|
data: T | null;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTodoApi<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResult<T>> {
|
||||||
|
const token = await authStore.getAccessToken();
|
||||||
|
const baseUrl = getTodoApiBase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error: new Error(errorData.message || `API error: ${response.status}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return { data, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error: error instanceof Error ? error : new Error('Unknown error'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Functions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tasks related to a specific contact (assigned or involved)
|
||||||
|
*/
|
||||||
|
export async function getTasksByContact(
|
||||||
|
contactId: string,
|
||||||
|
includeCompleted: boolean = false
|
||||||
|
): Promise<ApiResult<Task[]>> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (includeCompleted) {
|
||||||
|
params.set('includeCompleted', 'true');
|
||||||
|
}
|
||||||
|
const query = params.toString();
|
||||||
|
|
||||||
|
const result = await fetchTodoApi<{ tasks: Task[] }>(
|
||||||
|
`/tasks/by-contact/${contactId}${query ? `?${query}` : ''}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result.data?.tasks || null,
|
||||||
|
error: result.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a task
|
||||||
|
*/
|
||||||
|
export async function completeTask(taskId: string): Promise<ApiResult<Task>> {
|
||||||
|
const result = await fetchTodoApi<{ task: Task }>(`/tasks/${taskId}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result.data?.task || null,
|
||||||
|
error: result.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uncomplete a task
|
||||||
|
*/
|
||||||
|
export async function uncompleteTask(taskId: string): Promise<ApiResult<Task>> {
|
||||||
|
const result = await fetchTodoApi<{ task: Task }>(`/tasks/${taskId}/uncomplete`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result.data?.task || null,
|
||||||
|
error: result.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the Todo service is available
|
||||||
|
*/
|
||||||
|
export async function checkTodoServiceAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const baseUrl = getTodoApiBase();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/health`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority styling helpers
|
||||||
|
export const PRIORITY_COLORS: Record<TaskPriority, string> = {
|
||||||
|
urgent: 'var(--color-danger, #ef4444)',
|
||||||
|
high: 'var(--color-warning, #f59e0b)',
|
||||||
|
medium: 'var(--color-accent, #3b82f6)',
|
||||||
|
low: 'var(--color-success, #22c55e)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRIORITY_LABELS: Record<TaskPriority, { de: string; en: string }> = {
|
||||||
|
urgent: { de: 'Dringend', en: 'Urgent' },
|
||||||
|
high: { de: 'Wichtig', en: 'High' },
|
||||||
|
medium: { de: 'Normal', en: 'Medium' },
|
||||||
|
low: { de: 'Niedrig', en: 'Low' },
|
||||||
|
};
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { contactsApi, photoApi, type Contact } from '$lib/api/contacts';
|
import { contactsApi, photoApi, type Contact } from '$lib/api/contacts';
|
||||||
import ContactNotes from './ContactNotes.svelte';
|
import ContactNotes from './ContactNotes.svelte';
|
||||||
|
import ContactTasks from './ContactTasks.svelte';
|
||||||
import { ContactDetailSkeleton } from '$lib/components/skeletons';
|
import { ContactDetailSkeleton } from '$lib/components/skeletons';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -841,6 +842,9 @@
|
||||||
|
|
||||||
<!-- Contact Notes (separate from contact.notes field) -->
|
<!-- Contact Notes (separate from contact.notes field) -->
|
||||||
<ContactNotes {contactId} />
|
<ContactNotes {contactId} />
|
||||||
|
|
||||||
|
<!-- Tasks related to this contact -->
|
||||||
|
<ContactTasks {contactId} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
515
apps/contacts/apps/web/src/lib/components/ContactTasks.svelte
Normal file
515
apps/contacts/apps/web/src/lib/components/ContactTasks.svelte
Normal file
|
|
@ -0,0 +1,515 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { todosStore } from '$lib/stores/todos.svelte';
|
||||||
|
import { PRIORITY_COLORS, type Task } from '$lib/api/todos';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
contactId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { contactId }: Props = $props();
|
||||||
|
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let showCompleted = $state(false);
|
||||||
|
let displayLimit = $state(10);
|
||||||
|
|
||||||
|
// Categorized tasks
|
||||||
|
let assignedTasks = $state<Task[]>([]);
|
||||||
|
let involvedTasks = $state<Task[]>([]);
|
||||||
|
|
||||||
|
// Derived values
|
||||||
|
const visibleAssigned = $derived.by(() => {
|
||||||
|
const filtered = showCompleted ? assignedTasks : assignedTasks.filter((t) => !t.isCompleted);
|
||||||
|
return filtered.slice(0, displayLimit);
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleInvolved = $derived.by(() => {
|
||||||
|
const filtered = showCompleted ? involvedTasks : involvedTasks.filter((t) => !t.isCompleted);
|
||||||
|
return filtered.slice(0, displayLimit);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalVisible = $derived(visibleAssigned.length + visibleInvolved.length);
|
||||||
|
const totalTasks = $derived(assignedTasks.length + involvedTasks.length);
|
||||||
|
const hasMore = $derived(totalVisible < totalTasks);
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Always load with completed to get full list, filter in UI
|
||||||
|
await todosStore.loadTasksForContact(contactId, true);
|
||||||
|
const categorized = todosStore.categorizeTasksForContact(contactId);
|
||||||
|
assignedTasks = categorized.assigned;
|
||||||
|
involvedTasks = categorized.involved;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : $_('contact.tasks.error');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleComplete(task: Task) {
|
||||||
|
const success = await todosStore.toggleTaskCompletion(task.id, contactId);
|
||||||
|
if (success) {
|
||||||
|
// Refresh categorization
|
||||||
|
const categorized = todosStore.categorizeTasksForContact(contactId);
|
||||||
|
assignedTasks = categorized.assigned;
|
||||||
|
involvedTasks = categorized.involved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDueDate(dueDate: string | null | undefined): {
|
||||||
|
text: string;
|
||||||
|
status: 'overdue' | 'today' | 'upcoming' | 'none';
|
||||||
|
} {
|
||||||
|
if (!dueDate) return { text: '', status: 'none' };
|
||||||
|
|
||||||
|
const due = new Date(dueDate);
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate());
|
||||||
|
|
||||||
|
const diffDays = Math.floor((dueDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays < 0) {
|
||||||
|
return { text: $_('contact.tasks.overdue'), status: 'overdue' };
|
||||||
|
} else if (diffDays === 0) {
|
||||||
|
return { text: $_('contact.tasks.dueToday'), status: 'today' };
|
||||||
|
} else if (diffDays === 1) {
|
||||||
|
return { text: $_('contact.tasks.tomorrow'), status: 'upcoming' };
|
||||||
|
} else if (diffDays < 7) {
|
||||||
|
return { text: due.toLocaleDateString('de-DE', { weekday: 'short' }), status: 'upcoming' };
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
text: due.toLocaleDateString('de-DE', { day: '2-digit', month: 'short' }),
|
||||||
|
status: 'upcoming',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMore() {
|
||||||
|
displayLimit += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(loadTasks);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="tasks-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-icon">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="section-title">{$_('contact.tasks.title')}</h3>
|
||||||
|
<label class="show-completed-toggle">
|
||||||
|
<input type="checkbox" bind:checked={showCompleted} />
|
||||||
|
<span>{$_('contact.tasks.showCompleted')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error-message">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">
|
||||||
|
<span class="spinner"></span>
|
||||||
|
</div>
|
||||||
|
{:else if !todosStore.serviceAvailable}
|
||||||
|
<div class="service-unavailable">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p>{$_('contact.tasks.serviceUnavailable')}</p>
|
||||||
|
</div>
|
||||||
|
{:else if totalTasks === 0}
|
||||||
|
<div class="empty-tasks">
|
||||||
|
<p>{$_('contact.tasks.empty')}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Assigned Tasks -->
|
||||||
|
{#if assignedTasks.length > 0}
|
||||||
|
<div class="task-group">
|
||||||
|
<div class="group-header">
|
||||||
|
<span class="group-title">{$_('contact.tasks.assigned')}</span>
|
||||||
|
<span class="group-count">{visibleAssigned.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tasks-list">
|
||||||
|
{#each visibleAssigned as task (task.id)}
|
||||||
|
{@const dueInfo = formatDueDate(task.dueDate)}
|
||||||
|
<div class="task-item" class:completed={task.isCompleted}>
|
||||||
|
<button
|
||||||
|
class="task-checkbox"
|
||||||
|
onclick={() => handleToggleComplete(task)}
|
||||||
|
aria-label={task.isCompleted
|
||||||
|
? $_('contact.tasks.markIncomplete')
|
||||||
|
: $_('contact.tasks.markComplete')}
|
||||||
|
style="--priority-color: {PRIORITY_COLORS[task.priority]}"
|
||||||
|
>
|
||||||
|
{#if task.isCompleted}
|
||||||
|
<svg fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<div class="task-content">
|
||||||
|
<span class="task-title" class:completed={task.isCompleted}>{task.title}</span>
|
||||||
|
{#if task.project}
|
||||||
|
<span class="task-project" style="--project-color: {task.project.color}"
|
||||||
|
>{task.project.name}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if dueInfo.status !== 'none' && !task.isCompleted}
|
||||||
|
<span
|
||||||
|
class="task-due"
|
||||||
|
class:overdue={dueInfo.status === 'overdue'}
|
||||||
|
class:today={dueInfo.status === 'today'}
|
||||||
|
>
|
||||||
|
{dueInfo.text}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Involved Tasks -->
|
||||||
|
{#if involvedTasks.length > 0}
|
||||||
|
<div class="task-group">
|
||||||
|
<div class="group-header">
|
||||||
|
<span class="group-title">{$_('contact.tasks.involved')}</span>
|
||||||
|
<span class="group-count">{visibleInvolved.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tasks-list">
|
||||||
|
{#each visibleInvolved as task (task.id)}
|
||||||
|
{@const dueInfo = formatDueDate(task.dueDate)}
|
||||||
|
<div class="task-item" class:completed={task.isCompleted}>
|
||||||
|
<button
|
||||||
|
class="task-checkbox"
|
||||||
|
onclick={() => handleToggleComplete(task)}
|
||||||
|
aria-label={task.isCompleted
|
||||||
|
? $_('contact.tasks.markIncomplete')
|
||||||
|
: $_('contact.tasks.markComplete')}
|
||||||
|
style="--priority-color: {PRIORITY_COLORS[task.priority]}"
|
||||||
|
>
|
||||||
|
{#if task.isCompleted}
|
||||||
|
<svg fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<div class="task-content">
|
||||||
|
<span class="task-title" class:completed={task.isCompleted}>{task.title}</span>
|
||||||
|
{#if task.project}
|
||||||
|
<span class="task-project" style="--project-color: {task.project.color}"
|
||||||
|
>{task.project.name}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if dueInfo.status !== 'none' && !task.isCompleted}
|
||||||
|
<span
|
||||||
|
class="task-due"
|
||||||
|
class:overdue={dueInfo.status === 'overdue'}
|
||||||
|
class:today={dueInfo.status === 'today'}
|
||||||
|
>
|
||||||
|
{dueInfo.text}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Show More Button -->
|
||||||
|
{#if hasMore}
|
||||||
|
<button class="show-more-btn" onclick={showMore}>
|
||||||
|
{$_('contact.tasks.showMore', { values: { count: totalTasks - totalVisible } })}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tasks-section {
|
||||||
|
background: hsl(var(--color-surface));
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding-bottom: 0.625rem;
|
||||||
|
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-icon {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: hsl(var(--color-accent) / 0.1);
|
||||||
|
color: hsl(var(--color-accent));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-icon svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-completed-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-completed-toggle input {
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
accent-color: hsl(var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: hsl(var(--color-error) / 0.1);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: hsl(var(--color-error));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border: 2px solid hsl(var(--color-muted));
|
||||||
|
border-top-color: hsl(var(--color-accent));
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-unavailable {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-unavailable svg {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
color: hsl(var(--color-warning));
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-unavailable p {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tasks {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tasks p {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task Groups */
|
||||||
|
.task-group {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-group:last-of-type {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-count {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: hsl(var(--color-muted));
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tasks List */
|
||||||
|
.tasks-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
background: hsl(var(--color-muted) / 0.3);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:hover {
|
||||||
|
background: hsl(var(--color-muted) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item.completed {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-checkbox {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border: 2px solid var(--priority-color, hsl(var(--color-border)));
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-checkbox:hover {
|
||||||
|
background: hsl(var(--color-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item.completed .task-checkbox {
|
||||||
|
background: var(--priority-color, hsl(var(--color-primary)));
|
||||||
|
border-color: var(--priority-color, hsl(var(--color-primary)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-checkbox svg {
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title.completed {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-project {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--project-color, hsl(var(--color-muted-foreground)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-due {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: hsl(var(--color-muted));
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-due.overdue {
|
||||||
|
background: hsl(var(--color-error) / 0.1);
|
||||||
|
color: hsl(var(--color-error));
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-due.today {
|
||||||
|
background: hsl(var(--color-warning) / 0.1);
|
||||||
|
color: hsl(var(--color-warning));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show More Button */
|
||||||
|
.show-more-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: hsl(var(--color-muted) / 0.3);
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-more-btn:hover {
|
||||||
|
background: hsl(var(--color-muted) / 0.5);
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -102,7 +102,22 @@
|
||||||
"country": "Land",
|
"country": "Land",
|
||||||
"website": "Website",
|
"website": "Website",
|
||||||
"birthday": "Geburtstag",
|
"birthday": "Geburtstag",
|
||||||
"notes": "Notizen"
|
"notes": "Notizen",
|
||||||
|
"tasks": {
|
||||||
|
"title": "Aufgaben",
|
||||||
|
"assigned": "Zugewiesen",
|
||||||
|
"involved": "Beteiligt",
|
||||||
|
"empty": "Keine Aufgaben für diesen Kontakt",
|
||||||
|
"serviceUnavailable": "Todo-Service nicht erreichbar",
|
||||||
|
"error": "Fehler beim Laden der Aufgaben",
|
||||||
|
"overdue": "Überfällig",
|
||||||
|
"dueToday": "Heute",
|
||||||
|
"tomorrow": "Morgen",
|
||||||
|
"showCompleted": "Erledigte",
|
||||||
|
"showMore": "{count} weitere anzeigen",
|
||||||
|
"markComplete": "Als erledigt markieren",
|
||||||
|
"markIncomplete": "Als unerledigt markieren"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"title": "Gruppen",
|
"title": "Gruppen",
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,22 @@
|
||||||
"country": "Country",
|
"country": "Country",
|
||||||
"website": "Website",
|
"website": "Website",
|
||||||
"birthday": "Birthday",
|
"birthday": "Birthday",
|
||||||
"notes": "Notes"
|
"notes": "Notes",
|
||||||
|
"tasks": {
|
||||||
|
"title": "Tasks",
|
||||||
|
"assigned": "Assigned",
|
||||||
|
"involved": "Involved",
|
||||||
|
"empty": "No tasks for this contact",
|
||||||
|
"serviceUnavailable": "Todo service unavailable",
|
||||||
|
"error": "Failed to load tasks",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"dueToday": "Today",
|
||||||
|
"tomorrow": "Tomorrow",
|
||||||
|
"showCompleted": "Completed",
|
||||||
|
"showMore": "Show {count} more",
|
||||||
|
"markComplete": "Mark as complete",
|
||||||
|
"markIncomplete": "Mark as incomplete"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"title": "Groups",
|
"title": "Groups",
|
||||||
|
|
|
||||||
209
apps/contacts/apps/web/src/lib/stores/todos.svelte.ts
Normal file
209
apps/contacts/apps/web/src/lib/stores/todos.svelte.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
/**
|
||||||
|
* Todo Store for Contacts App
|
||||||
|
* Manages tasks related to contacts from the Todo service
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import {
|
||||||
|
getTasksByContact,
|
||||||
|
completeTask,
|
||||||
|
uncompleteTask,
|
||||||
|
checkTodoServiceAvailable,
|
||||||
|
type Task,
|
||||||
|
} from '$lib/api/todos';
|
||||||
|
|
||||||
|
// State
|
||||||
|
let tasksByContact = $state<Map<string, Task[]>>(new Map());
|
||||||
|
let loadingContacts = $state<Set<string>>(new Set());
|
||||||
|
let serviceAvailable = $state<boolean | null>(null);
|
||||||
|
let lastAvailabilityCheck = $state<number>(0);
|
||||||
|
|
||||||
|
// Cache TTL in milliseconds (5 minutes)
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000;
|
||||||
|
// Availability check interval (30 seconds)
|
||||||
|
const AVAILABILITY_CHECK_INTERVAL = 30 * 1000;
|
||||||
|
|
||||||
|
// Cache timestamps
|
||||||
|
const cacheTimestamps = new Map<string, number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if cached data is still valid
|
||||||
|
*/
|
||||||
|
function isCacheValid(contactId: string): boolean {
|
||||||
|
const timestamp = cacheTimestamps.get(contactId);
|
||||||
|
if (!timestamp) return false;
|
||||||
|
return Date.now() - timestamp < CACHE_TTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the Todo service is available (with caching)
|
||||||
|
*/
|
||||||
|
async function checkAvailability(): Promise<boolean> {
|
||||||
|
if (!browser) return false;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (serviceAvailable !== null && now - lastAvailabilityCheck < AVAILABILITY_CHECK_INTERVAL) {
|
||||||
|
return serviceAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = await checkTodoServiceAvailable();
|
||||||
|
serviceAvailable = available;
|
||||||
|
lastAvailabilityCheck = now;
|
||||||
|
return available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load tasks for a specific contact
|
||||||
|
*/
|
||||||
|
async function loadTasksForContact(
|
||||||
|
contactId: string,
|
||||||
|
includeCompleted: boolean = false,
|
||||||
|
forceRefresh: boolean = false
|
||||||
|
): Promise<Task[]> {
|
||||||
|
if (!browser) return [];
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (!forceRefresh && isCacheValid(contactId)) {
|
||||||
|
return tasksByContact.get(contactId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check service availability
|
||||||
|
const available = await checkAvailability();
|
||||||
|
if (!available) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as loading
|
||||||
|
loadingContacts = new Set([...loadingContacts, contactId]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await getTasksByContact(contactId, includeCompleted);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(`Failed to load tasks for contact ${contactId}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = data || [];
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
tasksByContact = new Map(tasksByContact).set(contactId, tasks);
|
||||||
|
cacheTimestamps.set(contactId, Date.now());
|
||||||
|
|
||||||
|
return tasks;
|
||||||
|
} finally {
|
||||||
|
// Remove from loading set
|
||||||
|
const newLoading = new Set(loadingContacts);
|
||||||
|
newLoading.delete(contactId);
|
||||||
|
loadingContacts = newLoading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached tasks for a contact (does not fetch)
|
||||||
|
*/
|
||||||
|
function getTasksForContact(contactId: string): Task[] {
|
||||||
|
return tasksByContact.get(contactId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if tasks are currently loading for a contact
|
||||||
|
*/
|
||||||
|
function isLoading(contactId: string): boolean {
|
||||||
|
return loadingContacts.has(contactId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle task completion
|
||||||
|
*/
|
||||||
|
async function toggleTaskCompletion(taskId: string, contactId: string): Promise<boolean> {
|
||||||
|
const tasks = tasksByContact.get(contactId) || [];
|
||||||
|
const task = tasks.find((t) => t.id === taskId);
|
||||||
|
|
||||||
|
if (!task) return false;
|
||||||
|
|
||||||
|
const { data, error } = task.isCompleted
|
||||||
|
? await uncompleteTask(taskId)
|
||||||
|
: await completeTask(taskId);
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
console.error('Failed to toggle task completion:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
const updatedTasks = tasks.map((t) => (t.id === taskId ? data : t));
|
||||||
|
tasksByContact = new Map(tasksByContact).set(contactId, updatedTasks);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cache for a specific contact
|
||||||
|
*/
|
||||||
|
function clearCacheForContact(contactId: string): void {
|
||||||
|
const newMap = new Map(tasksByContact);
|
||||||
|
newMap.delete(contactId);
|
||||||
|
tasksByContact = newMap;
|
||||||
|
cacheTimestamps.delete(contactId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached data
|
||||||
|
*/
|
||||||
|
function clearCache(): void {
|
||||||
|
tasksByContact = new Map();
|
||||||
|
cacheTimestamps.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorize tasks by their relation to the contact
|
||||||
|
*/
|
||||||
|
function categorizeTasksForContact(contactId: string): { assigned: Task[]; involved: Task[] } {
|
||||||
|
const tasks = tasksByContact.get(contactId) || [];
|
||||||
|
|
||||||
|
const assigned: Task[] = [];
|
||||||
|
const involved: Task[] = [];
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
const isAssignee = task.metadata?.assignee?.contactId === contactId;
|
||||||
|
const isInvolved = task.metadata?.involvedContacts?.some((c) => c.contactId === contactId);
|
||||||
|
|
||||||
|
if (isAssignee) {
|
||||||
|
assigned.push(task);
|
||||||
|
} else if (isInvolved) {
|
||||||
|
involved.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by due date (overdue first, then by date)
|
||||||
|
const sortByDueDate = (a: Task, b: Task): number => {
|
||||||
|
if (!a.dueDate && !b.dueDate) return 0;
|
||||||
|
if (!a.dueDate) return 1;
|
||||||
|
if (!b.dueDate) return -1;
|
||||||
|
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
|
||||||
|
};
|
||||||
|
|
||||||
|
assigned.sort(sortByDueDate);
|
||||||
|
involved.sort(sortByDueDate);
|
||||||
|
|
||||||
|
return { assigned, involved };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export store
|
||||||
|
export const todosStore = {
|
||||||
|
// Getters (reactive)
|
||||||
|
get serviceAvailable() {
|
||||||
|
return serviceAvailable;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
checkAvailability,
|
||||||
|
loadTasksForContact,
|
||||||
|
getTasksForContact,
|
||||||
|
isLoading,
|
||||||
|
toggleTaskCompletion,
|
||||||
|
clearCacheForContact,
|
||||||
|
clearCache,
|
||||||
|
categorizeTasksForContact,
|
||||||
|
};
|
||||||
|
|
@ -57,6 +57,12 @@ export const tasks = pgTable(
|
||||||
dueTime: varchar('due_time', { length: 5 }),
|
dueTime: varchar('due_time', { length: 5 }),
|
||||||
startDate: timestamp('start_date', { withTimezone: true }),
|
startDate: timestamp('start_date', { withTimezone: true }),
|
||||||
|
|
||||||
|
// Time-Blocking (for calendar integration)
|
||||||
|
scheduledDate: timestamp('scheduled_date', { withTimezone: true }),
|
||||||
|
scheduledStartTime: varchar('scheduled_start_time', { length: 5 }), // HH:mm
|
||||||
|
scheduledEndTime: varchar('scheduled_end_time', { length: 5 }), // HH:mm
|
||||||
|
estimatedDuration: integer('estimated_duration'), // in minutes
|
||||||
|
|
||||||
// Priority & Status
|
// Priority & Status
|
||||||
priority: varchar('priority', { length: 10 }).default('medium').$type<TaskPriority>(),
|
priority: varchar('priority', { length: 10 }).default('medium').$type<TaskPriority>(),
|
||||||
status: varchar('status', { length: 20 }).default('pending').$type<TaskStatus>(),
|
status: varchar('status', { length: 20 }).default('pending').$type<TaskStatus>(),
|
||||||
|
|
@ -90,6 +96,7 @@ export const tasks = pgTable(
|
||||||
projectIdx: index('tasks_project_idx').on(table.projectId),
|
projectIdx: index('tasks_project_idx').on(table.projectId),
|
||||||
userIdx: index('tasks_user_idx').on(table.userId),
|
userIdx: index('tasks_user_idx').on(table.userId),
|
||||||
dueDateIdx: index('tasks_due_date_idx').on(table.dueDate),
|
dueDateIdx: index('tasks_due_date_idx').on(table.dueDate),
|
||||||
|
scheduledDateIdx: index('tasks_scheduled_date_idx').on(table.scheduledDate),
|
||||||
statusIdx: index('tasks_status_idx').on(table.isCompleted, table.status),
|
statusIdx: index('tasks_status_idx').on(table.isCompleted, table.status),
|
||||||
parentIdx: index('tasks_parent_idx').on(table.parentTaskId),
|
parentIdx: index('tasks_parent_idx').on(table.parentTaskId),
|
||||||
orderIdx: index('tasks_order_idx').on(table.projectId, table.order),
|
orderIdx: index('tasks_order_idx').on(table.projectId, table.order),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ import {
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
Matches,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import type { TaskPriority } from '../../db/schema/tasks.schema';
|
import type { TaskPriority } from '../../db/schema/tasks.schema';
|
||||||
|
|
@ -47,6 +51,27 @@ export class CreateTaskDto {
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
startDate?: string | null;
|
startDate?: string | null;
|
||||||
|
|
||||||
|
// Time-Blocking fields
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
scheduledDate?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledStartTime must be in HH:mm format' })
|
||||||
|
scheduledStartTime?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledEndTime must be in HH:mm format' })
|
||||||
|
scheduledEndTime?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(1440) // Max 24 hours in minutes
|
||||||
|
estimatedDuration?: number | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(['low', 'medium', 'high', 'urgent'])
|
@IsEnum(['low', 'medium', 'high', 'urgent'])
|
||||||
priority?: TaskPriority;
|
priority?: TaskPriority;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ import {
|
||||||
IsObject,
|
IsObject,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
Matches,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import type { TaskPriority, TaskStatus, Subtask, TaskMetadata } from '../../db/schema/tasks.schema';
|
import type { TaskPriority, TaskStatus, Subtask, TaskMetadata } from '../../db/schema/tasks.schema';
|
||||||
|
|
||||||
|
|
@ -43,6 +47,27 @@ export class UpdateTaskDto {
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
startDate?: string | null;
|
startDate?: string | null;
|
||||||
|
|
||||||
|
// Time-Blocking fields
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
scheduledDate?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledStartTime must be in HH:mm format' })
|
||||||
|
scheduledStartTime?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledEndTime must be in HH:mm format' })
|
||||||
|
scheduledEndTime?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(1440) // Max 24 hours in minutes
|
||||||
|
estimatedDuration?: number | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(['low', 'medium', 'high', 'urgent'])
|
@IsEnum(['low', 'medium', 'high', 'urgent'])
|
||||||
priority?: TaskPriority;
|
priority?: TaskPriority;
|
||||||
|
|
@ -74,4 +99,9 @@ export class UpdateTaskDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
metadata?: TaskMetadata | null;
|
metadata?: TaskMetadata | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
labelIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,20 @@ export class TaskController {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('by-contact/:contactId')
|
||||||
|
async getByContact(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Param('contactId') contactId: string,
|
||||||
|
@Query('includeCompleted') includeCompleted?: string
|
||||||
|
) {
|
||||||
|
const tasks = await this.taskService.findByContact(
|
||||||
|
user.userId,
|
||||||
|
contactId,
|
||||||
|
includeCompleted === 'true'
|
||||||
|
);
|
||||||
|
return { tasks };
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||||
const task = await this.taskService.findByIdOrThrow(id, user.userId);
|
const task = await this.taskService.findByIdOrThrow(id, user.userId);
|
||||||
|
|
|
||||||
|
|
@ -151,8 +151,11 @@ export class TaskService {
|
||||||
await this.projectService.findByIdOrThrow(dto.projectId, userId);
|
await this.projectService.findByIdOrThrow(dto.projectId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract labelIds before spreading dto (it's not a db column)
|
||||||
|
const { labelIds, ...dtoWithoutLabels } = dto;
|
||||||
|
|
||||||
const updateData: Partial<NewTask> = {
|
const updateData: Partial<NewTask> = {
|
||||||
...dto,
|
...dtoWithoutLabels,
|
||||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : dto.dueDate === null ? null : undefined,
|
dueDate: dto.dueDate ? new Date(dto.dueDate) : dto.dueDate === null ? null : undefined,
|
||||||
startDate: dto.startDate
|
startDate: dto.startDate
|
||||||
? new Date(dto.startDate)
|
? new Date(dto.startDate)
|
||||||
|
|
@ -181,6 +184,11 @@ export class TaskService {
|
||||||
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
|
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
// Update labels if provided
|
||||||
|
if (labelIds !== undefined) {
|
||||||
|
await this.updateTaskLabels(id, userId, labelIds);
|
||||||
|
}
|
||||||
|
|
||||||
return this.loadTaskLabels(updated);
|
return this.loadTaskLabels(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,6 +419,41 @@ export class TaskService {
|
||||||
return this.findAll(userId, { isCompleted: false });
|
return this.findAll(userId, { isCompleted: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds all tasks where the given contact is either the assignee or involved.
|
||||||
|
* Searches in metadata->assignee->contactId and metadata->involvedContacts array.
|
||||||
|
*/
|
||||||
|
async findByContact(
|
||||||
|
userId: string,
|
||||||
|
contactId: string,
|
||||||
|
includeCompleted: boolean = false
|
||||||
|
): Promise<TaskWithLabels[]> {
|
||||||
|
// Build conditions for the query
|
||||||
|
const conditions: SQL[] = [eq(tasks.userId, userId)];
|
||||||
|
|
||||||
|
// Optionally exclude completed tasks
|
||||||
|
if (!includeCompleted) {
|
||||||
|
conditions.push(eq(tasks.isCompleted, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for contactId in metadata->assignee->contactId OR in metadata->involvedContacts array
|
||||||
|
const contactCondition = or(
|
||||||
|
// Check if assignee.contactId matches
|
||||||
|
sql`${tasks.metadata}->>'assignee' IS NOT NULL AND ${tasks.metadata}->'assignee'->>'contactId' = ${contactId}`,
|
||||||
|
// Check if contactId exists in involvedContacts array
|
||||||
|
sql`${tasks.metadata}->'involvedContacts' @> ${JSON.stringify([{ contactId }])}::jsonb`
|
||||||
|
);
|
||||||
|
|
||||||
|
conditions.push(contactCondition as SQL);
|
||||||
|
|
||||||
|
const result = await this.db.query.tasks.findMany({
|
||||||
|
where: and(...conditions),
|
||||||
|
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.loadTaskLabelsBatch(result);
|
||||||
|
}
|
||||||
|
|
||||||
async getTodayTasks(userId: string): Promise<TaskWithLabels[]> {
|
async getTodayTasks(userId: string): Promise<TaskWithLabels[]> {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@manacore/shared-auth": "workspace:*",
|
"@manacore/shared-auth": "workspace:*",
|
||||||
|
"@manacore/shared-types": "workspace:*",
|
||||||
"@manacore/shared-utils": "workspace:*",
|
"@manacore/shared-utils": "workspace:*",
|
||||||
"@manacore/shared-tags": "workspace:*",
|
"@manacore/shared-tags": "workspace:*",
|
||||||
"@manacore/shared-auth-ui": "workspace:*",
|
"@manacore/shared-auth-ui": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@
|
||||||
EffectiveDuration,
|
EffectiveDuration,
|
||||||
UpdateTaskInput,
|
UpdateTaskInput,
|
||||||
} from '@todo/shared';
|
} from '@todo/shared';
|
||||||
|
import type { ContactReference, ContactOrManual } from '@manacore/shared-types';
|
||||||
import { STATUS_OPTIONS, RECURRENCE_OPTIONS } from '@todo/shared';
|
import { STATUS_OPTIONS, RECURRENCE_OPTIONS } from '@todo/shared';
|
||||||
import { projectsStore } from '$lib/stores/projects.svelte';
|
import { projectsStore } from '$lib/stores/projects.svelte';
|
||||||
|
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import SubtaskList from './SubtaskList.svelte';
|
import SubtaskList from './SubtaskList.svelte';
|
||||||
import {
|
import {
|
||||||
|
|
@ -18,6 +20,7 @@
|
||||||
FunRatingPicker,
|
FunRatingPicker,
|
||||||
TagSelector,
|
TagSelector,
|
||||||
} from './form';
|
} from './form';
|
||||||
|
import { ContactSelector } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
task: Task;
|
task: Task;
|
||||||
|
|
@ -45,6 +48,10 @@
|
||||||
let storyPoints = $state<number | null>(null);
|
let storyPoints = $state<number | null>(null);
|
||||||
let effectiveDuration = $state<EffectiveDuration | null>(null);
|
let effectiveDuration = $state<EffectiveDuration | null>(null);
|
||||||
let funRating = $state<number | null>(null);
|
let funRating = $state<number | null>(null);
|
||||||
|
// Contact associations
|
||||||
|
let assignee = $state<ContactOrManual[]>([]);
|
||||||
|
let involvedContacts = $state<ContactOrManual[]>([]);
|
||||||
|
let contactsAvailable = $state<boolean | null>(null);
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
|
|
@ -69,7 +76,15 @@
|
||||||
storyPoints = task.metadata?.storyPoints ?? null;
|
storyPoints = task.metadata?.storyPoints ?? null;
|
||||||
effectiveDuration = task.metadata?.effectiveDuration ?? null;
|
effectiveDuration = task.metadata?.effectiveDuration ?? null;
|
||||||
funRating = task.metadata?.funRating ?? null;
|
funRating = task.metadata?.funRating ?? null;
|
||||||
|
// Contact associations
|
||||||
|
assignee = task.metadata?.assignee ? [task.metadata.assignee] : [];
|
||||||
|
involvedContacts = task.metadata?.involvedContacts || [];
|
||||||
showDeleteConfirm = false;
|
showDeleteConfirm = false;
|
||||||
|
|
||||||
|
// Check contacts availability
|
||||||
|
contactsStore.checkAvailability().then((available) => {
|
||||||
|
contactsAvailable = available;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -88,11 +103,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract ContactReference from ContactOrManual (filter out manual entries for now)
|
||||||
|
function toContactReference(contact: ContactOrManual): ContactReference | null {
|
||||||
|
if ('isManual' in contact && contact.isManual) {
|
||||||
|
return null; // Manual entries not stored as contacts
|
||||||
|
}
|
||||||
|
return contact as ContactReference;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!title.trim()) return;
|
if (!title.trim()) return;
|
||||||
|
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
|
// Convert assignee array to single ContactReference
|
||||||
|
const assigneeRef = assignee.length > 0 ? toContactReference(assignee[0]) : null;
|
||||||
|
// Convert involved contacts to array of ContactReferences
|
||||||
|
const involvedRefs = involvedContacts
|
||||||
|
.map(toContactReference)
|
||||||
|
.filter((c): c is ContactReference => c !== null);
|
||||||
|
|
||||||
const data: UpdateTaskInput = {
|
const data: UpdateTaskInput = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || null,
|
description: description.trim() || null,
|
||||||
|
|
@ -110,6 +140,8 @@
|
||||||
storyPoints: storyPoints ?? undefined,
|
storyPoints: storyPoints ?? undefined,
|
||||||
effectiveDuration: effectiveDuration ?? undefined,
|
effectiveDuration: effectiveDuration ?? undefined,
|
||||||
funRating: funRating ?? undefined,
|
funRating: funRating ?? undefined,
|
||||||
|
assignee: assigneeRef ?? undefined,
|
||||||
|
involvedContacts: involvedRefs.length > 0 ? involvedRefs : undefined,
|
||||||
},
|
},
|
||||||
labelIds: selectedLabelIds,
|
labelIds: selectedLabelIds,
|
||||||
};
|
};
|
||||||
|
|
@ -179,6 +211,37 @@
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Zuständige Person -->
|
||||||
|
<div class="form-section">
|
||||||
|
<label class="form-label">Zuständig</label>
|
||||||
|
<ContactSelector
|
||||||
|
selectedContacts={assignee}
|
||||||
|
onContactsChange={(contacts) => (assignee = contacts)}
|
||||||
|
onSearch={(q) => contactsStore.searchContacts(q)}
|
||||||
|
singleSelect={true}
|
||||||
|
allowManualEntry={false}
|
||||||
|
placeholder="Person zuweisen..."
|
||||||
|
addLabel="Zuweisen"
|
||||||
|
searchPlaceholder="Name oder E-Mail..."
|
||||||
|
isAvailable={contactsAvailable ?? false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beteiligte Personen -->
|
||||||
|
<div class="form-section">
|
||||||
|
<label class="form-label">Beteiligte</label>
|
||||||
|
<ContactSelector
|
||||||
|
selectedContacts={involvedContacts}
|
||||||
|
onContactsChange={(contacts) => (involvedContacts = contacts)}
|
||||||
|
onSearch={(q) => contactsStore.searchContacts(q)}
|
||||||
|
allowManualEntry={false}
|
||||||
|
placeholder="Personen hinzufügen..."
|
||||||
|
addLabel="Person hinzufügen"
|
||||||
|
searchPlaceholder="Name oder E-Mail..."
|
||||||
|
isAvailable={contactsAvailable ?? false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Zeitplanung -->
|
<!-- Zeitplanung -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<label class="form-label">Zeitplanung</label>
|
<label class="form-label">Zeitplanung</label>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { format, isToday, isPast, isTomorrow } from 'date-fns';
|
import { format, isToday, isPast, isTomorrow } from 'date-fns';
|
||||||
import { de } from 'date-fns/locale';
|
import { de } from 'date-fns/locale';
|
||||||
import { projectsStore } from '$lib/stores/projects.svelte';
|
import { projectsStore } from '$lib/stores/projects.svelte';
|
||||||
|
import { ContactAvatar } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
task: Task;
|
task: Task;
|
||||||
|
|
@ -165,6 +166,33 @@
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Assignee and involved contacts -->
|
||||||
|
{#if task.metadata?.assignee || (task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0)}
|
||||||
|
<div class="contacts-display">
|
||||||
|
{#if task.metadata?.assignee}
|
||||||
|
<div class="assignee-avatar" title="Zuständig: {task.metadata.assignee.displayName}">
|
||||||
|
<ContactAvatar
|
||||||
|
name={task.metadata.assignee.displayName}
|
||||||
|
photoUrl={task.metadata.assignee.photoUrl}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0}
|
||||||
|
<div class="involved-avatars">
|
||||||
|
{#each task.metadata.involvedContacts.slice(0, 2) as contact}
|
||||||
|
<div class="involved-avatar" title="Beteiligt: {contact.displayName}">
|
||||||
|
<ContactAvatar name={contact.displayName} photoUrl={contact.photoUrl} size="xs" />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if task.metadata.involvedContacts.length > 2}
|
||||||
|
<span class="more-contacts">+{task.metadata.involvedContacts.length - 2}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Due date (always on the right) -->
|
<!-- Due date (always on the right) -->
|
||||||
{#if dueDateText()}
|
{#if dueDateText()}
|
||||||
<span
|
<span
|
||||||
|
|
@ -424,6 +452,58 @@
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Contacts display */
|
||||||
|
.contacts-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignee-avatar {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignee-avatar::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
right: -1px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: #8b5cf6;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .assignee-avatar::after {
|
||||||
|
border-color: rgba(30, 30, 30, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.involved-avatars {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.involved-avatar {
|
||||||
|
margin-left: -0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.involved-avatar:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-contacts {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .more-contacts {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
/* Due date */
|
/* Due date */
|
||||||
.due-date {
|
.due-date {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|
|
||||||
|
|
@ -28,18 +28,19 @@
|
||||||
// Track which task is being animated for completion
|
// Track which task is being animated for completion
|
||||||
let animatingTaskId = $state<string | null>(null);
|
let animatingTaskId = $state<string | null>(null);
|
||||||
|
|
||||||
// Create a stable key from task IDs to detect real changes
|
// Create a stable key from task IDs and updatedAt to detect real changes
|
||||||
let lastTaskIds = '';
|
let lastTaskKey = '';
|
||||||
|
|
||||||
// Sync items with tasks only when the set of task IDs changes
|
// Sync items with tasks when IDs change OR when tasks are updated
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const currentIds = tasks
|
// Include updatedAt in the key to detect task updates
|
||||||
.map((t) => t.id)
|
const currentKey = tasks
|
||||||
|
.map((t) => `${t.id}:${t.updatedAt || ''}`)
|
||||||
.sort()
|
.sort()
|
||||||
.join(',');
|
.join(',');
|
||||||
if (currentIds !== lastTaskIds) {
|
if (currentKey !== lastTaskKey) {
|
||||||
items = [...tasks];
|
items = [...tasks];
|
||||||
lastTaskIds = currentIds;
|
lastTaskKey = currentKey;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -70,10 +71,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local state and sync lastTaskIds to prevent $effect from reverting
|
// Update local state and sync lastTaskKey to prevent $effect from reverting
|
||||||
items = newItems;
|
items = newItems;
|
||||||
lastTaskIds = newItems
|
lastTaskKey = newItems
|
||||||
.map((t) => t.id)
|
.map((t) => `${t.id}:${t.updatedAt || ''}`)
|
||||||
.sort()
|
.sort()
|
||||||
.join(',');
|
.join(',');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import type { Task } from '@todo/shared';
|
import type { Task } from '@todo/shared';
|
||||||
import { format, isToday, isPast, isTomorrow } from 'date-fns';
|
import { format, isToday, isPast, isTomorrow } from 'date-fns';
|
||||||
import { de } from 'date-fns/locale';
|
import { de } from 'date-fns/locale';
|
||||||
import { ConfirmationModal } from '@manacore/shared-ui';
|
import { ConfirmationModal, ContactAvatar } from '@manacore/shared-ui';
|
||||||
import TaskEditModal from '../TaskEditModal.svelte';
|
import TaskEditModal from '../TaskEditModal.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -249,6 +249,33 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Contacts display -->
|
||||||
|
{#if task.metadata?.assignee || (task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0)}
|
||||||
|
<div class="contacts-display">
|
||||||
|
{#if task.metadata?.assignee}
|
||||||
|
<div class="assignee-avatar" title="Zuständig: {task.metadata.assignee.displayName}">
|
||||||
|
<ContactAvatar
|
||||||
|
name={task.metadata.assignee.displayName}
|
||||||
|
photoUrl={task.metadata.assignee.photoUrl}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0}
|
||||||
|
<div class="involved-avatars">
|
||||||
|
{#each task.metadata.involvedContacts.slice(0, 2) as contact}
|
||||||
|
<div class="involved-avatar" title="Beteiligt: {contact.displayName}">
|
||||||
|
<ContactAvatar name={contact.displayName} photoUrl={contact.photoUrl} size="xs" />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if task.metadata.involvedContacts.length > 2}
|
||||||
|
<span class="more-contacts">+{task.metadata.involvedContacts.length - 2}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Context Menu -->
|
<!-- Context Menu -->
|
||||||
|
|
@ -500,6 +527,58 @@
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Contacts display */
|
||||||
|
.contacts-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignee-avatar {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignee-avatar::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
right: -1px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: #8b5cf6;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .assignee-avatar::after {
|
||||||
|
border-color: rgba(30, 30, 30, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.involved-avatars {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.involved-avatar {
|
||||||
|
margin-left: -0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.involved-avatar:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-contacts {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .more-contacts {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
/* Context Menu */
|
/* Context Menu */
|
||||||
.context-menu {
|
.context-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
|
||||||
175
apps/todo/apps/web/src/lib/stores/contacts.svelte.ts
Normal file
175
apps/todo/apps/web/src/lib/stores/contacts.svelte.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
/**
|
||||||
|
* Contacts Store for Todo App
|
||||||
|
*
|
||||||
|
* Provides access to contacts from the Contacts app for task assignment.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { createContactsClient, type ContactsClient } from '@manacore/shared-auth';
|
||||||
|
import type { ContactSummary } from '@manacore/shared-types';
|
||||||
|
import { authStore } from './auth.svelte';
|
||||||
|
|
||||||
|
// State
|
||||||
|
let client: ContactsClient | null = null;
|
||||||
|
let isAvailable = $state<boolean | null>(null);
|
||||||
|
let isChecking = $state(false);
|
||||||
|
let lastCheck = $state<number>(0);
|
||||||
|
|
||||||
|
// Cache for recent search results
|
||||||
|
let searchCache = $state<Map<string, { results: ContactSummary[]; timestamp: number }>>(new Map());
|
||||||
|
const CACHE_TTL = 60000; // 1 minute
|
||||||
|
|
||||||
|
// Get contacts API URL dynamically
|
||||||
|
function getContactsApiUrl(): string {
|
||||||
|
if (browser && typeof window !== 'undefined') {
|
||||||
|
const injectedUrl = (window as unknown as { __PUBLIC_CONTACTS_API_URL__?: string })
|
||||||
|
.__PUBLIC_CONTACTS_API_URL__;
|
||||||
|
return injectedUrl || 'http://localhost:3015/api/v1';
|
||||||
|
}
|
||||||
|
return 'http://localhost:3015/api/v1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize client lazily
|
||||||
|
function getClient(): ContactsClient {
|
||||||
|
if (!client) {
|
||||||
|
client = createContactsClient({
|
||||||
|
apiUrl: getContactsApiUrl(),
|
||||||
|
getAuthToken: async () => authStore.getAccessToken(),
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contactsStore = {
|
||||||
|
// Getters
|
||||||
|
get isAvailable() {
|
||||||
|
return isAvailable;
|
||||||
|
},
|
||||||
|
get isChecking() {
|
||||||
|
return isChecking;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the Contacts API is available
|
||||||
|
* Caches result for 30 seconds
|
||||||
|
*/
|
||||||
|
async checkAvailability(): Promise<boolean> {
|
||||||
|
const now = Date.now();
|
||||||
|
// Skip if checked recently
|
||||||
|
if (lastCheck && now - lastCheck < 30000 && isAvailable !== null) {
|
||||||
|
return isAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
isChecking = true;
|
||||||
|
try {
|
||||||
|
const available = await getClient().isAvailable();
|
||||||
|
isAvailable = available;
|
||||||
|
lastCheck = now;
|
||||||
|
return available;
|
||||||
|
} catch {
|
||||||
|
isAvailable = false;
|
||||||
|
lastCheck = now;
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isChecking = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search contacts by query
|
||||||
|
*/
|
||||||
|
async searchContacts(query: string): Promise<ContactSummary[]> {
|
||||||
|
// Check cache first
|
||||||
|
const cacheKey = query.toLowerCase().trim();
|
||||||
|
const cached = searchCache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
return cached.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check availability
|
||||||
|
if (isAvailable === null) {
|
||||||
|
await this.checkAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAvailable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await getClient().searchContacts({
|
||||||
|
query,
|
||||||
|
limit: 20,
|
||||||
|
excludeArchived: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache results
|
||||||
|
searchCache.set(cacheKey, {
|
||||||
|
results,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[contactsStore] Search failed:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single contact by ID
|
||||||
|
*/
|
||||||
|
async getContact(id: string): Promise<ContactSummary | null> {
|
||||||
|
if (isAvailable === null) {
|
||||||
|
await this.checkAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAvailable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await getClient().getContact(id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[contactsStore] Failed to get contact ${id}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get multiple contacts by IDs
|
||||||
|
*/
|
||||||
|
async getContacts(ids: string[]): Promise<ContactSummary[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
|
if (isAvailable === null) {
|
||||||
|
await this.checkAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAvailable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await getClient().getContacts(ids);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[contactsStore] Failed to get contacts:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the search cache
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
searchCache.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset availability check (force recheck on next call)
|
||||||
|
*/
|
||||||
|
resetAvailability() {
|
||||||
|
isAvailable = null;
|
||||||
|
lastCheck = 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -14,6 +14,9 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@manacore/shared-types": "workspace:*"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Label } from './label';
|
import type { Label } from './label';
|
||||||
|
import type { ContactReference } from '@manacore/shared-types';
|
||||||
|
|
||||||
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
|
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
|
|
@ -26,6 +27,9 @@ export interface TaskMetadata {
|
||||||
storyPoints?: number | null; // Fibonacci: 1, 2, 3, 5, 8, 13, 21
|
storyPoints?: number | null; // Fibonacci: 1, 2, 3, 5, 8, 13, 21
|
||||||
effectiveDuration?: EffectiveDuration | null; // Actual time spent
|
effectiveDuration?: EffectiveDuration | null; // Actual time spent
|
||||||
funRating?: number | null; // 1-10 scale
|
funRating?: number | null; // 1-10 scale
|
||||||
|
// Contact associations
|
||||||
|
assignee?: ContactReference | null; // Person responsible for the task
|
||||||
|
involvedContacts?: ContactReference[]; // Other people involved
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
|
|
@ -43,6 +47,12 @@ export interface Task {
|
||||||
dueTime?: string | null; // HH:mm format
|
dueTime?: string | null; // HH:mm format
|
||||||
startDate?: Date | string | null;
|
startDate?: Date | string | null;
|
||||||
|
|
||||||
|
// Time-Blocking (for calendar integration)
|
||||||
|
scheduledDate?: Date | string | null; // Date when task is scheduled
|
||||||
|
scheduledStartTime?: string | null; // HH:mm format - when to start
|
||||||
|
scheduledEndTime?: string | null; // HH:mm format - when to end
|
||||||
|
estimatedDuration?: number | null; // Duration in minutes
|
||||||
|
|
||||||
// Priority & Status
|
// Priority & Status
|
||||||
priority: TaskPriority;
|
priority: TaskPriority;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
|
|
@ -84,6 +94,11 @@ export interface CreateTaskInput {
|
||||||
dueDate?: string | null;
|
dueDate?: string | null;
|
||||||
dueTime?: string | null;
|
dueTime?: string | null;
|
||||||
startDate?: string | null;
|
startDate?: string | null;
|
||||||
|
// Time-Blocking
|
||||||
|
scheduledDate?: string | null;
|
||||||
|
scheduledStartTime?: string | null;
|
||||||
|
scheduledEndTime?: string | null;
|
||||||
|
estimatedDuration?: number | null;
|
||||||
priority?: TaskPriority;
|
priority?: TaskPriority;
|
||||||
recurrenceRule?: string | null;
|
recurrenceRule?: string | null;
|
||||||
recurrenceEndDate?: string | null;
|
recurrenceEndDate?: string | null;
|
||||||
|
|
@ -100,6 +115,11 @@ export interface UpdateTaskInput {
|
||||||
dueDate?: string | null;
|
dueDate?: string | null;
|
||||||
dueTime?: string | null;
|
dueTime?: string | null;
|
||||||
startDate?: string | null;
|
startDate?: string | null;
|
||||||
|
// Time-Blocking
|
||||||
|
scheduledDate?: string | null;
|
||||||
|
scheduledStartTime?: string | null;
|
||||||
|
scheduledEndTime?: string | null;
|
||||||
|
estimatedDuration?: number | null;
|
||||||
priority?: TaskPriority;
|
priority?: TaskPriority;
|
||||||
status?: TaskStatus;
|
status?: TaskStatus;
|
||||||
isCompleted?: boolean;
|
isCompleted?: boolean;
|
||||||
|
|
|
||||||
418
docs/optimizable/foundation-layer-improvements.md
Normal file
418
docs/optimizable/foundation-layer-improvements.md
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
# Foundation Layer - Verbesserungsvorschläge
|
||||||
|
|
||||||
|
> **Stand:** Dezember 2024
|
||||||
|
> **Betrifft:** Contacts, Todo, Calendar (Foundation Services)
|
||||||
|
|
||||||
|
## Aktuelle Architektur (Gut!)
|
||||||
|
|
||||||
|
Die drei Foundation Services sind korrekt als **separate Services mit eigenen Datenbanken** aufgesetzt:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Contacts │ │ Todo │ │ Calendar │
|
||||||
|
│ :3010 │ │ :3011 │ │ :3012 │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ contacts DB │ │ todo DB │ │ calendar DB │
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warum das richtig ist:**
|
||||||
|
- Unabhängige Deployments
|
||||||
|
- Failure Isolation
|
||||||
|
- Unabhängige Skalierung
|
||||||
|
- Keine Schema-Konflikte zwischen Teams
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbesserungsvorschläge
|
||||||
|
|
||||||
|
### 1. Foundation Clients Package
|
||||||
|
|
||||||
|
**Aufwand:** Mittel | **Priorität:** Hoch
|
||||||
|
|
||||||
|
Einheitlicher API-Client für alle Consumer Apps (Chat, Picture, Clock, etc.).
|
||||||
|
|
||||||
|
**Neues Package:** `packages/foundation-clients/`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/foundation-clients/src/index.ts
|
||||||
|
export class FoundationClients {
|
||||||
|
contacts: ContactsClient;
|
||||||
|
todo: TodoClient;
|
||||||
|
calendar: CalendarClient;
|
||||||
|
|
||||||
|
constructor(config: FoundationConfig) {
|
||||||
|
this.contacts = new ContactsClient(config);
|
||||||
|
this.todo = new TodoClient(config);
|
||||||
|
this.calendar = new CalendarClient(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// packages/foundation-clients/src/contacts.client.ts
|
||||||
|
export class ContactsClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
private cache: Map<string, CachedContact> = new Map();
|
||||||
|
|
||||||
|
async get(id: string): Promise<Contact | null> {
|
||||||
|
// Mit Caching
|
||||||
|
const cached = this.cache.get(id);
|
||||||
|
if (cached && !this.isStale(cached)) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/contacts/${id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${this.token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const contact = await response.json();
|
||||||
|
this.cache.set(id, { data: contact, fetchedAt: Date.now() });
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query: string): Promise<ContactSummary[]> {
|
||||||
|
// Für Autocomplete in anderen Apps
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBulk(ids: string[]): Promise<Contact[]> {
|
||||||
|
// Effizient für Listen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nutzung in Consumer Apps:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/chat/apps/backend/src/chat.service.ts
|
||||||
|
import { FoundationClients } from '@manacore/foundation-clients';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChatService {
|
||||||
|
private foundation: FoundationClients;
|
||||||
|
|
||||||
|
constructor(configService: ConfigService) {
|
||||||
|
this.foundation = new FoundationClients({
|
||||||
|
contactsUrl: configService.get('CONTACTS_API_URL'),
|
||||||
|
todoUrl: configService.get('TODO_API_URL'),
|
||||||
|
calendarUrl: configService.get('CALENDAR_API_URL'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessageWithContact(messageId: string) {
|
||||||
|
const message = await this.getMessage(messageId);
|
||||||
|
const sender = await this.foundation.contacts.get(message.senderId);
|
||||||
|
return { ...message, sender };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Event Bus (Redis Pub/Sub)
|
||||||
|
|
||||||
|
**Aufwand:** Mittel | **Priorität:** Mittel
|
||||||
|
|
||||||
|
Ermöglicht reaktive Updates zwischen Services ohne Polling.
|
||||||
|
|
||||||
|
**Events definieren:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/foundation-events/src/index.ts
|
||||||
|
export const FoundationEvents = {
|
||||||
|
// Contacts
|
||||||
|
CONTACT_CREATED: 'contact.created',
|
||||||
|
CONTACT_UPDATED: 'contact.updated',
|
||||||
|
CONTACT_DELETED: 'contact.deleted',
|
||||||
|
|
||||||
|
// Todo
|
||||||
|
TASK_CREATED: 'task.created',
|
||||||
|
TASK_COMPLETED: 'task.completed',
|
||||||
|
TASK_DELETED: 'task.deleted',
|
||||||
|
|
||||||
|
// Calendar
|
||||||
|
EVENT_CREATED: 'event.created',
|
||||||
|
EVENT_UPDATED: 'event.updated',
|
||||||
|
EVENT_DELETED: 'event.deleted',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface TaskCompletedEvent {
|
||||||
|
taskId: string;
|
||||||
|
userId: string;
|
||||||
|
completedAt: string;
|
||||||
|
linkedCalendarEventId?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Publisher (Todo Service):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/todo/apps/backend/src/task/task.service.ts
|
||||||
|
import { RedisService } from '@manacore/shared-redis';
|
||||||
|
import { FoundationEvents } from '@manacore/foundation-events';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TaskService {
|
||||||
|
constructor(private redis: RedisService) {}
|
||||||
|
|
||||||
|
async completeTask(taskId: string, userId: string) {
|
||||||
|
const task = await this.markCompleted(taskId);
|
||||||
|
|
||||||
|
// Event publizieren
|
||||||
|
await this.redis.publish(FoundationEvents.TASK_COMPLETED, {
|
||||||
|
taskId: task.id,
|
||||||
|
userId,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
linkedCalendarEventId: task.metadata?.linkedCalendarEventId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Subscriber (Calendar Service):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/calendar/apps/backend/src/calendar.module.ts
|
||||||
|
import { FoundationEvents } from '@manacore/foundation-events';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CalendarEventSubscriber implements OnModuleInit {
|
||||||
|
constructor(
|
||||||
|
private redis: RedisService,
|
||||||
|
private eventService: EventService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.redis.subscribe(FoundationEvents.TASK_COMPLETED, async (data) => {
|
||||||
|
if (data.linkedCalendarEventId) {
|
||||||
|
await this.eventService.markLinkedTaskCompleted(
|
||||||
|
data.linkedCalendarEventId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
|
||||||
|
| Event | Reaktion |
|
||||||
|
|-------|----------|
|
||||||
|
| `task.completed` | Calendar markiert verknüpftes Event |
|
||||||
|
| `contact.updated` | Chat aktualisiert Sender-Anzeige |
|
||||||
|
| `event.deleted` | Todo entfernt `linkedCalendarEventId` |
|
||||||
|
| `contact.deleted` | Alle Apps entfernen Kontakt-Referenzen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Bulk-Endpoints
|
||||||
|
|
||||||
|
**Aufwand:** Klein | **Priorität:** Hoch
|
||||||
|
|
||||||
|
Reduziert N+1 API-Calls bei Listen-Ansichten.
|
||||||
|
|
||||||
|
**Contacts Service:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/contacts/apps/backend/src/contact/contact.controller.ts
|
||||||
|
@Controller('contacts')
|
||||||
|
export class ContactController {
|
||||||
|
@Post('bulk')
|
||||||
|
async getBulk(@Body() body: { ids: string[] }): Promise<Contact[]> {
|
||||||
|
return this.contactService.findByIds(body.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('search')
|
||||||
|
async search(
|
||||||
|
@Query('q') query: string,
|
||||||
|
@Query('limit') limit = 10
|
||||||
|
): Promise<ContactSummary[]> {
|
||||||
|
return this.contactService.search(query, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Todo Service:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/todo/apps/backend/src/task/task.controller.ts
|
||||||
|
@Controller('tasks')
|
||||||
|
export class TaskController {
|
||||||
|
@Post('bulk')
|
||||||
|
async getBulk(@Body() body: { ids: string[] }): Promise<Task[]> {
|
||||||
|
return this.taskService.findByIds(body.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('by-contact/:contactId')
|
||||||
|
async getByContact(@Param('contactId') contactId: string): Promise<Task[]> {
|
||||||
|
// Tasks die mit einem Kontakt verknüpft sind
|
||||||
|
return this.taskService.findByLinkedContact(contactId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Calendar Service:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/calendar/apps/backend/src/event/event.controller.ts
|
||||||
|
@Controller('events')
|
||||||
|
export class EventController {
|
||||||
|
@Post('bulk')
|
||||||
|
async getBulk(@Body() body: { ids: string[] }): Promise<Event[]> {
|
||||||
|
return this.eventService.findByIds(body.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('by-attendee')
|
||||||
|
async getByAttendee(@Query('email') email: string): Promise<Event[]> {
|
||||||
|
return this.eventService.findByAttendeeEmail(email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Caching-Layer
|
||||||
|
|
||||||
|
**Aufwand:** Klein | **Priorität:** Mittel
|
||||||
|
|
||||||
|
Kontakte ändern sich selten - perfekt für Caching.
|
||||||
|
|
||||||
|
**In Foundation Clients (Client-Side Cache):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/foundation-clients/src/cache.ts
|
||||||
|
export class SimpleCache<T> {
|
||||||
|
private cache = new Map<string, { data: T; expiresAt: number }>();
|
||||||
|
private ttlMs: number;
|
||||||
|
|
||||||
|
constructor(ttlSeconds = 300) {
|
||||||
|
this.ttlMs = ttlSeconds * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string): T | null {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, data: T): void {
|
||||||
|
this.cache.set(key, {
|
||||||
|
data,
|
||||||
|
expiresAt: Date.now() + this.ttlMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(key: string): void {
|
||||||
|
this.cache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Redis Cache (Server-Side):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/contacts/apps/backend/src/contact/contact.service.ts
|
||||||
|
@Injectable()
|
||||||
|
export class ContactService {
|
||||||
|
private readonly CACHE_TTL = 300; // 5 Minuten
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Contact | null> {
|
||||||
|
// 1. Redis Cache prüfen
|
||||||
|
const cached = await this.redis.get(`contact:${id}`);
|
||||||
|
if (cached) return JSON.parse(cached);
|
||||||
|
|
||||||
|
// 2. DB Query
|
||||||
|
const contact = await this.db
|
||||||
|
.select()
|
||||||
|
.from(contacts)
|
||||||
|
.where(eq(contacts.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (contact[0]) {
|
||||||
|
// 3. In Cache speichern
|
||||||
|
await this.redis.setex(
|
||||||
|
`contact:${id}`,
|
||||||
|
this.CACHE_TTL,
|
||||||
|
JSON.stringify(contact[0])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return contact[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: UpdateContactDto): Promise<Contact> {
|
||||||
|
const updated = await this.db
|
||||||
|
.update(contacts)
|
||||||
|
.set(data)
|
||||||
|
.where(eq(contacts.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Cache invalidieren
|
||||||
|
await this.redis.del(`contact:${id}`);
|
||||||
|
|
||||||
|
return updated[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementierungs-Reihenfolge
|
||||||
|
|
||||||
|
| Phase | Task | Abhängigkeiten |
|
||||||
|
|-------|------|----------------|
|
||||||
|
| **1** | Bulk-Endpoints hinzufügen | Keine |
|
||||||
|
| **2** | Foundation Clients Package erstellen | Bulk-Endpoints |
|
||||||
|
| **3** | Client-Side Caching in Foundation Clients | Foundation Clients |
|
||||||
|
| **4** | Redis Cache in Services | Redis Setup |
|
||||||
|
| **5** | Event Bus Setup | Redis Setup |
|
||||||
|
| **6** | Event Publisher/Subscriber | Event Bus |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Neue Package-Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/
|
||||||
|
├── foundation-clients/ # NEU: API Clients
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── contacts.client.ts
|
||||||
|
│ │ ├── todo.client.ts
|
||||||
|
│ │ ├── calendar.client.ts
|
||||||
|
│ │ ├── cache.ts
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ └── package.json
|
||||||
|
│
|
||||||
|
├── foundation-events/ # NEU: Event Definitions
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── contact.events.ts
|
||||||
|
│ │ ├── task.events.ts
|
||||||
|
│ │ ├── calendar.events.ts
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ └── package.json
|
||||||
|
│
|
||||||
|
├── shared-types/ # Existiert bereits
|
||||||
|
│ └── src/
|
||||||
|
│ ├── contact.ts # ContactReference, ContactSummary
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
└── shared-redis/ # NEU oder erweitern
|
||||||
|
└── src/
|
||||||
|
├── redis.service.ts
|
||||||
|
├── pub-sub.ts
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offene Fragen
|
||||||
|
|
||||||
|
- [ ] Welche Consumer Apps werden als erste integriert?
|
||||||
|
- [ ] Redis bereits im Stack oder neu einführen?
|
||||||
|
- [ ] Cache TTL pro Entity-Typ oder einheitlich?
|
||||||
|
- [ ] Event Bus: Redis Pub/Sub vs. dediziertes System (Bull, etc.)?
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"lint": "eslint ."
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@manacore/shared-types": "workspace:*",
|
||||||
"base64-js": "^1.5.1"
|
"base64-js": "^1.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
204
packages/shared-auth/src/clients/contactsClient.ts
Normal file
204
packages/shared-auth/src/clients/contactsClient.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
/**
|
||||||
|
* Contacts API Client for cross-app integration
|
||||||
|
*
|
||||||
|
* This client allows other apps (Todo, Calendar) to search and fetch contacts
|
||||||
|
* from the Contacts app backend.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ContactSummary } from '@manacore/shared-types';
|
||||||
|
|
||||||
|
export interface ContactsClientConfig {
|
||||||
|
/** Base URL of the Contacts API (e.g., http://localhost:3015/api/v1) */
|
||||||
|
apiUrl: string;
|
||||||
|
/** Function to get the current auth token */
|
||||||
|
getAuthToken: () => Promise<string | null>;
|
||||||
|
/** Request timeout in ms (default: 5000) */
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactSearchOptions {
|
||||||
|
/** Search query string */
|
||||||
|
query?: string;
|
||||||
|
/** Maximum number of results */
|
||||||
|
limit?: number;
|
||||||
|
/** Skip archived contacts */
|
||||||
|
excludeArchived?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client for accessing the Contacts API from other apps
|
||||||
|
*/
|
||||||
|
export class ContactsClient {
|
||||||
|
private config: ContactsClientConfig;
|
||||||
|
private available: boolean | null = null;
|
||||||
|
|
||||||
|
constructor(config: ContactsClientConfig) {
|
||||||
|
this.config = {
|
||||||
|
timeout: 5000,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the Contacts API is available
|
||||||
|
*/
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.config.apiUrl}/health`, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.available = response.ok;
|
||||||
|
return this.available;
|
||||||
|
} catch {
|
||||||
|
this.available = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached availability status (call isAvailable() to refresh)
|
||||||
|
*/
|
||||||
|
getCachedAvailability(): boolean | null {
|
||||||
|
return this.available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search contacts by query string
|
||||||
|
*/
|
||||||
|
async searchContacts(options: ContactSearchOptions = {}): Promise<ContactSummary[]> {
|
||||||
|
const { query = '', limit = 20, excludeArchived = true } = options;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query) params.set('search', query);
|
||||||
|
if (limit) params.set('limit', String(limit));
|
||||||
|
if (excludeArchived) params.set('isArchived', 'false');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = (await this.fetchWithAuth(`/contacts?${params.toString()}`)) as {
|
||||||
|
contacts?: Record<string, unknown>[];
|
||||||
|
};
|
||||||
|
return this.mapToContactSummaries(response.contacts || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ContactsClient] Failed to search contacts:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single contact by ID
|
||||||
|
*/
|
||||||
|
async getContact(id: string): Promise<ContactSummary | null> {
|
||||||
|
try {
|
||||||
|
const response = (await this.fetchWithAuth(`/contacts/${id}`)) as {
|
||||||
|
contact?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
if (response.contact) {
|
||||||
|
return this.mapToContactSummary(response.contact);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[ContactsClient] Failed to get contact ${id}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get multiple contacts by IDs (batch fetch)
|
||||||
|
*/
|
||||||
|
async getContacts(ids: string[]): Promise<ContactSummary[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
|
// Contacts API doesn't have a batch endpoint, so we fetch individually
|
||||||
|
// but with Promise.allSettled to handle partial failures gracefully
|
||||||
|
const results = await Promise.allSettled(ids.map((id) => this.getContact(id)));
|
||||||
|
|
||||||
|
return results
|
||||||
|
.filter(
|
||||||
|
(result): result is PromiseFulfilledResult<ContactSummary | null> =>
|
||||||
|
result.status === 'fulfilled' && result.value !== null
|
||||||
|
)
|
||||||
|
.map((result) => result.value as ContactSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal fetch with auth token
|
||||||
|
*/
|
||||||
|
private async fetchWithAuth(endpoint: string, options: RequestInit = {}): Promise<unknown> {
|
||||||
|
const token = await this.config.getAuthToken();
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.config.apiUrl}${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||||
|
throw new Error(error.message || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw new Error('Request timeout');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map API contact response to ContactSummary
|
||||||
|
*/
|
||||||
|
private mapToContactSummary(contact: Record<string, unknown>): ContactSummary {
|
||||||
|
return {
|
||||||
|
id: contact.id as string,
|
||||||
|
displayName:
|
||||||
|
(contact.displayName as string) ||
|
||||||
|
[contact.firstName, contact.lastName].filter(Boolean).join(' ') ||
|
||||||
|
(contact.email as string) ||
|
||||||
|
'Unbekannt',
|
||||||
|
firstName: contact.firstName as string | undefined,
|
||||||
|
lastName: contact.lastName as string | undefined,
|
||||||
|
email: contact.email as string | undefined,
|
||||||
|
phone: (contact.phone as string) || (contact.mobile as string) || undefined,
|
||||||
|
company: contact.company as string | undefined,
|
||||||
|
photoUrl: contact.photoUrl as string | undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map array of contacts to ContactSummary[]
|
||||||
|
*/
|
||||||
|
private mapToContactSummaries(contacts: Record<string, unknown>[]): ContactSummary[] {
|
||||||
|
return contacts.map((c) => this.mapToContactSummary(c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a ContactsClient instance
|
||||||
|
*/
|
||||||
|
export function createContactsClient(config: ContactsClientConfig): ContactsClient {
|
||||||
|
return new ContactsClient(config);
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,10 @@ export {
|
||||||
} from './interceptors/fetchInterceptor';
|
} from './interceptors/fetchInterceptor';
|
||||||
export type { FetchInterceptorConfig } from './interceptors/fetchInterceptor';
|
export type { FetchInterceptorConfig } from './interceptors/fetchInterceptor';
|
||||||
|
|
||||||
|
// Contacts client for cross-app integration
|
||||||
|
export { ContactsClient, createContactsClient } from './clients/contactsClient';
|
||||||
|
export type { ContactsClientConfig, ContactSearchOptions } from './clients/contactsClient';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize auth service with all adapters for web
|
* Initialize auth service with all adapters for web
|
||||||
*
|
*
|
||||||
|
|
|
||||||
80
packages/shared-types/src/contact.ts
Normal file
80
packages/shared-types/src/contact.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
/**
|
||||||
|
* Contact-related types for cross-app integration
|
||||||
|
*
|
||||||
|
* These types are used when referencing contacts from the Contacts app
|
||||||
|
* in other apps like Todo and Calendar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference to a contact with cached display data.
|
||||||
|
* Used for offline display when Contacts API is unavailable.
|
||||||
|
*/
|
||||||
|
export interface ContactReference {
|
||||||
|
/** Contact ID from Contacts app */
|
||||||
|
contactId: string;
|
||||||
|
/** Cached display name */
|
||||||
|
displayName: string;
|
||||||
|
/** Cached email */
|
||||||
|
email?: string;
|
||||||
|
/** Cached photo URL */
|
||||||
|
photoUrl?: string;
|
||||||
|
/** Cached company name */
|
||||||
|
company?: string;
|
||||||
|
/** ISO timestamp when data was fetched (for cache invalidation) */
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary of a contact from the Contacts API.
|
||||||
|
* Contains essential fields for display in selectors and lists.
|
||||||
|
*/
|
||||||
|
export interface ContactSummary {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
company?: string;
|
||||||
|
photoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual contact entry (when contact doesn't exist in Contacts app).
|
||||||
|
* Used for calendar attendees who aren't in the user's contacts.
|
||||||
|
*/
|
||||||
|
export interface ManualContactEntry {
|
||||||
|
/** Email address (required for manual entries) */
|
||||||
|
email: string;
|
||||||
|
/** Display name (optional) */
|
||||||
|
name?: string;
|
||||||
|
/** Indicates this is a manual entry, not from Contacts app */
|
||||||
|
isManual: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type for contact references that can be either
|
||||||
|
* a real contact or a manual entry.
|
||||||
|
*/
|
||||||
|
export type ContactOrManual = ContactReference | ManualContactEntry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to check if a contact entry is manual
|
||||||
|
*/
|
||||||
|
export function isManualContact(contact: ContactOrManual): contact is ManualContactEntry {
|
||||||
|
return 'isManual' in contact && contact.isManual === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a ContactReference from a ContactSummary
|
||||||
|
*/
|
||||||
|
export function createContactReference(contact: ContactSummary): ContactReference {
|
||||||
|
return {
|
||||||
|
contactId: contact.id,
|
||||||
|
displayName: contact.displayName,
|
||||||
|
email: contact.email,
|
||||||
|
photoUrl: contact.photoUrl,
|
||||||
|
company: contact.company,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,9 @@ export * from './ui';
|
||||||
// Common utility types
|
// Common utility types
|
||||||
export * from './common';
|
export * from './common';
|
||||||
|
|
||||||
|
// Contact types for cross-app integration
|
||||||
|
export * from './contact';
|
||||||
|
|
||||||
// API types
|
// API types
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
"@manacore/shared-branding": "workspace:*",
|
"@manacore/shared-branding": "workspace:*",
|
||||||
"@manacore/shared-icons": "workspace:*",
|
"@manacore/shared-icons": "workspace:*",
|
||||||
"@manacore/shared-theme": "workspace:*",
|
"@manacore/shared-theme": "workspace:*",
|
||||||
|
"@manacore/shared-types": "workspace:*",
|
||||||
"d3-force": "^3.0.0",
|
"d3-force": "^3.0.0",
|
||||||
"d3-selection": "^3.0.0",
|
"d3-selection": "^3.0.0",
|
||||||
"d3-transition": "^3.0.0",
|
"d3-transition": "^3.0.0",
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ export {
|
||||||
// Feedback
|
// Feedback
|
||||||
export { EmptyState } from './molecules';
|
export { EmptyState } from './molecules';
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
export { ContactAvatar, ContactBadge, ContactSelector } from './molecules';
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
export { ModalFooter, DataCard, PageHeader, KeyboardShortcutsPanel } from './molecules';
|
export { ModalFooter, DataCard, PageHeader, KeyboardShortcutsPanel } from './molecules';
|
||||||
|
|
||||||
|
|
|
||||||
100
packages/shared-ui/src/molecules/contacts/ContactAvatar.svelte
Normal file
100
packages/shared-ui/src/molecules/contacts/ContactAvatar.svelte
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { User } from '@manacore/shared-icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Photo URL */
|
||||||
|
photoUrl?: string | null;
|
||||||
|
/** Display name (for initials fallback) */
|
||||||
|
name?: string;
|
||||||
|
/** Size in pixels */
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||||
|
/** Custom class */
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { photoUrl, name = '', size = 'md', class: className = '' }: Props = $props();
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
xs: 'w-5 h-5 text-[10px]',
|
||||||
|
sm: 'w-6 h-6 text-xs',
|
||||||
|
md: 'w-8 h-8 text-sm',
|
||||||
|
lg: 'w-10 h-10 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconSizes = {
|
||||||
|
xs: 10,
|
||||||
|
sm: 12,
|
||||||
|
md: 16,
|
||||||
|
lg: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate initials from name
|
||||||
|
const initials = $derived.by(() => {
|
||||||
|
if (!name) return '';
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return parts[0].charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate a consistent background color based on the name
|
||||||
|
const bgColor = $derived.by(() => {
|
||||||
|
if (!name) return 'bg-gray-400';
|
||||||
|
const colors = [
|
||||||
|
'bg-violet-500',
|
||||||
|
'bg-blue-500',
|
||||||
|
'bg-cyan-500',
|
||||||
|
'bg-teal-500',
|
||||||
|
'bg-green-500',
|
||||||
|
'bg-amber-500',
|
||||||
|
'bg-orange-500',
|
||||||
|
'bg-rose-500',
|
||||||
|
'bg-pink-500',
|
||||||
|
'bg-indigo-500',
|
||||||
|
];
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return colors[Math.abs(hash) % colors.length];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if photoUrl}
|
||||||
|
<img
|
||||||
|
src={photoUrl}
|
||||||
|
alt={name || 'Kontakt'}
|
||||||
|
class="
|
||||||
|
{sizeClasses[size]}
|
||||||
|
rounded-full object-cover
|
||||||
|
{className}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
{:else if initials}
|
||||||
|
<div
|
||||||
|
class="
|
||||||
|
{sizeClasses[size]}
|
||||||
|
{bgColor}
|
||||||
|
rounded-full
|
||||||
|
flex items-center justify-center
|
||||||
|
text-white font-medium
|
||||||
|
{className}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="
|
||||||
|
{sizeClasses[size]}
|
||||||
|
bg-gray-300 dark:bg-gray-600
|
||||||
|
rounded-full
|
||||||
|
flex items-center justify-center
|
||||||
|
text-gray-500 dark:text-gray-400
|
||||||
|
{className}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<User size={iconSizes[size]} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
185
packages/shared-ui/src/molecules/contacts/ContactBadge.svelte
Normal file
185
packages/shared-ui/src/molecules/contacts/ContactBadge.svelte
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { X } from '@manacore/shared-icons';
|
||||||
|
import ContactAvatar from './ContactAvatar.svelte';
|
||||||
|
import type {
|
||||||
|
ContactReference,
|
||||||
|
ManualContactEntry,
|
||||||
|
ContactOrManual,
|
||||||
|
} from '@manacore/shared-types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Contact to display */
|
||||||
|
contact: ContactOrManual;
|
||||||
|
/** Show remove button */
|
||||||
|
removable?: boolean;
|
||||||
|
/** Called when remove is clicked */
|
||||||
|
onRemove?: () => void;
|
||||||
|
/** Size variant */
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
/** Show email under name */
|
||||||
|
showEmail?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { contact, removable = false, onRemove, size = 'md', showEmail = false }: Props = $props();
|
||||||
|
|
||||||
|
// Check if this is a manual entry
|
||||||
|
const isManual = $derived('isManual' in contact && contact.isManual === true);
|
||||||
|
|
||||||
|
// Get display values
|
||||||
|
const displayName = $derived(
|
||||||
|
isManual
|
||||||
|
? (contact as ManualContactEntry).name || (contact as ManualContactEntry).email
|
||||||
|
: (contact as ContactReference).displayName
|
||||||
|
);
|
||||||
|
|
||||||
|
const email = $derived(
|
||||||
|
isManual ? (contact as ManualContactEntry).email : (contact as ContactReference).email
|
||||||
|
);
|
||||||
|
|
||||||
|
const photoUrl = $derived(isManual ? undefined : (contact as ContactReference).photoUrl);
|
||||||
|
|
||||||
|
const avatarSizes = {
|
||||||
|
sm: 'xs' as const,
|
||||||
|
md: 'sm' as const,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="contact-badge"
|
||||||
|
class:size-sm={size === 'sm'}
|
||||||
|
class:size-md={size === 'md'}
|
||||||
|
class:manual={isManual}
|
||||||
|
>
|
||||||
|
<ContactAvatar {photoUrl} name={displayName} size={avatarSizes[size]} />
|
||||||
|
|
||||||
|
<span class="contact-info">
|
||||||
|
<span class="contact-name">{displayName}</span>
|
||||||
|
{#if showEmail && email && email !== displayName}
|
||||||
|
<span class="contact-email">{email}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if removable}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove?.();
|
||||||
|
}}
|
||||||
|
class="remove-btn"
|
||||||
|
aria-label="Entfernen"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.contact-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
background: rgba(139, 92, 246, 0.12);
|
||||||
|
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .contact-badge {
|
||||||
|
background: rgba(139, 92, 246, 0.15);
|
||||||
|
border-color: rgba(139, 92, 246, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-badge:hover {
|
||||||
|
background: rgba(139, 92, 246, 0.18);
|
||||||
|
border-color: rgba(139, 92, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .contact-badge:hover {
|
||||||
|
background: rgba(139, 92, 246, 0.22);
|
||||||
|
border-color: rgba(139, 92, 246, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manual entry variant (dashed border) */
|
||||||
|
.contact-badge.manual {
|
||||||
|
background: rgba(107, 114, 128, 0.1);
|
||||||
|
border: 1px dashed rgba(107, 114, 128, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .contact-badge.manual {
|
||||||
|
background: rgba(156, 163, 175, 0.12);
|
||||||
|
border-color: rgba(156, 163, 175, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Size variants */
|
||||||
|
.size-sm {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-md {
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.2;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-name {
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .contact-name {
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-email {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: #6b7280;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .contact-email {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: 0.125rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .remove-btn {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .remove-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
711
packages/shared-ui/src/molecules/contacts/ContactSelector.svelte
Normal file
711
packages/shared-ui/src/molecules/contacts/ContactSelector.svelte
Normal file
|
|
@ -0,0 +1,711 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Plus, MagnifyingGlass, User, Envelope } from '@manacore/shared-icons';
|
||||||
|
import ContactBadge from './ContactBadge.svelte';
|
||||||
|
import ContactAvatar from './ContactAvatar.svelte';
|
||||||
|
import type {
|
||||||
|
ContactReference,
|
||||||
|
ContactSummary,
|
||||||
|
ManualContactEntry,
|
||||||
|
ContactOrManual,
|
||||||
|
createContactReference,
|
||||||
|
} from '@manacore/shared-types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Currently selected contacts */
|
||||||
|
selectedContacts: ContactOrManual[];
|
||||||
|
/** Called when selection changes */
|
||||||
|
onContactsChange: (contacts: ContactOrManual[]) => void;
|
||||||
|
/** Function to search contacts (async) */
|
||||||
|
onSearch: (query: string) => Promise<ContactSummary[]>;
|
||||||
|
/** Allow manual email entry (for contacts not in system) */
|
||||||
|
allowManualEntry?: boolean;
|
||||||
|
/** Maximum contacts that can be selected */
|
||||||
|
maxContacts?: number;
|
||||||
|
/** Single select mode (only one contact allowed) */
|
||||||
|
singleSelect?: boolean;
|
||||||
|
/** Placeholder text */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Add button label */
|
||||||
|
addLabel?: string;
|
||||||
|
/** Search placeholder */
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
/** Loading state */
|
||||||
|
loading?: boolean;
|
||||||
|
/** Disabled state */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Show "not available" message when contacts API is down */
|
||||||
|
unavailableMessage?: string;
|
||||||
|
/** Is contacts API available */
|
||||||
|
isAvailable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
selectedContacts,
|
||||||
|
onContactsChange,
|
||||||
|
onSearch,
|
||||||
|
allowManualEntry = false,
|
||||||
|
maxContacts,
|
||||||
|
singleSelect = false,
|
||||||
|
placeholder = 'Kontakt hinzufügen...',
|
||||||
|
addLabel = 'Kontakt hinzufügen',
|
||||||
|
searchPlaceholder = 'Name oder E-Mail suchen...',
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
unavailableMessage = 'Kontakte nicht verfügbar',
|
||||||
|
isAvailable = true,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let isOpen = $state(false);
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let searchResults = $state<ContactSummary[]>([]);
|
||||||
|
let isSearching = $state(false);
|
||||||
|
let showManualEntry = $state(false);
|
||||||
|
let manualEmail = $state('');
|
||||||
|
let manualName = $state('');
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let searchInputRef = $state<HTMLInputElement | null>(null);
|
||||||
|
let highlightedIndex = $state(-1);
|
||||||
|
|
||||||
|
// Focus search input when dropdown opens
|
||||||
|
$effect(() => {
|
||||||
|
if (isOpen && searchInputRef) {
|
||||||
|
setTimeout(() => searchInputRef?.focus(), 0);
|
||||||
|
highlightedIndex = -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset highlighted index when results change
|
||||||
|
$effect(() => {
|
||||||
|
if (searchResults.length > 0) {
|
||||||
|
highlightedIndex = -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectiveMax = $derived(singleSelect ? 1 : maxContacts);
|
||||||
|
const canAddMore = $derived(!effectiveMax || selectedContacts.length < effectiveMax);
|
||||||
|
|
||||||
|
// Check if an email looks valid
|
||||||
|
function isValidEmail(email: string): boolean {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
async function handleSearchInput(query: string) {
|
||||||
|
searchQuery = query;
|
||||||
|
|
||||||
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
searchResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeout = setTimeout(async () => {
|
||||||
|
if (!isAvailable) return;
|
||||||
|
|
||||||
|
isSearching = true;
|
||||||
|
try {
|
||||||
|
const results = await onSearch(query);
|
||||||
|
// Filter out already selected contacts
|
||||||
|
const selectedIds = new Set(
|
||||||
|
selectedContacts
|
||||||
|
.filter((c): c is ContactReference => 'contactId' in c)
|
||||||
|
.map((c) => c.contactId)
|
||||||
|
);
|
||||||
|
searchResults = results.filter((r) => !selectedIds.has(r.id));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Contact search failed:', error);
|
||||||
|
searchResults = [];
|
||||||
|
} finally {
|
||||||
|
isSearching = false;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectContact(contact: ContactSummary) {
|
||||||
|
if (!canAddMore) return;
|
||||||
|
|
||||||
|
const reference: ContactReference = {
|
||||||
|
contactId: contact.id,
|
||||||
|
displayName: contact.displayName,
|
||||||
|
email: contact.email,
|
||||||
|
photoUrl: contact.photoUrl,
|
||||||
|
company: contact.company,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (singleSelect) {
|
||||||
|
onContactsChange([reference]);
|
||||||
|
} else {
|
||||||
|
onContactsChange([...selectedContacts, reference]);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchQuery = '';
|
||||||
|
searchResults = [];
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveContact(index: number) {
|
||||||
|
onContactsChange(selectedContacts.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddManualEntry() {
|
||||||
|
if (!manualEmail.trim() || !isValidEmail(manualEmail)) return;
|
||||||
|
|
||||||
|
const entry: ManualContactEntry = {
|
||||||
|
email: manualEmail.trim(),
|
||||||
|
name: manualName.trim() || undefined,
|
||||||
|
isManual: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (singleSelect) {
|
||||||
|
onContactsChange([entry]);
|
||||||
|
} else {
|
||||||
|
onContactsChange([...selectedContacts, entry]);
|
||||||
|
}
|
||||||
|
|
||||||
|
manualEmail = '';
|
||||||
|
manualName = '';
|
||||||
|
showManualEntry = false;
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.closest('.contact-selector-container')) {
|
||||||
|
isOpen = false;
|
||||||
|
showManualEntry = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
isOpen = false;
|
||||||
|
showManualEntry = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchKeyDown(e: KeyboardEvent) {
|
||||||
|
if (!isOpen || searchResults.length === 0) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightedIndex = Math.min(highlightedIndex + 1, searchResults.length - 1);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightedIndex = Math.max(highlightedIndex - 1, -1);
|
||||||
|
} else if (e.key === 'Enter' && highlightedIndex >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelectContact(searchResults[highlightedIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onclick={handleClickOutside} onkeydown={handleKeyDown} />
|
||||||
|
|
||||||
|
<div class="contact-selector-container">
|
||||||
|
<!-- Selected Contacts Display -->
|
||||||
|
<div class="selected-contacts">
|
||||||
|
{#each selectedContacts as contact, index (index)}
|
||||||
|
<ContactBadge {contact} removable onRemove={() => handleRemoveContact(index)} />
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if canAddMore && !disabled}
|
||||||
|
<button type="button" onclick={() => (isOpen = !isOpen)} class="add-button" {disabled}>
|
||||||
|
<Plus size={14} weight="bold" />
|
||||||
|
<span>{addLabel}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dropdown -->
|
||||||
|
{#if isOpen}
|
||||||
|
<div class="dropdown">
|
||||||
|
{#if !isAvailable}
|
||||||
|
<!-- Unavailable State -->
|
||||||
|
<div class="unavailable-state">
|
||||||
|
<User size={24} />
|
||||||
|
<p>{unavailableMessage}</p>
|
||||||
|
{#if allowManualEntry}
|
||||||
|
<button type="button" onclick={() => (showManualEntry = true)} class="manual-link">
|
||||||
|
Manuell hinzufügen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div class="search-section">
|
||||||
|
<div class="search-input-wrapper">
|
||||||
|
<MagnifyingGlass size={16} class="search-icon" />
|
||||||
|
<input
|
||||||
|
bind:this={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
oninput={(e) => handleSearchInput(e.currentTarget.value)}
|
||||||
|
onkeydown={handleSearchKeyDown}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results List -->
|
||||||
|
<div class="results-list">
|
||||||
|
{#if isSearching || loading}
|
||||||
|
<div class="empty-state">Suche...</div>
|
||||||
|
{:else if searchResults.length > 0}
|
||||||
|
{#each searchResults as contact, index (contact.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => handleSelectContact(contact)}
|
||||||
|
class="result-item"
|
||||||
|
class:highlighted={index === highlightedIndex}
|
||||||
|
>
|
||||||
|
<ContactAvatar photoUrl={contact.photoUrl} name={contact.displayName} size="md" />
|
||||||
|
<div class="result-info">
|
||||||
|
<div class="result-name">{contact.displayName}</div>
|
||||||
|
{#if contact.email}
|
||||||
|
<div class="result-detail">{contact.email}</div>
|
||||||
|
{/if}
|
||||||
|
{#if contact.company}
|
||||||
|
<div class="result-detail">{contact.company}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{:else if searchQuery.trim()}
|
||||||
|
<div class="empty-state">Kein Kontakt gefunden</div>
|
||||||
|
{:else}
|
||||||
|
<div class="empty-state">Name oder E-Mail eingeben...</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Entry Option -->
|
||||||
|
{#if allowManualEntry}
|
||||||
|
<div class="manual-section">
|
||||||
|
{#if showManualEntry}
|
||||||
|
<div class="manual-form">
|
||||||
|
<div class="input-with-icon">
|
||||||
|
<Envelope size={14} />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
bind:value={manualEmail}
|
||||||
|
placeholder="E-Mail-Adresse *"
|
||||||
|
class="manual-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="input-with-icon">
|
||||||
|
<User size={14} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={manualName}
|
||||||
|
placeholder="Name (optional)"
|
||||||
|
class="manual-input"
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && handleAddManualEntry()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="manual-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showManualEntry = false)}
|
||||||
|
class="btn-cancel"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleAddManualEntry}
|
||||||
|
disabled={!manualEmail.trim() || !isValidEmail(manualEmail)}
|
||||||
|
class="btn-add"
|
||||||
|
>
|
||||||
|
Hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button type="button" onclick={() => (showManualEntry = true)} class="manual-trigger">
|
||||||
|
<Envelope size={14} />
|
||||||
|
<span>E-Mail manuell hinzufügen</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.contact-selector-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-contacts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #6b7280;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 9999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .add-button {
|
||||||
|
color: #9ca3af;
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button:hover:not(:disabled) {
|
||||||
|
color: #374151;
|
||||||
|
border-color: rgba(0, 0, 0, 0.3);
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .add-button:hover:not(:disabled) {
|
||||||
|
color: #e5e7eb;
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown */
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 50;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 320px;
|
||||||
|
background: rgba(255, 255, 255, 1);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow:
|
||||||
|
0 12px 28px -5px rgba(0, 0, 0, 0.2),
|
||||||
|
0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .dropdown {
|
||||||
|
background: rgba(45, 45, 45, 1);
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
box-shadow:
|
||||||
|
0 12px 28px -5px rgba(0, 0, 0, 0.4),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Section */
|
||||||
|
.search-section {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .search-section {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper :global(.search-icon) {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.75rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .search-input-wrapper :global(.search-icon) {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #374151;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .search-input {
|
||||||
|
color: #f3f4f6;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: #8b5cf6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Results List */
|
||||||
|
.results-list {
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .empty-state {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:hover,
|
||||||
|
.result-item.highlighted {
|
||||||
|
background: rgba(139, 92, 246, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .result-item:hover,
|
||||||
|
:global(.dark) .result-item.highlighted {
|
||||||
|
background: rgba(139, 92, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .result-name {
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-detail {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .result-detail {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manual Entry Section */
|
||||||
|
.manual-section {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .manual-section {
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-icon {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-icon > :global(svg) {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .input-with-icon > :global(svg) {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #374151;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .manual-input {
|
||||||
|
color: #f3f4f6;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-input:focus {
|
||||||
|
border-color: #8b5cf6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-input::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .btn-cancel {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .btn-cancel:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
background: #8b5cf6;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .manual-trigger {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-trigger:hover {
|
||||||
|
color: #374151;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .manual-trigger:hover {
|
||||||
|
color: #e5e7eb;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unavailable State */
|
||||||
|
.unavailable-state {
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .unavailable-state {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unavailable-state > :global(svg) {
|
||||||
|
margin: 0 auto 0.5rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unavailable-state p {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-link {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #8b5cf6;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
4
packages/shared-ui/src/molecules/contacts/index.ts
Normal file
4
packages/shared-ui/src/molecules/contacts/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// Contact selection and display components
|
||||||
|
export { default as ContactAvatar } from './ContactAvatar.svelte';
|
||||||
|
export { default as ContactBadge } from './ContactBadge.svelte';
|
||||||
|
export { default as ContactSelector } from './ContactSelector.svelte';
|
||||||
|
|
@ -39,6 +39,9 @@ export {
|
||||||
// Feedback components
|
// Feedback components
|
||||||
export { EmptyState } from './feedback';
|
export { EmptyState } from './feedback';
|
||||||
|
|
||||||
|
// Contact components
|
||||||
|
export { ContactAvatar, ContactBadge, ContactSelector } from './contacts';
|
||||||
|
|
||||||
// Layout components
|
// Layout components
|
||||||
export { default as ModalFooter } from './ModalFooter.svelte';
|
export { default as ModalFooter } from './ModalFooter.svelte';
|
||||||
export { default as DataCard } from './DataCard.svelte';
|
export { default as DataCard } from './DataCard.svelte';
|
||||||
|
|
|
||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
|
|
@ -253,6 +253,9 @@ importers:
|
||||||
'@manacore/shared-theme-ui':
|
'@manacore/shared-theme-ui':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../../../packages/shared-theme-ui
|
version: link:../../../../packages/shared-theme-ui
|
||||||
|
'@manacore/shared-types':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../../packages/shared-types
|
||||||
'@manacore/shared-ui':
|
'@manacore/shared-ui':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../../../packages/shared-ui
|
version: link:../../../../packages/shared-ui
|
||||||
|
|
@ -2655,6 +2658,9 @@ importers:
|
||||||
'@manacore/shared-theme-ui':
|
'@manacore/shared-theme-ui':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../../../packages/shared-theme-ui
|
version: link:../../../../packages/shared-theme-ui
|
||||||
|
'@manacore/shared-types':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../../packages/shared-types
|
||||||
'@manacore/shared-ui':
|
'@manacore/shared-ui':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../../../packages/shared-ui
|
version: link:../../../../packages/shared-ui
|
||||||
|
|
@ -2724,6 +2730,10 @@ importers:
|
||||||
version: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
|
version: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
|
||||||
|
|
||||||
apps/todo/packages/shared:
|
apps/todo/packages/shared:
|
||||||
|
dependencies:
|
||||||
|
'@manacore/shared-types':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../../packages/shared-types
|
||||||
devDependencies:
|
devDependencies:
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.9.3
|
specifier: ^5.9.3
|
||||||
|
|
@ -3886,6 +3896,9 @@ importers:
|
||||||
|
|
||||||
packages/shared-auth:
|
packages/shared-auth:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@manacore/shared-types':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../shared-types
|
||||||
base64-js:
|
base64-js:
|
||||||
specifier: ^1.5.1
|
specifier: ^1.5.1
|
||||||
version: 1.5.1
|
version: 1.5.1
|
||||||
|
|
@ -4289,6 +4302,9 @@ importers:
|
||||||
'@manacore/shared-theme':
|
'@manacore/shared-theme':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../shared-theme
|
version: link:../shared-theme
|
||||||
|
'@manacore/shared-types':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../shared-types
|
||||||
d3-force:
|
d3-force:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue