diff --git a/api/src/routes/auth.js b/api/src/routes/auth.js
index aee64f2..c5c27fb 100644
--- a/api/src/routes/auth.js
+++ b/api/src/routes/auth.js
@@ -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;
diff --git a/apps/tasks/app/tasks/[id]/collaborate/page.tsx b/apps/tasks/app/tasks/[id]/collaborate/page.tsx
new file mode 100644
index 0000000..0f9d836
--- /dev/null
+++ b/apps/tasks/app/tasks/[id]/collaborate/page.tsx
@@ -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
;
+ }
+ 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 (
+
+ {initials}
+
+ );
+}
+
+function UserSearchDropdown({
+ token,
+ onSelect,
+ placeholder,
+}: {
+ token: string;
+ onSelect: (user: UserResult) => void;
+ placeholder: string;
+}) {
+ const [query, setQuery] = useState("");
+ const [results, setResults] = useState([]);
+ 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 (
+
+
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 && (
+
+ )}
+ {results.length > 0 && (
+
+ {results.map((user) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+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(null);
+ const [subtasks, setSubtasks] = useState([]);
+ const [history, setHistory] = useState([]);
+ const [assignees, setAssignees] = useState([]);
+ 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(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();
+ (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 (
+
+ );
+ }
+
+ if (error && !task) {
+ return (
+
+
{error}
+
+
+ );
+ }
+
+ 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 = {
+ 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 (
+
+ {/* Header */}
+
+
+
{t("collab.title")}
+
+
+ {/* Task title */}
+ {task && (
+
+
{task.title}
+ {task.description &&
{task.description}
}
+
+ )}
+
+ {error && (
+
+ {error}
+
+
+ )}
+
+ {/* Assignees section */}
+
+
+ {t("collab.assignees")}
+
+
+ {assignees.length > 0 ? (
+
+ {assignees.map((a) => (
+
+
+ {a.name}
+
+ ))}
+
+ ) : (
+
{t("collab.noAssignees")}
+ )}
+
+ {/* Action buttons */}
+
+
+
+
+
+
+
+
+ {/* Assign search dropdown */}
+ {showAssignSearch && (
+
+
+
+ )}
+
+ {/* Transfer search dropdown */}
+ {showTransferSearch && (
+
+
+
+ )}
+
+
+ {/* Subtasks section */}
+
+
+
+ {t("collab.subtasks")}
+
+ {subtasksTotal > 0 && (
+
+ {subtasksDone}/{subtasksTotal}
+
+ )}
+
+
+ {/* Progress bar */}
+ {subtasksTotal > 0 && (
+
+ )}
+
+ {/* Subtask list */}
+
+ {subtasks.map((sub) => {
+ const isDone = sub.status === "done" || sub.status === "completed";
+ return (
+
+
+
+ {sub.title}
+
+ {sub.assignee_name && (
+
+ {sub.assignee_name}
+
+ )}
+
+
+ );
+ })}
+
+
+ {subtasks.length === 0 && (
+
{t("collab.noSubtasks")}
+ )}
+
+ {/* Add subtask form */}
+ {showSubtaskForm ? (
+
+
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 && (
+
+
+
{subtaskAssignee.name}
+
+
+ )}
+ {!subtaskAssignee && (
+
setSubtaskAssignee(user)}
+ placeholder={t("collab.assignSubtask")}
+ />
+ )}
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+ {/* Collaboration history */}
+
+
+ {t("collab.history")}
+
+
+ {history.length > 0 ? (
+
+ {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 (
+
+
+ {info.icon}
+
+
+
+ {h.from_name || t("collab.system")}
+ {info.label}
+ {h.to_name && {h.to_name}}
+
+ {h.message && (
+
"{h.message}"
+ )}
+
+ {dateStr}
+
+ {h.status === "accepted" ? t("collab.statusAccepted") : h.status === "rejected" ? t("collab.statusRejected") : t("collab.statusPending")}
+
+
+
+
+ );
+ })}
+
+ ) : (
+
{t("collab.noHistory")}
+ )}
+
+
+ );
+}
diff --git a/apps/tasks/app/tasks/[id]/page.tsx b/apps/tasks/app/tasks/[id]/page.tsx
index dc4a0d7..7e02f97 100644
--- a/apps/tasks/app/tasks/[id]/page.tsx
+++ b/apps/tasks/app/tasks/[id]/page.tsx
@@ -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([]);
+ const [assigneeNames, setAssigneeNames] = useState>({});
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 = {};
+ (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() {
)}
+
+ {/* Collaboration summary */}
+
+
+
+ {t("collab.collaboration")}
+
+
+
+
+ {/* Assigned users */}
+ {task.assigned_to && task.assigned_to.length > 0 && (
+
+
{t("collab.assignees")}:
+
+ {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 (
+
+ {initials}
+
+ );
+ })}
+ {task.assigned_to.length > 5 && (
+
+ +{task.assigned_to.length - 5}
+
+ )}
+
+
+ )}
+
+ {/* 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 (
+
+
+ {t("collab.subtasks")}
+ {done}/{total} {t("collab.subtasksDone")}
+
+
+
+ );
+ })()}
+
+ {(!task.assigned_to || task.assigned_to.length === 0) && subtasks.length === 0 && (
+
{t("collab.noAssignees")}
+ )}
+
);
}
diff --git a/apps/tasks/lib/api.ts b/apps/tasks/lib/api.ts
index 85fb599..ced75b1 100644
--- a/apps/tasks/lib/api.ts
+++ b/apps/tasks/lib/api.ts
@@ -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) {
+ 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(`/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 });
+}
diff --git a/apps/tasks/messages/cs.json b/apps/tasks/messages/cs.json
index e645467..313c84d 100644
--- a/apps/tasks/messages/cs.json
+++ b/apps/tasks/messages/cs.json
@@ -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"
}
-}
+}
\ No newline at end of file
diff --git a/apps/tasks/messages/he.json b/apps/tasks/messages/he.json
index d368c2c..5d9acf6 100644
--- a/apps/tasks/messages/he.json
+++ b/apps/tasks/messages/he.json
@@ -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": "שיתוף פעולה"
}
-}
+}
\ No newline at end of file
diff --git a/apps/tasks/messages/ru.json b/apps/tasks/messages/ru.json
index 8156cd8..f626174 100644
--- a/apps/tasks/messages/ru.json
+++ b/apps/tasks/messages/ru.json
@@ -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": "Сотрудничество"
}
-}
+}
\ No newline at end of file
diff --git a/apps/tasks/messages/ua.json b/apps/tasks/messages/ua.json
index e2e3e1c..b83dd0c 100644
--- a/apps/tasks/messages/ua.json
+++ b/apps/tasks/messages/ua.json
@@ -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": "Співпраця"
}
-}
+}
\ No newline at end of file
diff --git a/mobile/eas.json b/mobile/eas.json
new file mode 100644
index 0000000..11bbc92
--- /dev/null
+++ b/mobile/eas.json
@@ -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" }
+ }
+ }
+}