feat: add cutting detail view and enhance cutting data handling in various components

This commit is contained in:
Yoga Pangestu 2026-08-13 10:35:04 +07:00
parent 5182dcc012
commit a778e2f499
9 changed files with 508 additions and 104 deletions

View File

@ -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(

View File

@ -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
{

View File

@ -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([

View File

@ -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;

View File

@ -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: {

View File

@ -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<string>();
(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 (
<>
<Card className="overflow-hidden">
@ -84,7 +175,7 @@ export function CuttingCardRow({
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
{formatDateTime(cutting.created_at)}
{cutting.formatted_created_at}
</span>
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
{cutting.status === 'completed'
@ -110,20 +201,20 @@ export function CuttingCardRow({
{formatNumber(result.cutting_result)}
</span>
)}
{cutting.cost_per_unit && (
{cutting.formatted_cost_per_unit && (
<span className="font-semibold">
<span className="font-normal text-muted-foreground">
Per Produk:{' '}
</span>
{formatCurrency(cutting.cost_per_unit)}
{cutting.formatted_cost_per_unit}
</span>
)}
{cutting.total_material_cost && (
{cutting.formatted_total_material_cost && (
<span className="font-semibold">
<span className="font-normal text-muted-foreground">
Biaya Keseluruhan:{' '}
</span>
{formatCurrency(cutting.total_material_cost)}
{cutting.formatted_total_material_cost}
</span>
)}
</div>
@ -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"
/>
</div>
)}
</div>
{(can('cuttings.update') || can('cuttings.delete')) && (
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: can('cuttings.update'),
onClick: () => onEdit(cutting),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: can('cuttings.delete'),
onClick: () => onDelete(cutting),
},
]}
wrapperClassName="flex shrink-0 items-center gap-1"
/>
)}
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<Popover open={shareOpen} onOpenChange={setShareOpen}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Share2 className="h-4 w-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Bagikan</TooltipContent>
<PopoverContent align="end" className="w-48 space-y-1 p-2">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2"
onClick={handleShareWAMessenger}
>
WhatsApp Messenger
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2"
onClick={handleShareWABusiness}
>
WhatsApp Business
</Button>
</PopoverContent>
</Popover>
</Tooltip>
{(can('cuttings.update') || can('cuttings.delete')) && (
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: can('cuttings.update'),
onClick: () => onEdit(cutting),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: can('cuttings.delete'),
onClick: () => onDelete(cutting),
},
]}
wrapperClassName="flex shrink-0 items-center gap-1"
/>
)}
</div>
</div>
</CardContent>
</Card>

View File

@ -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 (
<div className="overflow-x-auto">
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
@ -113,67 +113,67 @@ acc[name] = [];
)}
</TableCell>
</TableRow>
{groupItems.map((item) => {
counter++;
{groupItems.map((item) => {
counter++;
return (
<TableRow key={item.id}>
<TableCell className="text-center">
{counter}
</TableCell>
<TableCell>
{item.raw_material_price
?.photo_url ? (
<ImagePreviewButton
srcs={[
item
.raw_material_price
.photo_conversion_url ??
return (
<TableRow key={item.id}>
<TableCell className="text-center">
{counter}
</TableCell>
<TableCell>
{item.raw_material_price
?.photo_url ? (
<ImagePreviewButton
srcs={[
item
.raw_material_price
.photo_url,
]}
modalSrc={
item
.raw_material_price
.photo_url
}
title={
item
.raw_material_price
.variant
}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
</TableCell>
<TableCell>
{item.raw_material_price
?.variant ?? '-'}
</TableCell>
<TableCell className="text-right">
{formatNumber(
item.material_usage,
)}
</TableCell>
<TableCell className="text-right">
{item.material_result !==
null
? formatNumber(
item.material_result,
)
: '-'}
</TableCell>
</TableRow>
);
})}
</Fragment>
);
},
)}
.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
}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
</TableCell>
<TableCell>
{item.raw_material_price
?.variant ?? '-'}
</TableCell>
<TableCell className="text-right">
{formatNumber(
item.material_usage,
)}
</TableCell>
<TableCell className="text-right">
{item.material_result !==
null
? formatNumber(
item.material_result,
)
: '-'}
</TableCell>
</TableRow>
);
})}
</Fragment>
);
},
)}
</>
)}
@ -232,7 +232,7 @@ acc[name] = [];
item
.raw_material_price
.photo_conversion_url ??
item
item
.raw_material_price
.photo_url,
]}

View File

@ -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<string, string> = {
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 (
<>
<Head title={`Cutting #${cutting.id}`} />
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 text-center">
<h1 className="text-2xl font-bold tracking-tight">
Detail Cutting
</h1>
<p className="mt-1 text-muted-foreground">
Rincian data proses cutting
</p>
</div>
<div className="overflow-hidden rounded-lg border bg-card shadow-sm">
<div className="border-b bg-muted/30 p-6">
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-xl font-semibold">
Cutting #{cutting.id}
</h2>
<Badge variant="secondary" className={statusBadgeClass}>
{cutting.status === 'completed'
? 'Selesai'
: cutting.status === 'cancelled'
? 'Dibatalkan'
: 'Dikerjakan'}
</Badge>
</div>
<div className="mt-3 space-y-1 text-sm text-muted-foreground">
<p>{cutting.formatted_created_at}</p>
<p>
Oleh{' '}
<span className="font-medium text-foreground">
{cutting.created_by?.user_profile?.full_name ?? '-'}
</span>
</p>
</div>
{cutting.description && (
<p className="mt-3 text-sm">{cutting.description}</p>
)}
{cutting.photo_url && (
<div className="mt-4">
<ImagePreviewButton
srcs={[cutting.photo_conversion_url ?? cutting.photo_url]}
modalSrc={cutting.photo_url}
title={result?.product_name ?? `Cutting #${cutting.id}`}
description={cutting.description ?? ''}
/>
</div>
)}
</div>
{result && (
<div className="border-b p-6">
<h3 className="mb-3 text-sm font-semibold">Hasil Produk</h3>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Produk</TableHead>
<TableHead className="text-right">Hasil</TableHead>
<TableHead className="text-right">Sample</TableHead>
<TableHead className="text-right">Diluar Sample</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>{result.product_name ?? '-'}</TableCell>
<TableCell className="text-right tabular-nums">
{result.cutting_result ?? '-'} pcs
</TableCell>
<TableCell className="text-right tabular-nums">
{result.sample ?? '-'} pcs
</TableCell>
<TableCell className="text-right tabular-nums">
{result.original_outside_sample ?? '-'} pcs
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
)}
{items.length > 0 && (
<div className="border-b p-6">
<h3 className="mb-3 text-sm font-semibold">Bahan Baku</h3>
<div className="pb-2">
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Total Pemakaian</p>
<p className="mt-1 text-lg font-semibold text-primary">
{formatNumber(totalUsage)}
</p>
</div>
{result?.cutting_result && (
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Total Hasil Cutting</p>
<p className="mt-1 text-lg font-semibold text-primary">
{formatNumber(result.cutting_result)} pcs
</p>
</div>
)}
{cutting.formatted_cost_per_unit && (
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Biaya Per Produk</p>
<p className="mt-1 text-lg font-semibold text-primary">
{cutting.formatted_cost_per_unit}
</p>
</div>
)}
{cutting.formatted_total_material_cost && (
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Total Biaya Bahan</p>
<p className="mt-1 text-lg font-semibold text-primary">
{cutting.formatted_total_material_cost}
</p>
</div>
)}
</div>
</div>
<i className="text-xs md:hidden">Geser kesamping untuk melihat lebih banyak</i>
<CuttingItemSubRow cutting={cutting} />
</div>
)}
</div>
<div className="mt-6 flex items-center justify-between">
<Button variant="outline" asChild>
<Link href={cuttingIndex.url()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Kembali
</Link>
</Button>
<p className="text-xs text-muted-foreground">
Data ini dibagikan dari sistem DST Collection
</p>
</div>
</div>
</>
);
}

View File

@ -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');