526 lines
18 KiB
TypeScript
526 lines
18 KiB
TypeScript
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)}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|