ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: интерактивный предпросмотр Voice Tasker
This commit is contained in:
parent
a13ff3b954
commit
323b4b964e
|
|
@ -691,6 +691,8 @@ def harden_voice_task_intent(parsed, transcript):
|
|||
|
||||
if parsed.get("intent") != "update_task":
|
||||
return parsed
|
||||
if parsed.get("target_task_id"):
|
||||
return parsed
|
||||
|
||||
target_memory_ref = normalize_string(parsed.get("target_memory_ref"), 80)
|
||||
has_explicit_issue_ref = bool(parse_issue_key_reference(target_memory_ref)) or transcript_has_issue_key_reference(
|
||||
|
|
@ -716,6 +718,9 @@ def harden_voice_task_intent(parsed, transcript):
|
|||
|
||||
|
||||
def voice_task_has_safe_existing_task_anchor(draft, transcript):
|
||||
if draft.get("target_task_id"):
|
||||
return True
|
||||
|
||||
target_memory_ref = normalize_string(draft.get("target_memory_ref"), 80)
|
||||
return bool(
|
||||
parse_issue_key_reference(target_memory_ref)
|
||||
|
|
@ -971,6 +976,26 @@ def resolve_voice_task_assignee(project, draft):
|
|||
return serialize_resolved_assignee(best_member, best_score, "assignee_hint")
|
||||
|
||||
|
||||
def resolve_explicit_voice_task_assignees(project, draft):
|
||||
if "assignee_ids" not in draft:
|
||||
return None
|
||||
|
||||
assignee_ids = draft.get("assignee_ids") if isinstance(draft.get("assignee_ids"), list) else []
|
||||
if not assignee_ids:
|
||||
return []
|
||||
|
||||
member_by_id = {
|
||||
str(project_member.member_id): project_member.member
|
||||
for project_member in get_voice_task_project_assignable_members(project)
|
||||
}
|
||||
|
||||
return [
|
||||
serialize_resolved_assignee(member_by_id[assignee_id], 1.0, "explicit_assignee_ids")
|
||||
for assignee_id in assignee_ids
|
||||
if assignee_id in member_by_id
|
||||
]
|
||||
|
||||
|
||||
def transcript_requests_all_project_assignees(transcript, draft=None):
|
||||
normalized = normalize_match_value(transcript)
|
||||
assignee_hint = normalize_match_value((draft or {}).get("assignee_hint"))
|
||||
|
|
@ -988,6 +1013,10 @@ def transcript_requests_all_project_assignees(transcript, draft=None):
|
|||
|
||||
|
||||
def resolve_voice_task_assignees(project, draft, transcript=None):
|
||||
explicit_assignees = resolve_explicit_voice_task_assignees(project, draft)
|
||||
if explicit_assignees is not None:
|
||||
return explicit_assignees
|
||||
|
||||
if transcript_requests_all_project_assignees(transcript, draft):
|
||||
return [
|
||||
serialize_resolved_assignee(project_member.member, 1.0, "all_project_members")
|
||||
|
|
@ -1081,8 +1110,14 @@ def resolve_voice_task_state(project, draft, allow_default=True):
|
|||
if not project:
|
||||
return None
|
||||
|
||||
state_hint = draft.get("state_hint")
|
||||
states = list(State.objects.filter(project=project).order_by("sequence"))
|
||||
explicit_state_id = normalize_uuid_string(draft.get("state_id"))
|
||||
if explicit_state_id:
|
||||
explicit_state = next((state for state in states if str(state.id) == explicit_state_id), None)
|
||||
if explicit_state:
|
||||
return serialize_resolved_state(explicit_state, 1.0, "explicit_state_id")
|
||||
|
||||
state_hint = draft.get("state_hint")
|
||||
if state_hint:
|
||||
best_state = None
|
||||
best_score = 0.0
|
||||
|
|
@ -1452,9 +1487,22 @@ def find_latest_voice_task_issue(memory_sessions, project_id=None):
|
|||
|
||||
def resolve_voice_task_memory_target(workspace, user, draft, current_session=None, client_context=None, transcript=None):
|
||||
target_memory_ref = normalize_string(draft.get("target_memory_ref"), 80)
|
||||
explicit_target_task_id = normalize_uuid_string(draft.get("target_task_id"))
|
||||
memory_sessions = get_committed_voice_task_memory_sessions(workspace, user, current_session=current_session)
|
||||
generic_memory_reference = transcript_has_generic_memory_reference(transcript)
|
||||
|
||||
if explicit_target_task_id:
|
||||
target_issue = (
|
||||
Issue.issue_objects.filter(workspace=workspace, id=explicit_target_task_id)
|
||||
.select_related("project")
|
||||
.first()
|
||||
)
|
||||
if (
|
||||
is_voice_task_issue_available(target_issue)
|
||||
and get_accessible_projects(workspace, user).filter(id=target_issue.project_id).exists()
|
||||
):
|
||||
return target_issue, "explicit_target_task_id", None
|
||||
|
||||
if target_memory_ref:
|
||||
target_uuid = None
|
||||
try:
|
||||
|
|
@ -1523,6 +1571,8 @@ def voice_task_has_update_fields(draft, resolution):
|
|||
or draft.get("due_time")
|
||||
or (draft.get("priority") and draft.get("priority") != "none")
|
||||
or draft.get("checklist")
|
||||
or "assignee_ids" in draft
|
||||
or draft.get("state_id")
|
||||
or (resolution.get("assignee") and resolution["assignee"]["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD)
|
||||
or (draft.get("state_hint") and resolution.get("state") and resolution["state"]["confidence"] >= VOICE_TASK_STATE_MATCH_THRESHOLD)
|
||||
or resolution.get("labels")
|
||||
|
|
@ -1846,7 +1896,13 @@ def build_voice_task_issue_update_payload(issue, draft, resolution, transcript=N
|
|||
if draft.get("priority") and draft["priority"] != "none":
|
||||
payload["priority"] = draft["priority"]
|
||||
|
||||
if assignees:
|
||||
if "assignee_ids" in draft:
|
||||
payload["assignee_ids"] = [
|
||||
assignee["id"]
|
||||
for assignee in assignees
|
||||
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD
|
||||
]
|
||||
elif assignees:
|
||||
payload["assignee_ids"] = [
|
||||
assignee["id"]
|
||||
for assignee in assignees
|
||||
|
|
@ -1855,7 +1911,7 @@ def build_voice_task_issue_update_payload(issue, draft, resolution, transcript=N
|
|||
elif assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
|
||||
payload["assignee_ids"] = [assignee["id"]]
|
||||
|
||||
if (draft.get("state_hint") or project_change) and state and state["confidence"] >= VOICE_TASK_STATE_MATCH_THRESHOLD:
|
||||
if (draft.get("state_hint") or draft.get("state_id") or project_change) and state and state["confidence"] >= VOICE_TASK_STATE_MATCH_THRESHOLD:
|
||||
payload["state_id"] = state["id"]
|
||||
|
||||
if labels:
|
||||
|
|
@ -2043,6 +2099,29 @@ def normalize_string(value, max_length=None):
|
|||
return normalized[:max_length] if max_length else normalized
|
||||
|
||||
|
||||
def normalize_uuid_string(value):
|
||||
normalized = normalize_string(value, 80)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
try:
|
||||
return str(uuid.UUID(normalized))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def normalize_uuid_list(value, limit=50):
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
result = []
|
||||
for item in value[:limit]:
|
||||
normalized = normalize_uuid_string(item)
|
||||
if normalized and normalized not in result:
|
||||
result.append(normalized)
|
||||
return result
|
||||
|
||||
|
||||
def normalize_string_list(value, limit=20, item_max_length=120):
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
|
@ -2097,6 +2176,9 @@ def normalize_voice_task_parse(parsed):
|
|||
normalized = {
|
||||
"intent": intent,
|
||||
"target_memory_ref": normalize_string(parsed.get("target_memory_ref"), 80),
|
||||
"project_id": normalize_uuid_string(parsed.get("project_id")),
|
||||
"state_id": normalize_uuid_string(parsed.get("state_id")),
|
||||
"target_task_id": normalize_uuid_string(parsed.get("target_task_id") or parsed.get("target_issue_id")),
|
||||
"project_hint": normalize_string(parsed.get("project_hint"), 255),
|
||||
"state_hint": normalize_string(parsed.get("state_hint"), 120),
|
||||
"assignee_hint": normalize_string(parsed.get("assignee_hint"), 255),
|
||||
|
|
@ -2115,6 +2197,8 @@ def normalize_voice_task_parse(parsed):
|
|||
},
|
||||
"questions": normalize_string_list(parsed.get("questions"), limit=10, item_max_length=255),
|
||||
}
|
||||
if "assignee_ids" in parsed:
|
||||
normalized["assignee_ids"] = normalize_uuid_list(parsed.get("assignee_ids"))
|
||||
|
||||
return normalized
|
||||
|
||||
|
|
|
|||
|
|
@ -238,19 +238,19 @@ export const ProjectDropdownBase = observer(function ProjectDropdownBase(props:
|
|||
multiple={multiple}
|
||||
>
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<Combobox.Options className="fixed z-[1000]" static>
|
||||
<div
|
||||
className="my-1 w-48 rounded-sm border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 text-11 shadow-raised-200 focus:outline-none"
|
||||
className="nodedc-dropdown-surface my-1 w-56 focus:outline-none"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 rounded-sm border border-subtle bg-surface-2 px-2">
|
||||
<div className="nodedc-dropdown-search">
|
||||
<SearchIcon className="h-3.5 w-3.5 text-placeholder" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-11 text-secondary placeholder:text-placeholder focus:outline-none"
|
||||
className="w-full bg-transparent py-0 text-12 text-secondary placeholder:text-placeholder focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("search")}
|
||||
|
|
@ -268,9 +268,9 @@ export const ProjectDropdownBase = observer(function ProjectDropdownBase(props:
|
|||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none ${
|
||||
active ? "bg-layer-transparent-hover" : ""
|
||||
} ${selected ? "text-primary" : "text-secondary"}`
|
||||
`nodedc-dropdown-option cursor-pointer ${active ? "bg-white/[0.06]" : ""} ${
|
||||
selected ? "text-primary" : "text-secondary"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
|
|
@ -283,10 +283,10 @@ export const ProjectDropdownBase = observer(function ProjectDropdownBase(props:
|
|||
);
|
||||
})
|
||||
) : (
|
||||
<p className="px-1.5 py-1 text-placeholder italic">{t("no_matching_results")}</p>
|
||||
<p className="px-3 py-2 text-12 text-placeholder italic">{t("no_matching_results")}</p>
|
||||
)
|
||||
) : (
|
||||
<p className="px-1.5 py-1 text-placeholder italic">{t("loading")}</p>
|
||||
<p className="px-3 py-2 text-12 text-placeholder italic">{t("loading")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,22 +5,57 @@
|
|||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { CheckCircle2, Mic, Pencil, Plus, RotateCcw, Square, Trash2, Upload, X } from "lucide-react";
|
||||
import {
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
FileText,
|
||||
Flag,
|
||||
FolderKanban,
|
||||
ListChecks,
|
||||
Mic,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Square,
|
||||
Target,
|
||||
Trash2,
|
||||
Upload,
|
||||
UserRound,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
// plane imports
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import type { TVoiceTaskCommitResult, TVoiceTaskUploadResult } from "@plane/types";
|
||||
import type {
|
||||
ISearchIssueResponse,
|
||||
TIssuePriorities,
|
||||
TVoiceTaskCommitResult,
|
||||
TVoiceTaskDraft,
|
||||
TVoiceTaskPriority,
|
||||
TVoiceTaskUploadResult,
|
||||
} from "@plane/types";
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import { cn, renderFormattedPayloadDate } from "@plane/utils";
|
||||
import { DateDropdown } from "@/components/dropdowns/date";
|
||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
||||
import { ProjectDropdown } from "@/components/dropdowns/project/dropdown";
|
||||
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import useDebounce from "@/hooks/use-debounce";
|
||||
// services
|
||||
import { ProjectService } from "@/services/project";
|
||||
import { WorkspaceAIService } from "@/services/workspace-ai.service";
|
||||
|
||||
const workspaceAIService = new WorkspaceAIService();
|
||||
const projectService = new ProjectService();
|
||||
|
||||
type TVoiceTaskerStatus = "idle" | "recording" | "uploading" | "success" | "committing" | "committed" | "error";
|
||||
|
||||
|
|
@ -31,6 +66,20 @@ const UNAVAILABLE_LABELS = {
|
|||
role_denied: "Voice Task недоступен для вашей роли",
|
||||
} as const;
|
||||
|
||||
const PRIORITY_LABELS: Record<Exclude<TVoiceTaskPriority, null>, string> = {
|
||||
none: "Без приоритета",
|
||||
low: "Низкий",
|
||||
medium: "Средний",
|
||||
high: "Высокий",
|
||||
urgent: "Срочный",
|
||||
};
|
||||
|
||||
const voiceTaskPropertyButtonClassName =
|
||||
"nodedc-work-item-property-button !h-8 !min-h-8 !w-full !justify-start !px-3 text-12";
|
||||
|
||||
const VOICE_TASK_TIME_HOURS = Array.from({ length: 24 }, (_, index) => index.toString().padStart(2, "0"));
|
||||
const VOICE_TASK_TIME_MINUTES = Array.from({ length: 60 }, (_, index) => index.toString().padStart(2, "0"));
|
||||
|
||||
function getSupportedMimeType() {
|
||||
if (typeof MediaRecorder === "undefined") return "";
|
||||
|
||||
|
|
@ -62,19 +111,18 @@ function getRouteParam(value: string | string[] | undefined) {
|
|||
}
|
||||
|
||||
function getCommitButtonLabel(intent?: string) {
|
||||
if (intent === "update_task") return "Применить изменения";
|
||||
if (intent === "delete_task") return "Удалить задачу";
|
||||
return "Создать задачу";
|
||||
if (intent === "delete_task") return "Опубликовать удаление";
|
||||
return "Опубликовать задачу";
|
||||
}
|
||||
|
||||
function getCommitStatusLabel(status: TVoiceTaskerStatus) {
|
||||
if (status === "committed") return "Committed";
|
||||
if (status === "success") return "Draft parsed";
|
||||
if (status === "committing") return "Committing";
|
||||
if (status === "uploading") return "Processing";
|
||||
if (status === "recording") return "Recording";
|
||||
if (status === "error") return "Error";
|
||||
return "Ready";
|
||||
if (status === "committed") return "Опубликовано";
|
||||
if (status === "success") return "Черновик готов";
|
||||
if (status === "committing") return "Публикуем";
|
||||
if (status === "uploading") return "Распознаем";
|
||||
if (status === "recording") return "Идет запись";
|
||||
if (status === "error") return "Ошибка";
|
||||
return "Готово к записи";
|
||||
}
|
||||
|
||||
function getCommitSuccessTitle(result: TVoiceTaskCommitResult) {
|
||||
|
|
@ -115,6 +163,304 @@ function getVoiceTaskAssigneesLabel(result: TVoiceTaskUploadResult) {
|
|||
return result.draft?.assignee_hint || "не распознано";
|
||||
}
|
||||
|
||||
function getVoiceTaskAssigneeIds(result: TVoiceTaskUploadResult | null) {
|
||||
const resolution = result?.resolution as TVoiceTaskResolutionWithAssignees | undefined;
|
||||
const resolvedAssignees = resolution?.assignees?.length
|
||||
? resolution.assignees
|
||||
: resolution?.assignee
|
||||
? [resolution.assignee]
|
||||
: [];
|
||||
|
||||
return resolvedAssignees.flatMap((assignee) => (assignee?.id ? [assignee.id] : []));
|
||||
}
|
||||
|
||||
function getPriorityLabel(priority: TVoiceTaskPriority) {
|
||||
return priority ? PRIORITY_LABELS[priority] : "Не распознано";
|
||||
}
|
||||
|
||||
function parseVoiceTaskTime(value?: string | null) {
|
||||
const match = value?.match(/^(\d{2}):(\d{2})/);
|
||||
if (!match) return { hour: null, minute: null };
|
||||
|
||||
const hour = VOICE_TASK_TIME_HOURS.includes(match[1]) ? match[1] : null;
|
||||
const minute = VOICE_TASK_TIME_MINUTES.includes(match[2]) ? match[2] : null;
|
||||
|
||||
return { hour, minute };
|
||||
}
|
||||
|
||||
type TVoiceTaskTargetOption = {
|
||||
id: string;
|
||||
title: string;
|
||||
projectId: string;
|
||||
projectIdentifier: string;
|
||||
sequenceId: number;
|
||||
typeId?: string | null;
|
||||
};
|
||||
|
||||
function getTargetOptionFromResolution(result: TVoiceTaskUploadResult | null): TVoiceTaskTargetOption | null {
|
||||
const target = result?.resolution?.target_task;
|
||||
if (!target) return null;
|
||||
|
||||
return {
|
||||
id: target.id,
|
||||
title: target.title,
|
||||
projectId: target.project_id,
|
||||
projectIdentifier: target.project_identifier,
|
||||
sequenceId: target.sequence_id,
|
||||
};
|
||||
}
|
||||
|
||||
function getTargetOptionFromSearchIssue(issue: ISearchIssueResponse): TVoiceTaskTargetOption {
|
||||
return {
|
||||
id: issue.id,
|
||||
title: issue.name,
|
||||
projectId: issue.project_id,
|
||||
projectIdentifier: issue.project__identifier,
|
||||
sequenceId: issue.sequence_id,
|
||||
typeId: issue.type_id,
|
||||
};
|
||||
}
|
||||
|
||||
function VoiceTaskPropertyBlock({
|
||||
children,
|
||||
className,
|
||||
icon: Icon,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
icon: ElementType;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-0 rounded-[18px] bg-white/[0.035] p-2.5 backdrop-blur-xl transition focus-within:z-[850] hover:z-20",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-10 font-semibold tracking-[0.18em] text-tertiary uppercase">
|
||||
<Icon className="size-3 text-[rgb(var(--nodedc-accent-rgb))]" />
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VoiceTaskTimePicker({ onChange, value }: { onChange: (value: string | null) => void; value?: string | null }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const selectedTime = parseVoiceTaskTime(value);
|
||||
const selectedHour = selectedTime.hour ?? "00";
|
||||
const selectedMinute = selectedTime.minute ?? "00";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setIsOpen(false);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setIsOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const updateTime = (hour: string, minute: string) => onChange(`${hour}:${minute}`);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative h-full">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
voiceTaskPropertyButtonClassName,
|
||||
"flex items-center justify-between gap-2 rounded-full text-left outline-none",
|
||||
isOpen && "bg-white/[0.06]"
|
||||
)}
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
>
|
||||
<span className={cn("truncate", value ? "text-primary" : "text-tertiary")}>{value || "--:--"}</span>
|
||||
<Clock3 className="size-3.5 flex-shrink-0 text-secondary" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="nodedc-dropdown-surface absolute top-full right-0 left-0 z-[900] mt-2 !rounded-[22px] !p-2">
|
||||
<div className="grid max-h-56 grid-cols-2 gap-2 overflow-hidden">
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
|
||||
{VOICE_TASK_TIME_HOURS.map((hour) => (
|
||||
<button
|
||||
key={hour}
|
||||
type="button"
|
||||
className={cn(
|
||||
"mx-auto flex size-8 items-center justify-center rounded-full text-12 font-semibold text-secondary transition outline-none hover:bg-white/[0.07] hover:text-primary",
|
||||
selectedHour === hour &&
|
||||
"bg-[rgb(var(--nodedc-accent-rgb))] text-black hover:bg-[rgb(var(--nodedc-accent-rgb))] hover:text-black"
|
||||
)}
|
||||
onClick={() => updateTime(hour, selectedMinute)}
|
||||
>
|
||||
{hour}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto pl-1">
|
||||
{VOICE_TASK_TIME_MINUTES.map((minute) => (
|
||||
<button
|
||||
key={minute}
|
||||
type="button"
|
||||
className={cn(
|
||||
"mx-auto flex size-8 items-center justify-center rounded-full text-12 font-semibold text-secondary transition outline-none hover:bg-white/[0.07] hover:text-primary",
|
||||
selectedMinute === minute &&
|
||||
"bg-[rgb(var(--nodedc-accent-rgb))] text-black hover:bg-[rgb(var(--nodedc-accent-rgb))] hover:text-black"
|
||||
)}
|
||||
onClick={() => updateTime(selectedHour, minute)}
|
||||
>
|
||||
{minute}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VoiceTaskTargetPicker({
|
||||
disabled = false,
|
||||
onChange,
|
||||
projectId,
|
||||
selectedIssue,
|
||||
workspaceSlug,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onChange: (issue: TVoiceTaskTargetOption) => void;
|
||||
projectId: string | null;
|
||||
selectedIssue: TVoiceTaskTargetOption | null;
|
||||
workspaceSlug: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 350);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !projectId) return;
|
||||
|
||||
let isMounted = true;
|
||||
setIsLoading(true);
|
||||
projectService
|
||||
.projectIssuesSearch(workspaceSlug, projectId, {
|
||||
search: debouncedSearchTerm,
|
||||
workspace_search: false,
|
||||
})
|
||||
.then((response) => {
|
||||
if (isMounted) setIssues(response);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted) setIssues([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMounted) setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [debouncedSearchTerm, isOpen, projectId, workspaceSlug]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between gap-3 rounded-full bg-white/[0.045] px-4 text-left text-13 text-primary transition outline-none hover:bg-white/[0.07]",
|
||||
{
|
||||
"cursor-not-allowed text-tertiary": disabled || !projectId,
|
||||
}
|
||||
)}
|
||||
disabled={disabled || !projectId}
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{selectedIssue ? (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<IssueIdentifier
|
||||
projectId={selectedIssue.projectId}
|
||||
projectIdentifier={selectedIssue.projectIdentifier}
|
||||
issueSequenceId={selectedIssue.sequenceId}
|
||||
issueTypeId={selectedIssue.typeId}
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
/>
|
||||
<span className="truncate">{selectedIssue.title}</span>
|
||||
</span>
|
||||
) : projectId ? (
|
||||
"Выбрать задачу"
|
||||
) : (
|
||||
"Сначала выберите проект"
|
||||
)}
|
||||
</span>
|
||||
<Search className="size-4 flex-shrink-0 text-tertiary" />
|
||||
</button>
|
||||
|
||||
{isOpen && projectId && (
|
||||
<div className="nodedc-dropdown-surface absolute top-full right-0 left-0 z-[760] mt-2 p-3">
|
||||
<div className="nodedc-dropdown-search">
|
||||
<Search className="size-3.5 text-placeholder" />
|
||||
<input
|
||||
autoFocus
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder="Найти задачу"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 max-h-72 space-y-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="px-3 py-2 text-12 text-tertiary">Загрузка задач...</div>
|
||||
) : issues.length ? (
|
||||
issues.map((issue) => (
|
||||
<button
|
||||
key={issue.id}
|
||||
type="button"
|
||||
className="nodedc-dropdown-option w-full text-left"
|
||||
onClick={() => {
|
||||
onChange(getTargetOptionFromSearchIssue(issue));
|
||||
setIsOpen(false);
|
||||
setSearchTerm("");
|
||||
}}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 truncate">
|
||||
<IssueIdentifier
|
||||
projectId={issue.project_id}
|
||||
projectIdentifier={issue.project__identifier}
|
||||
issueSequenceId={issue.sequence_id}
|
||||
issueTypeId={issue.type_id}
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
/>
|
||||
<span className="truncate">{issue.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-2 text-12 text-tertiary">Задачи не найдены</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
|
@ -142,6 +488,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [parseResult, setParseResult] = useState<TVoiceTaskUploadResult | null>(null);
|
||||
const [commitResult, setCommitResult] = useState<TVoiceTaskCommitResult | null>(null);
|
||||
const [selectedTargetIssue, setSelectedTargetIssue] = useState<TVoiceTaskTargetOption | null>(null);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
|
|
@ -199,6 +546,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
|||
setError(null);
|
||||
setParseResult(null);
|
||||
setCommitResult(null);
|
||||
setSelectedTargetIssue(null);
|
||||
setStatus("idle");
|
||||
}, [stopRecording]);
|
||||
|
||||
|
|
@ -308,6 +656,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
|||
try {
|
||||
const result = await workspaceAIService.uploadVoiceTaskAudio(workspaceSlug, formData);
|
||||
setParseResult(result);
|
||||
setSelectedTargetIssue(getTargetOptionFromResolution(result));
|
||||
setStatus("success");
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
|
|
@ -396,6 +745,40 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
|||
}
|
||||
};
|
||||
|
||||
const updateDraft = useCallback((patch: Partial<TVoiceTaskDraft>) => {
|
||||
setParseResult((current) => {
|
||||
if (!current?.draft) return current;
|
||||
return {
|
||||
...current,
|
||||
draft: {
|
||||
...current.draft,
|
||||
...patch,
|
||||
},
|
||||
};
|
||||
});
|
||||
setCommitResult(null);
|
||||
}, []);
|
||||
|
||||
const draft = parseResult?.draft;
|
||||
const selectedProjectId = draft?.project_id ?? parseResult?.resolution?.project?.id ?? null;
|
||||
const selectedStateId = draft?.state_id ?? parseResult?.resolution?.state?.id ?? null;
|
||||
const selectedAssigneeIds = draft?.assignee_ids ?? getVoiceTaskAssigneeIds(parseResult);
|
||||
const selectedTargetTask = selectedTargetIssue ?? getTargetOptionFromResolution(parseResult);
|
||||
const selectedTargetTaskId = draft?.target_task_id ?? selectedTargetTask?.id ?? null;
|
||||
const selectedPriority = (draft?.priority ?? "none") as TIssuePriorities;
|
||||
const warnings = parseResult ? getVoiceTaskWarnings(parseResult) : [];
|
||||
const canPublishDraft = Boolean(
|
||||
parseResult?.voice_session_id &&
|
||||
draft &&
|
||||
draft.intent !== "unknown" &&
|
||||
!isUploading &&
|
||||
!isCommitting &&
|
||||
!commitResult?.task_id &&
|
||||
(draft.intent === "create_task"
|
||||
? selectedProjectId && draft.title?.trim()
|
||||
: selectedTargetTaskId && (draft.intent === "delete_task" || selectedProjectId))
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-none fixed right-4 bottom-[calc(var(--nodedc-bottom-dock-offset,0px)+1rem)] z-[29]">
|
||||
|
|
@ -416,214 +799,336 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
|||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.MD}>
|
||||
<div className="px-5 py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-18 font-medium text-primary">Voice Task</h3>
|
||||
<p className="mt-1 text-13 text-secondary">Запись до {maxDuration} секунд</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-md text-tertiary hover:bg-layer-2 hover:text-primary"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-lg border-[0.5px] border-subtle bg-layer-1 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-24 font-semibold text-primary">{formatDuration(duration)}</div>
|
||||
<div className="mt-1 text-12 text-tertiary">{getCommitStatusLabel(status)}</div>
|
||||
<ModalCore
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
position={EModalPosition.CENTER}
|
||||
width={draft ? EModalWidth.VIIXL : EModalWidth.MD}
|
||||
className="overflow-visible"
|
||||
>
|
||||
<div className={cn("p-5", draft && "sm:p-6")}>
|
||||
{!draft ? (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-18 font-medium text-primary">Voice Tasker</h3>
|
||||
<p className="mt-1 text-13 text-secondary">Запись до {maxDuration} секунд</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-9 items-center justify-center rounded-full bg-white/[0.045] text-secondary transition outline-none hover:bg-white/[0.075] hover:text-primary"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-14 items-center justify-center rounded-full",
|
||||
isRecording ? "bg-red-500/15 text-red-500" : "bg-pink-500/10 text-pink-500"
|
||||
)}
|
||||
>
|
||||
<Mic className={cn("size-6", { "animate-pulse": isRecording })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{audioUrl && !isRecording && (
|
||||
<audio controls src={audioUrl} className="mt-4 w-full">
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="border-red-500/30 bg-red-500/10 text-red-500 mt-4 rounded-md border-[0.5px] px-3 py-2 text-12">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parseResult?.draft && (
|
||||
<div className="mt-4 space-y-3 rounded-md border-[0.5px] border-subtle bg-layer-2 p-3">
|
||||
<div className="flex items-center gap-2 text-13 font-medium text-primary">
|
||||
<CheckCircle2 className="text-green-500 size-4" />
|
||||
Draft готов
|
||||
<div className="mt-5 rounded-[24px] bg-white/[0.035] p-4 backdrop-blur-xl">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-28 font-semibold text-primary">{formatDuration(duration)}</div>
|
||||
<div className="mt-1 text-12 text-tertiary">{getCommitStatusLabel(status)}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-14 items-center justify-center rounded-full",
|
||||
isRecording
|
||||
? "bg-red-500/15 text-red-500"
|
||||
: "bg-white/[0.06] text-[rgb(var(--nodedc-accent-rgb))]"
|
||||
)}
|
||||
>
|
||||
<Mic className={cn("size-6", { "animate-pulse": isRecording })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{parseResult.transcript && (
|
||||
<div>
|
||||
<div className="text-11 font-medium text-tertiary uppercase">Транскрипт</div>
|
||||
<p className="mt-1 text-12 leading-5 break-words whitespace-pre-wrap text-secondary">
|
||||
{parseResult.transcript}
|
||||
</p>
|
||||
</div>
|
||||
{audioUrl && !isRecording && (
|
||||
<audio controls src={audioUrl} className="mt-4 w-full">
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 text-12 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-11 font-medium text-tertiary uppercase">Название</div>
|
||||
<div className="mt-0.5 text-primary">{parseResult.draft.title || "не распознано"}</div>
|
||||
{error && (
|
||||
<div className="border-red-500/25 bg-red-500/10 text-red-500 mt-4 rounded-[18px] border px-3 py-2 text-12">
|
||||
{error}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-11 font-medium text-tertiary uppercase">Intent</div>
|
||||
<div className="mt-0.5 text-primary">{parseResult.draft.intent}</div>
|
||||
</div>
|
||||
{parseResult.resolution?.target_task && (
|
||||
<div className="sm:col-span-2">
|
||||
<div className="text-11 font-medium text-tertiary uppercase">Целевая задача</div>
|
||||
<div className="mt-0.5 text-primary">
|
||||
{[parseResult.resolution.target_task.key, parseResult.resolution.target_task.title]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2">
|
||||
{audioBlob && !isRecording && (
|
||||
<Button variant="secondary" size="lg" onClick={resetRecording} disabled={isUploading || isCommitting}>
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
Перезаписать
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={isRecording ? "error-fill" : "secondary"}
|
||||
size="lg"
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
disabled={isUploading || isCommitting}
|
||||
>
|
||||
{isRecording ? <Square className="mr-2 size-4" /> : <Mic className="mr-2 size-4" />}
|
||||
{isRecording ? "Стоп" : "Записать"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={uploadAudio}
|
||||
loading={isUploading}
|
||||
disabled={!audioBlob || isRecording || isCommitting}
|
||||
>
|
||||
<Upload className="mr-2 size-4" />
|
||||
Сформировать карточку
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-22 font-semibold text-primary">Предпросмотр задачи</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 items-center justify-center rounded-full bg-white/[0.045] text-secondary transition outline-none hover:bg-white/[0.075] hover:text-primary"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<div className="text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">Исходные данные</div>
|
||||
|
||||
<div className="rounded-[28px] bg-white/[0.04] p-4 backdrop-blur-xl">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-28 font-semibold text-primary">{formatDuration(duration)}</div>
|
||||
<div className="mt-1 text-12 text-tertiary">{getCommitStatusLabel(status)}</div>
|
||||
</div>
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-black/45 text-[rgb(var(--nodedc-accent-rgb))]">
|
||||
<Mic className="size-5" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{parseResult.resolution?.project_change && (
|
||||
<div className="sm:col-span-2">
|
||||
<div className="text-11 font-medium text-tertiary uppercase">Перенос проекта</div>
|
||||
<div className="mt-0.5 text-primary">
|
||||
{parseResult.resolution.project_change.from.name} ->{" "}
|
||||
{parseResult.resolution.project_change.to.name}
|
||||
{audioUrl && !isRecording && (
|
||||
<audio controls src={audioUrl} className="mt-4 w-full">
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parseResult?.transcript && (
|
||||
<div className="rounded-[28px] bg-white/[0.035] p-4 backdrop-blur-xl">
|
||||
<div className="mb-2 flex items-center gap-2 text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">
|
||||
<FileText className="size-3.5 text-[rgb(var(--nodedc-accent-rgb))]" />
|
||||
Транскрипт
|
||||
</div>
|
||||
<p className="max-h-64 overflow-y-auto text-13 leading-6 break-words whitespace-pre-wrap text-secondary">
|
||||
{parseResult.transcript}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-11 font-medium text-tertiary uppercase">Проект</div>
|
||||
<div className="mt-0.5 text-primary">
|
||||
{parseResult.resolution?.project?.name || parseResult.draft.project_hint || "не распознано"}
|
||||
|
||||
<div className="rounded-[28px] bg-white/[0.035] p-4 backdrop-blur-xl">
|
||||
<div className="mb-3 flex items-center gap-2 text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">
|
||||
<ListChecks className="size-3.5 text-[rgb(var(--nodedc-accent-rgb))]" />
|
||||
Диагностика
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-11 font-medium text-tertiary uppercase">Статус</div>
|
||||
<div className="mt-0.5 text-primary">
|
||||
{parseResult.resolution?.state?.name || parseResult.draft.state_hint || "не распознано"}
|
||||
<div className="flex flex-wrap gap-2 text-11 text-secondary">
|
||||
<span className="rounded-full bg-white/[0.05] px-3 py-1">
|
||||
intent {formatConfidence(draft.confidence.intent)}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/[0.05] px-3 py-1">
|
||||
project {formatConfidence(draft.confidence.project)}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/[0.05] px-3 py-1">
|
||||
assignee {formatConfidence(draft.confidence.assignee)}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/[0.05] px-3 py-1">
|
||||
task {formatConfidence(draft.confidence.task)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-11 font-medium text-tertiary uppercase">Исполнитель</div>
|
||||
<div className="mt-0.5 text-primary">{getVoiceTaskAssigneesLabel(parseResult)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-11 font-medium text-tertiary uppercase">Срок</div>
|
||||
<div className="mt-0.5 text-primary">
|
||||
{[parseResult.draft.due_date, parseResult.draft.due_time].filter(Boolean).join(" ") ||
|
||||
"не распознано"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-11 font-medium text-tertiary uppercase">Приоритет</div>
|
||||
<div className="mt-0.5 text-primary">{parseResult.draft.priority || "не распознано"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{parseResult.draft.description && (
|
||||
<div>
|
||||
<div className="text-11 font-medium text-tertiary uppercase">Описание</div>
|
||||
<p className="mt-1 text-12 leading-5 break-words whitespace-pre-wrap text-secondary">
|
||||
{parseResult.draft.description}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">
|
||||
Сформированная карточка
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 text-11 text-secondary">
|
||||
<span className="rounded bg-layer-1 px-2 py-1">
|
||||
intent {formatConfidence(parseResult.draft.confidence.intent)}
|
||||
</span>
|
||||
<span className="rounded bg-layer-1 px-2 py-1">
|
||||
project {formatConfidence(parseResult.draft.confidence.project)}
|
||||
</span>
|
||||
<span className="rounded bg-layer-1 px-2 py-1">
|
||||
assignee {formatConfidence(parseResult.draft.confidence.assignee)}
|
||||
</span>
|
||||
<span className="rounded bg-layer-1 px-2 py-1">
|
||||
task {formatConfidence(parseResult.draft.confidence.task)}
|
||||
</span>
|
||||
{parseResult.resolution?.project && (
|
||||
<span className="rounded bg-layer-1 px-2 py-1">
|
||||
resolved project {formatConfidence(parseResult.resolution.project.confidence)}
|
||||
</span>
|
||||
<div className="rounded-[28px] bg-white/[0.04] p-4 backdrop-blur-xl">
|
||||
<label className="block text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
className="mt-2 w-full rounded-[20px] border-0 bg-white/[0.045] px-4 py-3 text-18 font-semibold text-primary outline-none placeholder:text-tertiary"
|
||||
value={draft.title ?? ""}
|
||||
onChange={(event) => updateDraft({ title: event.target.value || null })}
|
||||
placeholder="Название задачи"
|
||||
/>
|
||||
<label className="mt-4 block text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
className="mt-2 min-h-32 w-full resize-none rounded-[22px] border-0 bg-white/[0.045] px-4 py-3 text-13 leading-6 text-primary outline-none placeholder:text-tertiary"
|
||||
value={draft.description ?? ""}
|
||||
onChange={(event) => updateDraft({ description: event.target.value || null })}
|
||||
placeholder="Подробная постановка"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{draft.intent !== "create_task" && (
|
||||
<VoiceTaskPropertyBlock icon={Target} label="Целевая задача">
|
||||
<VoiceTaskTargetPicker
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={selectedProjectId}
|
||||
selectedIssue={selectedTargetTask}
|
||||
onChange={(issue) => {
|
||||
setSelectedTargetIssue(issue);
|
||||
updateDraft({
|
||||
target_task_id: issue.id,
|
||||
target_memory_ref: issue.id,
|
||||
project_id: issue.projectId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</VoiceTaskPropertyBlock>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-3">
|
||||
<VoiceTaskPropertyBlock icon={FolderKanban} label="Проект">
|
||||
<div className="h-8">
|
||||
<ProjectDropdown
|
||||
multiple={false}
|
||||
value={selectedProjectId}
|
||||
onChange={(projectId) => {
|
||||
updateDraft({
|
||||
project_id: projectId,
|
||||
state_id: null,
|
||||
target_task_id: null,
|
||||
target_memory_ref: null,
|
||||
assignee_ids: [],
|
||||
});
|
||||
setSelectedTargetIssue(null);
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
buttonClassName={voiceTaskPropertyButtonClassName}
|
||||
placeholder="Выбрать проект"
|
||||
/>
|
||||
</div>
|
||||
</VoiceTaskPropertyBlock>
|
||||
|
||||
<VoiceTaskPropertyBlock icon={Flag} label="Приоритет">
|
||||
<div className="h-8">
|
||||
<PriorityDropdown
|
||||
value={selectedPriority}
|
||||
onChange={(priority) => updateDraft({ priority: priority as TVoiceTaskPriority })}
|
||||
buttonVariant="border-with-text"
|
||||
buttonClassName={voiceTaskPropertyButtonClassName}
|
||||
placeholder={getPriorityLabel(draft.priority)}
|
||||
/>
|
||||
</div>
|
||||
</VoiceTaskPropertyBlock>
|
||||
|
||||
<VoiceTaskPropertyBlock icon={CheckCircle2} label="Статус">
|
||||
<div className="h-8">
|
||||
<StateDropdown
|
||||
value={selectedStateId}
|
||||
onChange={(stateId) => updateDraft({ state_id: stateId })}
|
||||
projectId={selectedProjectId ?? undefined}
|
||||
buttonVariant="border-with-text"
|
||||
buttonClassName={voiceTaskPropertyButtonClassName}
|
||||
disabled={!selectedProjectId}
|
||||
isForWorkItemCreation={draft.intent === "create_task"}
|
||||
/>
|
||||
</div>
|
||||
</VoiceTaskPropertyBlock>
|
||||
|
||||
<VoiceTaskPropertyBlock icon={UserRound} label="Исполнители">
|
||||
<div className="h-8">
|
||||
<MemberDropdown
|
||||
projectId={selectedProjectId ?? undefined}
|
||||
value={selectedAssigneeIds}
|
||||
onChange={(assigneeIds) => updateDraft({ assignee_ids: assigneeIds })}
|
||||
buttonVariant="border-with-text"
|
||||
buttonClassName={voiceTaskPropertyButtonClassName}
|
||||
placeholder={parseResult ? getVoiceTaskAssigneesLabel(parseResult) : "Выбрать исполнителей"}
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
</VoiceTaskPropertyBlock>
|
||||
|
||||
<VoiceTaskPropertyBlock icon={CalendarDays} label="Срок">
|
||||
<div className="h-8">
|
||||
<DateDropdown
|
||||
value={draft.due_date}
|
||||
onChange={(date) =>
|
||||
updateDraft({ due_date: date ? (renderFormattedPayloadDate(date) ?? null) : null })
|
||||
}
|
||||
buttonVariant="border-with-text"
|
||||
buttonClassName={voiceTaskPropertyButtonClassName}
|
||||
placeholder="Дата"
|
||||
/>
|
||||
</div>
|
||||
</VoiceTaskPropertyBlock>
|
||||
|
||||
<VoiceTaskPropertyBlock icon={Clock3} label="Время">
|
||||
<div className="h-8">
|
||||
<VoiceTaskTimePicker
|
||||
value={draft.due_time}
|
||||
onChange={(dueTime) => updateDraft({ due_time: dueTime })}
|
||||
/>
|
||||
</div>
|
||||
</VoiceTaskPropertyBlock>
|
||||
</div>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div className="border-yellow-500/25 bg-yellow-500/10 text-yellow-200 rounded-[22px] border px-4 py-3 text-12">
|
||||
{warnings.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="border-red-500/25 bg-red-500/10 text-red-500 rounded-[22px] border px-4 py-3 text-12">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{commitResult?.task_key && (
|
||||
<div className="border-green-500/25 bg-green-500/10 text-green-300 rounded-[22px] border px-4 py-3 text-12">
|
||||
{getCommitSuccessMessage(commitResult)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Boolean(getVoiceTaskWarnings(parseResult).length) && (
|
||||
<div className="border-yellow-500/30 bg-yellow-500/10 text-yellow-600 rounded border-[0.5px] px-3 py-2 text-11">
|
||||
{getVoiceTaskWarnings(parseResult).join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{commitResult?.task_key && (
|
||||
<div className="border-green-500/30 bg-green-500/10 text-green-600 rounded border-[0.5px] px-3 py-2 text-12">
|
||||
{getCommitSuccessMessage(commitResult)}
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap justify-end gap-2">
|
||||
<Button variant="secondary" size="lg" onClick={resetRecording} disabled={isUploading || isCommitting}>
|
||||
<Mic className="mr-2 size-4" />
|
||||
Перезаписать
|
||||
</Button>
|
||||
{!commitResult?.task_id && (
|
||||
<Button
|
||||
variant={draft.intent === "delete_task" ? "error-fill" : "primary"}
|
||||
size="lg"
|
||||
onClick={commitVoiceTask}
|
||||
loading={isCommitting}
|
||||
disabled={!canPublishDraft}
|
||||
>
|
||||
{draft.intent === "update_task" ? (
|
||||
<Pencil className="mr-2 size-4" />
|
||||
) : draft.intent === "delete_task" ? (
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 size-4" />
|
||||
)}
|
||||
{getCommitButtonLabel(draft.intent)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2">
|
||||
{audioBlob && !isRecording && (
|
||||
<Button variant="secondary" size="lg" onClick={resetRecording} disabled={isUploading || isCommitting}>
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
Перезаписать
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={isRecording ? "error-fill" : "secondary"}
|
||||
size="lg"
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
disabled={isUploading || isCommitting}
|
||||
>
|
||||
{isRecording ? <Square className="mr-2 size-4" /> : <Mic className="mr-2 size-4" />}
|
||||
{isRecording ? "Стоп" : "Записать"}
|
||||
</Button>
|
||||
{!parseResult?.draft && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={uploadAudio}
|
||||
loading={isUploading}
|
||||
disabled={!audioBlob || isRecording || isCommitting}
|
||||
>
|
||||
<Upload className="mr-2 size-4" />
|
||||
Отправить
|
||||
</Button>
|
||||
)}
|
||||
{parseResult?.draft?.intent && parseResult.draft.intent !== "unknown" && !commitResult?.task_id && (
|
||||
<Button
|
||||
variant={parseResult.draft.intent === "delete_task" ? "error-fill" : "primary"}
|
||||
size="lg"
|
||||
onClick={commitVoiceTask}
|
||||
loading={isCommitting}
|
||||
disabled={!parseResult.voice_session_id || !parseResult.resolution?.can_commit || isUploading}
|
||||
>
|
||||
{parseResult.draft.intent === "update_task" ? (
|
||||
<Pencil className="mr-2 size-4" />
|
||||
) : parseResult.draft.intent === "delete_task" ? (
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
) : (
|
||||
<Plus className="mr-2 size-4" />
|
||||
)}
|
||||
{getCommitButtonLabel(parseResult.draft.intent)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ModalCore>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -83,6 +83,9 @@ export type TVoiceTaskDraft = {
|
|||
intent: TVoiceTaskIntent;
|
||||
target_memory_ref: string | null;
|
||||
project_id?: string | null;
|
||||
state_id?: string | null;
|
||||
assignee_ids?: string[];
|
||||
target_task_id?: string | null;
|
||||
project_hint: string | null;
|
||||
state_hint: string | null;
|
||||
assignee_hint: string | null;
|
||||
|
|
|
|||
Loading…
Reference in New Issue