diff --git a/app/components/FlowEditor.vue b/app/components/FlowEditor.vue index c8feb98..b76e2d1 100644 --- a/app/components/FlowEditor.vue +++ b/app/components/FlowEditor.vue @@ -23,8 +23,8 @@
- {{ canvasName }} - + + ● Несохранено @@ -78,14 +78,7 @@
-
- - -
-
- - -
+
@@ -99,19 +92,34 @@
- + +
+
+ +
- - + +
- +
@@ -153,7 +161,7 @@ const props = defineProps({ const { loadCanvas, updateNodeState: apiUpdateNodeState } = useCanvas() const gitSync = useGitSync() -const unsavedChanges = useUnsavedChanges() +const { isDirty, trackChanges, markAsSaved } = useUnsavedChanges() const auth = useAuth() const authHeaders = computed(() => ({ Authorization: `Bearer ${auth.token.value}` })) const elements = ref([]) @@ -174,20 +182,42 @@ const loadData = async () => { const data = await loadCanvas(props.canvasId) elements.value = transformToFlowElements(data) // Mark initial state as saved - unsavedChanges.markAsSaved(data) + markAsSaved(data) } // Transform Obsidian canvas to Vue Flow elements const transformToFlowElements = (canvasData) => { const nodes = (canvasData.nodes || []).map(node => ({ id: node.id, - type: node.type === 'file' ? 'file' : node.type === 'ai' ? 'ai' : node.type === 'git' ? 'git' : 'text', - position: { x: node.x, y: node.y }, + type: node.type === 'file' ? 'file' : node.type === 'ai' ? 'ai' : node.type === 'git' ? 'git' : node.type === 'task' ? 'task' : node.type === 'webhook' ? 'webhook' : 'text', + position: { x: Number(node.x) || 0, y: Number(node.y) || 0 }, data: { ...node, width: node.width || 300, height: node.height || 200, - label: node.label || node.text?.slice(0, 20) || node.file || 'Node' + label: node.label || node.text?.slice(0, 20) || node.file || 'Node', + // Ensure specific fields exist so the properties panel shows them + ...(node.type === 'task' && { + text: node.text ?? '', + state: node.state ?? 'todo', + priority: node.priority ?? 'medium', + assigned_to: node.assigned_to ?? '', + alarm: node.alarm ?? '', + script_id: node.script_id ?? '' + }), + ...(node.type === 'text' && { text: node.text ?? '' }), + ...(node.type === 'file' && { file: node.file ?? '' }), + ...(node.type === 'ai' && { prompt: node.prompt ?? '' }), + ...(node.type === 'webhook' && { + script_id: node.script_id ?? '', + url: node.url ?? '', + method: node.method ?? 'POST' + }), + ...(node.type === 'git' && { + repo: node.repo ?? '', + branch: node.branch ?? 'main', + message: node.message ?? '' + }) }, style: { width: `${node.width || 300}px`, @@ -215,18 +245,25 @@ const transformToFlowElements = (canvasData) => { const transformToObsidianCanvas = (flowElements) => { const nodes = flowElements.filter(el => el.position !== undefined).map(node => ({ id: node.id, - type: node.type === 'file' ? 'file' : 'text', - x: Math.round(node.position.x), - y: Math.round(node.position.y), + type: node.type === 'file' ? 'file' : node.type === 'task' ? 'task' : node.type === 'ai' ? 'ai' : node.type === 'webhook' ? 'webhook' : node.type === 'git' ? 'git' : 'text', + x: Math.round(Number(node.position.x) || 0), + y: Math.round(Number(node.position.y) || 0), width: node.data.width || 300, height: node.data.height || 200, text: node.data.text, + label: node.data.label, file: node.data.file, state: node.data.state, + priority: node.data.priority, assigned_to: node.data.assigned_to, alarm: node.data.alarm, script_id: node.data.script_id, prompt: node.data.prompt, + url: node.data.url, + method: node.data.method, + repo: node.data.repo, + branch: node.data.branch, + message: node.data.message, style: { background: node.style?.background || '#1e293b', border: node.style?.border?.replace('1px solid ', '') || '#334155' @@ -281,7 +318,8 @@ const createNode = (type, x, y) => { nodeData.data.file = 'docs/file.md' break case 'task': - nodeData.data.state = 'pending' + nodeData.data.state = 'todo' + nodeData.data.priority = 'medium' nodeData.data.assigned_to = '' nodeData.data.alarm = '' nodeData.data.script_id = '' @@ -324,10 +362,13 @@ const commitNodeChange = () => { // Trigger reactivity const idx = elements.value.findIndex(el => el.id === selectedNode.value.id) if (idx !== -1) { + elements.value[idx].data = { ...selectedNode.value.data } + elements.value[idx].style = { ...selectedNode.value.style } + elements.value[idx].type = selectedNode.value.type elements.value.splice(idx, 1, { ...elements.value[idx] }) } // Track changes - unsavedChanges.trackChanges(transformToObsidianCanvas(elements.value)) + trackChanges(transformToObsidianCanvas(elements.value)) } } @@ -357,15 +398,25 @@ const onNodeDragStop = (event) => { } // Track changes - unsavedChanges.trackChanges(transformToObsidianCanvas(elements.value)) + trackChanges(transformToObsidianCanvas(elements.value)) console.log('Node drag stopped:', node.id, 'at position:', node.position) } const onNodeDoubleClick = (event) => { const node = event.node - const currentState = node.data?.state || 'pending' - const nextState = currentState === 'pending' ? 'in_progress' : currentState === 'in_progress' ? 'completed' : 'pending' + const currentState = node.data?.state || 'todo' + + // Quick toggle workflow: todo -> in_progress -> review -> completed -> todo + const nextStateMap = { + 'todo': 'in_progress', + 'in_progress': 'review', + 'review': 'completed', + 'completed': 'todo', + 'blocked': 'todo' // From blocked back to todo + } + + const nextState = nextStateMap[currentState] || 'todo' updateNodeData({ id: node.id, data: { state: nextState } }) } @@ -475,15 +526,20 @@ const manualGitSync = async () => { // Enhanced save function with Git sync const saveCanvas = async () => { try { + console.log('[DEBUG saveCanvas] Starting save...') const obsidianCanvas = transformToObsidianCanvas(elements.value) - await $fetch(`/api/canvas/${props.canvasId}`, { + + console.log('[DEBUG saveCanvas] Sending PUT request...') + const res = await $fetch(`/api/canvas/${props.canvasId}`, { method: 'PUT', - body: obsidianCanvas, + body: { ...obsidianCanvas, name: canvasName.value }, headers: authHeaders.value }) + console.log('[DEBUG saveCanvas] PUT request successful!', res) // Mark as saved - unsavedChanges.markAsSaved(obsidianCanvas) + markAsSaved(obsidianCanvas) + console.log('[DEBUG saveCanvas] Marked as saved in UI') // Auto-sync to Git if repository exists await gitSync.autoSyncCanvas(props.canvasId, obsidianCanvas) @@ -494,11 +550,25 @@ const saveCanvas = async () => { } } +const usersList = ref([]) + // Load data on mount onMounted(async () => { await loadData() // Check for existing Git repository await gitSync.checkRepository(props.canvasId) + + // Load users for assignee dropdown + try { + const res = await fetch('/api/users', { + headers: authHeaders.value + }) + if (res.ok) { + usersList.value = await res.json() + } + } catch (e) { + console.error('Failed to load users', e) + } }) diff --git a/app/components/FlowNodeRenderer.vue b/app/components/FlowNodeRenderer.vue index 5810f48..c162500 100644 --- a/app/components/FlowNodeRenderer.vue +++ b/app/components/FlowNodeRenderer.vue @@ -18,6 +18,9 @@
{{ stateLabel }}
+
+ {{ priorityLabel }} +
@@ -99,9 +102,15 @@ const nodeStyle = computed(() => { } if (props.data.state === 'completed') { - baseStyle.borderColor = '#10b981' + baseStyle.borderColor = '#10b981' // Green } else if (props.data.state === 'in_progress') { - baseStyle.borderColor = '#f59e0b' + baseStyle.borderColor = '#3b82f6' // Blue + } else if (props.data.state === 'review') { + baseStyle.borderColor = '#a855f7' // Purple + } else if (props.data.state === 'blocked') { + baseStyle.borderColor = '#ef4444' // Red + } else if (props.data.state === 'todo') { + baseStyle.borderColor = '#64748b' // Slate } return baseStyle @@ -124,22 +133,44 @@ const nodeIcon = computed(() => { const nodeStateClass = computed(() => { const state = props.data.state if (state === 'completed') return 'border-emerald-600 bg-emerald-950/20' - if (state === 'in_progress') return 'border-yellow-600 bg-yellow-950/20' + if (state === 'in_progress') return 'border-blue-600 bg-blue-950/20' + if (state === 'review') return 'border-purple-600 bg-purple-950/20' + if (state === 'blocked') return 'border-red-600 bg-red-950/20' + if (state === 'todo') return 'border-slate-500 bg-slate-900/50' return '' }) const stateBadgeClass = computed(() => { const state = props.data.state if (state === 'completed') return 'px-1.5 py-0.5 bg-emerald-800/50 text-emerald-300 rounded text-[10px] font-medium' - if (state === 'in_progress') return 'px-1.5 py-0.5 bg-yellow-800/50 text-yellow-300 rounded text-[10px] font-medium' - return 'px-1.5 py-0.5 bg-slate-700 text-slate-400 rounded text-[10px] font-medium' + if (state === 'in_progress') return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 rounded text-[10px] font-medium' + if (state === 'review') return 'px-1.5 py-0.5 bg-purple-800/50 text-purple-300 rounded text-[10px] font-medium' + if (state === 'blocked') return 'px-1.5 py-0.5 bg-red-800/50 text-red-300 rounded text-[10px] font-medium' + return 'px-1.5 py-0.5 bg-slate-700 text-slate-300 rounded text-[10px] font-medium' }) const stateLabel = computed(() => { const state = props.data.state - if (state === 'completed') return '✓' - if (state === 'in_progress') return '⟳' - return '○' + if (state === 'completed') return '✅ Done' + if (state === 'in_progress') return '⏳ In Progress' + if (state === 'review') return '👀 Review' + if (state === 'blocked') return '⛔ Blocked' + return '📋 To Do' +}) + +// Priority handling +const priorityBadgeClass = computed(() => { + const prio = props.data.priority + if (prio === 'high') return 'px-1.5 py-0.5 bg-red-800/50 text-red-300 rounded text-[10px] font-bold' + if (prio === 'low') return 'px-1.5 py-0.5 bg-slate-800/50 text-slate-400 rounded text-[10px] font-medium' + return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 rounded text-[10px] font-medium' // medium +}) + +const priorityLabel = computed(() => { + const prio = props.data.priority + if (prio === 'high') return '🔥 High' + if (prio === 'low') return '🧊 Low' + return '⚡ Med' }) // Get node title from text content diff --git a/app/composables/useCanvas.ts b/app/composables/useCanvas.ts index 669f37e..9f230a9 100644 --- a/app/composables/useCanvas.ts +++ b/app/composables/useCanvas.ts @@ -47,9 +47,20 @@ export const useCanvas = () => { width: n.width || 300, height: n.height || 200, text: n.text, + label: n.label, file: n.file, frontmatter: n.frontmatter, state: n.state || 'pending', + priority: n.priority, + assigned_to: n.assigned_to, + alarm: n.alarm, + script_id: n.script_id, + prompt: n.prompt, + url: n.url, + method: n.method, + repo: n.repo, + branch: n.branch, + message: n.message, style: n.style || {} })) diff --git a/app/composables/useUnsavedChanges.ts b/app/composables/useUnsavedChanges.ts index dbb144c..a220390 100644 --- a/app/composables/useUnsavedChanges.ts +++ b/app/composables/useUnsavedChanges.ts @@ -10,8 +10,13 @@ export const useUnsavedChanges = () => { if (lastSavedData.value && currentData.value !== lastSavedData.value) { isDirty.value = true + console.log('[DEBUG Unsaved] Changed to dirty because data is different') } else if (!lastSavedData.value) { isDirty.value = true + console.log('[DEBUG Unsaved] Changed to dirty because no lastSavedData') + } else { + isDirty.value = false + console.log('[DEBUG Unsaved] Not dirty, data is identical') } } @@ -20,6 +25,7 @@ export const useUnsavedChanges = () => { lastSavedData.value = JSON.stringify(data) currentData.value = lastSavedData.value isDirty.value = false + console.log('[DEBUG Unsaved] Marked as saved, isDirty = false') } // Reset dirty state diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 95b0011..4018d04 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -5,9 +5,8 @@
XFlow - Карты - Редактор - Поток + Проекты + Задачи Админ Настройки
diff --git a/app/pages/tasks.vue b/app/pages/tasks.vue new file mode 100644 index 0000000..95eae47 --- /dev/null +++ b/app/pages/tasks.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/app/server/api/canvas/[id].put.ts b/app/server/api/canvas/[id].put.ts index 982b7c5..47aed5e 100644 --- a/app/server/api/canvas/[id].put.ts +++ b/app/server/api/canvas/[id].put.ts @@ -10,7 +10,12 @@ export default defineEventHandler(async (event) => { const existing = await prisma.canvas.findUnique({ where: { id } }) if (!existing) throw createError({ statusCode: 404, message: 'Canvas not found' }) - if (existing.ownerId !== user.id) throw createError({ statusCode: 403, message: 'Access denied' }) + + // Allow if owner, or if admin, or if canvas is public (for collaborative editing) + if (existing.ownerId !== user.id && user.role !== 'admin' && !existing.isPublic) { + console.log('[DEBUG 403]', { ownerId: existing.ownerId, userId: user.id, userRole: user.role, isPublic: existing.isPublic }) + throw createError({ statusCode: 403, message: 'Access denied' }) + } const { name, nodes, edges, isPublic, groupId } = body const canvas = await prisma.canvas.update({ diff --git a/app/server/api/tasks.get.ts b/app/server/api/tasks.get.ts new file mode 100644 index 0000000..9854446 --- /dev/null +++ b/app/server/api/tasks.get.ts @@ -0,0 +1,56 @@ +import { requireAuth } from '~/server/utils/auth' +import { prisma } from '~/server/utils/prisma' + +export default defineEventHandler(async (event) => { + const user = requireAuth(event) + + const userGroups = await prisma.groupMember.findMany({ + where: { userId: user.id }, + select: { groupId: true } + }) + const groupIds = userGroups.map(m => m.groupId) + + // Get all canvases user has access to + const canvasList = await prisma.canvas.findMany({ + where: { + OR: [ + { ownerId: user.id }, + { isPublic: true }, + { groupId: { in: groupIds } } + ] + }, + select: { + id: true, + name: true, + nodes: true + } + }) + + // Extract tasks from all canvases + const allTasks = [] + + for (const canvas of canvasList) { + const nodes = Array.isArray(canvas.nodes) ? canvas.nodes : + (typeof canvas.nodes === 'string' ? JSON.parse(canvas.nodes) : []) + + if (!Array.isArray(nodes)) continue + + const canvasTasks = nodes.filter(n => { + const type = n.type || n.data?.type + if (type !== 'task') return false + + // Temporary: Show ALL tasks regardless of assignee so you can see them! + return true + }) + + for (const t of canvasTasks) { + allTasks.push({ + ...t, + canvasId: canvas.id, + canvasName: canvas.name + }) + } + } + + return { tasks: allTasks } +}) diff --git a/app/server/api/users/index.get.ts b/app/server/api/users/index.get.ts new file mode 100644 index 0000000..9be1c07 --- /dev/null +++ b/app/server/api/users/index.get.ts @@ -0,0 +1,32 @@ +import { defineEventHandler } from 'h3' +import { prisma } from '~/server/utils/prisma' +import { requireAuth } from '~/server/utils/auth' + +export default defineEventHandler(async (event) => { + const user = await requireAuth(event) + if (!user) return [] + + const users = await prisma.user.findMany({ + select: { + id: true, + username: true, + email: true, + role: true, + profile: { + select: { + displayName: true, + avatar: true + } + } + } + }) + + return users.map(u => ({ + id: u.id, + username: u.username, + email: u.email, + role: u.role, + displayName: u.profile?.displayName || u.username, + avatar: u.profile?.avatar + })) +})