/** * Copyright (c) 2023-present Plane Software, Inc. and contributors * SPDX-License-Identifier: AGPL-3.0-only * See the LICENSE file for details. */ import { useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { Archive, CalendarDays, Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import { observer } from "mobx-react"; import { ARCHIVABLE_STATE_GROUPS, EUserPermissions } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { PriorityIcon, StateGroupIcon, getStateGroupColor } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IState, TExternalContourBoardDirection, TExternalContourRequest, TIssue } from "@plane/types"; import { ActionDropdown, Avatar } from "@plane/ui"; import { cn, renderFormattedDate, renderFormattedPayloadDate } from "@plane/utils"; import { DateDropdown } from "@/components/dropdowns/date"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; import { MemberDropdownBase } from "@/components/dropdowns/member/base"; import { PresenceDot } from "@/components/presence/presence-dot"; import { useAppRouter } from "@/hooks/use-app-router"; import { useMember } from "@/hooks/store/use-member"; import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board"; import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; import { useProjectState } from "@/hooks/store/use-project-state"; import { useUserPermissions } from "@/hooks/store/user"; import { IssueArchiveService } from "@/services/issue/issue_archive.service"; import { IssueService } from "@/services/issue/issue.service"; import { NodedcWorkItemProgress } from "@/components/issues/issue-layouts/shared/nodedc-work-item-card"; import { ExternalContourDeleteModal } from "./delete-modal"; type Props = { direction: TExternalContourBoardDirection; projectId: string; request: TExternalContourRequest; workspaceSlug: string; }; const issueService = new IssueService(); const issueArchiveService = new IssueArchiveService(); const basePillClasses = "inline-flex min-h-8 items-center gap-1.5 rounded-full border-0 px-2.5 py-1 text-[10px] font-medium shadow-none outline-none transition-colors"; const buildSourceStateMap = ( states: { id: string; name: string; color: string; group: IState["group"] }[] | undefined, projectId: string | null ): Record => Object.fromEntries( (states ?? []).map((state, index) => [ state.id, { id: state.id, color: getStateGroupColor(state.group, state.color), default: false, description: "", group: state.group, name: state.name, order: index + 1, project_id: projectId ?? "", sequence: index + 1, workspace_id: "", } satisfies IState, ]) ); const resolveRequestStatus = ( issue: TExternalContourRequest["issue"], fallbackStatus: TExternalContourRequest["status"] ) => { const stateGroup = issue.state_detail?.group; if (!stateGroup) return fallbackStatus; return stateGroup === "completed" || stateGroup === "cancelled" ? "closed" : "open"; }; export const ExternalContoursBoardItem = observer(function ExternalContoursBoardItem(props: Props) { const { direction, projectId, request, workspaceSlug } = props; const router = useAppRouter(); const searchParams = useSearchParams(); const { t } = useTranslation(); const { getUserDetails, workspace } = useMember(); const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); const { getStateById, getProjectStateIds } = useProjectState(); const { fetchBoard, removeBoardItem, upsertBoardItems } = useProjectExternalContoursBoard(); const { deleteRequest, fetchTargetOptions, getTargetOptionsByProjectId, updateRequest, updateRequestIssue } = useProjectExternalContours(); const [isUpdating, setIsUpdating] = useState(false); const [isSourceOptionsLoading, setIsSourceOptionsLoading] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const issue = request.issue; const selectedInboxIssueId = searchParams.get("inboxIssueId"); const isActive = selectedInboxIssueId === request.id; const requester = request.requested_by?.display_name || request.requested_by_name || issue.created_by_detail?.display_name || "NODE.DC"; const requesterId = request.requested_by?.id || request.requested_by_id || issue.created_by_detail?.id || issue.created_by; const requesterAvatar = issue.created_by_detail?.avatar_url || ""; const counterpartContourName = direction === "outgoing" ? request.target_project?.name || request.target_project_name || issue.project_detail?.name : request.source_project?.name || request.source_project_name; const targetProjectId = issue.project_id || request.target_project?.id || request.target_project_id || null; const projectRole = targetProjectId ? getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId) : undefined; const canEditTargetIssue = direction === "incoming" && !!targetProjectId && projectRole !== undefined && projectRole !== EUserPermissions.GUEST; const canEditSourceRequest = direction === "outgoing" && !!request.capabilities?.can_edit_request && !!targetProjectId; const canEditCard = canEditTargetIssue || canEditSourceRequest; const requestLink = `/${workspaceSlug}/projects/${projectId}/external-contours?inboxIssueId=${request.id}`; const targetOptions = getTargetOptionsByProjectId(targetProjectId); const sourceStateMap = useMemo( () => buildSourceStateMap(targetOptions?.states, targetProjectId), [targetOptions?.states, targetProjectId] ); const sourceStateIds = useMemo(() => targetOptions?.states?.map((state) => state.id) ?? [], [targetOptions?.states]); const selectedState = canEditTargetIssue ? getStateById(issue.state_id) : sourceStateMap[issue.state_id ?? ""]; const projectStateIds = issue.project_id ? (getProjectStateIds(issue.project_id) ?? []) : []; const stateOptions = canEditTargetIssue ? projectStateIds.map((stateId) => getStateById(stateId)).filter((state): state is IState => !!state) : sourceStateIds.map((stateId) => sourceStateMap[stateId]).filter((state): state is IState => !!state); const foregroundClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white"; const subtleTextClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-[#B3B3B8]"; const pillBackgroundClasses = isActive ? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]" : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white"; const cornerActionButtonClasses = cn( "flex h-12 w-12 -translate-x-0.5 -translate-y-0.5 items-center justify-center rounded-full border bg-transparent shadow-none ring-0 transition-colors outline-none", isActive ? "border-black/25 text-black hover:bg-black/5" : "border-white/20 text-white hover:border-white/35 hover:bg-white/5" ); const assigneeButtonClasses = cn( "flex h-7 min-w-7 items-center justify-center rounded-full border-0 bg-transparent p-0 shadow-none outline-none transition-colors", isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white" ); const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none"); const checkerBlocksTotal = issue.checker_blocks_count ?? 0; const checkerItemsTotal = issue.checker_items_count ?? 0; const checkerItemsCompleted = issue.checker_items_completed_count ?? 0; const hasCheckerProgress = checkerBlocksTotal > 0; const canArchive = canEditTargetIssue && !!selectedState && ARCHIVABLE_STATE_GROUPS.includes(selectedState.group); if (!issue) return null; const stopCardPropagation = (event: React.MouseEvent) => { event.stopPropagation(); }; const openDetail = () => { if (isActive) return; router.push(requestLink); }; const syncBoardAfterMutation = async () => { await fetchBoard(workspaceSlug, projectId); }; const ensureSourceOptions = async () => { if (!canEditSourceRequest || !targetProjectId) return; const tasks: Promise[] = []; if (!targetOptions) { setIsSourceOptionsLoading(true); tasks.push(fetchTargetOptions(workspaceSlug, projectId, targetProjectId)); } if (!workspace.workspaceMemberIds) { tasks.push(workspace.fetchWorkspaceMembers(workspaceSlug)); } if (tasks.length === 0) return; try { await Promise.all(tasks); } finally { setIsSourceOptionsLoading(false); } }; const handleTargetIssueUpdate = async (data: Partial) => { if (!targetProjectId || !issue.id || isUpdating) return; setIsUpdating(true); try { const updatedIssue = await issueService.patchIssue(workspaceSlug, targetProjectId, issue.id, data); const nextIssue = { ...issue, ...updatedIssue }; const nextRequest = { ...request, issue: nextIssue, status: resolveRequestStatus(nextIssue, request.status), }; updateRequestIssue(request.id, nextIssue); upsertBoardItems([nextRequest]); await syncBoardAfterMutation(); } catch { setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") }); } finally { setIsUpdating(false); } }; const handleSourceRequestUpdate = async (data: Partial) => { if (!canEditSourceRequest || isUpdating) return; setIsUpdating(true); try { const updatedRequest = await updateRequest(workspaceSlug, projectId, request.id, data); if (updatedRequest) { upsertBoardItems([updatedRequest]); } await syncBoardAfterMutation(); } catch { setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") }); } finally { setIsUpdating(false); } }; const handleCardUpdate = async (data: Partial) => { if (canEditTargetIssue) { await handleTargetIssueUpdate(data); return; } await handleSourceRequestUpdate(data); }; const handleCopyLink = async () => { const absoluteLink = `${window.location.origin}${requestLink}`; await navigator.clipboard?.writeText(absoluteLink); setToast({ title: "Ссылка скопирована", type: TOAST_TYPE.SUCCESS }); }; const handleArchiveIssue = async () => { if (!targetProjectId || !issue.id || !canArchive || isUpdating) return; setIsUpdating(true); try { await issueArchiveService.archiveIssue(workspaceSlug, targetProjectId, issue.id); await syncBoardAfterMutation(); setToast({ title: "Задача архивирована", type: TOAST_TYPE.SUCCESS }); } catch { setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: "Не удалось архивировать задачу" }); } finally { setIsUpdating(false); } }; const handleDeleteRequest = async () => { if (direction !== "outgoing" || isUpdating) return; setIsUpdating(true); try { await deleteRequest(workspaceSlug, projectId, request.id); removeBoardItem(request.id); if (isActive) router.push(`/${workspaceSlug}/projects/${projectId}/external-contours`); setToast({ title: "Исходящая задача удалена", type: TOAST_TYPE.SUCCESS }); setIsDeleteModalOpen(false); } catch { setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: "Не удалось удалить исходящую задачу" }); } finally { setIsUpdating(false); } }; const priorityOptions: NonNullable[] = ["urgent", "high", "medium", "low", "none"]; const priorityLabels: Record, string> = { urgent: "Срочный", high: "Высокий", medium: "Средний", low: "Низкий", none: "Без приоритета", }; const menuItemClasses = "flex w-full items-center gap-2 rounded-[0.9rem] px-2.5 py-2 text-left text-12 text-secondary transition-colors hover:bg-white/6 disabled:cursor-not-allowed disabled:text-placeholder disabled:hover:bg-transparent"; return ( <> setIsDeleteModalOpen(false)} onSubmit={handleDeleteRequest} />
{ if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openDetail(); } }} >
} buttonClassName="h-12 w-12" menuClassName="nodedc-work-item-action-menu" onOpenChange={(isOpen) => { if (isOpen) void ensureSourceOptions(); }} items={[]} menuContent={({ closeDropdown }) => (
Приоритет
{priorityOptions.map((priority) => ( ))}
Статус
{isSourceOptionsLoading && stateOptions.length === 0 ? (
Загрузка статусов...
) : ( stateOptions.map((state) => ( )) )}
Быстрые действия
)} />
{requester}
{request.has_unread_updates && ( )}
{counterpartContourName || t("common.none")}
{hasCheckerProgress ? (
{issue.name}
) : (
{issue.name}
)}
{canEditTargetIssue ? ( void handleCardUpdate({ assignee_ids: assigneeIds })} disabled={!canEditCard || isUpdating} buttonVariant="transparent-without-text" button={
} /> ) : ( void handleCardUpdate({ assignee_ids: assigneeIds })} disabled={!canEditCard || isUpdating || !targetProjectId} onDropdownOpen={() => { void ensureSourceOptions(); }} buttonVariant="transparent-without-text" button={
} /> )} void handleCardUpdate({ target_date: targetDate ? renderFormattedPayloadDate(targetDate) : null, }) } disabled={!canEditCard || isUpdating} buttonVariant="transparent-without-text" button={
{dueDateLabel}
} />
); });