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:
2026-03-29 15:31:19 +00:00
parent 606fb047f8
commit 4e4bf34393
9 changed files with 934 additions and 5 deletions

View File

@@ -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;

View 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">&quot;{h.message}&quot;</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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 });
}

View File

@@ -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"
}
}
}

View File

@@ -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": "שיתוף פעולה"
}
}
}

View File

@@ -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": "Сотрудничество"
}
}
}

View File

@@ -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
View 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" }
}
}
}