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:
2026-03-30 01:18:23 +00:00
parent 8cf14dcf59
commit 6d68b68412
4 changed files with 143 additions and 269 deletions

View File

@@ -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
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.close")}
onClick={() => setSelectedGoal(null)} onClick={() => setSelectedGoal(null)}
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" variant="default"
> size="md"
<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>
</button>
</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>

View File

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

View File

@@ -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); }}
}} onTransfer={() => {
disabled={actionLoading} setShowTransferSearch(!showTransferSearch);
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" setShowAssignSearch(false);
> }}
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> onClaim={handleClaim}
<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" /> disabled={actionLoading}
</svg> t={t}
{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 */} {/* 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
icon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
}
label={t("collab.addSubtask")}
onClick={() => setShowSubtaskForm(true)} onClick={() => setShowSubtaskForm(true)}
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-700 font-medium" variant="primary"
> size="md"
<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> </div>

View File

@@ -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 */}