Add exact media replacement from issues
This commit is contained in:
@@ -105,6 +105,15 @@ type MediaServerStatus = {
|
||||
}
|
||||
}
|
||||
|
||||
type ReplacementFile = {
|
||||
id: number
|
||||
name: string
|
||||
quality?: string | null
|
||||
size?: number | null
|
||||
season_number?: number | null
|
||||
episodes?: string[]
|
||||
}
|
||||
|
||||
const ISSUE_CATEGORIES: Array<{
|
||||
id: IssueCategoryId
|
||||
marker: string
|
||||
@@ -265,6 +274,13 @@ const formatDate = (value?: string | null) => {
|
||||
return parsed.toLocaleString()
|
||||
}
|
||||
|
||||
const formatFileSize = (value?: number | null) => {
|
||||
if (!value || value <= 0) return 'Size unavailable'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const unitIndex = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1)
|
||||
return `${(value / 1024 ** unitIndex).toFixed(unitIndex >= 3 ? 1 : 0)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
const toPositiveInt = (value: string) => {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (Number.isNaN(parsed) || parsed <= 0) return null
|
||||
@@ -338,6 +354,17 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
const [mediaServerStatus, setMediaServerStatus] = useState<MediaServerStatus | null>(null)
|
||||
const [mediaServerChecking, setMediaServerChecking] = useState(false)
|
||||
const [mediaServerError, setMediaServerError] = useState<string | null>(null)
|
||||
const [issueMediaQuery, setIssueMediaQuery] = useState('')
|
||||
const [issueMediaSearching, setIssueMediaSearching] = useState(false)
|
||||
const [issueMediaResults, setIssueMediaResults] = useState<DiscoveryResult[]>([])
|
||||
const [issueSelectedMedia, setIssueSelectedMedia] = useState<DiscoveryResult | null>(null)
|
||||
const [replacementFiles, setReplacementFiles] = useState<ReplacementFile[]>([])
|
||||
const [replacementFilesLoading, setReplacementFilesLoading] = useState(false)
|
||||
const [replacementFilesMessage, setReplacementFilesMessage] = useState<string | null>(null)
|
||||
const [replacementFileFilter, setReplacementFileFilter] = useState('')
|
||||
const [selectedReplacementFileId, setSelectedReplacementFileId] = useState<number | null>(null)
|
||||
const [startReplacement, setStartReplacement] = useState(false)
|
||||
const [replacementAllowed, setReplacementAllowed] = useState(true)
|
||||
|
||||
const isAdmin = me?.role === 'admin'
|
||||
const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0)
|
||||
@@ -348,7 +375,21 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
const issueNeedsMediaTitle =
|
||||
Boolean(issueCategory) &&
|
||||
issueCategory !== 'service_unavailable' &&
|
||||
!(issueCategory === 'playback' && issueScope === 'everything')
|
||||
!(issueCategory === 'playback' && issueScope !== 'one_title')
|
||||
const issueSupportsReplacement =
|
||||
issueCategory === 'broken_media' ||
|
||||
issueCategory === 'audio' ||
|
||||
issueCategory === 'subtitle' ||
|
||||
(issueCategory === 'playback' && issueScope === 'one_title')
|
||||
const selectedReplacementFile =
|
||||
replacementFiles.find((file) => file.id === selectedReplacementFileId) ?? null
|
||||
const visibleReplacementFiles = replacementFiles.filter((file) => {
|
||||
const query = replacementFileFilter.trim().toLowerCase()
|
||||
if (!query) return true
|
||||
return [file.name, file.quality, ...(file.episodes ?? [])]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(query))
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
@@ -594,6 +635,129 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const loadReplacementFiles = async (media: DiscoveryResult) => {
|
||||
setReplacementFiles([])
|
||||
setSelectedReplacementFileId(null)
|
||||
setStartReplacement(false)
|
||||
setReplacementAllowed(true)
|
||||
setReplacementFileFilter('')
|
||||
if (!media.requestId) {
|
||||
setReplacementFilesMessage(
|
||||
'This title is not linked to an existing Magent request, so there is no managed file to replace.'
|
||||
)
|
||||
return
|
||||
}
|
||||
setReplacementFilesLoading(true)
|
||||
setReplacementFilesMessage(null)
|
||||
try {
|
||||
const response = await authFetch(
|
||||
`${getApiBase()}/requests/${media.requestId}/replacement-options`
|
||||
)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Could not load the managed files from Sonarr/Radarr.')
|
||||
}
|
||||
const payload = await response.json()
|
||||
const files = Array.isArray(payload?.files) ? payload.files as ReplacementFile[] : []
|
||||
setReplacementFiles(files)
|
||||
setReplacementAllowed(payload?.can_replace !== false)
|
||||
setReplacementFilesMessage(
|
||||
payload?.can_replace === false
|
||||
? 'Your account can report this file, but automatic replacement is disabled.'
|
||||
: payload?.message ?? null
|
||||
)
|
||||
if (files.length === 1 && payload?.can_replace !== false) {
|
||||
setSelectedReplacementFileId(files[0].id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setReplacementFilesMessage(
|
||||
err instanceof Error ? err.message : 'Could not load the managed files from Sonarr/Radarr.'
|
||||
)
|
||||
} finally {
|
||||
setReplacementFilesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const searchIssueMedia = async (event?: React.FormEvent) => {
|
||||
event?.preventDefault()
|
||||
const query = issueMediaQuery.trim()
|
||||
if (!query) {
|
||||
setError('Enter the movie or TV show you are having trouble with.')
|
||||
return
|
||||
}
|
||||
setIssueMediaSearching(true)
|
||||
setError(null)
|
||||
setIssueMediaResults([])
|
||||
setIssueSelectedMedia(null)
|
||||
setIssueMediaTitle('')
|
||||
setReplacementFiles([])
|
||||
setSelectedReplacementFileId(null)
|
||||
setStartReplacement(false)
|
||||
setReplacementAllowed(true)
|
||||
try {
|
||||
const response = await authFetch(
|
||||
`${getApiBase()}/requests/search?query=${encodeURIComponent(query)}`
|
||||
)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
throw new Error('Magent could not search the media catalogue.')
|
||||
}
|
||||
const payload = await response.json()
|
||||
const results: DiscoveryResult[] = Array.isArray(payload?.results)
|
||||
? payload.results
|
||||
.filter((item: any) => item?.type === 'movie' || item?.type === 'tv')
|
||||
.map((item: any) => ({
|
||||
title: item?.title ?? 'Untitled',
|
||||
year: typeof item?.year === 'number' ? item.year : null,
|
||||
type: item.type,
|
||||
tmdbId: typeof item?.tmdbId === 'number' ? item.tmdbId : null,
|
||||
requestId: typeof item?.requestId === 'number' ? item.requestId : null,
|
||||
statusLabel: item?.statusLabel ?? null,
|
||||
status: typeof item?.status === 'number' ? item.status : null,
|
||||
accessible: Boolean(item?.accessible),
|
||||
posterPath: item?.posterPath ?? null,
|
||||
backdropPath: item?.backdropPath ?? null,
|
||||
}))
|
||||
: []
|
||||
setIssueMediaResults(results.slice(0, 12))
|
||||
if (results.length === 0) {
|
||||
setError('No matching movie or TV show was found. Try the title without a year.')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Magent could not search the media catalogue.')
|
||||
} finally {
|
||||
setIssueMediaSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectIssueMedia = (media: DiscoveryResult) => {
|
||||
setIssueSelectedMedia(media)
|
||||
setIssueMediaTitle(media.title)
|
||||
setIssueMediaType(media.type === 'tv' ? 'tv' : 'movie')
|
||||
setIssueMediaResults([])
|
||||
setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ''}`)
|
||||
setError(null)
|
||||
if (issueSupportsReplacement) {
|
||||
void loadReplacementFiles(media)
|
||||
} else {
|
||||
setReplacementFiles([])
|
||||
setSelectedReplacementFileId(null)
|
||||
setStartReplacement(false)
|
||||
setReplacementFilesMessage(null)
|
||||
}
|
||||
}
|
||||
|
||||
const checkMediaServer = async () => {
|
||||
setMediaServerChecking(true)
|
||||
setMediaServerError(null)
|
||||
@@ -628,6 +792,21 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
setMediaServerError(null)
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
const supportsReplacement = ['broken_media', 'audio', 'subtitle', 'playback'].includes(category)
|
||||
if (category === 'service_unavailable') {
|
||||
setIssueSelectedMedia(null)
|
||||
setIssueMediaTitle('')
|
||||
setIssueMediaQuery('')
|
||||
setIssueMediaResults([])
|
||||
}
|
||||
if (supportsReplacement && issueSelectedMedia?.requestId) {
|
||||
void loadReplacementFiles(issueSelectedMedia)
|
||||
} else if (!supportsReplacement) {
|
||||
setReplacementFiles([])
|
||||
setSelectedReplacementFileId(null)
|
||||
setStartReplacement(false)
|
||||
setReplacementFilesMessage(null)
|
||||
}
|
||||
if (category === 'playback' || category === 'service_unavailable') {
|
||||
void checkMediaServer()
|
||||
}
|
||||
@@ -640,8 +819,27 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
return
|
||||
}
|
||||
const cleanMediaTitle = issueMediaTitle.trim()
|
||||
if (issueNeedsMediaTitle && !cleanMediaTitle) {
|
||||
setError('Enter the affected movie or TV show so the file can be identified.')
|
||||
if (issueNeedsMediaTitle && (!cleanMediaTitle || !issueSelectedMedia)) {
|
||||
setError('Search for and select the exact movie or TV show so the managed file can be identified.')
|
||||
return
|
||||
}
|
||||
if (startReplacement && (!issueSelectedMedia?.requestId || !selectedReplacementFile)) {
|
||||
setError('Select the exact Sonarr/Radarr file before starting a replacement.')
|
||||
return
|
||||
}
|
||||
if (startReplacement && !replacementAllowed) {
|
||||
setError('Automatic replacement is disabled for this account. The issue can still be submitted.')
|
||||
return
|
||||
}
|
||||
if (
|
||||
startReplacement &&
|
||||
selectedReplacementFile &&
|
||||
!window.confirm(
|
||||
`Replace “${selectedReplacementFile.name}”?\n\n` +
|
||||
'The current managed file will be removed and Sonarr/Radarr will immediately search for a replacement. ' +
|
||||
'The title may be unavailable until the new download is imported.'
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -683,6 +881,9 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
`Scope: ${scopeLabel}`,
|
||||
cleanMediaTitle ? `Affected title: ${cleanMediaTitle}` : null,
|
||||
cleanMediaTitle ? `Media type: ${issueMediaType === 'tv' ? 'TV show' : 'Movie'}` : null,
|
||||
issueSelectedMedia?.requestId ? `Magent request: #${issueSelectedMedia.requestId}` : null,
|
||||
selectedReplacementFile ? `Managed file: ${selectedReplacementFile.name}` : null,
|
||||
startReplacement ? 'Replacement action: confirmed by the reporting user' : null,
|
||||
issueEpisode.trim() ? `Season / episode / part: ${issueEpisode.trim()}` : null,
|
||||
issueDevice.trim() ? `Device or app: ${issueDevice.trim()}` : null,
|
||||
...diagnosticLines,
|
||||
@@ -709,6 +910,9 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
description,
|
||||
issue_type: resolvedIssueType,
|
||||
media_type: cleanMediaTitle ? issueMediaType : null,
|
||||
external_ref: issueSelectedMedia?.requestId
|
||||
? `/requests/${issueSelectedMedia.requestId}`
|
||||
: null,
|
||||
priority,
|
||||
}),
|
||||
})
|
||||
@@ -723,11 +927,39 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
}
|
||||
const data = await response.json()
|
||||
const item = data?.item as PortalItem | undefined
|
||||
setStatus(
|
||||
item?.id
|
||||
? `Issue #${item.id} submitted with the troubleshooting details.`
|
||||
: 'Issue submitted with the troubleshooting details.'
|
||||
)
|
||||
let completionMessage = item?.id
|
||||
? `Issue #${item.id} submitted with the troubleshooting details.`
|
||||
: 'Issue submitted with the troubleshooting details.'
|
||||
let replacementFailure: string | null = null
|
||||
if (startReplacement && issueSelectedMedia?.requestId && selectedReplacementFile) {
|
||||
const replacementResponse = await authFetch(
|
||||
`${getApiBase()}/requests/${issueSelectedMedia.requestId}/actions/replace`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
file_id: selectedReplacementFile.id,
|
||||
confirmed: true,
|
||||
issue_id: item?.id ?? null,
|
||||
}),
|
||||
}
|
||||
)
|
||||
if (replacementResponse.ok) {
|
||||
const replacementPayload = await replacementResponse.json()
|
||||
completionMessage = `${completionMessage} ${replacementPayload?.message ?? 'Replacement search started.'}`
|
||||
} else {
|
||||
let replacementDetail = 'The replacement action could not be started.'
|
||||
try {
|
||||
const replacementPayload = await replacementResponse.json()
|
||||
if (typeof replacementPayload?.detail === 'string') replacementDetail = replacementPayload.detail
|
||||
} catch {
|
||||
// Keep the user-safe fallback message.
|
||||
}
|
||||
replacementFailure = `The issue was saved, but ${replacementDetail}`
|
||||
}
|
||||
}
|
||||
setStatus(completionMessage)
|
||||
setError(replacementFailure)
|
||||
setIssueCategory(null)
|
||||
setIssueMediaTitle('')
|
||||
setIssueMediaType('movie')
|
||||
@@ -737,6 +969,15 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
setIssueDevice('')
|
||||
setIssueNotes('')
|
||||
setMediaServerStatus(null)
|
||||
setIssueMediaQuery('')
|
||||
setIssueMediaResults([])
|
||||
setIssueSelectedMedia(null)
|
||||
setReplacementFiles([])
|
||||
setReplacementFilesMessage(null)
|
||||
setReplacementFileFilter('')
|
||||
setSelectedReplacementFileId(null)
|
||||
setStartReplacement(false)
|
||||
setReplacementAllowed(true)
|
||||
await Promise.all([loadItems({ preferItemId: item?.id ?? null }), loadOverview()])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
@@ -1123,7 +1364,16 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
key={value}
|
||||
type="button"
|
||||
className={issueScope === value ? 'is-selected' : ''}
|
||||
onClick={() => setIssueScope(value as typeof issueScope)}
|
||||
onClick={() => {
|
||||
const nextScope = value as typeof issueScope
|
||||
setIssueScope(nextScope)
|
||||
if (nextScope !== 'one_title') {
|
||||
setStartReplacement(false)
|
||||
setSelectedReplacementFileId(null)
|
||||
} else if (issueSelectedMedia?.requestId) {
|
||||
void loadReplacementFiles(issueSelectedMedia)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
@@ -1134,27 +1384,137 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
|
||||
<div className="issue-question-grid">
|
||||
{issueNeedsMediaTitle ? (
|
||||
<label className="issue-field-span-2">
|
||||
<span>Affected movie or TV show</span>
|
||||
<input
|
||||
required
|
||||
value={issueMediaTitle}
|
||||
onChange={(event) => setIssueMediaTitle(event.target.value)}
|
||||
placeholder="Start typing the exact title"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{issueNeedsMediaTitle ? (
|
||||
<label>
|
||||
<span>Media type</span>
|
||||
<select
|
||||
value={issueMediaType}
|
||||
onChange={(event) => setIssueMediaType(event.target.value as 'movie' | 'tv')}
|
||||
>
|
||||
<option value="movie">Movie</option>
|
||||
<option value="tv">TV show</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="issue-media-finder issue-field-span-2">
|
||||
<label>
|
||||
<span>Find the exact movie or TV show</span>
|
||||
<div className="issue-media-search-row">
|
||||
<input
|
||||
value={issueMediaQuery}
|
||||
onChange={(event) => {
|
||||
setIssueMediaQuery(event.target.value)
|
||||
if (issueSelectedMedia) {
|
||||
setIssueSelectedMedia(null)
|
||||
setIssueMediaTitle('')
|
||||
setReplacementFiles([])
|
||||
setSelectedReplacementFileId(null)
|
||||
setStartReplacement(false)
|
||||
}
|
||||
}}
|
||||
placeholder="Search the Grizzlyflix catalogue"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void searchIssueMedia()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" onClick={() => void searchIssueMedia()} disabled={issueMediaSearching}>
|
||||
{issueMediaSearching ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
{issueMediaResults.length > 0 ? (
|
||||
<div className="issue-media-results">
|
||||
{issueMediaResults.map((media, index) => {
|
||||
const poster = resolveTmdbArtworkUrl(media.posterPath, 'w185')
|
||||
return (
|
||||
<button
|
||||
key={`${media.type}:${media.tmdbId ?? index}`}
|
||||
type="button"
|
||||
className="issue-media-result"
|
||||
onClick={() => selectIssueMedia(media)}
|
||||
>
|
||||
<span className="issue-media-poster">
|
||||
{poster ? <img src={poster} alt="" loading="lazy" /> : <i>No artwork</i>}
|
||||
</span>
|
||||
<span>
|
||||
<small>{media.type === 'tv' ? 'TV show' : 'Movie'}{media.year ? ` · ${media.year}` : ''}</small>
|
||||
<strong>{media.title}</strong>
|
||||
<b>{media.requestId ? media.statusLabel || `Request #${media.requestId}` : 'Not currently requested'}</b>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{issueSelectedMedia ? (
|
||||
<div className="issue-selected-media">
|
||||
<div>
|
||||
<span className="section-kicker">Selected title</span>
|
||||
<strong>{issueSelectedMedia.title}{issueSelectedMedia.year ? ` (${issueSelectedMedia.year})` : ''}</strong>
|
||||
<small>
|
||||
{issueSelectedMedia.type === 'tv' ? 'TV show' : 'Movie'}
|
||||
{issueSelectedMedia.requestId
|
||||
? ` · Magent request #${issueSelectedMedia.requestId} · ${issueSelectedMedia.statusLabel ?? 'Tracked'}`
|
||||
: ' · No existing Magent request'}
|
||||
</small>
|
||||
</div>
|
||||
{issueSelectedMedia.requestId ? (
|
||||
<button type="button" className="ghost-button" onClick={() => router.push(`/requests/${issueSelectedMedia.requestId}`)}>
|
||||
Open request
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{issueSelectedMedia && issueSupportsReplacement ? (
|
||||
<div className="issue-file-picker">
|
||||
<div>
|
||||
<span className="section-kicker">Sonarr / Radarr files</span>
|
||||
<h3>Select the exact file that is faulty</h3>
|
||||
<p>The selected file is validated again before Magent allows replacement.</p>
|
||||
</div>
|
||||
{replacementFilesLoading ? (
|
||||
<div className="issue-live-scan"><i /><span>Reading managed files from the collector</span></div>
|
||||
) : null}
|
||||
{replacementFilesMessage ? <div className="status-banner">{replacementFilesMessage}</div> : null}
|
||||
{replacementFiles.length > 6 ? (
|
||||
<input
|
||||
value={replacementFileFilter}
|
||||
onChange={(event) => setReplacementFileFilter(event.target.value)}
|
||||
placeholder="Filter by season, episode, filename, or quality"
|
||||
/>
|
||||
) : null}
|
||||
{visibleReplacementFiles.length > 0 ? (
|
||||
<div className="issue-file-list">
|
||||
{visibleReplacementFiles.map((file) => (
|
||||
<label key={file.id} className={selectedReplacementFileId === file.id ? 'is-selected' : ''}>
|
||||
<input
|
||||
type="radio"
|
||||
name="replacement-file"
|
||||
checked={selectedReplacementFileId === file.id}
|
||||
onChange={() => {
|
||||
setSelectedReplacementFileId(file.id)
|
||||
setStartReplacement(false)
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>{file.episodes?.length ? file.episodes.join(', ') : issueSelectedMedia.title}</strong>
|
||||
<small>{file.name}</small>
|
||||
</span>
|
||||
<b>{[file.quality, formatFileSize(file.size)].filter(Boolean).join(' · ')}</b>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedReplacementFile && replacementAllowed ? (
|
||||
<label className="issue-replacement-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={startReplacement}
|
||||
onChange={(event) => setStartReplacement(event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>Replace this file after submitting the issue</strong>
|
||||
<small>
|
||||
Removes only this managed file, then immediately starts a replacement search through {issueMediaType === 'tv' ? 'Sonarr' : 'Radarr'}.
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{issueNeedsMediaTitle && issueMediaType === 'tv' ? (
|
||||
<label>
|
||||
@@ -1232,13 +1592,15 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
<span className="section-kicker">Recommended path</span>
|
||||
<h2>{selectedIssueDefinition.outcome.replace('Likely action: ', '')}</h2>
|
||||
<p>
|
||||
{issueNeedsServerCheck
|
||||
{startReplacement && selectedReplacementFile
|
||||
? `After confirmation, ${selectedReplacementFile.name} will be removed and the collector will start searching immediately.`
|
||||
: issueNeedsServerCheck
|
||||
? 'The live check above will be saved in the report, giving administrators immediate context.'
|
||||
: 'Submit this report and it will arrive with the replacement or collection path already identified.'}
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" disabled={creating || mediaServerChecking}>
|
||||
{creating ? 'Submitting...' : 'Submit issue'}
|
||||
{creating ? 'Working...' : startReplacement ? 'Submit issue & replace file' : 'Submit issue'}
|
||||
</button>
|
||||
</section>
|
||||
</form>
|
||||
@@ -1502,6 +1864,18 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedItem.kind === 'issue' && selectedItem.external_ref?.startsWith('/requests/') ? (
|
||||
<div className="issue-linked-request">
|
||||
<div>
|
||||
<span className="section-kicker">Linked collection record</span>
|
||||
<strong>{selectedItem.external_ref.replace('/requests/', 'Request #')}</strong>
|
||||
</div>
|
||||
<button type="button" onClick={() => router.push(selectedItem.external_ref as string)}>
|
||||
Open request
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
|
||||
<label className="portal-field-span-2">
|
||||
<span>Title</span>
|
||||
|
||||
Reference in New Issue
Block a user