feat: add share functionality for cutting details with new RowShareAction component and Share pagex
This commit is contained in:
parent
e32a0138dd
commit
175ec30a5c
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
93
resources/js/components/button/RowShareAction.vue
Normal file
93
resources/js/components/button/RowShareAction.vue
Normal 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>
|
||||
@ -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';
|
||||
|
||||
197
resources/js/pages/admin/manage/cuttings/Share.vue
Normal file
197
resources/js/pages/admin/manage/cuttings/Share.vue
Normal 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>
|
||||
@ -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">
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user