Files
kandev/apps/web/app/office/projects/[id]/project-header.tsx
T
2026-09-10 18:14:05 +01:00

114 lines
4.0 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
import { IconDeviceFloppy } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Textarea } from "@kandev/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { toast } from "@/lib/toast/sonner";
import { updateProject } from "@/lib/api/domains/office-api";
import { useAppStore } from "@/components/state-provider";
import type { Project, ProjectStatus } from "@/lib/state/slices/office/types";
import { PROJECT_STATUS_LABEL_KEYS } from "../../lib/label-keys";
import { useTranslation } from "react-i18next";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
// `labelKey`, not `label` — module scope freezes a `t()` at the boot locale.
// The `value`s are the persisted project-status ids and stay untranslated.
const STATUS_OPTIONS: { value: ProjectStatus; labelKey: string }[] = (
["active", "completed", "on_hold", "archived"] as const
).map((value) => ({ value, labelKey: PROJECT_STATUS_LABEL_KEYS[value] }));
type ProjectHeaderProps = {
project: Project;
};
export function ProjectHeader({ project }: ProjectHeaderProps) {
const { t } = useTranslation();
const updateProjectStore = useAppStore((s) => s.updateProject);
const [name, setName] = useState(project.name);
const [description, setDescription] = useState(project.description ?? "");
const [status, setStatus] = useState<ProjectStatus>(project.status);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const handleSave = useCallback(async () => {
setSaving(true);
try {
const patch: Partial<Project> = {};
if (name !== project.name) patch.name = name;
if (description !== (project.description ?? "")) patch.description = description;
if (status !== project.status) patch.status = status;
if (Object.keys(patch).length > 0) {
await updateProject(project.id, patch);
updateProjectStore(project.workspaceId, project.id, patch);
}
setDirty(false);
toast.success(t("office:projectSaved"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("office:failedToSaveProject"));
} finally {
setSaving(false);
}
}, [name, description, status, project, updateProjectStore]);
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<span
className="h-4 w-4 rounded-sm shrink-0"
style={{ backgroundColor: project.color || "#6b7280" }}
/>
<Input
value={name}
onChange={(e) => {
setName(e.target.value);
setDirty(true);
}}
className={controlSizingClassName("standard", "text-lg font-semibold px-2.5")}
/>
<Select
value={status}
onValueChange={(v) => {
setStatus(v as ProjectStatus);
setDirty(true);
}}
>
<SelectTrigger className={controlSizingClassName("standard", "w-[140px] cursor-pointer")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="cursor-pointer">
{t(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
{dirty && (
<Button
onClick={handleSave}
disabled={saving}
className={controlSizingClassName("standard", "cursor-pointer")}
>
<IconDeviceFloppy className="h-4 w-4 mr-1" />
{saving ? t("office:saving") : t("common:save")}
</Button>
)}
</div>
<Textarea
value={description}
onChange={(e) => {
setDescription(e.target.value);
setDirty(true);
}}
placeholder={t("office:addADescription")}
className="min-h-[60px] text-sm resize-none"
/>
</div>
);
}