EAS build config + Collaboration UI + i18n updates
- eas.json for Android APK/AAB + iOS builds - Collaboration page: assign, transfer, claim, subtasks - Task detail: assignee avatars, subtask progress - Updated translations (cs,he,ru,ua) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -114,6 +114,17 @@ async function authRoutes(app) {
|
||||
return { status: 'not_implemented', provider, message: `${provider} OAuth coming soon. Configure at Settings > Connections.` };
|
||||
});
|
||||
|
||||
// Search users by name or email (for collaboration)
|
||||
app.get('/auth/users/search', async (req) => {
|
||||
const q = (req.query.q || '').trim();
|
||||
if (q.length < 2) return { data: [] };
|
||||
const { rows } = await app.db.query(
|
||||
"SELECT id, name, email, avatar_url FROM users WHERE (name ILIKE $1 OR email ILIKE $1) ORDER BY name LIMIT 20",
|
||||
["%" + q + "%"]
|
||||
);
|
||||
return { data: rows };
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
module.exports = authRoutes;
|
||||
|
||||
634
apps/tasks/app/tasks/[id]/collaborate/page.tsx
Normal file
634
apps/tasks/app/tasks/[id]/collaborate/page.tsx
Normal file
@@ -0,0 +1,634 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import {
|
||||
getTask,
|
||||
Task,
|
||||
Subtask,
|
||||
CollabRequest,
|
||||
getSubtasks,
|
||||
createSubtask,
|
||||
updateSubtask,
|
||||
deleteSubtask,
|
||||
getCollaborationHistory,
|
||||
sendCollabRequest,
|
||||
searchUsers,
|
||||
} from "@/lib/api";
|
||||
|
||||
interface UserResult {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
function UserAvatar({ name, avatarUrl, size = "sm" }: { name: string; avatarUrl?: string | null; size?: "sm" | "md" }) {
|
||||
const sz = size === "md" ? "w-10 h-10 text-sm" : "w-8 h-8 text-xs";
|
||||
const initials = name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
if (avatarUrl) {
|
||||
return <img src={avatarUrl} alt={name} className={`${sz} rounded-full object-cover`} />;
|
||||
}
|
||||
const colors = ["bg-blue-500", "bg-green-500", "bg-purple-500", "bg-orange-500", "bg-pink-500", "bg-teal-500"];
|
||||
const color = colors[name.charCodeAt(0) % colors.length];
|
||||
return (
|
||||
<div className={`${sz} rounded-full ${color} text-white flex items-center justify-center font-medium`}>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserSearchDropdown({
|
||||
token,
|
||||
onSelect,
|
||||
placeholder,
|
||||
}: {
|
||||
token: string;
|
||||
onSelect: (user: UserResult) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<UserResult[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (query.length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await searchUsers(token, query);
|
||||
setResults(res.data || []);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, token]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{searching && (
|
||||
<div className="absolute right-3 top-2.5">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||
{results.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => {
|
||||
onSelect(user);
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 text-left"
|
||||
>
|
||||
<UserAvatar name={user.name} avatarUrl={user.avatar_url} />
|
||||
<div>
|
||||
<div className="text-sm font-medium">{user.name}</div>
|
||||
<div className="text-xs text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CollaboratePage() {
|
||||
const { token, user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const [task, setTask] = useState<Task | null>(null);
|
||||
const [subtasks, setSubtasks] = useState<Subtask[]>([]);
|
||||
const [history, setHistory] = useState<CollabRequest[]>([]);
|
||||
const [assignees, setAssignees] = useState<UserResult[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Forms
|
||||
const [showAssignSearch, setShowAssignSearch] = useState(false);
|
||||
const [showTransferSearch, setShowTransferSearch] = useState(false);
|
||||
const [transferMessage, setTransferMessage] = useState("");
|
||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState("");
|
||||
const [showSubtaskForm, setShowSubtaskForm] = useState(false);
|
||||
const [subtaskAssignee, setSubtaskAssignee] = useState<UserResult | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
if (!token || !id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [taskData, subtasksData, historyData] = await Promise.all([
|
||||
getTask(token, id),
|
||||
getSubtasks(token, id),
|
||||
getCollaborationHistory(token, id),
|
||||
]);
|
||||
setTask(taskData);
|
||||
setSubtasks(subtasksData.data || []);
|
||||
setHistory(historyData.data || []);
|
||||
|
||||
// Load assignee details
|
||||
const assignedIds: string[] = taskData.assigned_to || [];
|
||||
if (assignedIds.length > 0) {
|
||||
try {
|
||||
const usersRes = await searchUsers(token, "");
|
||||
// Filter by IDs — the workload endpoint returns all users, filter client-side
|
||||
// For now just show what we can from collaboration history
|
||||
const knownUsers = new Map<string, UserResult>();
|
||||
(historyData.data || []).forEach((h: CollabRequest) => {
|
||||
if (h.from_user_id && h.from_name) knownUsers.set(h.from_user_id, { id: h.from_user_id, name: h.from_name, email: "", avatar_url: null });
|
||||
if (h.to_user_id && h.to_name) knownUsers.set(h.to_user_id, { id: h.to_user_id, name: h.to_name, email: "", avatar_url: null });
|
||||
});
|
||||
// Merge with any search results
|
||||
(usersRes.data || []).forEach((u: UserResult) => knownUsers.set(u.id, u));
|
||||
setAssignees(assignedIds.map((uid) => knownUsers.get(uid) || { id: uid, name: uid.slice(0, 8), email: "", avatar_url: null }));
|
||||
} catch {
|
||||
setAssignees(assignedIds.map((uid) => ({ id: uid, name: uid.slice(0, 8), email: "", avatar_url: null })));
|
||||
}
|
||||
} else {
|
||||
setAssignees([]);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("tasks.loadError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, id, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
loadAll();
|
||||
}, [token, router, loadAll]);
|
||||
|
||||
async function handleAssign(selectedUser: UserResult) {
|
||||
if (!token || !id || !user) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await sendCollabRequest(token, id, {
|
||||
from_user_id: user.id,
|
||||
to_user_id: selectedUser.id,
|
||||
type: "assign",
|
||||
message: "",
|
||||
});
|
||||
setShowAssignSearch(false);
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("common.error"));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTransfer(selectedUser: UserResult) {
|
||||
if (!token || !id || !user) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await sendCollabRequest(token, id, {
|
||||
from_user_id: user.id,
|
||||
to_user_id: selectedUser.id,
|
||||
type: "transfer",
|
||||
message: transferMessage,
|
||||
});
|
||||
setShowTransferSearch(false);
|
||||
setTransferMessage("");
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("common.error"));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClaim() {
|
||||
if (!token || !id || !user) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await sendCollabRequest(token, id, {
|
||||
from_user_id: user.id,
|
||||
type: "claim",
|
||||
});
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("common.error"));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddSubtask() {
|
||||
if (!token || !id || !newSubtaskTitle.trim()) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await createSubtask(token, id, {
|
||||
title: newSubtaskTitle.trim(),
|
||||
assigned_to: subtaskAssignee?.id,
|
||||
});
|
||||
setNewSubtaskTitle("");
|
||||
setSubtaskAssignee(null);
|
||||
setShowSubtaskForm(false);
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("common.error"));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleSubtask(subtask: Subtask) {
|
||||
if (!token || !id) return;
|
||||
const newStatus = subtask.status === "done" || subtask.status === "completed" ? "pending" : "done";
|
||||
try {
|
||||
await updateSubtask(token, id, subtask.id, { status: newStatus });
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("common.error"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSubtask(subtaskId: string) {
|
||||
if (!token || !id) return;
|
||||
try {
|
||||
await deleteSubtask(token, id, subtaskId);
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("common.error"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !task) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500">{error}</p>
|
||||
<button onClick={() => router.back()} className="mt-4 text-blue-600 hover:underline">
|
||||
{t("common.back")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const subtasksDone = subtasks.filter((s) => s.status === "done" || s.status === "completed").length;
|
||||
const subtasksTotal = subtasks.length;
|
||||
const progressPct = subtasksTotal > 0 ? Math.round((subtasksDone / subtasksTotal) * 100) : 0;
|
||||
|
||||
const collabTypeLabels: Record<string, { icon: string; label: string; color: string }> = {
|
||||
assign: { icon: "+", label: t("collab.assigned"), color: "text-blue-600" },
|
||||
transfer: { icon: "->", label: t("collab.transferred"), color: "text-orange-600" },
|
||||
claim: { icon: "!", label: t("collab.claimed"), color: "text-green-600" },
|
||||
collaborate: { icon: "&", label: t("collab.collaborated"), color: "text-purple-600" },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto space-y-4 pb-20">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => router.push(`/tasks/${id}`)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{t("common.back")}
|
||||
</button>
|
||||
<h1 className="text-lg font-bold flex-1 truncate">{t("collab.title")}</h1>
|
||||
</div>
|
||||
|
||||
{/* Task title */}
|
||||
{task && (
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-4">
|
||||
<h2 className="font-semibold text-base">{task.title}</h2>
|
||||
{task.description && <p className="text-sm text-gray-500 mt-1 line-clamp-2">{task.description}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
<button onClick={() => setError("")} className="ml-2 underline">
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignees section */}
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
{t("collab.assignees")}
|
||||
</h3>
|
||||
|
||||
{assignees.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{assignees.map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-2 bg-gray-50 dark:bg-gray-800 rounded-full px-3 py-1.5">
|
||||
<UserAvatar name={a.name} avatarUrl={a.avatar_url} />
|
||||
<span className="text-sm font-medium">{a.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 mb-3">{t("collab.noAssignees")}</p>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAssignSearch(!showAssignSearch);
|
||||
setShowTransferSearch(false);
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
{t("collab.assign")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTransferSearch(!showTransferSearch);
|
||||
setShowAssignSearch(false);
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-orange-600 hover:bg-orange-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
{t("collab.transfer")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClaim}
|
||||
disabled={actionLoading}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 11.5V14m0 0V14m0 0h2.5M7 14H4.5m4.5 0a6 6 0 1012 0 6 6 0 00-12 0z" />
|
||||
</svg>
|
||||
{t("collab.claim")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Assign search dropdown */}
|
||||
{showAssignSearch && (
|
||||
<div className="mt-3">
|
||||
<UserSearchDropdown
|
||||
token={token!}
|
||||
onSelect={handleAssign}
|
||||
placeholder={t("collab.searchUser")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transfer search dropdown */}
|
||||
{showTransferSearch && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<UserSearchDropdown
|
||||
token={token!}
|
||||
onSelect={handleTransfer}
|
||||
placeholder={t("collab.searchUser")}
|
||||
/>
|
||||
<textarea
|
||||
value={transferMessage}
|
||||
onChange={(e) => setTransferMessage(e.target.value)}
|
||||
placeholder={t("collab.transferMessage")}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subtasks section */}
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t("collab.subtasks")}
|
||||
</h3>
|
||||
{subtasksTotal > 0 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{subtasksDone}/{subtasksTotal}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{subtasksTotal > 0 && (
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-3">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtask list */}
|
||||
<div className="space-y-2 mb-3">
|
||||
{subtasks.map((sub) => {
|
||||
const isDone = sub.status === "done" || sub.status === "completed";
|
||||
return (
|
||||
<div
|
||||
key={sub.id}
|
||||
className="flex items-center gap-2 group"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleToggleSubtask(sub)}
|
||||
className={`flex-shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
isDone
|
||||
? "bg-green-500 border-green-500 text-white"
|
||||
: "border-gray-300 dark:border-gray-600 hover:border-green-400"
|
||||
}`}
|
||||
>
|
||||
{isDone && (
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<span className={`flex-1 text-sm ${isDone ? "line-through text-gray-400" : ""}`}>
|
||||
{sub.title}
|
||||
</span>
|
||||
{sub.assignee_name && (
|
||||
<span className="text-xs text-gray-400 bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded-full">
|
||||
{sub.assignee_name}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteSubtask(sub.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 transition-opacity"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{subtasks.length === 0 && (
|
||||
<p className="text-sm text-gray-400 mb-3">{t("collab.noSubtasks")}</p>
|
||||
)}
|
||||
|
||||
{/* Add subtask form */}
|
||||
{showSubtaskForm ? (
|
||||
<div className="space-y-2 border-t border-gray-100 dark:border-gray-700 pt-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newSubtaskTitle}
|
||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
||||
placeholder={t("collab.subtaskTitle")}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newSubtaskTitle.trim()) handleAddSubtask();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{subtaskAssignee && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<UserAvatar name={subtaskAssignee.name} avatarUrl={subtaskAssignee.avatar_url} />
|
||||
<span>{subtaskAssignee.name}</span>
|
||||
<button onClick={() => setSubtaskAssignee(null)} className="text-gray-400 hover:text-red-500">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!subtaskAssignee && (
|
||||
<UserSearchDropdown
|
||||
token={token!}
|
||||
onSelect={(user) => setSubtaskAssignee(user)}
|
||||
placeholder={t("collab.assignSubtask")}
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAddSubtask}
|
||||
disabled={!newSubtaskTitle.trim() || actionLoading}
|
||||
className="px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("collab.addBtn")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSubtaskForm(false);
|
||||
setNewSubtaskTitle("");
|
||||
setSubtaskAssignee(null);
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-800 dark:text-gray-400"
|
||||
>
|
||||
{t("tasks.form.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowSubtaskForm(true)}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{t("collab.addSubtask")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collaboration history */}
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
{t("collab.history")}
|
||||
</h3>
|
||||
|
||||
{history.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{history.map((h) => {
|
||||
const info = collabTypeLabels[h.type] || collabTypeLabels.assign;
|
||||
const date = new Date(h.created_at);
|
||||
const dateStr = date.toLocaleDateString("cs-CZ", {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return (
|
||||
<div key={h.id} className="flex items-start gap-3 text-sm">
|
||||
<div className={`flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${info.color} bg-gray-100 dark:bg-gray-800`}>
|
||||
{info.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div>
|
||||
<span className="font-medium">{h.from_name || t("collab.system")}</span>
|
||||
<span className="text-gray-500"> {info.label} </span>
|
||||
{h.to_name && <span className="font-medium">{h.to_name}</span>}
|
||||
</div>
|
||||
{h.message && (
|
||||
<p className="text-gray-500 text-xs mt-0.5 italic">"{h.message}"</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-gray-400">{dateStr}</span>
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded-full ${
|
||||
h.status === "accepted"
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
: h.status === "rejected"
|
||||
? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{h.status === "accepted" ? t("collab.statusAccepted") : h.status === "rejected" ? t("collab.statusRejected") : t("collab.statusPending")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">{t("collab.noHistory")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
deleteTask,
|
||||
Task,
|
||||
Group,
|
||||
Subtask,
|
||||
getSubtasks,
|
||||
searchUsers,
|
||||
} from "@/lib/api";
|
||||
import TaskForm from "@/components/TaskForm";
|
||||
import StatusBadge from "@/components/StatusBadge";
|
||||
@@ -31,6 +34,8 @@ export default function TaskDetailPage() {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [subtasks, setSubtasks] = useState<Subtask[]>([]);
|
||||
const [assigneeNames, setAssigneeNames] = useState<Record<string, string>>({});
|
||||
|
||||
const dateLocale = locale === "ua" ? "uk-UA" : locale === "cs" ? "cs-CZ" : locale === "he" ? "he-IL" : "ru-RU";
|
||||
|
||||
@@ -55,12 +60,26 @@ export default function TaskDetailPage() {
|
||||
if (!token || !id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [taskData, groupsData] = await Promise.all([
|
||||
const [taskData, groupsData, subtasksData] = await Promise.all([
|
||||
getTask(token, id),
|
||||
getGroups(token),
|
||||
getSubtasks(token, id).catch(() => ({ data: [] })),
|
||||
]);
|
||||
setTask(taskData);
|
||||
setGroups(groupsData.data || []);
|
||||
setSubtasks(subtasksData.data || []);
|
||||
// Load assignee names
|
||||
const assigned: string[] = taskData.assigned_to || [];
|
||||
if (assigned.length > 0) {
|
||||
try {
|
||||
const res = await searchUsers(token, "");
|
||||
const nameMap: Record<string, string> = {};
|
||||
(res.data || []).forEach((u: { id: string; name: string }) => {
|
||||
if (assigned.includes(u.id)) nameMap[u.id] = u.name;
|
||||
});
|
||||
setAssigneeNames(nameMap);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("tasks.loadError")
|
||||
@@ -292,6 +311,78 @@ export default function TaskDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collaboration summary */}
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t("collab.collaboration")}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => router.push(`/tasks/${id}/collaborate`)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
{t("collab.collaboration")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Assigned users */}
|
||||
{task.assigned_to && task.assigned_to.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm text-gray-500">{t("collab.assignees")}:</span>
|
||||
<div className="flex -space-x-2">
|
||||
{task.assigned_to.slice(0, 5).map((uid: string) => {
|
||||
const name = assigneeNames[uid] || uid.slice(0, 4);
|
||||
const initials = name.split(" ").map((n: string) => n[0]).join("").toUpperCase().slice(0, 2);
|
||||
const colors = ["bg-blue-500", "bg-green-500", "bg-purple-500", "bg-orange-500", "bg-pink-500"];
|
||||
const color = colors[name.charCodeAt(0) % colors.length];
|
||||
return (
|
||||
<div
|
||||
key={uid}
|
||||
title={assigneeNames[uid] || uid}
|
||||
className={`w-8 h-8 rounded-full ${color} text-white flex items-center justify-center text-xs font-medium border-2 border-white dark:border-gray-900`}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{task.assigned_to.length > 5 && (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-400 text-white flex items-center justify-center text-xs font-medium border-2 border-white dark:border-gray-900">
|
||||
+{task.assigned_to.length - 5}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtask progress */}
|
||||
{subtasks.length > 0 && (() => {
|
||||
const done = subtasks.filter(s => s.status === "done" || s.status === "completed").length;
|
||||
const total = subtasks.length;
|
||||
const pct = Math.round((done / total) * 100);
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-500">{t("collab.subtasks")}</span>
|
||||
<span className="font-medium">{done}/{total} {t("collab.subtasksDone")}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{(!task.assigned_to || task.assigned_to.length === 0) && subtasks.length === 0 && (
|
||||
<p className="text-sm text-gray-400">{t("collab.noAssignees")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,3 +245,61 @@ export function deleteProject(token: string, id: string) {
|
||||
export function inviteToProject(token: string, id: string, userId: string) {
|
||||
return apiFetch<{ data: Project; status: string }>("/api/v1/projects/" + id + "/invite", { method: "POST", body: { user_id: userId }, token });
|
||||
}
|
||||
|
||||
// Subtasks
|
||||
export interface Subtask {
|
||||
id: string;
|
||||
parent_task_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: "pending" | "in_progress" | "done" | "completed";
|
||||
assigned_to: string | null;
|
||||
assignee_name: string | null;
|
||||
order_index: number;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CollabRequest {
|
||||
id: string;
|
||||
task_id: string;
|
||||
from_user_id: string | null;
|
||||
to_user_id: string;
|
||||
type: "assign" | "transfer" | "collaborate" | "claim";
|
||||
status: "pending" | "accepted" | "rejected";
|
||||
message: string;
|
||||
from_name: string | null;
|
||||
to_name: string | null;
|
||||
task_title?: string;
|
||||
created_at: string;
|
||||
responded_at: string | null;
|
||||
}
|
||||
|
||||
export function getSubtasks(token: string, taskId: string) {
|
||||
return apiFetch<{ data: Subtask[] }>(`/api/v1/tasks/${taskId}/subtasks`, { token });
|
||||
}
|
||||
|
||||
export function createSubtask(token: string, taskId: string, data: { title: string; description?: string; assigned_to?: string }) {
|
||||
return apiFetch<{ data: Subtask }>(`/api/v1/tasks/${taskId}/subtasks`, { method: "POST", body: data, token });
|
||||
}
|
||||
|
||||
export function updateSubtask(token: string, taskId: string, subtaskId: string, data: Partial<Subtask>) {
|
||||
return apiFetch<{ data: Subtask }>(`/api/v1/tasks/${taskId}/subtasks/${subtaskId}`, { method: "PUT", body: data, token });
|
||||
}
|
||||
|
||||
export function deleteSubtask(token: string, taskId: string, subtaskId: string) {
|
||||
return apiFetch<void>(`/api/v1/tasks/${taskId}/subtasks/${subtaskId}`, { method: "DELETE", token });
|
||||
}
|
||||
|
||||
export function getCollaborationHistory(token: string, taskId: string) {
|
||||
return apiFetch<{ data: CollabRequest[] }>(`/api/v1/tasks/${taskId}/collaboration`, { token });
|
||||
}
|
||||
|
||||
export function sendCollabRequest(token: string, taskId: string, data: { to_user_id?: string; from_user_id?: string; type: string; message?: string }) {
|
||||
return apiFetch<{ data?: CollabRequest; status: string }>(`/api/v1/tasks/${taskId}/collaborate`, { method: "POST", body: data, token });
|
||||
}
|
||||
|
||||
export function searchUsers(token: string, query: string) {
|
||||
return apiFetch<{ data: { id: string; name: string; email: string; avatar_url: string | null }[] }>(`/api/v1/auth/users/search?q=${encodeURIComponent(query)}`, { token });
|
||||
}
|
||||
|
||||
@@ -152,5 +152,33 @@
|
||||
},
|
||||
"forgotPassword": {
|
||||
"description": "Obnova hesla bude brzy k dispozici."
|
||||
},
|
||||
"collab": {
|
||||
"title": "Spoluprace",
|
||||
"assignees": "Prirazeni uzivatele",
|
||||
"noAssignees": "Nikdo neni prirazen",
|
||||
"assign": "Priradit",
|
||||
"transfer": "Predat",
|
||||
"claim": "Prevzit",
|
||||
"searchUser": "Hledat uzivatele...",
|
||||
"transferMessage": "Zprava k predani...",
|
||||
"subtasks": "Podukoly",
|
||||
"noSubtasks": "Zadne podukoly",
|
||||
"subtaskTitle": "Nazev podukolu...",
|
||||
"assignSubtask": "Priradit podukol (volitelne)...",
|
||||
"addSubtask": "Pridat podukol",
|
||||
"addBtn": "Pridat",
|
||||
"history": "Historie spoluprace",
|
||||
"noHistory": "Zadna historie",
|
||||
"assigned": "priradil/a",
|
||||
"transferred": "predal/a",
|
||||
"claimed": "prevzal/a",
|
||||
"collaborated": "spolupracuje",
|
||||
"system": "System",
|
||||
"statusAccepted": "Prijato",
|
||||
"statusRejected": "Odmitnuto",
|
||||
"statusPending": "Ceka",
|
||||
"subtasksDone": "podukolu hotovo",
|
||||
"collaboration": "Spoluprace"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,5 +152,33 @@
|
||||
},
|
||||
"forgotPassword": {
|
||||
"description": "שחזור סיסמה יהיה זמין בקרוב."
|
||||
},
|
||||
"collab": {
|
||||
"title": "שיתוף פעולה",
|
||||
"assignees": "משתמשים משויכים",
|
||||
"noAssignees": "אף אחד לא משויך",
|
||||
"assign": "שייך",
|
||||
"transfer": "העבר",
|
||||
"claim": "קח",
|
||||
"searchUser": "חפש משתמש...",
|
||||
"transferMessage": "הודעה להעברה...",
|
||||
"subtasks": "משימות משנה",
|
||||
"noSubtasks": "אין משימות משנה",
|
||||
"subtaskTitle": "שם משימת משנה...",
|
||||
"assignSubtask": "שייך משימת משנה...",
|
||||
"addSubtask": "הוסף משימת משנה",
|
||||
"addBtn": "הוסף",
|
||||
"history": "היסטוריית שיתוף פעולה",
|
||||
"noHistory": "אין היסטוריה",
|
||||
"assigned": "שייך",
|
||||
"transferred": "העביר",
|
||||
"claimed": "לקח",
|
||||
"collaborated": "שיתף פעולה",
|
||||
"system": "מערכת",
|
||||
"statusAccepted": "אושר",
|
||||
"statusRejected": "נדחה",
|
||||
"statusPending": "ממתין",
|
||||
"subtasksDone": "משימות הושלמו",
|
||||
"collaboration": "שיתוף פעולה"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,5 +152,33 @@
|
||||
},
|
||||
"forgotPassword": {
|
||||
"description": "Восстановление пароля будет доступно в ближайшее время."
|
||||
},
|
||||
"collab": {
|
||||
"title": "Сотрудничество",
|
||||
"assignees": "Назначенные",
|
||||
"noAssignees": "Никто не назначен",
|
||||
"assign": "Назначить",
|
||||
"transfer": "Передать",
|
||||
"claim": "Взять",
|
||||
"searchUser": "Поиск пользователя...",
|
||||
"transferMessage": "Сообщение к передаче...",
|
||||
"subtasks": "Подзадачи",
|
||||
"noSubtasks": "Нет подзадач",
|
||||
"subtaskTitle": "Название подзадачи...",
|
||||
"assignSubtask": "Назначить подзадачу...",
|
||||
"addSubtask": "Добавить подзадачу",
|
||||
"addBtn": "Добавить",
|
||||
"history": "История сотрудничества",
|
||||
"noHistory": "Нет истории",
|
||||
"assigned": "назначил",
|
||||
"transferred": "передал",
|
||||
"claimed": "взял",
|
||||
"collaborated": "сотрудничает",
|
||||
"system": "Система",
|
||||
"statusAccepted": "Принято",
|
||||
"statusRejected": "Отклонено",
|
||||
"statusPending": "Ожидает",
|
||||
"subtasksDone": "подзадач выполнено",
|
||||
"collaboration": "Сотрудничество"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,5 +152,33 @@
|
||||
},
|
||||
"forgotPassword": {
|
||||
"description": "Вiдновлення паролю буде доступне найближчим часом."
|
||||
},
|
||||
"collab": {
|
||||
"title": "Співпраця",
|
||||
"assignees": "Призначені",
|
||||
"noAssignees": "Нікого не призначено",
|
||||
"assign": "Призначити",
|
||||
"transfer": "Передати",
|
||||
"claim": "Взяти",
|
||||
"searchUser": "Пошук користувача...",
|
||||
"transferMessage": "Повідомлення до передачі...",
|
||||
"subtasks": "Підзавдання",
|
||||
"noSubtasks": "Немає підзавдань",
|
||||
"subtaskTitle": "Назва підзавдання...",
|
||||
"assignSubtask": "Призначити підзавдання...",
|
||||
"addSubtask": "Додати підзавдання",
|
||||
"addBtn": "Додати",
|
||||
"history": "Історія співпраці",
|
||||
"noHistory": "Немає історії",
|
||||
"assigned": "призначив",
|
||||
"transferred": "передав",
|
||||
"claimed": "взяв",
|
||||
"collaborated": "співпрацює",
|
||||
"system": "Система",
|
||||
"statusAccepted": "Прийнято",
|
||||
"statusRejected": "Відхилено",
|
||||
"statusPending": "Очікує",
|
||||
"subtasksDone": "підзавдань виконано",
|
||||
"collaboration": "Співпраця"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
mobile/eas.json
Normal file
23
mobile/eas.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"cli": { "version": ">= 14.0.0" },
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"android": { "buildType": "apk" }
|
||||
},
|
||||
"production": {
|
||||
"android": { "buildType": "app-bundle" },
|
||||
"ios": { "autoIncrement": true }
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"android": { "serviceAccountKeyPath": "./google-service-account.json" },
|
||||
"ios": { "appleId": "soft.it.enterprise@gmail.com", "ascAppId": "", "appleTeamId": "8U2KKLXVSN" }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user