UI icon-only buttons: 9 components, compact header, inline edit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,9 @@ import {
|
|||||||
GoalReport,
|
GoalReport,
|
||||||
Group,
|
Group,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import PageActionBar from "@/components/features/PageActionBar";
|
||||||
|
import GoalActionButtons from "@/components/features/GoalActionButtons";
|
||||||
|
import IconButton from "@/components/features/IconButton";
|
||||||
|
|
||||||
export default function GoalsPage() {
|
export default function GoalsPage() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
@@ -33,7 +36,6 @@ export default function GoalsPage() {
|
|||||||
const [aiLoading, setAiLoading] = useState<string | null>(null);
|
const [aiLoading, setAiLoading] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [formTitle, setFormTitle] = useState("");
|
const [formTitle, setFormTitle] = useState("");
|
||||||
const [formDate, setFormDate] = useState("");
|
const [formDate, setFormDate] = useState("");
|
||||||
const [formGroup, setFormGroup] = useState("");
|
const [formGroup, setFormGroup] = useState("");
|
||||||
@@ -172,18 +174,14 @@ export default function GoalsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 pb-24 sm:pb-8 px-4 sm:px-0">
|
<div className="space-y-4 pb-24 sm:pb-8 px-4 sm:px-0">
|
||||||
{/* Header */}
|
<PageActionBar
|
||||||
<div className="flex items-center justify-between">
|
title={t("goals.title")}
|
||||||
<h1 className="text-xl font-bold dark:text-white">{t("goals.title")}</h1>
|
showAdd
|
||||||
<button
|
onToggleAdd={() => setShowForm(!showForm)}
|
||||||
onClick={() => setShowForm(!showForm)}
|
addOpen={showForm}
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors min-h-[44px]"
|
t={t}
|
||||||
>
|
/>
|
||||||
{showForm ? t("tasks.form.cancel") : `+ ${t("goals.add")}`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-red-700 dark:text-red-300 text-sm">
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-red-700 dark:text-red-300 text-sm">
|
||||||
{error}
|
{error}
|
||||||
@@ -191,7 +189,6 @@ export default function GoalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create form */}
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form onSubmit={handleCreate} className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 space-y-3">
|
<form onSubmit={handleCreate} className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -237,7 +234,6 @@ export default function GoalsPage() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Goals list */}
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||||
@@ -278,7 +274,6 @@ export default function GoalsPage() {
|
|||||||
{goal.progress_pct}%
|
{goal.progress_pct}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Progress bar */}
|
|
||||||
<div className="mt-3 h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
<div className="mt-3 h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-500 ${progressColor(goal.progress_pct)}`}
|
className={`h-full rounded-full transition-all duration-500 ${progressColor(goal.progress_pct)}`}
|
||||||
@@ -294,20 +289,23 @@ export default function GoalsPage() {
|
|||||||
{selectedGoal && (
|
{selectedGoal && (
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 space-y-4">
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 space-y-4">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<h2 className="text-lg font-bold dark:text-white">{selectedGoal.title}</h2>
|
<h2 className="text-lg font-bold dark:text-white">{selectedGoal.title}</h2>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{formatDate(selectedGoal.target_date)} | {t("goals.progress")}: {selectedGoal.progress_pct}%
|
{formatDate(selectedGoal.target_date)} | {t("goals.progress")}: {selectedGoal.progress_pct}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<IconButton
|
||||||
onClick={() => setSelectedGoal(null)}
|
icon={
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1 min-h-[44px] min-w-[44px] flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
}
|
||||||
|
label={t("tasks.close")}
|
||||||
|
onClick={() => setSelectedGoal(null)}
|
||||||
|
variant="default"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress slider */}
|
{/* Progress slider */}
|
||||||
@@ -325,47 +323,15 @@ export default function GoalsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Action buttons */}
|
{/* AI Action buttons - icon only */}
|
||||||
<div className="flex gap-2">
|
<GoalActionButtons
|
||||||
<button
|
onPlan={() => handleGeneratePlan(selectedGoal.id)}
|
||||||
onClick={() => handleGeneratePlan(selectedGoal.id)}
|
onReport={() => handleGetReport(selectedGoal.id)}
|
||||||
disabled={aiLoading === "plan"}
|
onDelete={() => handleDelete(selectedGoal.id)}
|
||||||
className="flex-1 py-2.5 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 text-white rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2 min-h-[44px]"
|
planLoading={aiLoading === "plan"}
|
||||||
>
|
reportLoading={aiLoading === "report"}
|
||||||
{aiLoading === "plan" ? (
|
t={t}
|
||||||
<>
|
/>
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
|
||||||
{t("common.loading")}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
|
||||||
</svg>
|
|
||||||
{t("goals.plan")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleGetReport(selectedGoal.id)}
|
|
||||||
disabled={aiLoading === "report"}
|
|
||||||
className="flex-1 py-2.5 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2 min-h-[44px]"
|
|
||||||
>
|
|
||||||
{aiLoading === "report" ? (
|
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
|
||||||
{t("common.loading")}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
||||||
</svg>
|
|
||||||
{t("goals.report")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plan result */}
|
{/* Plan result */}
|
||||||
{planResult && (
|
{planResult && (
|
||||||
@@ -468,14 +434,6 @@ export default function GoalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete button */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(selectedGoal.id)}
|
|
||||||
className="w-full py-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg text-sm font-medium transition-colors min-h-[44px]"
|
|
||||||
>
|
|
||||||
{t("tasks.delete")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
deleteProject,
|
deleteProject,
|
||||||
Project,
|
Project,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import PageActionBar from "@/components/features/PageActionBar";
|
||||||
|
import DeleteIconButton from "@/components/features/DeleteIconButton";
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
@@ -20,7 +22,6 @@ export default function ProjectsPage() {
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [formName, setFormName] = useState("");
|
const [formName, setFormName] = useState("");
|
||||||
const [formDesc, setFormDesc] = useState("");
|
const [formDesc, setFormDesc] = useState("");
|
||||||
const [formColor, setFormColor] = useState("#3B82F6");
|
const [formColor, setFormColor] = useState("#3B82F6");
|
||||||
@@ -96,18 +97,14 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 pb-24 sm:pb-8 px-4 sm:px-0">
|
<div className="space-y-4 pb-24 sm:pb-8 px-4 sm:px-0">
|
||||||
{/* Header */}
|
<PageActionBar
|
||||||
<div className="flex items-center justify-between">
|
title={t("nav.projects")}
|
||||||
<h1 className="text-xl font-bold dark:text-white">{t("nav.projects")}</h1>
|
showAdd
|
||||||
<button
|
onToggleAdd={() => setShowForm(!showForm)}
|
||||||
onClick={() => setShowForm(!showForm)}
|
addOpen={showForm}
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors min-h-[44px]"
|
t={t}
|
||||||
>
|
/>
|
||||||
{showForm ? t("tasks.form.cancel") : `+ ${t("projects.add")}`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-red-700 dark:text-red-300 text-sm">
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-red-700 dark:text-red-300 text-sm">
|
||||||
{error}
|
{error}
|
||||||
@@ -115,7 +112,6 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create form */}
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form onSubmit={handleCreate} className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 space-y-3">
|
<form onSubmit={handleCreate} className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -179,7 +175,6 @@ export default function ProjectsPage() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Projects list */}
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||||
@@ -219,15 +214,11 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: project.color || "#3B82F6" }} />
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: project.color || "#3B82F6" }} />
|
||||||
<button
|
<DeleteIconButton
|
||||||
onClick={() => handleDelete(project.id)}
|
onClick={() => handleDelete(project.id)}
|
||||||
className="p-2 text-gray-400 hover:text-red-500 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
|
label={t("tasks.delete")}
|
||||||
title={t("tasks.delete")}
|
size="sm"
|
||||||
>
|
/>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import {
|
|||||||
sendCollabRequest,
|
sendCollabRequest,
|
||||||
searchUsers,
|
searchUsers,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import CollabBackButton from "@/components/features/CollabBackButton";
|
||||||
|
import CollabActionButtons from "@/components/features/CollabActionButtons";
|
||||||
|
import IconButton from "@/components/features/IconButton";
|
||||||
|
|
||||||
interface UserResult {
|
interface UserResult {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -130,7 +133,6 @@ export default function CollaboratePage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
// Forms
|
|
||||||
const [showAssignSearch, setShowAssignSearch] = useState(false);
|
const [showAssignSearch, setShowAssignSearch] = useState(false);
|
||||||
const [showTransferSearch, setShowTransferSearch] = useState(false);
|
const [showTransferSearch, setShowTransferSearch] = useState(false);
|
||||||
const [transferMessage, setTransferMessage] = useState("");
|
const [transferMessage, setTransferMessage] = useState("");
|
||||||
@@ -152,19 +154,15 @@ export default function CollaboratePage() {
|
|||||||
setSubtasks(subtasksData.data || []);
|
setSubtasks(subtasksData.data || []);
|
||||||
setHistory(historyData.data || []);
|
setHistory(historyData.data || []);
|
||||||
|
|
||||||
// Load assignee details
|
|
||||||
const assignedIds: string[] = taskData.assigned_to || [];
|
const assignedIds: string[] = taskData.assigned_to || [];
|
||||||
if (assignedIds.length > 0) {
|
if (assignedIds.length > 0) {
|
||||||
try {
|
try {
|
||||||
const usersRes = await searchUsers(token, "");
|
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>();
|
const knownUsers = new Map<string, UserResult>();
|
||||||
(historyData.data || []).forEach((h: CollabRequest) => {
|
(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.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 });
|
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));
|
(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 }));
|
setAssignees(assignedIds.map((uid) => knownUsers.get(uid) || { id: uid, name: uid.slice(0, 8), email: "", avatar_url: null }));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -316,18 +314,13 @@ export default function CollaboratePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg mx-auto space-y-4 pb-20">
|
<div className="max-w-lg mx-auto space-y-4 pb-20 px-4 sm:px-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<CollabBackButton
|
||||||
onClick={() => router.push(`/tasks/${id}`)}
|
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"
|
label={t("common.back")}
|
||||||
>
|
/>
|
||||||
<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>
|
<h1 className="text-lg font-bold flex-1 truncate">{t("collab.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -367,47 +360,20 @@ export default function CollaboratePage() {
|
|||||||
<p className="text-sm text-gray-400 mb-3">{t("collab.noAssignees")}</p>
|
<p className="text-sm text-gray-400 mb-3">{t("collab.noAssignees")}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons - icon only with tooltips */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<CollabActionButtons
|
||||||
<button
|
onAssign={() => {
|
||||||
onClick={() => {
|
|
||||||
setShowAssignSearch(!showAssignSearch);
|
setShowAssignSearch(!showAssignSearch);
|
||||||
setShowTransferSearch(false);
|
setShowTransferSearch(false);
|
||||||
}}
|
}}
|
||||||
disabled={actionLoading}
|
onTransfer={() => {
|
||||||
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);
|
setShowTransferSearch(!showTransferSearch);
|
||||||
setShowAssignSearch(false);
|
setShowAssignSearch(false);
|
||||||
}}
|
}}
|
||||||
|
onClaim={handleClaim}
|
||||||
disabled={actionLoading}
|
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"
|
t={t}
|
||||||
>
|
/>
|
||||||
<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 */}
|
{/* Assign search dropdown */}
|
||||||
{showAssignSearch && (
|
{showAssignSearch && (
|
||||||
@@ -452,7 +418,6 @@ export default function CollaboratePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
{subtasksTotal > 0 && (
|
{subtasksTotal > 0 && (
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-3">
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-3">
|
||||||
<div
|
<div
|
||||||
@@ -462,10 +427,9 @@ export default function CollaboratePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Subtask list */}
|
|
||||||
<div className="space-y-2 mb-3">
|
<div className="space-y-2 mb-3">
|
||||||
{subtasks.map((sub) => {
|
{subtasks.map((sub) => {
|
||||||
const isDone = sub.status === "done" || sub.status === "completed";
|
const subDone = sub.status === "done" || sub.status === "completed";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={sub.id}
|
key={sub.id}
|
||||||
@@ -474,18 +438,18 @@ export default function CollaboratePage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleToggleSubtask(sub)}
|
onClick={() => handleToggleSubtask(sub)}
|
||||||
className={`flex-shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
className={`flex-shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||||
isDone
|
subDone
|
||||||
? "bg-green-500 border-green-500 text-white"
|
? "bg-green-500 border-green-500 text-white"
|
||||||
: "border-gray-300 dark:border-gray-600 hover:border-green-400"
|
: "border-gray-300 dark:border-gray-600 hover:border-green-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isDone && (
|
{subDone && (
|
||||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<span className={`flex-1 text-sm ${isDone ? "line-through text-gray-400" : ""}`}>
|
<span className={`flex-1 text-sm ${subDone ? "line-through text-gray-400" : ""}`}>
|
||||||
{sub.title}
|
{sub.title}
|
||||||
</span>
|
</span>
|
||||||
{sub.assignee_name && (
|
{sub.assignee_name && (
|
||||||
@@ -495,7 +459,8 @@ export default function CollaboratePage() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteSubtask(sub.id)}
|
onClick={() => handleDeleteSubtask(sub.id)}
|
||||||
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 transition-opacity"
|
title={t("tasks.delete")}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 transition-opacity p-1 rounded"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -510,7 +475,6 @@ export default function CollaboratePage() {
|
|||||||
<p className="text-sm text-gray-400 mb-3">{t("collab.noSubtasks")}</p>
|
<p className="text-sm text-gray-400 mb-3">{t("collab.noSubtasks")}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add subtask form */}
|
|
||||||
{showSubtaskForm ? (
|
{showSubtaskForm ? (
|
||||||
<div className="space-y-2 border-t border-gray-100 dark:border-gray-700 pt-3">
|
<div className="space-y-2 border-t border-gray-100 dark:border-gray-700 pt-3">
|
||||||
<input
|
<input
|
||||||
@@ -543,35 +507,47 @@ export default function CollaboratePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t("collab.addBtn")}
|
||||||
onClick={handleAddSubtask}
|
onClick={handleAddSubtask}
|
||||||
disabled={!newSubtaskTitle.trim() || actionLoading}
|
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"
|
variant="primary"
|
||||||
>
|
size="md"
|
||||||
{t("collab.addBtn")}
|
/>
|
||||||
</button>
|
<IconButton
|
||||||
<button
|
icon={
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label={t("tasks.form.cancel")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowSubtaskForm(false);
|
setShowSubtaskForm(false);
|
||||||
setNewSubtaskTitle("");
|
setNewSubtaskTitle("");
|
||||||
setSubtaskAssignee(null);
|
setSubtaskAssignee(null);
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-800 dark:text-gray-400"
|
variant="default"
|
||||||
>
|
size="md"
|
||||||
{t("tasks.form.cancel")}
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<IconButton
|
||||||
onClick={() => setShowSubtaskForm(true)}
|
icon={
|
||||||
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
>
|
|
||||||
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
{t("collab.addSubtask")}
|
}
|
||||||
</button>
|
label={t("collab.addSubtask")}
|
||||||
|
onClick={() => setShowSubtaskForm(true)}
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
import TaskForm from "@/components/TaskForm";
|
import TaskForm from "@/components/TaskForm";
|
||||||
import InviteModal from "@/components/InviteModal";
|
import InviteModal from "@/components/InviteModal";
|
||||||
import StatusBadge from "@/components/StatusBadge";
|
import StatusBadge from "@/components/StatusBadge";
|
||||||
|
import TaskDetailActions from "@/components/features/TaskDetailActions";
|
||||||
|
import InlineEditField from "@/components/features/InlineEditField";
|
||||||
|
|
||||||
function isDone(status: string): boolean {
|
function isDone(status: string): boolean {
|
||||||
return status === "done" || status === "completed";
|
return status === "done" || status === "completed";
|
||||||
@@ -70,7 +72,6 @@ export default function TaskDetailPage() {
|
|||||||
setTask(taskData);
|
setTask(taskData);
|
||||||
setGroups(groupsData.data || []);
|
setGroups(groupsData.data || []);
|
||||||
setSubtasks(subtasksData.data || []);
|
setSubtasks(subtasksData.data || []);
|
||||||
// Load assignee names
|
|
||||||
const assigned: string[] = taskData.assigned_to || [];
|
const assigned: string[] = taskData.assigned_to || [];
|
||||||
if (assigned.length > 0) {
|
if (assigned.length > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -106,6 +107,16 @@ export default function TaskDetailPage() {
|
|||||||
loadTask();
|
loadTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleInlineUpdate(field: string, value: string) {
|
||||||
|
if (!token || !id) return;
|
||||||
|
try {
|
||||||
|
await updateTask(token, id, { [field]: value });
|
||||||
|
loadTask();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("common.error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!token || !id) return;
|
if (!token || !id) return;
|
||||||
if (!confirm(t("tasks.confirmDelete"))) return;
|
if (!confirm(t("tasks.confirmDelete"))) return;
|
||||||
@@ -183,90 +194,46 @@ export default function TaskDetailPage() {
|
|||||||
const taskDone = isDone(task.status);
|
const taskDone = isDone(task.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg mx-auto space-y-4">
|
<div className="max-w-lg mx-auto space-y-4 px-4 sm:px-0">
|
||||||
{/* Action bar - all buttons in one compact row */}
|
{/* Action bar - all icon buttons in one compact row */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<TaskDetailActions
|
||||||
<button
|
taskDone={taskDone}
|
||||||
onClick={() => router.push("/tasks")}
|
deleting={deleting}
|
||||||
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"
|
onBack={() => router.push("/tasks")}
|
||||||
>
|
onEdit={() => setEditing(true)}
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
onDelete={handleDelete}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
onToggleStatus={() => handleQuickStatus(taskDone ? "pending" : "done")}
|
||||||
</svg>
|
onInvite={() => setShowInvite(true)}
|
||||||
{t("common.back")}
|
onCollaborate={() => router.push(`/tasks/${id}/collaborate`)}
|
||||||
</button>
|
t={t}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex-1" />
|
{/* Task detail card with inline editing */}
|
||||||
|
|
||||||
{!taskDone && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleQuickStatus("done")}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
{t("tasks.markDone")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{taskDone && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleQuickStatus("pending")}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-yellow-600 hover:bg-yellow-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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
{t("tasks.reopen")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setEditing(true)}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
||||||
</svg>
|
|
||||||
{t("tasks.edit")}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleting}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
{deleting ? t("tasks.deleting") : t("tasks.delete")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Task detail card */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-6">
|
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-2xl p-6">
|
||||||
<div className="flex items-start justify-between gap-4 mb-4">
|
<div className="flex items-start justify-between gap-4 mb-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||||
{task.group_icon && (
|
{task.group_icon && (
|
||||||
<span className="text-2xl">{task.group_icon}</span>
|
<span className="text-2xl flex-shrink-0">{task.group_icon}</span>
|
||||||
)}
|
)}
|
||||||
<h1
|
<InlineEditField
|
||||||
className={`text-xl font-bold ${
|
value={task.title}
|
||||||
taskDone ? "line-through text-muted" : ""
|
onSave={(val) => handleInlineUpdate("title", val)}
|
||||||
}`}
|
className={`text-xl font-bold ${taskDone ? "line-through text-muted" : ""}`}
|
||||||
>
|
/>
|
||||||
{task.title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={task.status} size="md" />
|
<StatusBadge status={task.status} size="md" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.description && (
|
<div className="mb-4">
|
||||||
<p className="text-muted mb-4 whitespace-pre-wrap leading-relaxed">
|
<InlineEditField
|
||||||
{task.description}
|
value={task.description || ""}
|
||||||
</p>
|
onSave={(val) => handleInlineUpdate("description", val)}
|
||||||
)}
|
as="textarea"
|
||||||
|
multiline
|
||||||
|
placeholder={t("tasks.form.descPlaceholder")}
|
||||||
|
className="text-muted whitespace-pre-wrap leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
@@ -320,24 +287,6 @@ export default function TaskDetailPage() {
|
|||||||
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
{t("collab.collaboration")}
|
{t("collab.collaboration")}
|
||||||
</h2>
|
</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>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowInvite(true)}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
||||||
</svg>
|
|
||||||
Pozvat
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Assigned users */}
|
{/* Assigned users */}
|
||||||
|
|||||||
Reference in New Issue
Block a user