feat(timesheet): add tags management and responsive time tracking flows

This commit is contained in:
2026-04-24 22:23:50 +03:30
parent c4d8379924
commit 987d2e2b59
13 changed files with 3710 additions and 134 deletions

242
src/pages/Tags.tsx Normal file
View File

@@ -0,0 +1,242 @@
import { useEffect, useState } from "react";
import { Edit2, Plus, Tag as TagIcon, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { createTag, deleteTag, getTags, type Tag, updateTag } from "../api/tags";
import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation";
import FilterBar from "../components/FilterBar";
import { Modal } from "../components/Modal";
import { Pagination } from "../components/Pagination";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input";
const DEFAULT_COLOR = "#3B82F6";
export default function Tags() {
const { t } = useTranslation();
const { activeWorkspace } = useWorkspace();
const [tags, setTags] = useState<Tag[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [ordering, setOrdering] = useState("-updated_at");
const [currentPage, setCurrentPage] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [limit, setLimit] = useState(10);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(null);
const [formName, setFormName] = useState("");
const [formColor, setFormColor] = useState(DEFAULT_COLOR);
const [isSaving, setIsSaving] = useState(false);
const orderingOptions = [
{ value: "-updated_at", label: t.ordering?.updatedAtDesc || "Recently Updated" },
{ value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
{ value: "created_at", label: t.ordering?.createdAt || "Oldest First" },
{ value: "name", label: t.ordering?.name || "Name (A-Z)" },
{ value: "-name", label: t.ordering?.nameDesc || "Name (Z-A)" },
];
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, ordering]);
useEffect(() => {
if (!activeWorkspace?.id) return;
const timeoutId = setTimeout(() => {
void loadTags();
}, 250);
return () => clearTimeout(timeoutId);
}, [activeWorkspace?.id, searchQuery, ordering, currentPage, limit]);
const loadTags = async () => {
if (!activeWorkspace?.id) return;
try {
setIsLoading(true);
const data = await getTags(activeWorkspace.id, {
limit,
offset: (currentPage - 1) * limit,
ordering,
search: searchQuery,
});
setTags(data.results || []);
setTotalItems(data.count || 0);
} catch (error) {
console.error(error);
toast.error(t.tags?.fetchError || "Failed to load tags");
} finally {
setIsLoading(false);
}
};
const openCreateModal = () => {
setEditingTag(null);
setFormName("");
setFormColor(DEFAULT_COLOR);
setIsModalOpen(true);
};
const openEditModal = (tag: Tag) => {
setEditingTag(tag);
setFormName(tag.name);
setFormColor(tag.color || DEFAULT_COLOR);
setIsModalOpen(true);
};
const closeModal = () => {
if (isSaving) return;
setIsModalOpen(false);
setEditingTag(null);
setFormName("");
setFormColor(DEFAULT_COLOR);
};
const handleSubmit = async () => {
if (!activeWorkspace?.id || !formName.trim()) return;
try {
setIsSaving(true);
if (editingTag) {
await updateTag(editingTag.id, { name: formName.trim(), color: formColor });
toast.success(t.tags?.updateSuccess || "Tag updated");
} else {
await createTag(activeWorkspace.id, { name: formName.trim(), color: formColor });
toast.success(t.tags?.createSuccess || "Tag created");
}
closeModal();
await loadTags();
} catch (error) {
console.error(error);
toast.error(t.tags?.saveError || "Failed to save tag");
} finally {
setIsSaving(false);
}
};
const handleDelete = async (tag: Tag) => {
try {
await deleteTag(tag.id);
toast.success(t.tags?.deleteSuccess || "Tag deleted");
await loadTags();
} catch (error) {
console.error(error);
toast.error(t.tags?.deleteError || "Failed to delete tag");
}
};
if (!activeWorkspace) {
return <div className="p-6 text-center text-slate-500">{t.tags?.selectWorkspace || t.clients.selectWorkspace}</div>;
}
return (
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.tags?.title || "Tags"}</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1">
{t.tags?.description?.(activeWorkspace.name) || `Manage tags for ${activeWorkspace.name}`}
</p>
</div>
<Button onClick={openCreateModal} className="gap-2 shadow-sm">
<Plus className="h-4 w-4" />
{t.tags?.create || "Create Tag"}
</Button>
</div>
<FilterBar
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
ordering={ordering}
setOrdering={setOrdering}
orderingOptions={orderingOptions}
searchPlaceholder={t.tags?.searchPlaceholder || "Search tags..."}
/>
{isLoading ? (
<div className="p-12 flex justify-center text-slate-500">{t.loading || "Loading..."}</div>
) : (
<div className="flex flex-col flex-1">
<div className="flex flex-col gap-4 mb-6">
{tags.map((tag) => (
<Card key={tag.id} className="dark:bg-slate-800 dark:border-slate-700 shadow-sm">
<CardContent className="flex items-center justify-between gap-4 py-4 px-6">
<div className="flex items-center gap-4 min-w-0">
<div className="h-10 w-10 rounded-full border border-slate-200 dark:border-slate-700" style={{ backgroundColor: tag.color || DEFAULT_COLOR }} />
<div className="min-w-0">
<CardTitle className="text-lg truncate text-slate-900 dark:text-white">{tag.name}</CardTitle>
<p className="text-sm text-slate-500 dark:text-slate-400">{tag.color || DEFAULT_COLOR}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => openEditModal(tag)} title={t.actions?.edit || "Edit"}>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => void handleDelete(tag)} title={t.actions?.delete || "Delete"}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</CardContent>
</Card>
))}
{tags.length === 0 && (
<div className="py-16 flex flex-col items-center justify-center border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl text-slate-500 dark:text-slate-400">
<TagIcon className="w-10 h-10 mb-3" />
<p>{t.tags?.emptyState || "No tags found"}</p>
</div>
)}
</div>
<Pagination
currentPage={currentPage}
totalCount={totalItems}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={setLimit}
/>
</div>
)}
<Modal
isOpen={isModalOpen}
onClose={closeModal}
title={editingTag ? (t.tags?.editTitle || "Edit Tag") : (t.tags?.createTitle || "Create Tag")}
footer={
<>
<Button variant="secondary" onClick={closeModal}>
{t.actions?.cancel || "Cancel"}
</Button>
<Button onClick={() => void handleSubmit()} disabled={isSaving || !formName.trim()}>
{isSaving ? "..." : (editingTag ? (t.save || "Save") : (t.create || "Create"))}
</Button>
</>
}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t.tags?.nameLabel || "Tag name"}
</label>
<Input value={formName} onChange={(event) => setFormName(event.target.value)} placeholder={t.tags?.namePlaceholder || "Design"} />
</div>
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
{t.tags?.colorLabel || "Color"}
</label>
<input type="color" value={formColor} onChange={(event) => setFormColor(event.target.value)} className="h-10 w-14 cursor-pointer rounded-md border border-slate-200 dark:border-slate-700 bg-transparent" />
</div>
</div>
</Modal>
</div>
);
}

2495
src/pages/Timesheet.tsx Normal file

File diff suppressed because it is too large Load Diff