diff --git a/app/Http/Controllers/Admin/Media/PresignedUploadController.php b/app/Http/Controllers/Admin/Media/PresignedUploadController.php index 30c509e..023ddc4 100644 --- a/app/Http/Controllers/Admin/Media/PresignedUploadController.php +++ b/app/Http/Controllers/Admin/Media/PresignedUploadController.php @@ -6,6 +6,7 @@ use Aws\S3\S3Client; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; class PresignedUploadController extends Controller @@ -20,23 +21,29 @@ public function presign(Request $request): JsonResponse $extension = pathinfo($validated['filename'], PATHINFO_EXTENSION) ?: 'bin'; $key = 'temp/'.Str::uuid().'.'.$extension; - $s3 = new S3Client([ - 'region' => config('filesystems.disks.s3.region'), - 'endpoint' => config('filesystems.disks.s3.endpoint'), - 'use_path_style_endpoint' => config('filesystems.disks.s3.use_path_style_endpoint'), - 'credentials' => [ - 'key' => config('filesystems.disks.s3.key'), - 'secret' => config('filesystems.disks.s3.secret'), - ], - ]); + $defaultDisk = config('filesystems.default'); - $command = $s3->getCommand('PutObject', [ - 'Bucket' => config('filesystems.disks.s3.bucket'), - 'Key' => $key, - 'ContentType' => $validated['mime_type'], - ]); + if ($defaultDisk === 's3') { + $s3 = new S3Client([ + 'region' => config('filesystems.disks.s3.region'), + 'endpoint' => config('filesystems.disks.s3.endpoint'), + 'use_path_style_endpoint' => config('filesystems.disks.s3.use_path_style_endpoint'), + 'credentials' => [ + 'key' => config('filesystems.disks.s3.key'), + 'secret' => config('filesystems.disks.s3.secret'), + ], + ]); - $presignedUrl = (string) $s3->createPresignedRequest($command, '+15 minutes')->getUri(); + $command = $s3->getCommand('PutObject', [ + 'Bucket' => config('filesystems.disks.s3.bucket'), + 'Key' => $key, + 'ContentType' => $validated['mime_type'], + ]); + + $presignedUrl = (string) $s3->createPresignedRequest($command, '+15 minutes')->getUri(); + } else { + $presignedUrl = route('media.local-upload', ['key' => $key]); + } return response()->json([ 'key' => $key, @@ -44,4 +51,19 @@ public function presign(Request $request): JsonResponse 'expires_at' => now()->addMinutes(15)->toISOString(), ]); } + + public function localUpload(Request $request): JsonResponse + { + $key = $request->query('key'); + if (! $key || ! str_starts_with($key, 'temp/')) { + return response()->json(['error' => 'Invalid key'], 400); + } + + $content = $request->getContent(); + $disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public'); + + Storage::disk($disk)->put($key, $content); + + return response()->json(['success' => true]); + } } diff --git a/app/Http/Requests/Admin/Manage/CuttingRequest.php b/app/Http/Requests/Admin/Manage/CuttingRequest.php index 51734f9..73c12a7 100644 --- a/app/Http/Requests/Admin/Manage/CuttingRequest.php +++ b/app/Http/Requests/Admin/Manage/CuttingRequest.php @@ -3,11 +3,14 @@ namespace App\Http\Requests\Admin\Manage; use App\Enums\Permission; +use App\Http\Requests\Concerns\ValidatesMediaUploads; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class CuttingRequest extends FormRequest { + use ValidatesMediaUploads; + public function authorize(): bool { $permission = $this->isMethod('POST') @@ -53,7 +56,10 @@ public function rules(): array $rules['results.*.original_outside_sample'] = ['required', 'integer', 'min:0']; } - return $rules; + return array_merge( + $rules, + $this->photoRules('photos', 10), + ); } /** @@ -61,18 +67,21 @@ public function rules(): array */ public function attributes(): array { - return [ - 'description' => 'Keterangan', - 'materials' => 'Bahan Baku', - 'materials.*.raw_material_price_id' => 'Bahan Baku', - 'materials.*.material_usage' => 'Pemakaian', - 'results' => 'Hasil Produk', - 'results.*.product_variant_id' => 'Varian Produk', - 'results.*.cutting_result' => 'Hasil', - 'results.*.sample' => 'sample', - 'results.*.original_outside_sample' => 'Diluar sample', - 'sewing_cost' => 'Jasa Jahit', - 'other_cost' => 'Biaya Lainnya', - ]; + return array_merge( + [ + 'description' => 'Keterangan', + 'materials' => 'Bahan Baku', + 'materials.*.raw_material_price_id' => 'Bahan Baku', + 'materials.*.material_usage' => 'Pemakaian', + 'results' => 'Hasil Produk', + 'results.*.product_variant_id' => 'Varian Produk', + 'results.*.cutting_result' => 'Hasil', + 'results.*.sample' => 'sample', + 'results.*.original_outside_sample' => 'Diluar sample', + 'sewing_cost' => 'Jasa Jahit', + 'other_cost' => 'Biaya Lainnya', + ], + $this->photoUploadAttributes('Foto Cutting'), + ); } } diff --git a/app/Models/Cutting.php b/app/Models/Cutting.php index 4fc4adb..7c4258d 100644 --- a/app/Models/Cutting.php +++ b/app/Models/Cutting.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\CuttingStatus; +use App\Models\Concerns\HasModuleMedia; use App\Models\Concerns\HasRejection; use App\Models\Concerns\InteractsWithActivityLog; use Illuminate\Database\Eloquent\Attributes\Appends; @@ -16,16 +17,17 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Validation\ValidationException; +use Spatie\MediaLibrary\HasMedia; #[Guarded(['id'])] #[Appends([ 'created_at_formatted', 'status_label', ])] -class Cutting extends Model +class Cutting extends Model implements HasMedia { // 1. Use Trait - use HasFactory, HasRejection, InteractsWithActivityLog, SoftDeletes; + use HasFactory, HasModuleMedia, HasRejection, InteractsWithActivityLog, SoftDeletes; // 2. Casting protected function casts(): array @@ -81,6 +83,16 @@ public function statusLabel(): Attribute } // 5. Other Methods + public static function mediaModuleName(): string + { + return 'cutting'; + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('images'); + } + public function ensureEditable(): void { if (! $this->status->isEditable()) { diff --git a/app/Services/Manage/CuttingService.php b/app/Services/Manage/CuttingService.php index eb2279b..c2def27 100644 --- a/app/Services/Manage/CuttingService.php +++ b/app/Services/Manage/CuttingService.php @@ -35,6 +35,7 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi { $query = Cutting::query() ->with([ + 'media', 'createdBy.profile', 'rejection.rejectedBy.profile', 'materials.rawMaterialPrice.rawMaterial:id,name,unit', @@ -66,7 +67,7 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi $cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user)); $cutting->setAttribute('is_editable', $cutting->status->isEditable()); $this->appendCostPreview($cutting); - $this->appendPhotos($cutting); + $this->appendImages($cutting); $cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m)); return $cutting; @@ -77,6 +78,7 @@ public function getInProgressCuttings(User $user): Collection { return Cutting::query() ->with([ + 'media', 'createdBy.profile', 'rejection.rejectedBy.profile', 'materials.rawMaterialPrice.rawMaterial:id,name,unit', @@ -91,7 +93,7 @@ public function getInProgressCuttings(User $user): Collection $cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user)); $cutting->setAttribute('is_editable', $cutting->status->isEditable()); $this->appendCostPreview($cutting); - $this->appendPhotos($cutting); + $this->appendImages($cutting); $cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m)); }); } @@ -100,6 +102,7 @@ public function getCompletedCuttings(User $user): Collection { return Cutting::query() ->with([ + 'media', 'createdBy.profile', 'rejection.rejectedBy.profile', 'materials.rawMaterialPrice.rawMaterial:id,name,unit', @@ -114,7 +117,7 @@ public function getCompletedCuttings(User $user): Collection $cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user)); $cutting->setAttribute('is_editable', $cutting->status->isEditable()); $this->appendCostPreview($cutting); - $this->appendPhotos($cutting); + $this->appendImages($cutting); $cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m)); }); } @@ -189,6 +192,7 @@ public function productCatalog(?Cutting $cutting = null, ?User $user = null): Co public function findForEdit(Cutting $cutting): Cutting { $cutting->load([ + 'media', 'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.media', 'results.productVariant.product:id,name', @@ -196,7 +200,7 @@ public function findForEdit(Cutting $cutting): Cutting 'rejection.rejectedBy.profile', ]); - $this->appendPhotos($cutting); + $this->appendImages($cutting); $cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m)); return $cutting; @@ -205,6 +209,7 @@ public function findForEdit(Cutting $cutting): Cutting public function findForShare(Cutting $cutting): Cutting { $cutting->load([ + 'media', 'createdBy.profile', 'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.media', @@ -213,14 +218,16 @@ public function findForShare(Cutting $cutting): Cutting ]); $this->appendCostPreview($cutting); - $this->appendPhotos($cutting); + $this->appendImages($cutting); $cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m)); return $cutting; } - private function appendPhotos(Cutting $cutting): void + private function appendImages(Cutting $cutting): void { + $cutting->setAttribute('images', MediaPresenter::collection($cutting, 'images')); + $cutting->materials->each(function (CuttingMaterial $material): void { $price = $material->rawMaterialPrice; @@ -423,6 +430,8 @@ public function create(array $validated, User $user): Cutting 'created_by_id' => $user->id, ]); + $this->syncImages($cutting, $validated); + foreach ($draftMaterials as $material) { $material->cutting_id = $cutting->id; $material->user_id = null; @@ -495,6 +504,8 @@ public function update(Cutting $cutting, array $validated): void } $cutting->save(); + $this->syncImages($cutting, $validated); + foreach ($materials as $materialData) { $cutting->materials()->create($materialData); } @@ -551,6 +562,7 @@ public function delete(Cutting $cutting): void $cutting->materials()->delete(); $cutting->results()->delete(); + $cutting->clearMediaCollection('images'); $cutting->delete(); }); } catch (ValidationException $e) { @@ -1164,4 +1176,18 @@ private function formatStockForUnit(float $stock, RawMaterialUnit $unit): string return "{$formatted} {$unit->abbreviation()}"; } + + private function syncImages(Cutting $cutting, array $validated): void + { + $this->mediaService->syncCollection( + $cutting, + 'images', + $validated['images'] ?? null, + $validated['remove_media_ids'] ?? null, + 10, + required: false, + errorKey: 'images', + s3Keys: $validated['s3_keys'] ?? null, + ); + } } diff --git a/app/Services/Media/MediaService.php b/app/Services/Media/MediaService.php index 66b2c45..0cf2ab2 100644 --- a/app/Services/Media/MediaService.php +++ b/app/Services/Media/MediaService.php @@ -90,12 +90,13 @@ public function registerS3Key( string $collection, ?string $type = null, ): Media { + $disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public'); try { - return $model->addMediaFromDisk($s3Key, 's3') + return $model->addMediaFromDisk($s3Key, $disk) ->withCustomProperties($this->customProperties($model, $type ?? $collection)) ->toMediaCollection($collection); } finally { - Storage::disk('s3')->delete($s3Key); + Storage::disk($disk)->delete($s3Key); } } diff --git a/app/Support/Media/MediaPresenter.php b/app/Support/Media/MediaPresenter.php index fa1dd41..1fe7efe 100644 --- a/app/Support/Media/MediaPresenter.php +++ b/app/Support/Media/MediaPresenter.php @@ -2,7 +2,6 @@ namespace App\Support\Media; -use Illuminate\Support\Facades\Storage; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\MediaCollections\Models\Media; @@ -48,11 +47,20 @@ private static function temporaryUrl(Media $media, ?string $conversion = null): $disk = $media->disk; if ($disk === 's3') { - $path = $conversion - ? $media->getPath($conversion) - : $media->getPath(); + $visibility = config('filesystems.disks.s3.visibility', 'private'); + if ($visibility === 'public') { + return $conversion + ? $media->getUrl($conversion) + : $media->getUrl(); + } - return Storage::disk($disk)->temporaryUrl($path, now()->addMinutes(30)); + try { + return $media->getTemporaryUrl(now()->addMinutes(30), $conversion ?? ''); + } catch (\Exception $e) { + return $conversion + ? $media->getUrl($conversion) + : $media->getUrl(); + } } return $conversion diff --git a/config/filesystems.php b/config/filesystems.php index 37d8fca..93af0c4 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -56,6 +56,7 @@ 'url' => env('AWS_URL'), 'endpoint' => env('AWS_ENDPOINT'), 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'visibility' => env('AWS_VISIBILITY', 'private'), 'throw' => false, 'report' => false, ], diff --git a/resources/js/types/cutting.ts b/resources/js/types/cutting.ts index 41444f1..48264e6 100644 --- a/resources/js/types/cutting.ts +++ b/resources/js/types/cutting.ts @@ -65,6 +65,7 @@ export type CuttingListItem = { status: string; status_label: string; description: string | null; + images?: MediaItem[]; is_editable: boolean; available_actions: CuttingStatusAction[]; created_at_formatted: string; @@ -134,6 +135,7 @@ export type CuttingEditItem = { id: number; status: string; description: string | null; + images?: MediaItem[]; sewing_cost?: number; other_cost?: number; materials: Array<{ diff --git a/routes/web.php b/routes/web.php index 68244d8..0b1c443 100644 --- a/routes/web.php +++ b/routes/web.php @@ -52,6 +52,7 @@ // Media presigned upload Route::post('/admin/media/presign', [PresignedUploadController::class, 'presign'])->name('media.presign'); + Route::put('/admin/media/local-upload', [PresignedUploadController::class, 'localUpload'])->name('media.local-upload'); // Push Notifications Route::post('/push-subscriptions', [PushSubscriptionController::class, 'store'])->name('push_subscriptions.store');