feat: update photo handling to support multiple images in PurchaseRequest and related components
This commit is contained in:
parent
018c3adf12
commit
8c26f942bf
@ -41,7 +41,8 @@ public function rules(): array
|
|||||||
'discount' => ['nullable', 'integer', 'min:0'],
|
'discount' => ['nullable', 'integer', 'min:0'],
|
||||||
'shipping_cost' => ['nullable', 'integer', 'min:0'],
|
'shipping_cost' => ['nullable', 'integer', 'min:0'],
|
||||||
'notes' => ['nullable', 'string', 'max:100'],
|
'notes' => ['nullable', 'string', 'max:100'],
|
||||||
'photo_key' => ['nullable', 'string', 'max:500'],
|
'photo_keys' => ['nullable', 'array', 'max:5'],
|
||||||
|
'photo_keys.*' => ['required', 'string', 'max:500'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,7 +64,8 @@ public function attributes(): array
|
|||||||
'discount' => 'diskon',
|
'discount' => 'diskon',
|
||||||
'shipping_cost' => 'ongkir',
|
'shipping_cost' => 'ongkir',
|
||||||
'notes' => 'keterangan',
|
'notes' => 'keterangan',
|
||||||
'photo_key' => 'foto',
|
'photo_keys' => 'foto',
|
||||||
|
'photo_keys.*' => 'foto',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use App\Services\S3PresignedService;
|
use App\Services\S3PresignedService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class PurchaseService
|
class PurchaseService
|
||||||
@ -49,19 +50,19 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
|||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
|
|
||||||
$paginator->getCollection()->each(function (Purchase $purchase) {
|
$paginator->getCollection()->each(function (Purchase $purchase) {
|
||||||
$purchaseMedia = $purchase->getFirstMedia('photos');
|
$purchaseMedia = $purchase->getMedia('photos');
|
||||||
$purchase->photo_url = $purchaseMedia
|
$purchase->photo_urls = $purchaseMedia->map(
|
||||||
? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath())
|
fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath())
|
||||||
: null;
|
)->toArray();
|
||||||
$purchase->photo_conversion_url = $purchaseMedia
|
$purchase->photo_conversion_urls = $purchaseMedia->map(
|
||||||
? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath('thumb'))
|
fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
|
||||||
: null;
|
)->toArray();
|
||||||
});
|
});
|
||||||
|
|
||||||
return $paginator;
|
return $paginator;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getItems(Purchase $purchase): \Illuminate\Support\Collection
|
public function getItems(Purchase $purchase): Collection
|
||||||
{
|
{
|
||||||
return $purchase->purchaseItems()
|
return $purchase->purchaseItems()
|
||||||
->select(['id', 'purchase_id', 'raw_material_price_id', 'quantity', 'unit_price', 'subtotal'])
|
->select(['id', 'purchase_id', 'raw_material_price_id', 'quantity', 'unit_price', 'subtotal'])
|
||||||
@ -146,11 +147,13 @@ public function getForEdit(Purchase $purchase): array
|
|||||||
->whereIn('raw_material_price_id', $items->pluck('raw_material_price_id'))
|
->whereIn('raw_material_price_id', $items->pluck('raw_material_price_id'))
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
$purchaseMedia = $purchase->getFirstMedia('photos');
|
$purchaseMedia = $purchase->getMedia('photos');
|
||||||
$purchasePhotoKey = $purchaseMedia?->getCustomProperty('s3_key') ?? $purchaseMedia?->file_name;
|
$purchasePhotoKeys = $purchaseMedia->map(
|
||||||
$purchasePhotoUrl = $purchaseMedia
|
fn ($media) => $media->getCustomProperty('s3_key') ?? $media->file_name
|
||||||
? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath())
|
)->toArray();
|
||||||
: null;
|
$purchasePhotoUrls = $purchaseMedia->map(
|
||||||
|
fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath())
|
||||||
|
)->toArray();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $purchase->id,
|
'id' => $purchase->id,
|
||||||
@ -160,8 +163,8 @@ public function getForEdit(Purchase $purchase): array
|
|||||||
'discount' => $purchase->discount,
|
'discount' => $purchase->discount,
|
||||||
'shipping_cost' => $purchase->shipping_cost,
|
'shipping_cost' => $purchase->shipping_cost,
|
||||||
'notes' => $purchase->notes,
|
'notes' => $purchase->notes,
|
||||||
'photo_key' => $purchasePhotoKey,
|
'photo_keys' => $purchasePhotoKeys,
|
||||||
'photo_url' => $purchasePhotoUrl,
|
'photo_urls' => $purchasePhotoUrls,
|
||||||
'variants' => $variants,
|
'variants' => $variants,
|
||||||
'default_mode' => $singleMaterial && ! $sharedWithOther ? 'new' : 'existing',
|
'default_mode' => $singleMaterial && ! $sharedWithOther ? 'new' : 'existing',
|
||||||
'existing_material_name' => $singleMaterial ? $materials->first()->name : null,
|
'existing_material_name' => $singleMaterial ? $materials->first()->name : null,
|
||||||
@ -228,12 +231,11 @@ private function storeFromExisting(array $data): Purchase
|
|||||||
->increment('stock', (int) $item['quantity']);
|
->increment('stock', (int) $item['quantity']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! empty($data['photo_key'])) {
|
if (! empty($data['photo_keys']) && is_array($data['photo_keys'])) {
|
||||||
$this->registerMedia(
|
$this->registerPhotos(
|
||||||
model: $purchase,
|
model: $purchase,
|
||||||
s3Key: $data['photo_key'],
|
photoKeys: $data['photo_keys'],
|
||||||
collectionName: 'photos',
|
collectionName: 'photos',
|
||||||
orderColumn: 1,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -318,12 +320,11 @@ private function storeNew(array $data): Purchase
|
|||||||
|
|
||||||
DB::table('purchase_items')->insert($purchaseItems);
|
DB::table('purchase_items')->insert($purchaseItems);
|
||||||
|
|
||||||
if (! empty($data['photo_key'])) {
|
if (! empty($data['photo_keys']) && is_array($data['photo_keys'])) {
|
||||||
$this->registerMedia(
|
$this->registerPhotos(
|
||||||
model: $purchase,
|
model: $purchase,
|
||||||
s3Key: $data['photo_key'],
|
photoKeys: $data['photo_keys'],
|
||||||
collectionName: 'photos',
|
collectionName: 'photos',
|
||||||
orderColumn: 1,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -474,7 +475,7 @@ public function update(Purchase $purchase, array $data): Purchase
|
|||||||
}
|
}
|
||||||
DB::table('purchase_items')->insert($itemRows);
|
DB::table('purchase_items')->insert($itemRows);
|
||||||
|
|
||||||
$this->syncPhoto($purchase, $data);
|
$this->syncPhotos($purchase, $data['photo_keys'] ?? [], 'photos');
|
||||||
|
|
||||||
return $purchase;
|
return $purchase;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export type PurchaseDraftData = {
|
|||||||
variant: string;
|
variant: string;
|
||||||
price: number;
|
price: number;
|
||||||
stock: number;
|
stock: number;
|
||||||
photo?: string;
|
photo_keys?: string[];
|
||||||
}>;
|
}>;
|
||||||
mode?: 'new' | 'existing';
|
mode?: 'new' | 'existing';
|
||||||
selectedMaterialName?: string;
|
selectedMaterialName?: string;
|
||||||
|
|||||||
@ -16,8 +16,8 @@ export type Purchase = {
|
|||||||
shipping_cost: number;
|
shipping_cost: number;
|
||||||
total: number;
|
total: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
photo_url: string | null;
|
photo_urls: string[];
|
||||||
photo_conversion_url: string | null;
|
photo_conversion_urls: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
variants_count: number;
|
variants_count: number;
|
||||||
total_qty: number;
|
total_qty: number;
|
||||||
@ -65,8 +65,8 @@ export type PurchaseForEdit = {
|
|||||||
discount: number;
|
discount: number;
|
||||||
shipping_cost: number;
|
shipping_cost: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
photo_key: string | null;
|
photo_keys: string[];
|
||||||
photo_url: string | null;
|
photo_urls: string[];
|
||||||
variants: {
|
variants: {
|
||||||
id: number;
|
id: number;
|
||||||
variant: string;
|
variant: string;
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import {
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ConfirmDialog } from '@/components/dialogs';
|
import { ConfirmDialog } from '@/components/dialogs';
|
||||||
import { FileUpload } from '@/components/inputs';
|
import { FileUpload, FileUploadMultiple } from '@/components/inputs';
|
||||||
import { ImagePreviewModal } from '@/components/dialogs';
|
import { ImagePreviewModal } from '@/components/dialogs';
|
||||||
import { InputError } from '@/components/ui';
|
import { InputError } from '@/components/ui';
|
||||||
import { NumberInput } from '@/components/inputs';
|
import { NumberInput } from '@/components/inputs';
|
||||||
@ -114,10 +114,15 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
|
|||||||
const [discount, setDiscount] = useState(draft?.discount ?? 0);
|
const [discount, setDiscount] = useState(draft?.discount ?? 0);
|
||||||
const [shippingCost, setShippingCost] = useState(draft?.shippingCost ?? 0);
|
const [shippingCost, setShippingCost] = useState(draft?.shippingCost ?? 0);
|
||||||
const [notes, setNotes] = useState(draft?.notes ?? '');
|
const [notes, setNotes] = useState(draft?.notes ?? '');
|
||||||
const [photo, setPhoto] = useState<string | null>(draft?.photo ?? null);
|
const [photos, setPhotos] = useState<{ key: string; url: string | null }[]>(() => {
|
||||||
const [photoUrl, setPhotoUrl] = useState<string | null>(
|
if (draft?.photo_keys && draft.photo_keys.length > 0) {
|
||||||
draft?.photo ? getTemporaryUrl(draft.photo) : null,
|
return draft.photo_keys.map((key: string) => ({
|
||||||
);
|
key,
|
||||||
|
url: getTemporaryUrl(key),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
const [mode, setMode] = useState<'new' | 'existing'>(draft?.mode ?? 'new');
|
const [mode, setMode] = useState<'new' | 'existing'>(draft?.mode ?? 'new');
|
||||||
@ -158,7 +163,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
|
|||||||
qty,
|
qty,
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
photo: photo ?? undefined,
|
photo_keys: photos.map((p) => p.key),
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
name,
|
name,
|
||||||
@ -171,7 +176,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
|
|||||||
mode,
|
mode,
|
||||||
selectedMaterialName,
|
selectedMaterialName,
|
||||||
quantities,
|
quantities,
|
||||||
photo,
|
photos,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -390,7 +395,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
|
|||||||
discount,
|
discount,
|
||||||
shipping_cost: shippingCost,
|
shipping_cost: shippingCost,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
photo_key: photo,
|
photo_keys: photos.map((p) => p.key),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === 'existing') {
|
if (mode === 'existing') {
|
||||||
@ -1099,21 +1104,15 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
|
|||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Foto</Label>
|
<Label>Foto</Label>
|
||||||
<FileUpload
|
<FileUploadMultiple
|
||||||
value={photo}
|
value={photos}
|
||||||
onChange={(key) => {
|
onChange={setPhotos}
|
||||||
setPhoto(key);
|
|
||||||
const url = key
|
|
||||||
? `https://dstpabuaran.s3.ap-southeast-1.amazonaws.com/${key}`
|
|
||||||
: null;
|
|
||||||
setPhotoUrl(url);
|
|
||||||
}}
|
|
||||||
folder="purchase"
|
folder="purchase"
|
||||||
existingUrl={photoUrl}
|
maxItems={5}
|
||||||
onUploadingChange={setUploading}
|
onUploadingChange={setUploading}
|
||||||
/>
|
/>
|
||||||
<InputError
|
<InputError
|
||||||
message={errors.photo_key}
|
message={errors.photo_keys as string}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ConfirmDialog } from '@/components/dialogs';
|
import { ConfirmDialog } from '@/components/dialogs';
|
||||||
import { FileUpload } from '@/components/inputs';
|
import { FileUploadMultiple } from '@/components/inputs';
|
||||||
import { ImagePreviewModal } from '@/components/dialogs';
|
import { ImagePreviewModal } from '@/components/dialogs';
|
||||||
import { InputError } from '@/components/ui';
|
import { InputError } from '@/components/ui';
|
||||||
import { NumberInput } from '@/components/inputs';
|
import { NumberInput } from '@/components/inputs';
|
||||||
@ -73,8 +73,15 @@ export default function PurchaseEdit({
|
|||||||
const [discount, setDiscount] = useState(purchase.discount);
|
const [discount, setDiscount] = useState(purchase.discount);
|
||||||
const [shippingCost, setShippingCost] = useState(purchase.shipping_cost);
|
const [shippingCost, setShippingCost] = useState(purchase.shipping_cost);
|
||||||
const [notes, setNotes] = useState(purchase.notes ?? '');
|
const [notes, setNotes] = useState(purchase.notes ?? '');
|
||||||
const [photo, setPhoto] = useState<string | null>(purchase.photo_key);
|
const [photos, setPhotos] = useState<{ key: string; url: string | null }[]>(() => {
|
||||||
const [photoUrl, setPhotoUrl] = useState<string | null>(purchase.photo_url);
|
if (purchase.photo_keys && purchase.photo_keys.length > 0) {
|
||||||
|
return purchase.photo_keys.map((key: string, index: number) => ({
|
||||||
|
key,
|
||||||
|
url: purchase.photo_urls?.[index] ?? getTemporaryUrl(key),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
const [selectedMaterialName, setSelectedMaterialName] = useState(
|
const [selectedMaterialName, setSelectedMaterialName] = useState(
|
||||||
@ -185,7 +192,7 @@ export default function PurchaseEdit({
|
|||||||
discount,
|
discount,
|
||||||
shipping_cost: shippingCost,
|
shipping_cost: shippingCost,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
photo_key: photo,
|
photo_keys: photos.map((p) => p.key),
|
||||||
existing_items: Object.entries(quantities)
|
existing_items: Object.entries(quantities)
|
||||||
.map(([priceId, quantity]) => ({
|
.map(([priceId, quantity]) => ({
|
||||||
raw_material_price_id: Number(priceId),
|
raw_material_price_id: Number(priceId),
|
||||||
@ -538,21 +545,15 @@ export default function PurchaseEdit({
|
|||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Foto</Label>
|
<Label>Foto</Label>
|
||||||
<FileUpload
|
<FileUploadMultiple
|
||||||
value={photo}
|
value={photos}
|
||||||
onChange={(key) => {
|
onChange={setPhotos}
|
||||||
setPhoto(key);
|
|
||||||
const url = key
|
|
||||||
? `https://dstpabuaran.s3.ap-southeast-1.amazonaws.com/${key}`
|
|
||||||
: null;
|
|
||||||
setPhotoUrl(url);
|
|
||||||
}}
|
|
||||||
folder="purchase"
|
folder="purchase"
|
||||||
existingUrl={photoUrl}
|
maxItems={5}
|
||||||
onUploadingChange={setUploading}
|
onUploadingChange={setUploading}
|
||||||
/>
|
/>
|
||||||
<InputError
|
<InputError
|
||||||
message={errors.photo_key}
|
message={errors.photo_keys as string}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -110,11 +110,11 @@ export function PurchaseCardRow({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{purchase.photo_url && (
|
{purchase.photo_urls && purchase.photo_urls.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<ImagePreviewButton
|
<ImagePreviewButton
|
||||||
srcs={[purchase.photo_conversion_url ?? purchase.photo_url]}
|
srcs={purchase.photo_conversion_urls?.length ? purchase.photo_conversion_urls : purchase.photo_urls}
|
||||||
modalSrc={purchase.photo_url}
|
modalSrcs={purchase.photo_urls}
|
||||||
title={purchase.supplier.name}
|
title={purchase.supplier.name}
|
||||||
description={formatDateTime(purchase.created_at)}
|
description={formatDateTime(purchase.created_at)}
|
||||||
className="h-16 w-16"
|
className="h-16 w-16"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user