|
@@ -70,6 +70,26 @@ const KINDS: Record<
|
|
|
},
|
|
},
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** The union of what the single kinds take, for the grid that shows them all. */
|
|
|
|
|
+const ANY_ACCEPT = '.jpg,.jpeg,.png,.webp,.mp4,.webm,.mov,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt'
|
|
|
|
|
+
|
|
|
|
|
+/** The kind a file is drawn as: its own category, or a document for one this grid does not know. */
|
|
|
|
|
+function kindOfFile(file: Attachment): MediaKind {
|
|
|
|
|
+ return file.category in KINDS ? (file.category as MediaKind) : 'document'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Where a file added to the grid of everything is filed. A photo is a photo
|
|
|
|
|
+ * and a video a video; anything else is a document. A diagnostic report and
|
|
|
|
|
+ * a drop-off photo are what somebody says they are, not what the file is, so
|
|
|
|
|
+ * those are added from their own tabs.
|
|
|
|
|
+ */
|
|
|
|
|
+function kindForUpload(file: File): MediaKind {
|
|
|
|
|
+ if (KINDS.image.types(file.type)) return 'image'
|
|
|
|
|
+ if (KINDS.video.types(file.type)) return 'video'
|
|
|
|
|
+ return 'document'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
type Result<T = unknown> = { success: boolean; data?: T; error?: string }
|
|
type Result<T = unknown> = { success: boolean; data?: T; error?: string }
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -104,13 +124,16 @@ function serviceStore(serviceRecordId: string): MediaStore {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
interface MediaGridProps {
|
|
interface MediaGridProps {
|
|
|
- kind: MediaKind
|
|
|
|
|
|
|
+ /** One kind of file, or 'all' for every file on the job in one grid. */
|
|
|
|
|
+ kind: MediaKind | 'all'
|
|
|
serviceRecordId: string
|
|
serviceRecordId: string
|
|
|
/** Saves somewhere other than the work order's attachments. */
|
|
/** Saves somewhere other than the work order's attachments. */
|
|
|
store?: MediaStore
|
|
store?: MediaStore
|
|
|
files: Attachment[]
|
|
files: Attachment[]
|
|
|
/** The plan's cap for this kind of file; absent means no cap. */
|
|
/** The plan's cap for this kind of file; absent means no cap. */
|
|
|
max?: number
|
|
max?: number
|
|
|
|
|
+ /** The caps by kind, for the grid of everything, where one number cannot say it. */
|
|
|
|
|
+ caps?: Partial<Record<MediaKind, number>>
|
|
|
/** Photos can be sent to the customer over WhatsApp when there is one. */
|
|
/** Photos can be sent to the customer over WhatsApp when there is one. */
|
|
|
customerId?: string
|
|
customerId?: string
|
|
|
}
|
|
}
|
|
@@ -131,6 +154,7 @@ export function MediaGrid({
|
|
|
store: customStore,
|
|
store: customStore,
|
|
|
files,
|
|
files,
|
|
|
max,
|
|
max,
|
|
|
|
|
+ caps,
|
|
|
customerId,
|
|
customerId,
|
|
|
}: MediaGridProps) {
|
|
}: MediaGridProps) {
|
|
|
const store = customStore ?? serviceStore(serviceRecordId)
|
|
const store = customStore ?? serviceStore(serviceRecordId)
|
|
@@ -152,48 +176,66 @@ export function MediaGrid({
|
|
|
setItems(files)
|
|
setItems(files)
|
|
|
}, [savedKey]) // eslint-disable-line react-hooks/exhaustive-deps -- keyed on the ids, not the array's identity
|
|
}, [savedKey]) // eslint-disable-line react-hooks/exhaustive-deps -- keyed on the ids, not the array's identity
|
|
|
|
|
|
|
|
- const config = KINDS[kind]
|
|
|
|
|
- const Icon = config.icon
|
|
|
|
|
|
|
+ const all = kind === 'all'
|
|
|
|
|
+ const kindOf = (file: Attachment): MediaKind => (kind === 'all' ? kindOfFile(file) : kind)
|
|
|
const atLimit = max !== undefined && items.length >= max
|
|
const atLimit = max !== undefined && items.length >= max
|
|
|
- const isPhotos = kind === 'image' || kind === 'dropoff'
|
|
|
|
|
|
|
+ // The carousel pages through the photos only, wherever they sit in the grid.
|
|
|
|
|
+ const photos = items.filter((file) => file.fileType.startsWith('image/'))
|
|
|
|
|
|
|
|
// A drop-off photo taken from the phone's list carries which shot it is as
|
|
// A drop-off photo taken from the phone's list carries which shot it is as
|
|
|
// its description ('front', 'odometer'). That is shown in words, and stays
|
|
// its description ('front', 'odometer'). That is shown in words, and stays
|
|
|
// the shot's name until somebody types a caption of their own.
|
|
// the shot's name until somebody types a caption of their own.
|
|
|
const captionOf = (file: Attachment) =>
|
|
const captionOf = (file: Attachment) =>
|
|
|
- kind === 'dropoff' && isDropoffSlot(file.description)
|
|
|
|
|
|
|
+ kindOf(file) === 'dropoff' && isDropoffSlot(file.description)
|
|
|
? t(`photoHandoff.dropoff.slots.${file.description}`)
|
|
? t(`photoHandoff.dropoff.slots.${file.description}`)
|
|
|
: file.description || ''
|
|
: file.description || ''
|
|
|
|
|
|
|
|
- const upload = async (list: FileList | File[]) => {
|
|
|
|
|
- let chosen = Array.from(list)
|
|
|
|
|
- const refused = chosen.filter((file) => !config.types(file.type))
|
|
|
|
|
|
|
+ /** The files of one kind that may go up: the right type, and within the plan's cap. */
|
|
|
|
|
+ const admit = (target: MediaKind, candidates: File[], cap: number | undefined): File[] => {
|
|
|
|
|
+ let chosen = candidates
|
|
|
|
|
+ const refused = chosen.filter((file) => !KINDS[target].types(file.type))
|
|
|
if (refused.length > 0) {
|
|
if (refused.length > 0) {
|
|
|
toast.error(t('modern.media.wrongType', { names: refused.map((f) => f.name).join(', ') }))
|
|
toast.error(t('modern.media.wrongType', { names: refused.map((f) => f.name).join(', ') }))
|
|
|
- chosen = chosen.filter((file) => config.types(file.type))
|
|
|
|
|
|
|
+ chosen = chosen.filter((file) => KINDS[target].types(file.type))
|
|
|
}
|
|
}
|
|
|
- if (max !== undefined) {
|
|
|
|
|
- const room = max - items.length
|
|
|
|
|
- if (room <= 0) {
|
|
|
|
|
- toast.error(t('modern.media.limit', { count: items.length, max }))
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
- if (chosen.length > room) {
|
|
|
|
|
- toast.warning(
|
|
|
|
|
- t(isPhotos ? 'images.onlyUploading' : 'documents.onlyUploading', {
|
|
|
|
|
- count: room,
|
|
|
|
|
- })
|
|
|
|
|
- )
|
|
|
|
|
- chosen = chosen.slice(0, room)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (cap === undefined) return chosen
|
|
|
|
|
+
|
|
|
|
|
+ const held = all ? items.filter((file) => kindOf(file) === target).length : items.length
|
|
|
|
|
+ const room = cap - held
|
|
|
|
|
+ if (room <= 0) {
|
|
|
|
|
+ if (chosen.length > 0) toast.error(t('modern.media.limit', { count: held, max: cap }))
|
|
|
|
|
+ return []
|
|
|
}
|
|
}
|
|
|
|
|
+ if (chosen.length > room) {
|
|
|
|
|
+ const photo = target === 'image' || target === 'dropoff'
|
|
|
|
|
+ toast.warning(t(photo ? 'images.onlyUploading' : 'documents.onlyUploading', { count: room }))
|
|
|
|
|
+ chosen = chosen.slice(0, room)
|
|
|
|
|
+ }
|
|
|
|
|
+ return chosen
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const upload = async (list: FileList | File[]) => {
|
|
|
|
|
+ const picked = Array.from(list)
|
|
|
|
|
+ // One kind on its own tab; on the grid of everything each file goes
|
|
|
|
|
+ // where its type says.
|
|
|
|
|
+ const chosen: { file: File; target: MediaKind }[] =
|
|
|
|
|
+ kind === 'all'
|
|
|
|
|
+ ? (['image', 'video', 'document'] as const).flatMap((target) =>
|
|
|
|
|
+ admit(
|
|
|
|
|
+ target,
|
|
|
|
|
+ picked.filter((file) => kindForUpload(file) === target),
|
|
|
|
|
+ caps?.[target]
|
|
|
|
|
+ ).map((file) => ({ file, target }))
|
|
|
|
|
+ )
|
|
|
|
|
+ : admit(kind, picked, max).map((file) => ({ file, target: kind }))
|
|
|
if (chosen.length === 0) return
|
|
if (chosen.length === 0) return
|
|
|
|
|
|
|
|
setUploading(true)
|
|
setUploading(true)
|
|
|
let added = 0
|
|
let added = 0
|
|
|
- for (let file of chosen) {
|
|
|
|
|
|
|
+ for (const { file: original, target } of chosen) {
|
|
|
|
|
+ let file = original
|
|
|
try {
|
|
try {
|
|
|
- if (isPhotos) file = await compressImage(file)
|
|
|
|
|
|
|
+ if (target === 'image' || target === 'dropoff') file = await compressImage(file)
|
|
|
const body = new FormData()
|
|
const body = new FormData()
|
|
|
body.append('file', file)
|
|
body.append('file', file)
|
|
|
const res = await fetch('/api/protected/upload/service-files', { method: 'POST', body })
|
|
const res = await fetch('/api/protected/upload/service-files', { method: 'POST', body })
|
|
@@ -208,12 +250,12 @@ export function MediaGrid({
|
|
|
fileUrl: data.url,
|
|
fileUrl: data.url,
|
|
|
fileType: data.fileType,
|
|
fileType: data.fileType,
|
|
|
fileSize: data.fileSize,
|
|
fileSize: data.fileSize,
|
|
|
- category: kind,
|
|
|
|
|
|
|
+ category: target,
|
|
|
// Shown to the customer unless somebody says otherwise, as the
|
|
// Shown to the customer unless somebody says otherwise, as the
|
|
|
// classic tabs do. For a video that means the shared link, which
|
|
// classic tabs do. For a video that means the shared link, which
|
|
|
// plays it; the printed invoice has nothing to draw for one.
|
|
// plays it; the printed invoice has nothing to draw for one.
|
|
|
// A drop-off photo is the workshop's own record, so it starts hidden.
|
|
// A drop-off photo is the workshop's own record, so it starts hidden.
|
|
|
- includeInInvoice: kind !== 'dropoff',
|
|
|
|
|
|
|
+ includeInInvoice: target !== 'dropoff',
|
|
|
})
|
|
})
|
|
|
if (result.success && result.data) {
|
|
if (result.success && result.data) {
|
|
|
setItems((prev) => [...prev, result.data as Attachment])
|
|
setItems((prev) => [...prev, result.data as Attachment])
|
|
@@ -267,7 +309,9 @@ export function MediaGrid({
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<div className="grid grid-cols-2 gap-3 p-5 pt-4 @xl:grid-cols-3 @3xl:grid-cols-4">
|
|
<div className="grid grid-cols-2 gap-3 p-5 pt-4 @xl:grid-cols-3 @3xl:grid-cols-4">
|
|
|
- {items.map((file, index) => {
|
|
|
|
|
|
|
+ {items.map((file) => {
|
|
|
|
|
+ const config = KINDS[kindOf(file)]
|
|
|
|
|
+ const Icon = config.icon
|
|
|
const isImage = file.fileType.startsWith('image/')
|
|
const isImage = file.fileType.startsWith('image/')
|
|
|
const isVideo = file.fileType.startsWith('video/')
|
|
const isVideo = file.fileType.startsWith('video/')
|
|
|
return (
|
|
return (
|
|
@@ -280,7 +324,7 @@ export function MediaGrid({
|
|
|
{isImage ? (
|
|
{isImage ? (
|
|
|
<button
|
|
<button
|
|
|
type="button"
|
|
type="button"
|
|
|
- onClick={() => setCarouselIndex(index)}
|
|
|
|
|
|
|
+ onClick={() => setCarouselIndex(photos.indexOf(file))}
|
|
|
className="h-full w-full cursor-zoom-in"
|
|
className="h-full w-full cursor-zoom-in"
|
|
|
aria-label={file.description || file.fileName}
|
|
aria-label={file.description || file.fileName}
|
|
|
>
|
|
>
|
|
@@ -388,7 +432,9 @@ export function MediaGrid({
|
|
|
<p className="truncate text-xs text-muted-foreground" suppressHydrationWarning>
|
|
<p className="truncate text-xs text-muted-foreground" suppressHydrationWarning>
|
|
|
{/* To the minute for a drop-off photo: when it was taken is
|
|
{/* To the minute for a drop-off photo: when it was taken is
|
|
|
the point of it. */}
|
|
the point of it. */}
|
|
|
- {kind === 'dropoff' ? formatDateTime(file.createdAt) : formatDate(file.createdAt)}{' '}
|
|
|
|
|
|
|
+ {kindOf(file) === 'dropoff'
|
|
|
|
|
+ ? formatDateTime(file.createdAt)
|
|
|
|
|
+ : formatDate(file.createdAt)}{' '}
|
|
|
· {formatFileSize(file.fileSize)}
|
|
· {formatFileSize(file.fileSize)}
|
|
|
</p>
|
|
</p>
|
|
|
{isImage && customerId && !customStore && (
|
|
{isImage && customerId && !customStore && (
|
|
@@ -451,7 +497,7 @@ export function MediaGrid({
|
|
|
ref={inputRef}
|
|
ref={inputRef}
|
|
|
type="file"
|
|
type="file"
|
|
|
multiple
|
|
multiple
|
|
|
- accept={config.accept}
|
|
|
|
|
|
|
+ accept={kind === 'all' ? ANY_ACCEPT : KINDS[kind].accept}
|
|
|
className="hidden"
|
|
className="hidden"
|
|
|
onChange={(e) => {
|
|
onChange={(e) => {
|
|
|
if (e.target.files?.length) void upload(e.target.files)
|
|
if (e.target.files?.length) void upload(e.target.files)
|
|
@@ -476,9 +522,9 @@ export function MediaGrid({
|
|
|
</DialogContent>
|
|
</DialogContent>
|
|
|
</Dialog>
|
|
</Dialog>
|
|
|
|
|
|
|
|
- {isPhotos && (
|
|
|
|
|
|
|
+ {photos.length > 0 && (
|
|
|
<ImageCarousel
|
|
<ImageCarousel
|
|
|
- images={items}
|
|
|
|
|
|
|
+ images={photos}
|
|
|
currentIndex={carouselIndex}
|
|
currentIndex={carouselIndex}
|
|
|
onClose={() => setCarouselIndex(null)}
|
|
onClose={() => setCarouselIndex(null)}
|
|
|
onChangeIndex={setCarouselIndex}
|
|
onChangeIndex={setCarouselIndex}
|