MDM/mdm-front/src/pages/DevicePage/components/DeviceMaintenanceModal/DeviceMaintenanceModal.tsx

526 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
useEffect,
useRef,
useState,
type ReactNode,
} from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { useMutation, useQuery } from '@apollo/client/react'
import SimpleBar from 'simplebar-react'
import { Check, CircleAlert, History, Wrench, X } from 'lucide-react'
import {
GET_ACTIVE_PHONE_MAINTENANCE_CASE_QUERY,
MARK_PHONE_HEALTHY_MUTATION,
RETURN_PHONE_FROM_MAINTENANCE_MUTATION,
SEND_PHONE_TO_MAINTENANCE_MUTATION,
SET_PHONE_MAINTENANCE_REQUIRED_MUTATION,
} from '../../../../entities/device/api/device.graphql'
import {
deviceMalfunctionLabels,
isRedundantMalfunctionComment,
} from '../../../../entities/device/lib/maintenance'
import type {
DeviceMalfunction,
GetActivePhoneMaintenanceCaseData,
GetActivePhoneMaintenanceCaseVariables,
MarkPhoneHealthyData,
MarkPhoneHealthyVariables,
ReturnPhoneFromMaintenanceData,
ReturnPhoneFromMaintenanceVariables,
SendPhoneToMaintenanceData,
SendPhoneToMaintenanceVariables,
SetPhoneMaintenanceRequiredData,
SetPhoneMaintenanceRequiredVariables,
} from '../../../../entities/device/model/types'
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
import {
Notification,
type NotificationVariant,
} from '../../../../widgets/Notification/Notification'
import type { Device } from '../../types'
import { conditionText, getStatusClass } from '../../types'
import { DeviceMaintenanceMetrics } from '../DeviceMaintenanceMetrics/DeviceMaintenanceMetrics'
import './DeviceMaintenanceModal.scss'
export type DeviceMaintenanceMode = 'view' | 'manage'
type DeviceMaintenanceModalProps = {
open: boolean
mode: DeviceMaintenanceMode
device: Device
onOpenChange: (open: boolean) => void
onOpenHistory: () => void
onUpdated: () => Promise<unknown> | unknown
}
type NotificationState = {
variant: NotificationVariant
title: string
description?: string
}
const malfunctionOptions: Array<{
value: DeviceMalfunction
description: string
}> = [
{
value: 'LowBatteryCapacity',
description: 'Остаточная ёмкость аккумулятора ниже допустимой.',
},
{
value: 'CriticalHits',
description: 'Количество зафиксированных ударов достигло лимита.',
},
{
value: 'CriticalChargeCycles',
description: 'Количество циклов зарядки достигло лимита.',
},
{
value: 'DoesNotTurnOn',
description: 'Устройство не загружается или не реагирует на включение.',
},
{
value: 'Other',
description: 'Неисправность не относится к перечисленным категориям.',
},
]
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : 'Не удалось выполнить действие.'
}
function ActionButton({
children,
className = '',
disabled,
onClick,
}: {
children: ReactNode
className?: string
disabled?: boolean
onClick: () => void
}) {
return (
<button
className={`device-maintenance-action ${className}`}
type="button"
disabled={disabled}
onClick={onClick}
>
{children}
</button>
)
}
export function DeviceMaintenanceModal({
open,
mode,
device,
onOpenChange,
onOpenHistory,
onUpdated,
}: DeviceMaintenanceModalProps) {
const [selectedMalfunctions, setSelectedMalfunctions] = useState<DeviceMalfunction[]>([])
const [comment, setComment] = useState('')
const [notification, setNotification] = useState<NotificationState | null>(null)
const [activeAction, setActiveAction] = useState<string | null>(null)
const initializedDraftKey = useRef('')
const {
data: activeCaseData,
loading: isActiveCaseLoading,
error: activeCaseError,
refetch: refetchActiveCase,
} = useQuery<
GetActivePhoneMaintenanceCaseData,
GetActivePhoneMaintenanceCaseVariables
>(GET_ACTIVE_PHONE_MAINTENANCE_CASE_QUERY, {
variables: { phoneId: String(device.id) },
skip: !open,
fetchPolicy: 'cache-and-network',
})
const [setMaintenanceRequired] = useMutation<
SetPhoneMaintenanceRequiredData,
SetPhoneMaintenanceRequiredVariables
>(SET_PHONE_MAINTENANCE_REQUIRED_MUTATION)
const [markPhoneHealthy] = useMutation<
MarkPhoneHealthyData,
MarkPhoneHealthyVariables
>(MARK_PHONE_HEALTHY_MUTATION)
const [sendPhoneToMaintenance] = useMutation<
SendPhoneToMaintenanceData,
SendPhoneToMaintenanceVariables
>(SEND_PHONE_TO_MAINTENANCE_MUTATION)
const [returnPhoneFromMaintenance] = useMutation<
ReturnPhoneFromMaintenanceData,
ReturnPhoneFromMaintenanceVariables
>(RETURN_PHONE_FROM_MAINTENANCE_MUTATION)
const isManageMode = mode === 'manage'
const isBusy = activeAction !== null
const isOtherSelected = selectedMalfunctions.includes('Other')
const isDraftValid =
selectedMalfunctions.length > 0 && (!isOtherSelected || comment.trim().length > 0)
const visibleComment = isRedundantMalfunctionComment(
device.malfunctions,
device.malfunctionComment,
)
? ''
: device.malfunctionComment?.trim() ?? ''
const activeMaintenanceCase =
activeCaseData?.getActivePhoneMaintenanceCase ?? null
useEffect(() => {
if (!open) {
initializedDraftKey.current = ''
return
}
const draftKey = `${device.id}:${device.technicalStatus}`
if (initializedDraftKey.current === draftKey) return
initializedDraftKey.current = draftKey
setSelectedMalfunctions(device.malfunctions)
setComment(device.malfunctionComment ?? '')
}, [
device.id,
device.malfunctionComment,
device.malfunctions,
device.technicalStatus,
open,
])
function toggleMalfunction(value: DeviceMalfunction) {
setSelectedMalfunctions((current) =>
current.includes(value)
? current.filter((item) => item !== value)
: [...current, value],
)
}
async function runAction(
action: string,
title: string,
callback: () => Promise<unknown>,
) {
setActiveAction(action)
try {
await callback()
await Promise.all([
Promise.resolve(onUpdated()),
refetchActiveCase(),
])
setNotification({ variant: 'success', title })
} catch (actionError) {
setNotification({
variant: 'error',
title: 'Не удалось изменить состояние устройства',
description: getErrorMessage(actionError),
})
} finally {
setActiveAction(null)
}
}
function saveMaintenanceRequired() {
if (!isDraftValid) return
void runAction('save', 'Неисправность сохранена', () =>
setMaintenanceRequired({
variables: {
id: String(device.id),
malfunctions: selectedMalfunctions,
comment: comment.trim() || null,
},
}),
)
}
function setHealthy() {
void runAction('healthy', 'Устройство признано исправным', () =>
markPhoneHealthy({ variables: { id: String(device.id) } }),
)
}
function sendToMaintenance() {
if (!isDraftValid) return
void runAction('service', 'Устройство отправлено на обслуживание', () =>
sendPhoneToMaintenance({
variables: {
id: String(device.id),
malfunctions: selectedMalfunctions,
comment: comment.trim() || null,
},
}),
)
}
function returnToService() {
void runAction('return', 'Устройство возвращено в эксплуатацию', () =>
returnPhoneFromMaintenance({ variables: { id: String(device.id) } }),
)
}
return (
<>
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<MotionDialogContent
open={open}
overlayClassName="device-maintenance-modal__overlay"
contentClassName="device-maintenance-modal"
motionPreset="fade"
>
<header className="device-maintenance-modal__header">
<div>
<Dialog.Title>Техническое состояние</Dialog.Title>
<Dialog.Description>
{device.factoryNumber} · ID {device.id}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
className="device-maintenance-modal__close"
type="button"
aria-label="Закрыть"
disabled={isBusy}
>
<X size={20} />
</button>
</Dialog.Close>
</header>
<SimpleBar className="device-maintenance-modal__scroll">
<div className="device-maintenance-modal__body">
<section className="device-maintenance-summary">
<div className={`device-maintenance-summary__icon ${getStatusClass(device.condition)}`}>
{device.condition === 'ok' ? <Check size={22} /> : <Wrench size={22} />}
</div>
<div className="device-maintenance-summary__field">
<span>Текущее состояние</span>
<strong>{conditionText[device.condition]}</strong>
</div>
{visibleComment && (
<div className="device-maintenance-summary__field device-maintenance-summary__comment">
<span>Комментарий</span>
<p>{visibleComment}</p>
</div>
)}
</section>
{device.technicalStatus !== 'Healthy' && (
<section className="device-maintenance-section">
<div className="device-maintenance-section__heading">
<div>
<h3>
{device.technicalStatus === 'InService'
? 'Показатели при отправке'
: 'Показатели устройства'}
</h3>
<p>
{device.technicalStatus === 'InService'
? 'Снимок устройства при передаче на обслуживание.'
: 'Текущие показатели для открытого случая.'}
</p>
</div>
</div>
{activeMaintenanceCase ? (
<DeviceMaintenanceMetrics
metrics={activeMaintenanceCase.metrics}
/>
) : (
<p className={`device-maintenance-metrics-message ${activeCaseError ? 'is-error' : ''}`}>
{isActiveCaseLoading
? 'Загружаем показатели...'
: activeCaseError
? 'Не удалось загрузить показатели.'
: 'Показатели не зафиксированы.'}
</p>
)}
</section>
)}
{!isManageMode && device.malfunctions.length > 0 && (
<section className="device-maintenance-section">
<div className="device-maintenance-section__heading">
<div>
<h3>Неисправности</h3>
<p>Неисправности, закреплённые за текущим состоянием.</p>
</div>
</div>
<div className="device-maintenance-current-malfunctions">
{device.malfunctions.map((malfunction) => (
<span key={malfunction}>
{deviceMalfunctionLabels[malfunction]}
</span>
))}
</div>
</section>
)}
{isManageMode && device.technicalStatus !== 'InService' && (
<section className="device-maintenance-section">
<div className="device-maintenance-section__heading">
<div>
<h3>Неисправности</h3>
<p>Укажите одну или несколько обнаруженных неисправностей.</p>
</div>
<CircleAlert size={19} />
</div>
<div className="device-maintenance-options">
{malfunctionOptions.map((option) => {
const isSelected = selectedMalfunctions.includes(option.value)
return (
<button
className={`device-maintenance-option ${isSelected ? 'is-selected' : ''}`}
type="button"
key={option.value}
aria-pressed={isSelected}
disabled={isBusy}
onClick={() => toggleMalfunction(option.value)}
>
<span className="device-maintenance-option__check">
{isSelected && <Check size={15} />}
</span>
<span>
<b>{deviceMalfunctionLabels[option.value]}</b>
<small>{option.description}</small>
</span>
</button>
)
})}
</div>
<label className="device-maintenance-comment">
<span>Комментарий {isOtherSelected && <b>обязателен</b>}</span>
<textarea
value={comment}
disabled={isBusy}
placeholder="Опишите неисправность или добавьте уточнение"
onChange={(event) => setComment(event.target.value)}
/>
</label>
</section>
)}
{isManageMode &&
device.technicalStatus === 'InService' &&
device.malfunctions.length > 0 && (
<section className="device-maintenance-section">
<div className="device-maintenance-section__heading">
<div>
<h3>Неисправности</h3>
<p>Зафиксированы при отправке устройства на обслуживание.</p>
</div>
</div>
<div className="device-maintenance-current-malfunctions">
{device.malfunctions.map((malfunction) => (
<span key={malfunction}>
{deviceMalfunctionLabels[malfunction]}
</span>
))}
</div>
</section>
)}
</div>
</SimpleBar>
<footer className="device-maintenance-modal__footer">
<button
className="device-maintenance-action device-maintenance-action--history"
type="button"
disabled={isBusy}
onClick={onOpenHistory}
>
<History size={17} />
Журнал обслуживания
</button>
<div className="device-maintenance-modal__actions">
{isManageMode ? (
<>
{device.technicalStatus === 'Healthy' && (
<>
<ActionButton
disabled={!isDraftValid || isBusy}
onClick={saveMaintenanceRequired}
>
{activeAction === 'save' ? 'Сохраняем...' : 'Зафиксировать неисправность'}
</ActionButton>
<ActionButton
className="is-primary"
disabled={!isDraftValid || isBusy}
onClick={sendToMaintenance}
>
{activeAction === 'service' ? 'Отправляем...' : 'Отправить на обслуживание'}
</ActionButton>
</>
)}
{device.technicalStatus === 'NeedsMaintenance' && (
<>
<ActionButton disabled={isBusy} onClick={setHealthy}>
{activeAction === 'healthy' ? 'Сохраняем...' : 'Признать исправным'}
</ActionButton>
<ActionButton
disabled={!isDraftValid || isBusy}
onClick={saveMaintenanceRequired}
>
{activeAction === 'save' ? 'Сохраняем...' : 'Сохранить'}
</ActionButton>
<ActionButton
className="is-primary"
disabled={!isDraftValid || isBusy}
onClick={sendToMaintenance}
>
{activeAction === 'service' ? 'Отправляем...' : 'Отправить на обслуживание'}
</ActionButton>
</>
)}
{device.technicalStatus === 'InService' && (
<ActionButton
className="is-primary"
disabled={isBusy}
onClick={returnToService}
>
{activeAction === 'return' ? 'Возвращаем...' : 'Вернуть в эксплуатацию'}
</ActionButton>
)}
</>
) : (
<Dialog.Close asChild>
<button className="device-maintenance-action" type="button">
Закрыть
</button>
</Dialog.Close>
)}
</div>
</footer>
</MotionDialogContent>
</Dialog.Root>
{notification && (
<Notification
variant={notification.variant}
title={notification.title}
description={notification.description}
onClose={() => setNotification(null)}
/>
)}
</>
)
}