diff --git a/app/Http/Controllers/Admin/Manage/CuttingController.php b/app/Http/Controllers/Admin/Manage/CuttingController.php index dbb5079..ac4f88c 100644 --- a/app/Http/Controllers/Admin/Manage/CuttingController.php +++ b/app/Http/Controllers/Admin/Manage/CuttingController.php @@ -42,6 +42,13 @@ public function create(): Response ]); } + public function show(Cutting $cutting): Response + { + return Inertia::render('admin/manage/cutting/show', [ + 'cutting' => $this->service->getForShow($cutting), + ]); + } + public function store(CuttingRequest $request): RedirectResponse { return $this->handleAction( diff --git a/app/Models/Cutting.php b/app/Models/Cutting.php index bffcede..f8b727f 100644 --- a/app/Models/Cutting.php +++ b/app/Models/Cutting.php @@ -18,7 +18,7 @@ use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\MediaCollections\Models\Media; -#[Appends(['formatted_cost_per_unit', 'formatted_other_cost', 'formatted_sewing_cost', 'status_label', 'formatted_total_material_cost'])] +#[Appends(['formatted_cost_per_unit', 'formatted_other_cost', 'formatted_sewing_cost', 'status_label', 'formatted_total_material_cost', 'formatted_created_at'])] #[Guarded(['id'])] class Cutting extends Model implements HasMedia { @@ -70,6 +70,13 @@ protected function formattedTotalMaterialCost(): Attribute ); } + protected function formattedCreatedAt(): Attribute + { + return Attribute::make( + get: fn () => $this->created_at?->translatedFormat('l, d F Y'), + ); + } + #[Scope] protected function cancelled(Builder $query): void { diff --git a/app/Services/Admin/Manage/CuttingService.php b/app/Services/Admin/Manage/CuttingService.php index d94ac21..a94b299 100644 --- a/app/Services/Admin/Manage/CuttingService.php +++ b/app/Services/Admin/Manage/CuttingService.php @@ -29,9 +29,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = ->with([ 'createdBy:id', 'createdBy.userProfile:id,user_id,full_name', - 'cuttingResults:id,cutting_id,product_name,cutting_result,sample,original_outside_sample', + 'cuttingResults:id,cutting_id,product_name,cutting_result', 'cuttingMaterials:id,cutting_id,raw_material_price_id,material_usage,material_result,combination_id', - 'cuttingMaterials.rawMaterialPrice:id,raw_material_id,variant,price,stock', + 'cuttingMaterials.rawMaterialPrice:id,raw_material_id,variant', 'cuttingMaterials.rawMaterialPrice.rawMaterial:id,name,unit', 'cuttingMaterialCombinations:id,cutting_id,material_result', ]) @@ -80,6 +80,89 @@ public function getProductNames(): Collection ->get(); } + public function getForShow(Cutting $cutting): array + { + $cutting->load([ + 'createdBy:id', + 'createdBy.userProfile:id,user_id,full_name', + 'cuttingResults:id,cutting_id,product_name,cutting_result,sample,original_outside_sample', + 'cuttingMaterials:id,cutting_id,raw_material_price_id,material_usage,material_result,combination_id', + 'cuttingMaterials.rawMaterialPrice:id,raw_material_id,variant', + 'cuttingMaterials.rawMaterialPrice.rawMaterial:id,name,unit', + 'cuttingMaterialCombinations:id,cutting_id,material_result', + ]); + + $cuttingMedia = $cutting->getFirstMedia('images'); + $cutting->photo_url = $cuttingMedia + ? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath()) + : null; + $cutting->photo_conversion_url = $cuttingMedia + ? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath('thumb')) + : null; + + $cutting->cuttingMaterials->each(function (CuttingMaterial $material) { + $media = $material->rawMaterialPrice?->getFirstMedia('images'); + if ($material->rawMaterialPrice) { + $material->rawMaterialPrice->photo_url = $media + ? $this->s3Service->getTemporaryUrl($media->getPath()) + : null; + $material->rawMaterialPrice->photo_conversion_url = $media + ? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) + : null; + } + }); + + return [ + 'id' => $cutting->id, + 'status' => $cutting->status->value, + 'status_label' => $cutting->status_label, + 'description' => $cutting->description, + 'total_material_cost' => $cutting->total_material_cost, + 'formatted_total_material_cost' => $cutting->formatted_total_material_cost, + 'cost_per_unit' => $cutting->cost_per_unit, + 'formatted_cost_per_unit' => $cutting->formatted_cost_per_unit, + 'created_at' => $cutting->created_at, + 'formatted_created_at' => $cutting->formatted_created_at, + 'photo_url' => $cutting->photo_url, + 'photo_conversion_url' => $cutting->photo_conversion_url, + 'created_by' => [ + 'id' => $cutting->createdBy->id, + 'user_profile' => [ + 'full_name' => $cutting->createdBy->userProfile->full_name ?? '-', + ], + ], + 'cutting_results' => $cutting->cuttingResults->map(fn ($r) => [ + 'id' => $r->id, + 'product_name' => $r->product_name, + 'cutting_result' => $r->cutting_result, + 'sample' => $r->sample, + 'original_outside_sample' => $r->original_outside_sample, + ]), + 'cutting_materials' => $cutting->cuttingMaterials->map(fn ($m) => [ + 'id' => $m->id, + 'material_usage' => $m->material_usage, + 'material_result' => $m->material_result, + 'combination_id' => $m->combination_id, + 'raw_material_price' => [ + 'id' => $m->rawMaterialPrice->id, + 'variant' => $m->rawMaterialPrice->variant, + 'price' => $m->rawMaterialPrice->price, + 'photo_url' => $m->rawMaterialPrice->photo_url, + 'photo_conversion_url' => $m->rawMaterialPrice->photo_conversion_url, + 'raw_material' => [ + 'id' => $m->rawMaterialPrice->rawMaterial->id, + 'name' => $m->rawMaterialPrice->rawMaterial->name, + 'unit' => $m->rawMaterialPrice->rawMaterial->unit, + ], + ], + ]), + 'cutting_material_combinations' => $cutting->cuttingMaterialCombinations->map(fn ($c) => [ + 'id' => $c->id, + 'material_result' => $c->material_result, + ]), + ]; + } + public function getForEdit(Cutting $cutting): array { $cutting->load([ diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 044e7bd..fafdf8e 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -24,7 +24,7 @@ createInertiaApp({ title: (title) => (title ? `${title} - ${appName}` : appName), layout: (name) => { switch (true) { - case name === 'welcome': + case name === 'admin/manage/cutting/show': return null; case name.startsWith('auth/'): return AuthLayout; diff --git a/resources/js/pages/admin/manage/cutting/columns.tsx b/resources/js/pages/admin/manage/cutting/columns.tsx index 8bae4e3..97e043e 100644 --- a/resources/js/pages/admin/manage/cutting/columns.tsx +++ b/resources/js/pages/admin/manage/cutting/columns.tsx @@ -26,10 +26,13 @@ export type Cutting = { status: string; description: string | null; total_material_cost: number | null; + formatted_total_material_cost: string | null; cost_per_unit: number | null; + formatted_cost_per_unit: string | null; photo_url: string | null; photo_conversion_url: string | null; created_at: string; + formatted_created_at: string; created_by: { id: number; user_profile: { @@ -46,8 +49,6 @@ export type Cutting = { raw_material_price: { id: number; variant: string; - price: number; - stock: number; photo_url: string | null; photo_conversion_url: string | null; raw_material: { diff --git a/resources/js/pages/admin/manage/cutting/cutting-card.tsx b/resources/js/pages/admin/manage/cutting/cutting-card.tsx index f7c4219..8c5c98e 100644 --- a/resources/js/pages/admin/manage/cutting/cutting-card.tsx +++ b/resources/js/pages/admin/manage/cutting/cutting-card.tsx @@ -1,13 +1,90 @@ -import { ChevronDown, Pencil, Trash2 } from 'lucide-react'; -import { ImagePreviewButton } from '@/components/dialogs'; import { RowActions } from '@/components/data-display'; +import { ImagePreviewButton } from '@/components/dialogs'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useCan } from '@/hooks/use-can'; -import { formatDateTime, formatNumber } from '@/lib/format'; -import { formatCurrency } from '@/lib/utils'; +import { formatNumber } from '@/lib/format'; +import { show as cuttingShow } from '@/routes/admin/manage/cuttings'; +import { Briefcase, ChevronDown, Copy, MessageCircle, Pencil, Share2, Trash2 } from 'lucide-react'; +import { useState } from 'react'; import type { Cutting } from './columns'; +function generateShareLink(cuttingId: number): string { + return window.location.origin + cuttingShow.url(cuttingId); +} + +function generateWhatsappText(cutting: Cutting): string { + const result = cutting.cutting_results?.[0]; + const items = cutting.cutting_materials ?? []; + const shareLink = generateShareLink(cutting.id); + + let text = `*Cutting #${cutting.id}*\n`; + text += `Status: ${cutting.status === 'completed' ? 'Selesai' : cutting.status === 'cancelled' ? 'Dibatalkan' : 'Dikerjakan'}\n`; + text += `Tanggal: ${cutting.formatted_created_at}\n`; + text += `Oleh: ${cutting.created_by?.user_profile?.full_name ?? '-'}\n`; + + if (cutting.description) { + text += `Deskripsi: ${cutting.description}\n`; + } + + const productNames = new Set(); + (cutting.cutting_results ?? []).forEach((r) => { + if (r.product_name) { + productNames.add(r.product_name); + } + }); + + if (productNames.size > 0) { + text += `\nNama Produk: ${Array.from(productNames).join(', ')}\n`; + } + + const totalUsage = items.reduce( + (sum, item) => sum + Number(item.material_usage), + 0, + ); + text += `Total Pemakaian: ${formatNumber(totalUsage)}\n`; + + if (result?.cutting_result) { + text += `Total Hasil Cutting: ${formatNumber(result.cutting_result)} pcs\n`; + } + + text += `\nLihat detail lengkap:\n${shareLink}`; + + return text; +} + +function openWAWeb(text: string) { + const encoded = encodeURIComponent(text); + window.open(`https://wa.me/?text=${encoded}`, '_blank'); +} + +function openWAAndroid(text: string, pkg: string) { + const encoded = encodeURIComponent(text); + window.open(`intent://send?text=${encoded}#Intent;scheme=whatsapp;package=${pkg};end`, '_blank'); +} + +function shareViaWhatsAppMessenger(text: string) { + if (/android/i.test(navigator.userAgent)) { + openWAAndroid(text, 'com.whatsapp'); + } else { + openWAWeb(text); + } +} + +function shareViaWhatsAppBusiness(text: string) { + if (/android/i.test(navigator.userAgent)) { + openWAAndroid(text, 'com.whatsapp.w4b'); + } else { + openWAWeb(text); + } +} + export type CuttingCardRowParams = { cutting: Cutting; index: number; @@ -26,6 +103,8 @@ export function CuttingCardRow({ onDelete, }: CuttingCardRowParams) { const { can } = useCan(); + const [shareOpen, setShareOpen] = useState(false); + const [copied, setCopied] = useState(false); const items = cutting.cutting_materials ?? []; const result = cutting.cutting_results?.[0]; const singleCount = items.filter( @@ -43,6 +122,18 @@ export function CuttingCardRow({ 0, ); + const shareText = generateWhatsappText(cutting); + + function handleShareWAMessenger() { + shareViaWhatsAppMessenger(shareText); + setShareOpen(false); + } + + function handleShareWABusiness() { + shareViaWhatsAppBusiness(shareText); + setShareOpen(false); + } + return ( <> @@ -84,7 +175,7 @@ export function CuttingCardRow({
- {formatDateTime(cutting.created_at)} + {cutting.formatted_created_at} {cutting.status === 'completed' @@ -110,20 +201,20 @@ export function CuttingCardRow({ {formatNumber(result.cutting_result)} )} - {cutting.cost_per_unit && ( + {cutting.formatted_cost_per_unit && ( Per Produk:{' '} - {formatCurrency(cutting.cost_per_unit)} + {cutting.formatted_cost_per_unit} )} - {cutting.total_material_cost && ( + {cutting.formatted_total_material_cost && ( Biaya Keseluruhan:{' '} - {formatCurrency(cutting.total_material_cost)} + {cutting.formatted_total_material_cost} )}
@@ -134,34 +225,66 @@ export function CuttingCardRow({ srcs={[cutting.photo_conversion_url ?? cutting.photo_url]} modalSrc={cutting.photo_url} title={productName} - description={cutting.description ?? formatDateTime(cutting.created_at)} + description={cutting.description ?? cutting.formatted_created_at} className="h-16 w-16" /> )} - {(can('cuttings.update') || can('cuttings.delete')) && ( - , - show: can('cuttings.update'), - onClick: () => onEdit(cutting), - }, - { - label: 'Hapus', - icon: ( - - ), - show: can('cuttings.delete'), - onClick: () => onDelete(cutting), - }, - ]} - wrapperClassName="flex shrink-0 items-center gap-1" - /> - )} +
+ + + + + + + + Bagikan + + + + + + + {(can('cuttings.update') || can('cuttings.delete')) && ( + , + show: can('cuttings.update'), + onClick: () => onEdit(cutting), + }, + { + label: 'Hapus', + icon: ( + + ), + show: can('cuttings.delete'), + onClick: () => onDelete(cutting), + }, + ]} + wrapperClassName="flex shrink-0 items-center gap-1" + /> + )} +
diff --git a/resources/js/pages/admin/manage/cutting/cutting-sub-row.tsx b/resources/js/pages/admin/manage/cutting/cutting-sub-row.tsx index 63424c2..fadf1d4 100644 --- a/resources/js/pages/admin/manage/cutting/cutting-sub-row.tsx +++ b/resources/js/pages/admin/manage/cutting/cutting-sub-row.tsx @@ -1,4 +1,3 @@ -import { Fragment } from 'react'; import { ImagePreviewButton } from '@/components/dialogs'; import { Table, @@ -9,6 +8,7 @@ import { TableRow, } from '@/components/ui/table'; import { formatNumber } from '@/lib/format'; +import { Fragment } from 'react'; import type { Cutting } from './columns'; export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) { @@ -23,8 +23,8 @@ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) { const comboId = item.combination_id!; if (!comboGroups[comboId]) { -comboGroups[comboId] = []; -} + comboGroups[comboId] = []; + } comboGroups[comboId].push(item); }); @@ -35,8 +35,8 @@ comboGroups[comboId] = []; item.raw_material_price?.raw_material?.name ?? 'BING'; if (!acc[name]) { -acc[name] = []; -} + acc[name] = []; + } acc[name].push(item); @@ -51,7 +51,7 @@ acc[name] = []; let counter = 0; return ( -
+
@@ -113,67 +113,67 @@ acc[name] = []; )} - {groupItems.map((item) => { - counter++; + {groupItems.map((item) => { + counter++; - return ( - - - {counter} - - - {item.raw_material_price - ?.photo_url ? ( - + + {counter} + + + {item.raw_material_price + ?.photo_url ? ( + - ) : ( -
- N/A -
- )} -
- - {item.raw_material_price - ?.variant ?? '-'} - - - {formatNumber( - item.material_usage, - )} - - - {item.material_result !== - null - ? formatNumber( - item.material_result, - ) - : '-'} - -
- ); - })} - - ); - }, - )} + .raw_material_price + .photo_conversion_url ?? + item + .raw_material_price + .photo_url, + ]} + modalSrc={ + item + .raw_material_price + .photo_url + } + title={ + item + .raw_material_price + .variant + } + /> + ) : ( +
+ N/A +
+ )} + + + {item.raw_material_price + ?.variant ?? '-'} + + + {formatNumber( + item.material_usage, + )} + + + {item.material_result !== + null + ? formatNumber( + item.material_result, + ) + : '-'} + + + ); + })} + + ); + }, + )} )} @@ -232,7 +232,7 @@ acc[name] = []; item .raw_material_price .photo_conversion_url ?? - item + item .raw_material_price .photo_url, ]} diff --git a/resources/js/pages/admin/manage/cutting/show.tsx b/resources/js/pages/admin/manage/cutting/show.tsx new file mode 100644 index 0000000..7a1b920 --- /dev/null +++ b/resources/js/pages/admin/manage/cutting/show.tsx @@ -0,0 +1,183 @@ +import { Head, Link } from '@inertiajs/react'; +import { ArrowLeft } from 'lucide-react'; +import { ImagePreviewButton } from '@/components/dialogs'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { formatNumber } from '@/lib/format'; +import { index as cuttingIndex } from '@/routes/admin/manage/cuttings'; +import type { Cutting } from './columns'; +import { CuttingItemSubRow } from './cutting-sub-row'; + +type Props = { + cutting: Cutting; +}; + +const STATUS_BADGE_CLASSES: Record = { + completed: 'bg-green-100 text-green-800 hover:bg-green-100', + cancelled: 'bg-red-100 text-red-800 hover:bg-red-100', + in_progress: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100', +}; + +export default function CuttingShow({ cutting }: Props) { + const items = cutting.cutting_materials ?? []; + const result = cutting.cutting_results?.[0]; + const totalUsage = items.reduce( + (sum, item) => sum + Number(item.material_usage), + 0, + ); + const statusBadgeClass = + STATUS_BADGE_CLASSES[cutting.status] ?? STATUS_BADGE_CLASSES.in_progress; + + return ( + <> + + +
+
+

+ Detail Cutting +

+

+ Rincian data proses cutting +

+
+ +
+
+
+

+ Cutting #{cutting.id} +

+ + {cutting.status === 'completed' + ? 'Selesai' + : cutting.status === 'cancelled' + ? 'Dibatalkan' + : 'Dikerjakan'} + +
+
+

{cutting.formatted_created_at}

+

+ Oleh{' '} + + {cutting.created_by?.user_profile?.full_name ?? '-'} + +

+
+ {cutting.description && ( +

{cutting.description}

+ )} + + {cutting.photo_url && ( +
+ +
+ )} +
+ + {result && ( +
+

Hasil Produk

+
+
+ + + Produk + Hasil + Sample + Diluar Sample + + + + + {result.product_name ?? '-'} + + {result.cutting_result ?? '-'} pcs + + + {result.sample ?? '-'} pcs + + + {result.original_outside_sample ?? '-'} pcs + + + +
+
+
+ )} + + {items.length > 0 && ( +
+

Bahan Baku

+ +
+
+
+

Total Pemakaian

+

+ {formatNumber(totalUsage)} +

+
+ {result?.cutting_result && ( +
+

Total Hasil Cutting

+

+ {formatNumber(result.cutting_result)} pcs +

+
+ )} + {cutting.formatted_cost_per_unit && ( +
+

Biaya Per Produk

+

+ {cutting.formatted_cost_per_unit} +

+
+ )} + {cutting.formatted_total_material_cost && ( +
+

Total Biaya Bahan

+

+ {cutting.formatted_total_material_cost} +

+
+ )} +
+
+ + Geser kesamping untuk melihat lebih banyak + +
+ )} + + +
+ +

+ Data ini dibagikan dari sistem DST Collection +

+
+ + + ); +} diff --git a/routes/web.php b/routes/web.php index ba41671..b7eab06 100644 --- a/routes/web.php +++ b/routes/web.php @@ -64,7 +64,7 @@ Route::prefix('manage')->name('admin.manage.')->group(function () { Route::resource('purchases', PurchaseController::class)->except(['show'])->middleware('permission:purchases.view|purchases.create|purchases.update|purchases.delete'); - Route::resource('cuttings', CuttingController::class)->except(['show'])->middleware('permission:cuttings.view|cuttings.create|cuttings.update|cuttings.delete'); + Route::resource('cuttings', CuttingController::class)->middleware('permission:cuttings.view|cuttings.create|cuttings.update|cuttings.delete'); Route::resource('transactions', TransactionController::class)->except(['show'])->middleware('permission:orders.view|orders.create|orders.update|orders.delete'); Route::patch('transactions/{transaction}/status', [TransactionController::class, 'updateStatus'])->name('transactions.updateStatus')->middleware('permission:orders.update');