114 lines
4.0 KiB
TypeScript
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>
|
|
);
|
|
}
|