feat(timesheet): add tags management and responsive time tracking flows
This commit is contained in:
242
src/pages/Tags.tsx
Normal file
242
src/pages/Tags.tsx
Normal 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
2495
src/pages/Timesheet.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user