feat: add share functionality for cutting details with new RowShareAction component and Share pagex

This commit is contained in:
Yoga Pangestu 2026-06-29 19:56:02 +07:00
parent e32a0138dd
commit 175ec30a5c
7 changed files with 319 additions and 1 deletions

View File

@ -109,4 +109,11 @@ public function transitionStatus(CuttingStatusTransitionRequest $request, Cuttin
return redirect()->route('admin.manage.cuttings.index');
}
public function share(Cutting $cutting): Response
{
return Inertia::render('admin/manage/cuttings/Share', [
'cutting' => $this->cuttingService->findForShare($cutting),
]);
}
}

View File

@ -211,6 +211,21 @@ public function findForEdit(Cutting $cutting): Cutting
return $cutting;
}
public function findForShare(Cutting $cutting): Cutting
{
$cutting->load([
'createdBy.profile',
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
'results.productVariant.product:id,name',
'results.productVariant:id,product_id,name',
]);
$this->appendCostPreview($cutting);
$cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m));
return $cutting;
}
public function draftMaterialsForUser(User $user): array
{
return $this->draftMaterialsQuery($user)

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import { Copy, MessageCircle, Share2 } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { share } from '@/routes/admin/manage/cuttings';
import type { CuttingListItem } from '@/types/cutting';
const props = defineProps<{
cutting: CuttingListItem;
tooltip?: string;
disabled?: boolean;
}>();
const open = ref(false);
const copied = ref(false);
const shareLink = computed(() => {
return window.location.origin + share.url(props.cutting.id);
});
const whatsappText = computed(() => {
const c = props.cutting;
let text = `*Cutting #${c.id}*\n`;
text += `Status: ${c.status_label}\n`;
text += `Tanggal: ${c.created_at_formatted}\n`;
text += `Oleh: ${c.created_by?.profile?.full_name ?? c.created_by?.username ?? '-'}\n`;
if (c.description) {
text += `Deskripsi: ${c.description}\n`;
}
text += `\nTotal Hasil Cutting: ${c.total_result_pieces ?? 0} pcs\n`;
text += `\nLihat detail lengkap:\n${shareLink.value}`;
return text;
});
async function copyLink() {
try {
await navigator.clipboard.writeText(shareLink.value);
copied.value = true;
toast.success('Link berhasil disalin');
setTimeout(() => {
copied.value = false;
}, 2000);
open.value = false;
} catch {
toast.error('Gagal menyalin link');
}
}
function shareViaWhatsApp() {
const encodedText = encodeURIComponent(whatsappText.value);
const url = `https://wa.me/?text=${encodedText}`;
window.open(url, '_blank');
open.value = false;
}
</script>
<template>
<Tooltip>
<Popover v-model:open="open">
<TooltipTrigger as-child>
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" :disabled="disabled">
<Share2 class="size-4" />
<span class="sr-only">{{ tooltip || 'Bagikan' }}</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{{ tooltip || 'Bagikan' }}</TooltipContent>
<PopoverContent align="end" class="w-48 space-y-1 p-2">
<Button variant="ghost" size="sm" class="w-full justify-start gap-2" @click="copyLink">
<Copy class="size-4" />
{{ copied ? 'Tersalin!' : 'Salin Link' }}
</Button>
<Button variant="ghost" size="sm" class="w-full justify-start gap-2" @click="shareViaWhatsApp">
<MessageCircle class="size-4" />
WhatsApp
</Button>
</PopoverContent>
</Popover>
</Tooltip>
</template>

View File

@ -9,5 +9,6 @@ export { default as RowStatusAction } from './RowStatusAction.vue';
export { default as RowPrintAction } from './RowPrintAction.vue';
export { default as RowDetailAction } from './RowDetailAction.vue';
export { default as RowTransferAction } from './RowTransferAction.vue';
export { default as RowShareAction } from './RowShareAction.vue';
export { default as CreateButton } from './CreateButton.vue';
export { default as BackButton } from './BackButton.vue';

View File

@ -0,0 +1,197 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { cuttingStatusBadgeVariant } from '@/constants/cutting-status';
import type { CuttingListItem } from '@/types/cutting';
const props = defineProps<{
cutting: CuttingListItem;
}>();
interface GroupedMaterials {
key: number;
name: string;
unitLabel?: string;
items: typeof props.cutting.materials;
}
interface GroupedResults {
key: number;
name: string;
items: typeof props.cutting.results;
}
const groupedMaterials = computed<GroupedMaterials[]>(() => {
const groups: Record<number, GroupedMaterials> = {};
props.cutting.materials.forEach((item) => {
const key = item.raw_material_id ?? 0;
const name = item.raw_material_name || 'Bahan Baku Tidak Diketahui';
if (!groups[key]) {
groups[key] = { key, name, unitLabel: item.raw_material_unit_label, items: [] };
}
groups[key].items.push(item);
});
return Object.values(groups);
});
const groupedResults = computed<GroupedResults[]>(() => {
const groups: Record<number, GroupedResults> = {};
props.cutting.results.forEach((item) => {
const product = item.product_variant?.product;
const key = product?.id ?? 0;
const name = product?.name ?? 'Produk Tidak Diketahui';
if (!groups[key]) {
groups[key] = { key, name, items: [] };
}
groups[key].items.push(item);
});
return Object.values(groups);
});
</script>
<template>
<div class="min-h-screen bg-background">
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Header -->
<div class="mb-8 text-center">
<h1 class="text-2xl font-bold tracking-tight">Detail Cutting</h1>
<p class="mt-1 text-muted-foreground">Rincian data proses cutting</p>
</div>
<!-- Main Card -->
<div class="overflow-hidden rounded-lg border bg-card shadow-sm">
<!-- Cutting Info Header -->
<div class="border-b bg-muted/30 p-6">
<div class="flex flex-wrap items-center gap-3">
<h2 class="text-xl font-semibold">Cutting #{{ cutting.id }}</h2>
<Badge :variant="cuttingStatusBadgeVariant(cutting.status)">
{{ cutting.status_label }}
</Badge>
</div>
<div class="mt-3 space-y-1 text-sm text-muted-foreground">
<p>{{ cutting.created_at_formatted }}</p>
<p>Oleh {{ cutting.created_by?.profile?.full_name ?? cutting.created_by?.username }}</p>
</div>
<p v-if="cutting.description" class="mt-3 text-sm">
{{ cutting.description }}
</p>
</div>
<!-- Summary -->
<div class="border-b p-6">
<h3 class="mb-3 text-sm font-semibold">Ringkasan</h3>
<div class="grid gap-4 sm:grid-cols-2">
<div class="rounded-lg border p-3">
<p class="text-xs text-muted-foreground">Total Hasil Cutting</p>
<p class="mt-1 text-lg font-semibold text-primary">
{{ cutting.total_result_pieces ?? 0 }} pcs
</p>
</div>
<div v-if="cutting.total_material_usage_summary_formatted" class="rounded-lg border p-3">
<p class="text-xs text-muted-foreground">Total Pemakaian Bahan</p>
<p class="mt-1 text-lg font-semibold text-primary">
{{ cutting.total_material_usage_summary_formatted }}
</p>
</div>
</div>
</div>
<!-- Materials Section -->
<div v-if="cutting.materials.length" class="border-b p-6">
<h3 class="mb-3 text-sm font-semibold">Bahan Baku</h3>
<div class="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Varian</TableHead>
<TableHead class="text-right">Pemakaian</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-for="group in groupedMaterials" :key="group.key">
<TableRow class="bg-muted/20 hover:bg-muted/20">
<TableCell colspan="2" class="font-semibold">
{{ group.name }}
<Badge v-if="group.unitLabel" variant="secondary" class="ml-2 font-normal">
{{ group.unitLabel }}
</Badge>
</TableCell>
</TableRow>
<TableRow v-for="material in group.items" :key="material.id">
<TableCell class="pl-6">
{{ material.variant }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ material.material_usage_formatted }}
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</div>
</div>
<!-- Results Section -->
<div v-if="cutting.results.length" class="p-6">
<h3 class="mb-3 text-sm font-semibold">Hasil Produk</h3>
<div class="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Varian</TableHead>
<TableHead class="text-right">Hasil</TableHead>
<TableHead class="text-right">Sample</TableHead>
<TableHead class="text-right">Diluar Sample</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-for="group in groupedResults" :key="group.key">
<TableRow class="bg-muted/20 hover:bg-muted/20">
<TableCell colspan="4" class="font-semibold">
{{ group.name }}
</TableCell>
</TableRow>
<TableRow v-for="result in group.items" :key="result.id">
<TableCell class="pl-6">
{{ result.product_variant?.name }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ result.cutting_result }} pcs
</TableCell>
<TableCell class="text-right tabular-nums">
{{ result.sample }} pcs
</TableCell>
<TableCell class="text-right tabular-nums">
{{ result.original_outside_sample }} pcs
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</div>
</div>
</div>
<!-- Footer -->
<div class="mt-6 text-center text-xs text-muted-foreground">
<p>Data ini dibagikan dari sistem DST Collection</p>
</div>
</div>
</div>
</template>

View File

@ -2,7 +2,7 @@
import { router } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import { RowDeleteAction, RowEditAction, RowStatusAction } from '@/components/button';
import { RowDeleteAction, RowEditAction, RowShareAction, RowStatusAction } from '@/components/button';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import OwnerVerificationRowActions from '@/components/owner-verification/OwnerVerificationRowActions.vue';
import { useCan } from '@/composables/useCan';
@ -104,6 +104,8 @@ function transitionStatus() {
:label="action.label" :destructive="action.destructive" @click="openStatusConfirm(action)" />
</template>
<RowShareAction :cutting="cutting" tooltip="Bagikan Data" />
<RowEditAction v-if="canEdit" :href="edit.url(cutting.id)" />
<template v-if="canDelete">

View File

@ -346,6 +346,9 @@
->middleware('permission:'.Permission::CUTTINGS_COMPLETE->value.'|'.Permission::CUTTINGS_VERIFY->value.'|'.Permission::CUTTINGS_REJECT->value)
->name('transition_status');
Route::get('{cutting}/share', [CuttingController::class, 'share'])
->name('share');
Route::delete('{cutting}', [CuttingController::class, 'destroy'])
->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_DELETE->value)
->name('destroy');