diff --git a/src/components/TaskCard.tsx b/src/components/TaskCard.tsx index ed08083..b4c05b6 100644 --- a/src/components/TaskCard.tsx +++ b/src/components/TaskCard.tsx @@ -1,7 +1,7 @@ import { type Component, createMemo } from "solid-js"; import { type Task, toggleTask, calculateUrgencyFromDate, setActiveTaskId, store, copyTask } from "@/store"; import { cn } from "@/lib/utils"; -import { CheckCircle2, Circle, Clock, ArrowUpCircle, Copy } from "lucide-solid"; +import { CheckCircle2, Circle, Clock, ArrowUpCircle, Copy, Users2 } from "lucide-solid"; import { Badge } from "@/components/ui/badge"; import { For } from "solid-js"; import { useTheme } from "./ThemeProvider"; @@ -78,6 +78,31 @@ export const TaskCard: Component<{ task: Task }> = (props) => { P{props.task.priority} + {/* Shared Indicator - shows for individual shares OR tag-based ShareRules */} + {(() => { + const hasIndividualShares = props.task.sharedWith && props.task.sharedWith.length > 0; + // Check if any tag-based ShareRules match this task's tags (exclude 'all' type) + const hasTagRuleShares = store.shareRules.some(rule => + rule.type === 'tag' && + rule.tagName && + props.task.tags?.includes(rule.tagName) + ); + const isShared = hasIndividualShares || hasTagRuleShares; + + if (!isShared) return null; + + const shareCount = (props.task.sharedWith?.length || 0); + const tooltip = hasIndividualShares + ? `Shared with ${shareCount} user${shareCount > 1 ? 's' : ''}` + : 'Shared via tag rule'; + + return ( +
+ +
+ ); + })()} + {/* Tags */} {props.task.tags && props.task.tags.length > 0 && (
diff --git a/src/components/TaskDetail.tsx b/src/components/TaskDetail.tsx index 68f790c..e2c3c03 100644 --- a/src/components/TaskDetail.tsx +++ b/src/components/TaskDetail.tsx @@ -1,9 +1,9 @@ import { type Component, createEffect, createSignal, For } from "solid-js"; import { Sheet, SheetContent } from "@/components/ui/sheet"; -import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate } from "@/store"; +import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask } from "@/store"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge } from "lucide-solid"; +import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings } from "lucide-solid"; import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store"; import { Button } from "./ui/button"; import { TagPicker } from "./TagPicker"; @@ -15,6 +15,7 @@ import { toast } from "solid-sonner"; import { lazy, Suspense } from "solid-js"; import { useTheme } from "./ThemeProvider"; import { getThemeAdjustedColor } from "@/lib/colors"; +import { pb } from "@/lib/pocketbase"; const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor }))); @@ -468,6 +469,16 @@ export const TaskDetail: Component = (props) => { Create Template from Task
+ + {/* Sharing Section */} +
+
Sharing
+ +
@@ -536,3 +547,140 @@ const MetadataItem: Component<{ ); }; + +// User Share Picker Component +const UserSharePicker: Component<{ + taskId: string; + sharedWith: Array<{ userId: string; access: 'view' | 'edit' }>; + tags: string[]; +}> = (props) => { + const [users, setUsers] = createSignal>([]); + const [selectedUserId, setSelectedUserId] = createSignal(""); + const [loading, setLoading] = createSignal(true); + + // Get matching ShareRules for this task + const matchingRules = () => { + const currentUserId = pb.authStore.model?.id; + return store.shareRules.filter(rule => { + if (rule.ownerId !== currentUserId) return false; + if (rule.type === 'all') return true; + if (rule.type === 'tag' && props.tags.includes(rule.tagName!)) return true; + return false; + }); + }; + + const getUserName = (userId: string) => { + return users().find(u => u.id === userId)?.name || userId; + }; + + // Fetch users on mount + createEffect(async () => { + try { + const records = await pb.collection('users').getFullList({ + fields: 'id,name' + }); + setUsers(records.map(r => ({ id: r.id, name: r.name || r.email || r.id }))); + } catch (err) { + console.error("Failed to load users:", err); + } finally { + setLoading(false); + } + }); + + + const availableUsers = () => { + const sharedUserIds = props.sharedWith.map(s => s.userId); + const currentUserId = pb.authStore.model?.id; + return users().filter(u => !sharedUserIds.includes(u.id) && u.id !== currentUserId); + }; + + return ( + <> +
+ + value={users().find(u => u.id === selectedUserId()) || null} + onChange={(user) => setSelectedUserId(user?.id || "")} + options={availableUsers()} + optionValue="id" + optionTextValue="name" + placeholder={loading() ? "Loading..." : "Select user..."} + itemComponent={(itemProps: any) => ( + {itemProps.item.rawValue.name} + )} + > + + > + {(state) => state.selectedOption()?.name || "Select user..."} + + + + + +
+ + {(share) => ( +
+ {getUserName(share.userId)} +
+ + +
+
+ )} +
+ + {/* ShareRule-based sharing */} + {matchingRules().length > 0 && ( +
+
Automatic Rule Shares
+ + {(rule) => ( +
+
+ {rule.type === 'tag' ? : } +
+
+ + {getUserName(rule.targetUserId)} + + + {rule.type === 'all' ? 'All Tasks Rule' : `Matched Tag: ${rule.tagName}`} + +
+
+ )} +
+
+ )} + + ); +}; diff --git a/src/store/index.ts b/src/store/index.ts index e3a17b3..ecdefd6 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -33,6 +33,7 @@ export interface Task { lastUncompleted?: string; // ISO date of last automatic reset }; size?: number; // 0-10, task complexity/size + sharedWith?: Array<{ userId: string; access: 'view' | 'edit' }>; // Users this task is shared with } export const checkRecurringTasks = () => { @@ -139,6 +140,14 @@ export interface Filter { editedToday: boolean; } +export interface ShareRule { + id: string; + ownerId: string; + targetUserId: string; + type: 'tag' | 'all'; + tagName?: string; +} + interface TaskStore { tasks: Task[]; pWeight: number; @@ -148,6 +157,7 @@ interface TaskStore { tagDefinitions: TagDefinition[]; templates: TaskTemplate[]; filter: Filter; + shareRules: ShareRule[]; } // Initial empty state @@ -166,7 +176,8 @@ export const [store, setStore] = createStore({ urgencyMin: 1, urgencyMax: 10, editedToday: false - } + }, + shareRules: [] }); export const matchesFilter = (task: Task) => { @@ -310,10 +321,45 @@ const mapRecordToTask = (r: any): Task => { created: r.created, updated: r.updated, recurrence: r.recurrence, - size: (r.size === null || r.size === undefined) ? undefined : r.size + size: (r.size === null || r.size === undefined) ? undefined : r.size, + sharedWith: r.sharedWith }; }; +const mapRecordToShareRule = (r: any): ShareRule => { + return { + id: r.id, + ownerId: r.ownerId, + targetUserId: r.targetUserId, + type: r.type as 'tag' | 'all', + tagName: r.tagName + }; +}; + +// Helper to check if a task should be visible based on ownership, shares, or rules +const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => { + // Own task + if (task.user === currentUserId) return true; + + // Individually shared + const sharedWith = task.sharedWith || []; + if (sharedWith.some((s: any) => s.userId === currentUserId)) return true; + + // ShareRule match + const matchingRule = store.shareRules.find(rule => { + if (rule.targetUserId !== currentUserId) return false; + if (task.user !== rule.ownerId) return false; + if (rule.type === 'all') return true; + if (rule.type === 'tag') { + const tags = task.tags || []; + return tags.includes(rule.tagName); + } + return false; + }); + + return !!matchingRule; +}; + export const subscribeToRealtime = async () => { // Unsubscribe first to avoid duplicates await pb.collection(TASGRID_COLLECTION).unsubscribe('*'); @@ -325,9 +371,6 @@ export const subscribeToRealtime = async () => { // Check if it's a template const isTemplate = e.record.tags?.includes("__template__"); if (isTemplate) { - // We don't auto-add templates to the main task list usually? - // Wait, `store.templates` is separate. - // We should handle templates too if we want sync on templates. let meta: any = {}; try { meta = JSON.parse(e.record.content || "{}"); } catch (e) { } const newTemplate: TaskTemplate = { @@ -343,11 +386,17 @@ export const subscribeToRealtime = async () => { return; } - // Regular Task + // Regular Task - check if already exists and if it should be visible const exists = store.tasks.find(t => t.id === e.record.id); if (!exists) { - const newTask = mapRecordToTask(e.record); - setStore("tasks", t => [newTask, ...t]); + const currentUserId = pb.authStore.model?.id; + const isOwnTask = e.record.user === currentUserId; + + // Only add if it's own task or should be visible via sharing + if (isOwnTask || (currentUserId && shouldTaskBeVisible(e.record, currentUserId))) { + const newTask = mapRecordToTask(e.record); + setStore("tasks", t => [newTask, ...t]); + } } } @@ -369,23 +418,36 @@ export const subscribeToRealtime = async () => { return; } - // Normal Task - only apply if incoming record is newer or equal + const currentUserId = pb.authStore.model?.id; const existingTask = store.tasks.find(t => t.id === e.record.id); + const isOwnTask = e.record.user === currentUserId; + const incomingUpdated = new Date(e.record.updated).getTime(); + if (existingTask) { - const incomingUpdated = new Date(e.record.updated).getTime(); const localUpdated = new Date(existingTask.updated).getTime(); // Only apply update if incoming is newer (or we don't have a timestamp) if (!existingTask.updated || incomingUpdated >= localUpdated) { + // For shared tasks, re-evaluate visibility (e.g., tags may have changed) + if (!isOwnTask && currentUserId) { + if (!shouldTaskBeVisible(e.record, currentUserId)) { + // No longer visible, remove it + setStore("tasks", t => t.filter(x => x.id !== e.record.id)); + return; + } + } + const updatedTask = mapRecordToTask(e.record); setStore("tasks", t => t.id === e.record.id, updatedTask); } else { console.log("Skipping stale realtime update for task:", e.record.id); } } else { - // Task doesn't exist locally, add it - const updatedTask = mapRecordToTask(e.record); - setStore("tasks", t => [updatedTask, ...t]); + // Task doesn't exist locally - check if it should be visible now + if (currentUserId && shouldTaskBeVisible(e.record, currentUserId)) { + const newTask = mapRecordToTask(e.record); + setStore("tasks", t => [newTask, ...t]); + } } } @@ -395,6 +457,67 @@ export const subscribeToRealtime = async () => { setStore("templates", t => (t || []).filter(x => x.id !== e.record.id)); } }); + + // Subscribe to Share Rules + await pb.collection(SHARE_RULES_COLLECTION).unsubscribe('*'); + pb.collection(SHARE_RULES_COLLECTION).subscribe('*', async (e) => { + const currentUserId = pb.authStore.model?.id; + + if (e.action === 'create') { + const newRule = mapRecordToShareRule(e.record); + + // Add to shareRules store + setStore("shareRules", (rules) => { + if (rules.some(r => r.id === newRule.id)) return rules; + return [...rules, newRule]; + }); + + // If I'm the target, fetch matching tasks from the owner + if (newRule.targetUserId === currentUserId) { + try { + let filter = `user = "${newRule.ownerId}"`; + if (newRule.type === 'tag' && newRule.tagName) { + filter += ` && tags ~ "${newRule.tagName}"`; + } + + const records = await pb.collection(TASGRID_COLLECTION).getFullList({ + filter, + sort: '-created', + requestKey: null + }); + + records.forEach((r: any) => { + if (!r.tags?.includes("__template__") && !store.tasks.find(t => t.id === r.id)) { + const newTask = mapRecordToTask(r); + setStore("tasks", t => [newTask, ...t]); + } + }); + } catch (err) { + console.warn("Failed to fetch tasks for new share rule:", err); + } + } + } + + if (e.action === 'delete') { + const deletedRuleId = e.record.id; + const deletedRule = store.shareRules.find(r => r.id === deletedRuleId); + + // Remove from shareRules store + setStore("shareRules", (rules) => rules.filter(r => r.id !== deletedRuleId)); + + // If I was the target, remove tasks that are no longer visible + if (deletedRule && deletedRule.targetUserId === currentUserId) { + setStore("tasks", tasks => + tasks.filter(task => shouldTaskBeVisible(task, currentUserId!)) + ); + } + } + + if (e.action === 'update') { + const updatedRule = mapRecordToShareRule(e.record); + setStore("shareRules", (rules) => rules.map(r => r.id === updatedRule.id ? updatedRule : r)); + } + }); }; export const initStore = async () => { @@ -456,16 +579,44 @@ export const initStore = async () => { console.warn("Failed to load tags:", tagErr); } - // 2. Fetch Tasks & Templates - const records = await pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `user = "${pb.authStore.model?.id}"`, + // 2. Fetch Tasks & Templates + Shared Tasks in PARALLEL + const currentUserId = pb.authStore.model?.id; + + // Prepare parallel fetch promises (requestKey: null prevents auto-cancellation) + const ownTasksPromise = pb.collection(TASGRID_COLLECTION).getFullList({ + filter: `user = "${currentUserId}"`, sort: '-created', + requestKey: null }); + const individuallySharedPromise = currentUserId + ? pb.collection(TASGRID_COLLECTION).getFullList({ + filter: `sharedWith ~ "${currentUserId}"`, + sort: '-created', + requestKey: null + }).catch(err => { console.warn("Failed to load individually shared tasks:", err); return []; }) + : Promise.resolve([]); + + const shareRulesPromise = currentUserId + ? pb.collection(SHARE_RULES_COLLECTION).getFullList({ + filter: `targetUserId = "${currentUserId}"`, + requestKey: null + }).catch(err => { console.warn("Failed to load share rules:", err); return []; }) + : Promise.resolve([]); + + // Wait for all initial fetches + const [ownRecords, individuallySharedRecords, incomingRules] = await Promise.all([ + ownTasksPromise, + individuallySharedPromise, + shareRulesPromise + ]); + const allTasks: Task[] = []; const loadedTemplates: TaskTemplate[] = []; + const seenIds = new Set(); - records.forEach(r => { + // Process own tasks & templates + ownRecords.forEach(r => { const tags = r.tags || []; if (tags.includes("__template__")) { let meta: any = {}; @@ -481,15 +632,72 @@ export const initStore = async () => { }); } else { allTasks.push(mapRecordToTask(r)); + seenIds.add(r.id); } }); + // Process individually shared tasks + individuallySharedRecords.forEach((r: any) => { + if (!seenIds.has(r.id)) { + const tags = r.tags || []; + if (!tags.includes("__template__")) { + allTasks.push(mapRecordToTask(r)); + seenIds.add(r.id); + } + } + }); + + // Process ShareRules - fetch tasks from each owner in parallel + if (incomingRules.length > 0) { + // Group rules by owner + const ownerRules = new Map(); + incomingRules.forEach((rule: any) => { + const existing = ownerRules.get(rule.ownerId) || []; + existing.push({ type: rule.type, tagName: rule.tagName }); + ownerRules.set(rule.ownerId, existing); + }); + + // Fetch from all owners in parallel + const ownerFetchPromises = Array.from(ownerRules.entries()).map(async ([ownerId, rules]) => { + const hasAllRule = rules.some(r => r.type === 'all'); + const tagRules = rules.filter(r => r.type === 'tag').map(r => r.tagName).filter(Boolean); + + let filter = `user = "${ownerId}"`; + if (!hasAllRule && tagRules.length > 0) { + const tagFilters = tagRules.map(t => `tags ~ "${t}"`).join(' || '); + filter += ` && (${tagFilters})`; + } + + try { + return await pb.collection(TASGRID_COLLECTION).getFullList({ filter, sort: '-created', requestKey: null }); + } catch (err) { + console.warn(`Failed to fetch tasks from owner ${ownerId}:`, err); + return []; + } + }); + + const ownerResults = await Promise.all(ownerFetchPromises); + ownerResults.flat().forEach((r: any) => { + if (!seenIds.has(r.id)) { + const tags = r.tags || []; + if (!tags.includes("__template__")) { + allTasks.push(mapRecordToTask(r)); + seenIds.add(r.id); + } + } + }); + } + + // Set store ONCE with all tasks setStore("tasks", reconcile(allTasks)); setStore("templates", loadedTemplates); // Check recurring tasks after load checkRecurringTasks(); + // 2.7. Fetch Share Rules (owned by current user) + await loadShareRules(); + // 3. Subscribe to Realtime Updates await subscribeToRealtime(); @@ -931,6 +1139,136 @@ export const setFilter = (update: Partial) => { setStore("filter", (f) => ({ ...f, ...update })); }; +// -- Sharing Functions -- + +export const shareTask = async (taskId: string, userId: string, access: 'view' | 'edit' = 'view') => { + if (!pb.authStore.isValid) return; + + const task = store.tasks.find(t => t.id === taskId); + if (!task) return; + + const existingShares = task.sharedWith || []; + if (existingShares.some(s => s.userId === userId)) { + toast.info(`Task is already shared with this user`); + return; + } + + const newShares = [...existingShares, { userId, access }]; + + // Update task with new sharedWith + await updateTask(taskId, { sharedWith: newShares }); + toast.success(`Task shared successfully`); +}; + +export const revokeShare = async (taskId: string, userId: string) => { + if (!pb.authStore.isValid) return; + + const task = store.tasks.find(t => t.id === taskId); + if (!task) return; + + const existingShares = task.sharedWith || []; + const newShares = existingShares.filter(s => s.userId !== userId); + + await updateTask(taskId, { sharedWith: newShares }); + toast.success(`Share revoked`); +}; + +export const splitTask = async (taskId: string, userId: string) => { + if (!pb.authStore.isValid) return; + + const task = store.tasks.find(t => t.id === taskId); + if (!task) return; + + try { + // Create a duplicate task owned by the target user + const duplicateData = { + user: userId, + title: task.title, + content: task.content, + priority: task.priority, + size: task.size, + tags: task.tags, + startDate: task.startDate, + dueDate: task.dueDate, + completed: task.completed, + deletedAt: null, + sharedWith: [], // Start fresh with no shares + recurrence: task.recurrence + }; + + await pb.collection(TASGRID_COLLECTION).create(duplicateData, { requestKey: null }); + + // Remove the user from the original task's sharedWith + const existingShares = task.sharedWith || []; + const newShares = existingShares.filter(s => s.userId !== userId); + await updateTask(taskId, { sharedWith: newShares }); + + toast.success(`Task split - user now has their own copy`); + } catch (err: any) { + console.error("Failed to split task:", err); + console.error("Error details:", err.response?.data || err.data || err.message); + toast.error("Failed to split task: " + (err.response?.data?.message || err.message || "Unknown error")); + } +}; + +// -- Share Rules (for tag-based and all-tasks sharing) -- + +const SHARE_RULES_COLLECTION = 'TasGrid_ShareRules'; + +export const loadShareRules = async () => { + if (!pb.authStore.isValid) return; + + try { + const records = await pb.collection(SHARE_RULES_COLLECTION).getFullList({ + filter: `ownerId = "${pb.authStore.model?.id}"` + }); + + const rules: ShareRule[] = records.map(r => ({ + id: r.id, + ownerId: r.ownerId, + targetUserId: r.targetUserId, + type: r.type as 'tag' | 'all', + tagName: r.tagName + })); + + setStore("shareRules", rules); + } catch (err) { + console.error("Failed to load share rules:", err); + // Collection might not exist yet, that's okay + } +}; + +export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, tagName?: string) => { + if (!pb.authStore.isValid) return; + + try { + await pb.collection(SHARE_RULES_COLLECTION).create({ + ownerId: pb.authStore.model?.id, + targetUserId, + type, + tagName: type === 'tag' ? tagName : null + }); + // Realtime subscription will handle adding to store + toast.success(`Share rule created`); + } catch (err) { + console.error("Failed to create share rule:", err); + toast.error("Failed to create share rule. Make sure the collection exists in PocketBase."); + } +}; + +export const removeShareRule = async (ruleId: string) => { + if (!pb.authStore.isValid) return; + + try { + await pb.collection(SHARE_RULES_COLLECTION).delete(ruleId); + setStore("shareRules", (rules) => rules.filter(r => r.id !== ruleId)); + toast.success("Share rule removed"); + } catch (err) { + console.error("Failed to delete share rule:", err); + toast.error("Failed to delete share rule."); + } +}; + export const clearFilter = () => { setStore("filter", { query: "", diff --git a/src/views/SettingsView.tsx b/src/views/SettingsView.tsx index 15286a2..09c0ea0 100644 --- a/src/views/SettingsView.tsx +++ b/src/views/SettingsView.tsx @@ -1,15 +1,16 @@ import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js"; import { ThemeToggle } from "../components/ThemeToggle"; -import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition } from "@/store"; +import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule } from "@/store"; import { useTheme } from "@/components/ThemeProvider"; -import { Trash2, Undo2, ArrowLeftRight, Hash, Copy, Plus, ChevronDown, ChevronRight, Type } from "lucide-solid"; +import { Trash2, Undo2, ArrowLeftRight, Hash, Copy, Plus, ChevronDown, ChevronRight, Type, Share2, Users, Tag } from "lucide-solid"; import { TagPicker } from "@/components/TagPicker"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { toast } from "solid-sonner"; -import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { PRIORITY_OPTIONS, URGENCY_OPTIONS } from "@/lib/constants"; import { pb } from "@/lib/pocketbase"; +import { createEffect } from "solid-js"; const TaskEditor = lazy(() => import("../components/TaskEditor").then(m => ({ default: m.TaskEditor }))); @@ -18,7 +19,35 @@ export const SettingsView: Component = () => { const [isTagsOpen, setIsTagsOpen] = createSignal(false); const [isTemplatesOpen, setIsTemplatesOpen] = createSignal(false); const [isTrashOpen, setIsTrashOpen] = createSignal(false); + const [isSharingOpen, setIsSharingOpen] = createSignal(false); const [expandedTemplateId, setExpandedTemplateId] = createSignal(null); + const [shareUserId, setShareUserId] = createSignal(""); + const [shareType, setShareType] = createSignal<'all' | 'tag'>('all'); + const [shareTag, setShareTag] = createSignal(""); + const [allUsers, setAllUsers] = createSignal>([]); + const [usersLoading, setUsersLoading] = createSignal(false); + + // Fetch users when sharing section opens + createEffect(() => { + if (isSharingOpen() && allUsers().length === 0 && !usersLoading()) { + setUsersLoading(true); + pb.collection('users').getFullList({ fields: 'id,name,email' }) + .then(records => { + setAllUsers(records.map((r: any) => ({ id: r.id, name: r.name || r.email || r.id }))); + }) + .catch(err => console.error("Failed to load users:", err)) + .finally(() => setUsersLoading(false)); + } + }); + + const getUserName = (userId: string) => { + return allUsers().find(u => u.id === userId)?.name || userId; + }; + + const availableUsers = () => { + const currentUserId = pb.authStore.model?.id; + return allUsers().filter(u => u.id !== currentUserId); + }; return (
@@ -53,6 +82,132 @@ export const SettingsView: Component = () => {
+ {/* Sharing */} +
+
setIsSharingOpen(!isSharingOpen())} + > +
+

+ + Task Sharing +

+

Share all tasks or tasks with specific tags with others.

+
+
+ {isSharingOpen() ? : } +
+
+ + +
+ {/* Add new share rule */} +
+
+ + +
+ + + + + +
+ + value={allUsers().find(u => u.id === shareUserId()) || null} + onChange={(user) => setShareUserId(user?.id || "")} + options={availableUsers()} + optionValue="id" + optionTextValue="name" + placeholder={usersLoading() ? "Loading..." : "Select user..."} + itemComponent={(itemProps: any) => ( + {itemProps.item.rawValue.name} + )} + > + + > + {(state) => state.selectedOption()?.name || "Select user..."} + + + + + +
+
+ + {/* Active share rules */} + + No active share rules. +
+ }> + {(rule) => ( +
+
+ {rule.type === 'all' ? : } +
+

{getUserName(rule.targetUserId)}

+

+ {rule.type === 'all' ? 'All Tasks' : `Tag: ${rule.tagName}`} +

+
+
+ +
+ )} + + +
+
+ {/* Matrix Configuration */}