feat: add cutting management functionality with CRUD operations

- Implemented CuttingIndex component for listing and managing cuttings.
- Added routes for cutting management in web.php.
- Created CuttingTest for testing cutting-related features including authorization, validation, and stock management.
- Updated roles create and edit pages to include necessary imports.
- Refactored settings and profile pages to streamline imports.
- Enhanced permissions checks for cutting management actions.
This commit is contained in:
Yoga Pangestu 2026-08-04 02:24:11 +07:00
parent 00a848747f
commit e7aa582572
43 changed files with 3570 additions and 131 deletions

View File

@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\CuttingRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Cutting;
use App\Services\Admin\Manage\CuttingService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class CuttingController extends Controller
{
public function __construct(
private CuttingService $service,
) {}
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/manage/cutting/index', [
'cuttings' => $this->service->paginated(
...$request->validatedWithDefaults(),
),
]);
}
public function create(): Response
{
return Inertia::render('admin/manage/cutting/create', [
'data' => $this->service->getForCreate(),
]);
}
public function store(CuttingRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
'Cutting berhasil ditambahkan.',
'admin.manage.cuttings.index',
'admin.manage.cuttings.create'
);
}
public function edit(Cutting $cutting): Response
{
return Inertia::render('admin/manage/cutting/edit', [
'cutting' => $this->service->getForEdit($cutting),
'data' => $this->service->getForCreate(),
]);
}
public function update(CuttingRequest $request, Cutting $cutting): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->update($cutting, $request->validated()),
'Cutting berhasil diperbarui.',
'admin.manage.cuttings.index',
'admin.manage.cuttings.edit',
['cutting' => $cutting]
);
}
public function destroy(Cutting $cutting): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->delete($cutting),
'Cutting berhasil dihapus.',
'admin.manage.cuttings.index'
);
}
}

View File

@ -39,7 +39,9 @@ public function share(Request $request): array
...parent::share($request),
'name' => config('app.name'),
'auth' => [
'user' => $request->user()?->load('userProfile'),
'user' => $request->user()
? $request->user()->load('userProfile', 'roles', 'permissions')
: null,
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'seo' => [

View File

@ -0,0 +1,51 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use Illuminate\Foundation\Http\FormRequest;
class CuttingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'description' => ['nullable', 'string', 'max:100'],
'product_name' => ['required', 'string', 'max:255'],
'sample' => ['required', 'integer', 'min:0'],
'original_outside_sample' => ['required', 'integer', 'min:0'],
'cutting_result' => ['required', 'integer', 'min:0'],
'materials' => ['required', 'array', 'min:1'],
'materials.*.raw_material_price_id' => ['required', 'integer', 'exists:raw_material_prices,id'],
'materials.*.material_usage' => ['required', 'integer', 'min:0'],
'materials.*.material_result' => ['required', 'integer', 'min:0'],
'materials.*.combination_index' => ['nullable', 'integer', 'min:0'],
'combinations' => ['nullable', 'array'],
'combinations.*.material_result' => ['nullable', 'integer', 'min:0'],
'photo_key' => ['nullable', 'string', 'max:500'],
];
}
public function attributes(): array
{
return [
'description' => 'Keterangan',
'product_name' => 'Nama Produk',
'sample' => 'Sample',
'original_outside_sample' => 'Diluar Sample',
'cutting_result' => 'Hasil',
'materials' => 'Bahan Baku',
'materials.*.raw_material_price_id' => 'Varian Bahan Baku',
'materials.*.material_usage' => 'Pemakaian',
'materials.*.material_result' => 'Hasil Material',
'materials.*.combination_index' => 'Indeks Kombinasi',
'combinations' => 'Kombinasi',
'combinations.*.material_result' => 'Hasil Kombinasi',
'photo_key' => 'Foto',
];
}
}

View File

@ -4,48 +4,28 @@
use App\Enums\CuttingStatus;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
#[Guarded(['id'])]
class Cutting extends Model
class Cutting extends Model implements HasMedia
{
use HasFactory, SoftDeletes;
use HasFactory, InteractsWithMedia, SoftDeletes;
protected function casts(): array
{
return [
'status' => CuttingStatus::class,
'total_material_cost' => 'integer',
'sewing_cost' => 'integer',
'other_cost' => 'integer',
'cost_per_unit' => 'integer',
];
}
#[Scope]
protected function cancelled(Builder $query): void
{
$query->where('status', CuttingStatus::CANCELLED);
}
#[Scope]
protected function completed(Builder $query): void
{
$query->where('status', CuttingStatus::COMPLETED);
}
#[Scope]
protected function inProgress(Builder $query): void
{
$query->where('status', CuttingStatus::IN_PROGRESS);
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_id');
@ -65,9 +45,4 @@ public function cuttingResults(): HasMany
{
return $this->hasMany(CuttingResult::class);
}
public function submittedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'submitted_by_id');
}
}

View File

@ -0,0 +1,395 @@
<?php
namespace App\Services\Admin\Manage;
use App\Models\Cutting;
use App\Models\CuttingMaterial;
use App\Models\CuttingMaterialCombination;
use App\Models\CuttingResult;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Services\Concerns\RegistersMedia;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CuttingService
{
use RegistersMedia;
public function __construct(
private S3PresignedService $s3Service = new S3PresignedService,
) {}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
{
$paginator = Cutting::query()
->select('id', 'created_by_id', 'status', 'description', 'total_material_cost', 'cost_per_unit', 'created_at')
->with([
'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,price,stock',
'cuttingMaterials.rawMaterialPrice.rawMaterial:id,name,unit',
'cuttingMaterialCombinations:id,cutting_id,material_result',
])
->when($search, function ($q) use ($search) {
$q->whereHas('cuttingResults', fn ($rq) => $rq->where('product_name', 'like', "%{$search}%"))
->orWhere('description', 'like', "%{$search}%");
})
->orderBy($sort, $direction)
->paginate($perPage);
$paginator->getCollection()->each(function (Cutting $cutting) {
$cuttingMedia = $cutting->getFirstMedia('photos');
$cutting->photo_url = $cuttingMedia
? $this->s3Service->getTemporaryUrl($cuttingMedia->file_name)
: null;
$cutting->cuttingMaterials->each(function (CuttingMaterial $material) {
$media = $material->rawMaterialPrice?->getFirstMedia('photos');
if ($material->rawMaterialPrice) {
$material->rawMaterialPrice->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
}
});
});
return $paginator;
}
public function getForCreate(): array
{
return [
'rawMaterials' => RawMaterial::query()
->select('id', 'name', 'unit', 'is_active')
->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
])
->orderBy('name')
->get()
->each(function (RawMaterial $rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$media = $price->getFirstMedia('photos');
$price->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
});
}),
];
}
public function getForEdit(Cutting $cutting): array
{
$cutting->load([
'cuttingResults',
'cuttingMaterials.rawMaterialPrice.rawMaterial',
'cuttingMaterialCombinations',
]);
$result = $cutting->cuttingResults->first();
$materials = $cutting->cuttingMaterials->map(function (CuttingMaterial $material) {
$media = $material->rawMaterialPrice?->getFirstMedia('photos');
return [
'id' => $material->id,
'raw_material_price_id' => $material->raw_material_price_id,
'material_usage' => $material->material_usage,
'material_result' => $material->material_result,
'combination_id' => $material->combination_id,
'variant' => $material->rawMaterialPrice?->variant,
'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null,
];
});
$combinations = $cutting->cuttingMaterialCombinations->map(function (CuttingMaterialCombination $combination) use ($materials) {
$materialIndices = $materials
->filter(fn ($m) => $m['combination_id'] === $combination->id)
->keys()
->values();
return [
'id' => $combination->id,
'material_result' => $combination->material_result,
'material_indices' => $materialIndices,
];
});
$cuttingMedia = $cutting->getFirstMedia('photos');
$photoKey = $cuttingMedia?->file_name;
$photoUrl = $cuttingMedia
? $this->s3Service->getTemporaryUrl($cuttingMedia->file_name)
: null;
return [
'id' => $cutting->id,
'status' => $cutting->status->value,
'description' => $cutting->description,
'product_name' => $result?->product_name ?? '',
'sample' => $result?->sample ?? 0,
'original_outside_sample' => $result?->original_outside_sample ?? 0,
'cutting_result' => $result?->cutting_result ?? 0,
'materials' => $materials,
'combinations' => $combinations,
'photo_key' => $photoKey,
'photo_url' => $photoUrl,
];
}
public function create(array $data): Cutting
{
foreach ($data['materials'] as $materialData) {
$usage = (int) ($materialData['material_usage'] ?? 0);
if ($usage <= 0) {
continue;
}
$price = RawMaterialPrice::find($materialData['raw_material_price_id']);
if (! $price) {
continue;
}
if ($usage > $price->stock) {
throw ValidationException::withMessages([
'materials' => "Stok {$price->variant} tidak mencukupi. Tersedia: {$price->stock}, dibutuhkan: {$usage}.",
]);
}
}
return DB::transaction(function () use ($data) {
$cutting = Cutting::create([
'created_by_id' => auth()->id(),
'status' => 'in_progress',
'description' => $data['description'] ?? null,
]);
$totalMaterialCost = 0;
$now = now();
$combinations = $data['combinations'] ?? [];
$combinationMap = [];
foreach ($combinations as $index => $combo) {
$combination = CuttingMaterialCombination::create([
'cutting_id' => $cutting->id,
'user_id' => auth()->id(),
'material_result' => $combo['material_result'] ?? null,
'created_at' => $now,
'updated_at' => $now,
]);
$combinationMap[$index] = $combination->id;
}
foreach ($data['materials'] as $materialData) {
$price = RawMaterialPrice::find($materialData['raw_material_price_id']);
$materialCost = $price ? $price->price * ($materialData['material_usage'] ?? 0) : 0;
$totalMaterialCost += $materialCost;
$combinationId = null;
if (isset($materialData['combination_index']) && isset($combinationMap[$materialData['combination_index']])) {
$combinationId = $combinationMap[$materialData['combination_index']];
}
CuttingMaterial::create([
'cutting_id' => $cutting->id,
'user_id' => auth()->id(),
'raw_material_price_id' => $materialData['raw_material_price_id'],
'material_usage' => $materialData['material_usage'] ?? 0,
'material_result' => $materialData['material_result'] ?? null,
'combination_id' => $combinationId,
'created_at' => $now,
'updated_at' => $now,
]);
if ($price && ($materialData['material_usage'] ?? 0) > 0) {
$price->decrement('stock', (int) $materialData['material_usage']);
}
}
$costPerUnit = 0;
if (($data['cutting_result'] ?? 0) > 0) {
$costPerUnit = (int) ($totalMaterialCost / $data['cutting_result']);
}
$cutting->update([
'total_material_cost' => $totalMaterialCost,
'cost_per_unit' => $costPerUnit,
]);
CuttingResult::create([
'cutting_id' => $cutting->id,
'user_id' => auth()->id(),
'product_name' => $data['product_name'] ?? null,
'sample' => $data['sample'] ?? null,
'original_outside_sample' => $data['original_outside_sample'] ?? null,
'cutting_result' => $data['cutting_result'] ?? null,
'created_at' => $now,
'updated_at' => $now,
]);
if (! empty($data['photo_key'])) {
$this->registerMedia(
model: $cutting,
s3Key: $data['photo_key'],
collectionName: 'photos',
orderColumn: 1,
);
}
return $cutting;
});
}
public function update(Cutting $cutting, array $data): Cutting
{
$cutting->load(['cuttingMaterials.rawMaterialPrice']);
foreach ($cutting->cuttingMaterials as $oldMaterial) {
if ($oldMaterial->material_usage > 0 && $oldMaterial->rawMaterialPrice) {
$oldMaterial->rawMaterialPrice->increment('stock', (int) $oldMaterial->material_usage);
}
}
foreach ($data['materials'] as $materialData) {
$usage = (int) ($materialData['material_usage'] ?? 0);
if ($usage <= 0) {
continue;
}
$price = RawMaterialPrice::find($materialData['raw_material_price_id']);
if (! $price) {
continue;
}
if ($usage > $price->stock) {
throw ValidationException::withMessages([
'materials' => "Stok {$price->variant} tidak mencukupi. Tersedia: {$price->stock}, dibutuhkan: {$usage}.",
]);
}
}
return DB::transaction(function () use ($cutting, $data) {
$cutting->load(['cuttingMaterials', 'cuttingMaterialCombinations', 'cuttingResults']);
$cutting->cuttingResults()->delete();
$cutting->cuttingMaterials()->delete();
$cutting->cuttingMaterialCombinations()->delete();
$totalMaterialCost = 0;
$now = now();
$combinations = $data['combinations'] ?? [];
$combinationMap = [];
foreach ($combinations as $index => $combo) {
$combination = CuttingMaterialCombination::create([
'cutting_id' => $cutting->id,
'user_id' => auth()->id(),
'material_result' => $combo['material_result'] ?? null,
'created_at' => $now,
'updated_at' => $now,
]);
$combinationMap[$index] = $combination->id;
}
foreach ($data['materials'] as $materialData) {
$price = RawMaterialPrice::find($materialData['raw_material_price_id']);
$materialCost = $price ? $price->price * ($materialData['material_usage'] ?? 0) : 0;
$totalMaterialCost += $materialCost;
$combinationId = null;
if (isset($materialData['combination_index']) && isset($combinationMap[$materialData['combination_index']])) {
$combinationId = $combinationMap[$materialData['combination_index']];
}
CuttingMaterial::create([
'cutting_id' => $cutting->id,
'user_id' => auth()->id(),
'raw_material_price_id' => $materialData['raw_material_price_id'],
'material_usage' => $materialData['material_usage'] ?? 0,
'material_result' => $materialData['material_result'] ?? null,
'combination_id' => $combinationId,
'created_at' => $now,
'updated_at' => $now,
]);
if ($price && ($materialData['material_usage'] ?? 0) > 0) {
$price->decrement('stock', (int) $materialData['material_usage']);
}
}
$costPerUnit = 0;
if (($data['cutting_result'] ?? 0) > 0) {
$costPerUnit = (int) ($totalMaterialCost / $data['cutting_result']);
}
$cutting->update([
'description' => $data['description'] ?? null,
'total_material_cost' => $totalMaterialCost,
'cost_per_unit' => $costPerUnit,
]);
CuttingResult::create([
'cutting_id' => $cutting->id,
'user_id' => auth()->id(),
'product_name' => $data['product_name'] ?? null,
'sample' => $data['sample'] ?? null,
'original_outside_sample' => $data['original_outside_sample'] ?? null,
'cutting_result' => $data['cutting_result'] ?? null,
'created_at' => $now,
'updated_at' => $now,
]);
$this->syncCuttingPhoto($cutting, $data);
return $cutting;
});
}
private function syncCuttingPhoto(Cutting $cutting, array $data): void
{
if (! array_key_exists('photo_key', $data)) {
return;
}
$currentKey = $cutting->getFirstMedia('photos')?->file_name;
if ($data['photo_key'] === $currentKey) {
return;
}
$cutting->clearMediaCollection('photos');
if (! empty($data['photo_key'])) {
$this->registerMedia(
model: $cutting,
s3Key: $data['photo_key'],
collectionName: 'photos',
orderColumn: 1,
);
}
}
public function delete(Cutting $cutting): bool
{
return DB::transaction(function () use ($cutting) {
$cutting->load('cuttingMaterials.rawMaterialPrice');
foreach ($cutting->cuttingMaterials as $material) {
if ($material->material_usage > 0 && $material->rawMaterialPrice) {
$material->rawMaterialPrice->increment('stock', (int) $material->material_usage);
}
}
$cutting->clearMediaCollection('photos');
$cutting->cuttingResults()->delete();
$cutting->cuttingMaterials()->delete();
$cutting->cuttingMaterialCombinations()->delete();
$cutting->delete();
return true;
});
}
}

View File

@ -10,16 +10,12 @@ class CuttingFactory extends Factory
public function definition(): array
{
$materialCost = fake()->numberBetween(50000, 5000000);
$sewingCost = fake()->numberBetween(10000, 500000);
$otherCost = fake()->numberBetween(0, 200000);
return [
'created_by_id' => User::factory(),
'status' => fake()->randomElement(['in_progress', 'completed', 'cancelled']),
'description' => fake()->sentence(),
'total_material_cost' => $materialCost,
'sewing_cost' => $sewingCost,
'other_cost' => $otherCost,
'cost_per_unit' => fake()->numberBetween(1000, 50000),
];
}

View File

@ -28,7 +28,10 @@ public function run(): void
'attendance' => ['view', 'check-in', 'check-out', 'by-date'],
'settings' => ['view', 'update-system', 'update-homepage', 'update-social-media', 'update-marketplace', 'update-hr'],
'purchase' => ['view', 'create', 'update', 'delete'],
'cutting' => ['view', 'create', 'update', 'delete'],
'restock' => ['view', 'create', 'update', 'delete'],
'dashboard' => ['attendance', 'revenue', 'expense', 'orders_channel', 'orders_payment', 'orders_marketing', 'orders_status'],
'analysis' => ['attendance', 'cash', 'raw_materials', 'product_stock', 'revenue', 'expense', 'profit_gross', 'profit_hpp', 'profit_orders', 'marketing_sales', 'top_suppliers', 'top_products', 'top_customers', 'busy_hours'],
];
foreach ($permissions as $module => $actions) {
@ -56,6 +59,7 @@ public function run(): void
'Admin Bahan Baku' => array_filter($allPermissions, function ($p) {
return str_starts_with($p, 'supplier.')
|| str_starts_with($p, 'purchase.')
|| str_starts_with($p, 'cutting.')
|| str_starts_with($p, 'restock.')
|| $p === 'category.view'
|| $p === 'customer.view'

View File

@ -1,33 +1,3 @@
import React from 'react';
import AppLogo from '@/components/app-logo';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { dashboard } from '@/routes';
import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts';
import { index as employeeAdvancesIndex } from '@/routes/admin/finance/employee-advances';
import { index as expensesIndex } from '@/routes/admin/finance/expenses';
import { current as payrollCurrent } from '@/routes/admin/finance/payroll-periods';
import { index as attendancesIndex } from '@/routes/admin/hr/attendances';
import { index as employeesIndex } from '@/routes/admin/hr/employees';
import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests';
import { index as categoriesIndex } from '@/routes/admin/master/categories';
import { index as customersIndex } from '@/routes/admin/master/customers';
import { index as productsIndex } from '@/routes/admin/master/products';
import { index as rawMaterialsIndex } from '@/routes/admin/master/raw-materials';
import { index as purchasesIndex } from '@/routes/admin/manage/purchases';
import { index as restocksIndex } from '@/routes/admin/manage/restocks';
import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
import { index as rolesIndex } from '@/routes/admin/settings/roles';
import { Link, router } from '@inertiajs/react';
import type { LucideIcon } from 'lucide-react';
import {
@ -53,6 +23,38 @@ import {
Users,
Wallet,
} from 'lucide-react';
import React from 'react';
import AppLogo from '@/components/app-logo';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { dashboard } from '@/routes';
import admin from '@/routes/admin';
import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts';
import { index as employeeAdvancesIndex } from '@/routes/admin/finance/employee-advances';
import { index as expensesIndex } from '@/routes/admin/finance/expenses';
import { current as payrollCurrent } from '@/routes/admin/finance/payroll-periods';
import { index as attendancesIndex } from '@/routes/admin/hr/attendances';
import { index as employeesIndex } from '@/routes/admin/hr/employees';
import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests';
import { index as cuttingsIndex } from '@/routes/admin/manage/cuttings';
import { index as purchasesIndex } from '@/routes/admin/manage/purchases';
import { index as restocksIndex } from '@/routes/admin/manage/restocks';
import { index as categoriesIndex } from '@/routes/admin/master/categories';
import { index as customersIndex } from '@/routes/admin/master/customers';
import { index as productsIndex } from '@/routes/admin/master/products';
import { index as rawMaterialsIndex } from '@/routes/admin/master/raw-materials';
import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
import { index as rolesIndex } from '@/routes/admin/settings/roles';
type NavMenuItem = { title: string; href: string; icon: LucideIcon };
@ -78,7 +80,7 @@ const masterItems: NavMenuItem[] = [
const kelolaItems: NavMenuItem[] = [
{ title: 'Belanja', href: purchasesIndex.url(), icon: ShoppingCart },
{ title: 'Cutting', href: '#', icon: Scissors },
{ title: 'Cutting', href: cuttingsIndex.url(), icon: Scissors },
{ title: 'Restock', href: restocksIndex.url(), icon: RefreshCw },
{ title: 'Stok Opname', href: '#', icon: ClipboardCheck },
];
@ -133,7 +135,9 @@ export function AppSidebar() {
const { isMobile, setOpenMobile } = useSidebar();
React.useEffect(() => {
if (!isMobile) return;
if (!isMobile) {
return;
}
const cleanup = router.on('finish', () => {
setOpenMobile(false);

View File

@ -1,6 +1,6 @@
import { Camera, RotateCcw, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Camera, RotateCcw, X } from 'lucide-react';
interface CameraCaptureProps {
onCapture: (dataUrl: string) => void;
@ -20,9 +20,11 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
video: { facingMode: 'user', width: 640, height: 480 },
});
setStream(mediaStream);
if (videoRef.current) {
videoRef.current.srcObject = mediaStream;
}
setError(null);
} catch {
setError(
@ -33,13 +35,16 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
useEffect(() => {
startCamera();
return () => {
stream?.getTracks().forEach((track) => track.stop());
};
}, []);
const capture = () => {
if (!videoRef.current || !canvasRef.current) return;
if (!videoRef.current || !canvasRef.current) {
return;
}
const canvas = canvasRef.current;
const video = videoRef.current;
@ -47,7 +52,10 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return;
if (!ctx) {
return;
}
ctx.translate(canvas.width, 0);
ctx.scale(-1, 1);

View File

@ -63,6 +63,7 @@ function useDebounce(callback: (value: string) => void, delay: number) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(value);
}, delay);
@ -102,13 +103,17 @@ export function CardTable<TData>({
function handleSearchChange(value: string) {
setLocalSearch(value);
if (isServerMode) {
handleSearchDebounced(value);
}
}
function isItemExpanded(key: number | string): boolean {
if (expandedKeys === 'all') return true;
if (expandedKeys === 'all') {
return true;
}
return expandedKeys.has(key);
}

View File

@ -0,0 +1,98 @@
import { Clock, LogIn, LogOut } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
type TodayAttendance = {
id: number;
attendance_date: string;
check_in_at: string | null;
check_out_at: string | null;
check_in_photo: string | null;
check_out_photo: string | null;
check_in_latitude: number;
check_in_longitude: number;
check_out_latitude: number | null;
check_out_longitude: number | null;
work_duration_minutes: number | null;
} | null;
type AttendanceCardProps = {
todayAttendance: TodayAttendance;
isOnLeave: boolean;
canCheckIn: boolean;
onCheckIn?: () => void;
onCheckOut?: () => void;
};
export function AttendanceCard({ todayAttendance, isOnLeave, canCheckIn, onCheckIn, onCheckOut }: AttendanceCardProps) {
const hasCheckedIn = !!todayAttendance?.check_in_at;
const hasCheckedOut = !!todayAttendance?.check_out_at;
function formatTime(dateStr: string | null): string {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', hour12: false });
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Clock className="size-4 text-muted-foreground" />
</div>
<CardTitle>Presensi Hari Ini</CardTitle>
</div>
</CardHeader>
<CardContent>
{isOnLeave ? (
<div className="rounded-md bg-yellow-50 p-3 text-sm text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400">
Anda sedang cuti hari ini.
</div>
) : !canCheckIn ? (
<div className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
Anda tidak memiliki akses presensi.
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md bg-muted p-3">
<p className="text-xs text-muted-foreground">Masuk</p>
<p className="text-lg font-semibold">{formatTime(todayAttendance?.check_in_at ?? null)}</p>
</div>
<div className="rounded-md bg-muted p-3">
<p className="text-xs text-muted-foreground">Pulang</p>
<p className="text-lg font-semibold">{formatTime(todayAttendance?.check_out_at ?? null)}</p>
</div>
</div>
{todayAttendance?.work_duration_minutes != null && (
<div className="rounded-md bg-muted p-3">
<p className="text-xs text-muted-foreground">Durasi Kerja</p>
<p className="text-lg font-semibold">
{Math.floor(todayAttendance.work_duration_minutes / 60)}j {todayAttendance.work_duration_minutes % 60}m
</p>
</div>
)}
<div className="flex gap-2">
{!hasCheckedIn ? (
<Button onClick={onCheckIn} className="flex-1">
<LogIn className="mr-2 size-4" />
Presensi Masuk
</Button>
) : !hasCheckedOut ? (
<Button onClick={onCheckOut} className="flex-1" variant="destructive">
<LogOut className="mr-2 size-4" />
Presensi Pulang
</Button>
) : (
<div className="flex-1 rounded-md bg-green-50 p-3 text-center text-sm text-green-700 dark:bg-green-900/20 dark:text-green-400">
Presensi selesai untuk hari ini.
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -1,9 +1,8 @@
import * as React from 'react';
import { format } from 'date-fns';
import { id } from 'date-fns/locale';
import { CalendarIcon } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
@ -11,6 +10,7 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
interface DatePickerProps {
value?: Date | string | null;
@ -38,13 +38,22 @@ function DatePicker({
const [open, setOpen] = React.useState(false);
const date = React.useMemo(() => {
if (!value) return undefined;
if (value instanceof Date) return value;
if (!value) {
return undefined;
}
if (value instanceof Date) {
return value;
}
return new Date(value);
}, [value]);
const formattedDate = React.useMemo(() => {
if (!date) return '';
if (!date) {
return '';
}
return format(date, 'dd MMM yyyy', { locale: id });
}, [date]);
@ -74,8 +83,14 @@ function DatePicker({
setOpen(false);
}}
disabled={(date) => {
if (min && date < min) return true;
if (max && date > max) return true;
if (min && date < min) {
return true;
}
if (max && date > max) {
return true;
}
return false;
}}
initialFocus

View File

@ -62,6 +62,7 @@ function formatMaxSize(bytes: number): string {
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(0)}KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
}

View File

@ -1,3 +1,4 @@
import { Filter, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import {
@ -5,7 +6,6 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Filter, X } from 'lucide-react';
type FilterPopoverProps = {
open: boolean;

View File

@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import { useEffect, useRef } from 'react';
import 'leaflet/dist/leaflet.css';
interface LocationMapProps {
@ -19,7 +19,9 @@ export function LocationMap({
const mapInstanceRef = useRef<L.Map | null>(null);
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
if (!mapRef.current || mapInstanceRef.current) {
return;
}
const map = L.map(mapRef.current, {
center: [latitude, longitude],

View File

@ -194,6 +194,7 @@ export function NotificationBell() {
className="min-w-0 flex-1 cursor-pointer"
onClick={(e) => {
e.preventDefault();
if (notification.url) {
window.location.href =
notification.url;

View File

@ -1,5 +1,5 @@
import { Input } from '@/components/ui/input';
import { useCallback, useRef, useState } from 'react';
import { Input } from '@/components/ui/input';
type PhoneNumberInputProps = {
name?: string;
@ -12,9 +12,11 @@ type PhoneNumberInputProps = {
function formatPhone(value: string | null | undefined): string {
const digits = (value ?? '').replace(/[^0-9]/g, '');
const groups: string[] = [];
for (let i = 0; i < digits.length; i += 4) {
groups.push(digits.slice(i, i + 4));
}
return groups.join(' ');
}

View File

@ -47,6 +47,7 @@ export function RupiahInput({
if (isControlled) {
const formatted = formatRupiah(value);
if (formatted !== displayValue) {
setDisplayValue(formatted);
lastValidRef.current = value;

View File

@ -1,3 +1,5 @@
import { Link, router } from '@inertiajs/react';
import { LogOut, Settings } from 'lucide-react';
import {
DropdownMenuGroup,
DropdownMenuItem,
@ -9,8 +11,6 @@ import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
import { logout } from '@/routes';
import { edit } from '@/routes/profile';
import type { User } from '@/types';
import { Link, router } from '@inertiajs/react';
import { LogOut, Settings } from 'lucide-react';
type Props = {
user: User;

View File

@ -0,0 +1,48 @@
import { usePage } from '@inertiajs/react';
type RoleOrPermission = { name: string } | string;
type User = {
id: number;
username?: string;
roles?: RoleOrPermission[];
permissions?: RoleOrPermission[];
[key: string]: unknown;
};
type PageProps = {
auth: {
user?: User;
};
};
function extractNames(items?: RoleOrPermission[]): string[] {
if (!items) return [];
return items.map((item) => (typeof item === 'string' ? item : item.name));
}
export function useCan() {
const { auth } = usePage().props as PageProps;
const user = auth.user;
const roleNames = extractNames(user?.roles);
const permissionNames = extractNames(user?.permissions);
function can(permission: string): boolean {
if (!user) return false;
if (roleNames.includes('developer') || roleNames.includes('owner')) return true;
return permissionNames.includes(permission);
}
function hasRole(role: string): boolean {
if (!user) return false;
return roleNames.includes(role);
}
function hasAnyRole(roles: string[]): boolean {
if (!user) return false;
return roles.some((role) => roleNames.includes(role));
}
return { can, hasRole, hasAnyRole };
}

View File

@ -0,0 +1,20 @@
import { useDraftSave } from '@/hooks/use-draft-save';
import { clearCuttingDraft, saveCuttingDraft } from '@/lib/cutting-draft';
import type { CuttingDraftData } from '@/lib/cutting-draft';
type DraftType = 'create' | 'edit';
export function useCuttingDraftSave(
type: DraftType,
data: CuttingDraftData,
userId?: number,
delay = 500,
) {
return useDraftSave({
type,
data,
userId,
delay,
store: { save: saveCuttingDraft, clear: clearCuttingDraft },
});
}

View File

@ -27,7 +27,9 @@ export function useInfiniteScroll<T>({
const sentinelRef = useRef<HTMLDivElement | null>(null);
const loadMore = useCallback(() => {
if (loading || currentPage >= lastPage) return;
if (loading || currentPage >= lastPage) {
return;
}
setLoading(true);
@ -42,7 +44,7 @@ export function useInfiniteScroll<T>({
preserveState: true,
replace: true,
only: ['mutations'],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onSuccess: (page: any) => {
const newMutations = (page.props as Record<string, unknown>)
.mutations as PaginatedData<T>;
@ -61,7 +63,10 @@ export function useInfiniteScroll<T>({
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
if (!sentinel) {
return;
}
const observer = new IntersectionObserver(
(entries) => {

View File

@ -0,0 +1,48 @@
import { createDraftStore } from '@/lib/draft-store';
export type CuttingDraftData = {
productName: string;
sample: number;
originalOutsideSample: number;
notes: string;
materials: Array<{
raw_material_price_id: number;
material_usage: number;
material_result: number | null;
combination_index: number | null;
variant: string;
material_name: string;
unit: string;
photo_url: string | null;
}>;
combinations: Array<{
material_result: number | null;
}>;
selectedMaterialName?: string;
photo?: string;
};
export const cuttingDraftStore =
createDraftStore<CuttingDraftData>('cutting-draft');
export function saveCuttingDraft(
type: 'create' | 'edit',
data: CuttingDraftData,
userId?: number,
): boolean {
return cuttingDraftStore.save(type, data, userId);
}
export function loadCuttingDraft(
type: 'create' | 'edit',
userId?: number,
): CuttingDraftData | null {
return cuttingDraftStore.load(type, userId);
}
export function clearCuttingDraft(
type: 'create' | 'edit',
userId?: number,
): void {
cuttingDraftStore.clear(type, userId);
}

View File

@ -0,0 +1,20 @@
export function formatRupiah(value: number): string {
return value.toLocaleString('id-ID');
}
export function formatRupiahShort(value: number): string {
if (value >= 1_000_000_000) {
return (value / 1_000_000_000).toFixed(1).replace('.0', '') + 'jt';
}
if (value >= 1_000_000) {
return (value / 1_000_000).toFixed(1).replace('.0', '') + 'jt';
}
if (value >= 1_000) {
return (value / 1_000).toFixed(0) + 'rb';
}
return value.toString();
}
export function formatRupiahDisplay(value: number): string {
return 'Rp' + formatRupiah(value);
}

View File

@ -1,19 +1,3 @@
import { CameraCapture } from '@/components/camera-capture';
import { LocationMap } from '@/components/location-map';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
index as attendanceIndex,
store,
update,
} from '@/routes/admin/hr/attendances';
import { Head, router } from '@inertiajs/react';
import { addMonths, format, subMonths } from 'date-fns';
import { id } from 'date-fns/locale';
@ -31,6 +15,22 @@ import {
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { CameraCapture } from '@/components/camera-capture';
import { LocationMap } from '@/components/location-map';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
index as attendanceIndex,
store,
update,
} from '@/routes/admin/hr/attendances';
type Attendance = {
id: number;
@ -85,6 +85,7 @@ function isSameDay(d1: Date, d2: Date): boolean {
function checkIsToday(date: Date): boolean {
const t = new Date();
return (
date.getDate() === t.getDate() &&
date.getMonth() === t.getMonth() &&
@ -94,6 +95,7 @@ function checkIsToday(date: Date): boolean {
function isWeekend(date: Date): boolean {
const day = date.getDay();
return day === 0 || day === 6;
}
@ -102,10 +104,14 @@ function isLate(
officeHour: number,
officeMinute: number,
): boolean {
if (!checkInAt) return false;
if (!checkInAt) {
return false;
}
const d = new Date(checkInAt);
const h = d.getHours();
const m = d.getMinutes();
return h > officeHour || (h === officeHour && m > officeMinute);
}
@ -114,18 +120,29 @@ function getLateMinutes(
officeHour: number,
officeMinute: number,
): number {
if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) return 0;
if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) {
return 0;
}
const d = new Date(checkInAt);
const officeStart = new Date(d);
officeStart.setHours(officeHour, officeMinute, 0, 0);
return Math.ceil((d.getTime() - officeStart.getTime()) / 60000);
}
function formatMinutes(minutes: number | null): string {
if (!minutes) return '-';
if (!minutes) {
return '-';
}
const hours = Math.floor(minutes / 60);
const mins = Math.floor(minutes % 60);
if (mins === 0) return `${hours} jam`;
if (mins === 0) {
return `${hours} jam`;
}
return `${hours} jam ${mins} menit`;
}
@ -161,11 +178,13 @@ export default function AttendanceIndex({
attendances.forEach((att) => {
dates.set(att.attendance_date, att);
});
return dates;
}, [attendances]);
const selectedAttendance = useMemo(() => {
const dateStr = format(selectedDate, 'yyyy-MM-dd');
return attendanceDates.get(dateStr) ?? null;
}, [selectedDate, attendanceDates]);
@ -199,6 +218,7 @@ export default function AttendanceIndex({
}
const remaining = 42 - days.length;
for (let i = 1; i <= remaining; i++) {
const m = viewMonth === 12 ? 1 : viewMonth + 1;
const y = viewMonth === 12 ? viewYear + 1 : viewYear;
@ -222,6 +242,7 @@ export default function AttendanceIndex({
if (!navigator.geolocation) {
setLocationLoading(false);
toast.error('Geolocation tidak didukung di browser ini.');
return;
}
@ -268,7 +289,10 @@ export default function AttendanceIndex({
};
function formatTime(dateStr: string | null): string {
if (!dateStr) return '-';
if (!dateStr) {
return '-';
}
return format(new Date(dateStr), 'HH:mm');
}
@ -555,8 +579,10 @@ export default function AttendanceIndex({
key={idx}
onClick={() => {
setSelectedDate(cell.date);
if (attendance)
setDetailAttendance(attendance);
if (attendance) {
setDetailAttendance(attendance);
}
}}
className={`group relative flex min-h-[100px] flex-col p-2 text-left transition-colors hover:bg-muted/50 ${
!cell.isCurrentMonth

View File

@ -1,3 +1,6 @@
import { Form, Head } from '@inertiajs/react';
import { AlertCircle, ArrowLeft } from 'lucide-react';
import { useState } from 'react';
import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error';
import { PhoneNumberInput } from '@/components/phone-number-input';
@ -17,9 +20,6 @@ import {
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { index as employeeIndex, store } from '@/routes/admin/hr/employees';
import { Form, Head } from '@inertiajs/react';
import { AlertCircle, ArrowLeft } from 'lucide-react';
import { useState } from 'react';
export default function EmployeeCreate() {
const [joinDate, setJoinDate] = useState<Date | undefined>(undefined);

View File

@ -1,3 +1,6 @@
import { Form, Head } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
import { useState } from 'react';
import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error';
import { PhoneNumberInput } from '@/components/phone-number-input';
@ -16,9 +19,6 @@ import {
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { index as employeeIndex, update } from '@/routes/admin/hr/employees';
import { Form, Head } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
import { useState } from 'react';
type EmployeeData = {
id: number;

View File

@ -0,0 +1,104 @@
export type CuttingMaterial = {
id: number;
raw_material_price_id: number;
material_usage: number;
material_result: number | null;
combination_id: number | null;
variant_name: string;
};
export type CuttingResult = {
id: number;
product_name: string | null;
cutting_result: number | null;
sample: number | null;
original_outside_sample: number | null;
};
export type CuttingCombination = {
id: number;
material_result: number | null;
};
export type Cutting = {
id: number;
created_by_id: number;
status: string;
description: string | null;
total_material_cost: number | null;
cost_per_unit: number | null;
photo_url: string | null;
created_at: string;
created_by: {
id: number;
user_profile: {
full_name: string;
};
};
cutting_results: CuttingResult[];
cutting_materials: {
id: number;
raw_material_price_id: number;
material_usage: number;
material_result: number | null;
combination_id: number | null;
raw_material_price: {
id: number;
variant: string;
price: number;
stock: number;
photo_url: string | null;
raw_material: {
id: number;
name: string;
unit: string;
};
};
}[];
cutting_material_combinations: {
id: number;
material_result: number | null;
}[];
};
export type CuttingForEdit = {
id: number;
status: string;
description: string | null;
product_name: string;
sample: number;
original_outside_sample: number;
cutting_result: number;
materials: {
id: number;
raw_material_price_id: number;
material_usage: number;
material_result: number | null;
combination_id: number | null;
variant: string | null;
photo_url: string | null;
}[];
combinations: {
id: number;
material_result: number | null;
material_indices: number[];
}[];
photo_key: string | null;
photo_url: string | null;
};
export type CuttingCreateData = {
rawMaterials: {
id: number;
name: string;
unit: string;
is_active: boolean;
raw_material_prices: {
id: number;
variant: string;
price: number;
stock: number;
photo_url: string | null;
}[];
}[];
};

View File

@ -0,0 +1,700 @@
'use no memo';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUpload } from '@/components/file-upload';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import InputError from '@/components/input-error';
import { NumberInput } from '@/components/number-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from '@/components/ui/combobox';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { useCuttingDraftSave } from '@/hooks/use-cutting-draft';
import { loadCuttingDraft } from '@/lib/cutting-draft';
import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils';
import { index as cuttingIndex, store } from '@/routes/admin/manage/cuttings';
import { Form, Head, usePage } from '@inertiajs/react';
import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import type { CuttingCreateData } from './columns';
type MaterialState = {
raw_material_price_id: number;
material_usage: number;
material_result: number;
combination_id: number | null;
variant: string;
material_name: string;
unit: string;
photo_url: string | null;
};
type CombinationState = {
material_result: number;
};
type Props = {
data: CuttingCreateData;
};
export default function CuttingCreate({ data }: Props) {
const { rawMaterials } = data;
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
const userId = auth.user?.id;
const draft = loadCuttingDraft('create', userId);
const [materials, setMaterials] = useState<MaterialState[]>(() => {
if (draft?.materials && draft.materials.length > 0) {
return draft.materials.map((m) => ({
raw_material_price_id: m.raw_material_price_id,
material_usage: m.material_usage,
material_result: m.material_result ?? 0,
combination_id: m.combination_index ?? null,
variant: m.variant,
material_name: m.material_name,
unit: m.unit,
photo_url: m.photo_url,
}));
}
return [];
});
const [combinations, setCombinations] = useState<CombinationState[]>(() => {
if (draft?.combinations && draft.combinations.length > 0) {
return draft.combinations.map((c) => ({
material_result: c.material_result ?? 0,
}));
}
return [];
});
const [selectedMaterialName, setSelectedMaterialName] = useState(draft?.selectedMaterialName ?? '');
const [productName, setProductName] = useState(draft?.productName ?? '');
const [sample, setSample] = useState(draft?.sample ?? 0);
const [originalOutsideSample, setOriginalOutsideSample] = useState(draft?.originalOutsideSample ?? 0);
const cuttingResult = sample + originalOutsideSample;
const [notes, setNotes] = useState(draft?.notes ?? '');
const [photo, setPhoto] = useState<string | null>(draft?.photo ?? null);
const [photoUrl, setPhotoUrl] = useState<string | null>(
draft?.photo ? getTemporaryUrl(draft.photo) : null,
);
const [uploading, setUploading] = useState(false);
const submittingRef = useRef(false);
const [cartOpen, setCartOpen] = useState(false);
const [previewKey, setPreviewKey] = useState<string | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteMaterialIndex, setDeleteMaterialIndex] = useState<number | null>(null);
const [cartDeleteConfirmOpen, setCartDeleteConfirmOpen] = useState(false);
const [cartDeleteIndex, setCartDeleteIndex] = useState<number | null>(null);
const [comboDeleteConfirmOpen, setComboDeleteConfirmOpen] = useState(false);
const [comboDeleteIndex, setComboDeleteIndex] = useState<number | null>(null);
const [comboDialogOpen, setComboDialogOpen] = useState(false);
const [comboMaterialName, setComboMaterialName] = useState('');
const [comboSelectedPriceIds, setComboSelectedPriceIds] = useState<number[]>([]);
const [comboResult, setComboResult] = useState(0);
const comboMaterial = useMemo(
() => rawMaterials.find((m) => m.name === comboMaterialName) ?? null,
[rawMaterials, comboMaterialName],
);
const draftData = useMemo(
() => ({
productName,
sample,
originalOutsideSample,
notes,
materials: materials.map((m) => ({
raw_material_price_id: m.raw_material_price_id,
material_usage: m.material_usage,
material_result: m.material_result,
combination_index: m.combination_id,
variant: m.variant,
material_name: m.material_name,
unit: m.unit,
photo_url: m.photo_url,
})),
combinations: combinations.map((c) => ({
material_result: c.material_result,
})),
selectedMaterialName,
photo: photo ?? undefined,
}),
[productName, sample, originalOutsideSample, notes, materials, combinations, selectedMaterialName, photo],
);
useCuttingDraftSave('create', draftData, userId);
const materialsRef = useRef(materials);
materialsRef.current = materials;
const priceMap = useMemo(
() => new Map(rawMaterials.flatMap((m) => m.raw_material_prices.map((p) => [p.id, p]))),
[rawMaterials],
);
const selectedMaterial = useMemo(
() => rawMaterials.find((m) => m.name === selectedMaterialName) ?? null,
[rawMaterials, selectedMaterialName],
);
const addVariant = useCallback(
(priceId: number) => {
if (!selectedMaterial) return;
const price = selectedMaterial.raw_material_prices.find((p) => p.id === priceId);
if (!price) return;
setMaterials((prev) => {
if (prev.some((m) => m.raw_material_price_id === priceId && m.combination_id === null)) return prev;
return [
...prev,
{
raw_material_price_id: price.id,
material_usage: 0,
material_result: 0,
combination_id: null,
variant: price.variant,
material_name: selectedMaterial.name,
unit: selectedMaterial.unit,
photo_url: price.photo_url,
},
];
});
},
[selectedMaterial],
);
const openComboDialog = useCallback((materialName: string, preSelectPriceId?: number) => {
setComboMaterialName(materialName);
setComboSelectedPriceIds(preSelectPriceId ? [preSelectPriceId] : []);
setComboResult(0);
setComboDialogOpen(true);
}, []);
const toggleComboPrice = useCallback((priceId: number) => {
setComboSelectedPriceIds((prev) =>
prev.includes(priceId) ? prev.filter((id) => id !== priceId) : [...prev, priceId],
);
}, []);
const confirmCombo = useCallback(() => {
if (comboSelectedPriceIds.length < 2) return;
const comboIndex = combinations.length;
const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => {
let foundMaterial: typeof rawMaterials[number] | undefined;
let foundPrice: typeof rawMaterials[number]['raw_material_prices'][number] | undefined;
for (const rm of rawMaterials) {
const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
if (p) {
foundMaterial = rm;
foundPrice = p;
break;
}
}
return {
raw_material_price_id: priceId,
material_usage: 0,
material_result: 0,
combination_id: comboIndex,
variant: foundPrice?.variant ?? '',
material_name: foundMaterial?.name ?? '',
unit: foundMaterial?.unit ?? '',
photo_url: foundPrice?.photo_url ?? null,
};
});
setMaterials((prev) => [...prev, ...newMaterials]);
setCombinations((prev) => [
...prev,
{
material_result: comboResult,
},
]);
setComboDialogOpen(false);
setComboMaterialName('');
setComboSelectedPriceIds([]);
setComboResult(0);
}, [comboSelectedPriceIds, comboResult, rawMaterials, combinations.length]);
const removeMaterial = useCallback((index: number) => {
setDeleteMaterialIndex(index);
setDeleteConfirmOpen(true);
}, []);
const updateMaterial = useCallback(
(index: number, field: keyof MaterialState, value: unknown) => {
setMaterials((prev) => {
const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value;
return updated;
});
},
[],
);
const updateCombinationResult = useCallback((comboIndex: number, value: number) => {
setCombinations((prev) => prev.map((c, i) => (i === comboIndex ? { ...c, material_result: value } : c)));
}, []);
const totalMaterialCost = useMemo(() => {
return materials.reduce((sum, m) => {
const price = priceMap.get(m.raw_material_price_id);
return sum + (price ? price.price * m.material_usage : 0);
}, 0);
}, [materials, priceMap]);
const totalCost = totalMaterialCost;
const costPerUnit = cuttingResult > 0 ? Math.floor(totalCost / cuttingResult) : 0;
function formatQuantity(value: number): string {
return formatNumber(value, { maximumFractionDigits: 4 });
}
function getPayload() {
return {
description: notes || null,
product_name: productName || null,
sample: sample || null,
original_outside_sample: originalOutsideSample || null,
cutting_result: cuttingResult || null,
materials: materialsRef.current.map((m) => ({
raw_material_price_id: m.raw_material_price_id,
material_usage: m.material_usage,
material_result: m.material_result,
combination_index: m.combination_id,
})),
combinations: combinations.map((c) => ({
material_result: c.material_result,
})),
photo_key: photo,
};
}
return (
<>
<Head title="Tambah Cutting" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Tambah Cutting</h2>
<Button asChild variant="outline">
<a href={cuttingIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</a>
</Button>
</div>
<Form action={store()} transform={(data) => ({ ...data, ...getPayload() })} onSubmit={() => { submittingRef.current = true; }}>
{({ errors, processing }) => (
<div className="grid gap-6 md:grid-cols-3">
<div className="space-y-6 md:col-span-2">
<Card>
<CardHeader>
<CardTitle>Pilih Bahan Baku</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<Label>
Nama Bahan Baku <span className="text-destructive">*</span>
</Label>
<Combobox
items={rawMaterials}
itemToStringLabel={(m) => m.name}
value={selectedMaterial}
onValueChange={(value) => setSelectedMaterialName(value?.name ?? '')}
>
<ComboboxInput placeholder="Cari bahan baku..." className="w-full" />
<ComboboxContent>
<ComboboxEmpty>Tidak ada bahan baku ditemukan.</ComboboxEmpty>
<ComboboxList>
{(m) => (
<ComboboxItem key={m.id} value={m}>
{m.name} ({m.unit})
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
{selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Pilih Varian</Label>
<div className="space-y-2">
{selectedMaterial.raw_material_prices.map((price) => {
const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
return (
<div key={price.id} className={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}>
<div className="flex min-w-0 items-center gap-3">
{price.photo_url ? (
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}
<div className="min-w-0">
<p className="truncate font-medium">{price.variant}</p>
<p className="text-xs text-muted-foreground">
Stok: {formatNumber(Number(price.stock))} {selectedMaterial.unit} · {formatCurrency(price.price)}
{addedCount > 0 && ` · ×${addedCount}`}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(price.id)}>
<Plus className="h-4 w-4" />
Tambah
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => openComboDialog(selectedMaterial.name, price.id)}>
<Layers className="h-4 w-4" />
Kombinasi
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-6 md:col-span-1">
<Card className="sticky top-6">
<CardHeader>
<CardTitle>Ringkasan</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="product_name">
Nama Produk <span className="text-destructive">*</span>
</Label>
<Input id="product_name" name="product_name" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Masukkan nama produk" />
<InputError message={errors.product_name} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="sample">
Sample <span className="text-destructive">*</span>
</Label>
<NumberInput id="sample" value={sample} onValueChange={setSample} />
<InputError message={errors.sample} />
</div>
<div className="grid gap-2">
<Label htmlFor="original_outside_sample">
Diluar Sample <span className="text-destructive">*</span>
</Label>
<NumberInput id="original_outside_sample" value={originalOutsideSample} onValueChange={setOriginalOutsideSample} />
<InputError message={errors.original_outside_sample} />
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="cutting_result">Hasil</Label>
<NumberInput id="cutting_result" value={cuttingResult} disabled />
<InputError message={errors.cutting_result} />
</div>
<div className="space-y-2">
<div className="border-t pt-2">
<div className="flex items-center justify-between text-sm font-semibold">
<span>Total</span>
<span>{formatCurrency(totalCost)}</span>
</div>
</div>
{cuttingResult > 0 && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Biaya Per Unit</span>
<span className="font-medium">{formatCurrency(costPerUnit)}</span>
</div>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Keterangan</Label>
<Textarea id="notes" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Masukkan keterangan" maxLength={100} />
<InputError message={errors.description} />
</div>
<div className="grid gap-2">
<Label>Foto</Label>
<FileUpload value={photo} onChange={(key) => { setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null); }} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} />
<InputError message={errors.photo_key} />
</div>
<Button type="submit" className="w-full" disabled={processing || submittingRef.current || uploading || materials.length === 0 || !productName || !sample}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</CardContent>
</Card>
</div>
</div>
)}
</Form>
<Button type="button" onClick={() => setCartOpen(true)} className="fixed top-1/2 right-4 z-50 h-14 w-14 -translate-y-1/2 rounded-full shadow-lg" size="icon" aria-label="Buka keranjang cutting">
<ShoppingCart className="h-5 w-5" />
{materials.length > 0 && (
<span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs font-semibold text-white">
{materials.length}
</span>
)}
</Button>
<Sheet open={cartOpen} onOpenChange={setCartOpen}>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>Keranjang Cutting</SheetTitle>
</SheetHeader>
<div className="flex-1 space-y-3 overflow-y-auto px-6 pb-6">
{materials.length === 0 ? (
<p className="text-sm text-muted-foreground">Keranjang kosong.</p>
) : (
(() => {
const groups: { comboIndex: number | null; items: { m: MaterialState; index: number }[] }[] = [];
const comboMap = new Map<number, { m: MaterialState; index: number }[]>();
const singleItems: { m: MaterialState; index: number }[] = [];
materials.forEach((m, i) => {
if (m.combination_id !== null) {
if (!comboMap.has(m.combination_id)) comboMap.set(m.combination_id, []);
comboMap.get(m.combination_id)!.push({ m, index: i });
} else {
singleItems.push({ m, index: i });
}
});
comboMap.forEach((items, comboIdx) => groups.push({ comboIndex: comboIdx, items }));
singleItems.forEach((item) => groups.push({ comboIndex: null, items: [item] }));
return groups.map((group, gi) => (
<div key={gi} className="space-y-2 rounded-lg border p-3">
{group.comboIndex !== null && (
<div className="flex items-center justify-between border-b pb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground">Kombinasi {group.comboIndex + 1}</span>
<span className="text-xs text-muted-foreground">·</span>
<Label className="text-xs whitespace-nowrap">Hasil <span className="text-destructive">*</span></Label>
<NumberInput
className="w-20"
value={combinations[group.comboIndex]?.material_result ?? 0}
onValueChange={(val) => updateCombinationResult(group.comboIndex!, val)}
/>
</div>
<Button type="button" variant="ghost" size="icon-sm" onClick={() => {
setComboDeleteIndex(group.comboIndex);
setComboDeleteConfirmOpen(true);
}}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
)}
{group.items.map(({ m, index }) => {
const price = priceMap.get(m.raw_material_price_id);
const cartKey = `material-${index}`;
return (
<div key={cartKey} className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-3">
{m.photo_url ? (
<button type="button" onClick={() => setPreviewKey(cartKey)} className="block h-8 w-8 shrink-0 overflow-hidden rounded-md border transition-opacity hover:opacity-80">
<img src={m.photo_url} alt={m.variant} className="h-full w-full object-cover" />
</button>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}
<div>
<p className="text-sm font-medium">{m.material_name}</p>
<p className="text-xs text-muted-foreground">{m.variant}{price ? ` · ${formatNumber(Number(price.stock))} ${m.unit}` : ''}</p>
</div>
</div>
{group.comboIndex === null && (
<Button type="button" variant="ghost" size="icon-sm" onClick={() => {
setCartDeleteIndex(index);
setCartDeleteConfirmOpen(true);
}}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">Pemakaian <span className="text-destructive">*</span></span>
<NumberInput
className="flex-1"
value={m.material_usage}
onValueChange={(val) => updateMaterial(index, 'material_usage', val)}
/>
</div>
{group.comboIndex === null && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">Hasil <span className="text-destructive">*</span></span>
<NumberInput
className="flex-1"
value={m.material_result}
onValueChange={(val) => updateMaterial(index, 'material_result', val)}
/>
</div>
)}
</div>
);
})}
</div>
));
})()
)}
</div>
<SheetFooter>
<div className="flex items-center justify-between border-t pt-4">
<span className="text-sm">Total Pemakaian</span>
<span className="text-sm font-semibold">{formatQuantity(materials.reduce((sum, m) => sum + m.material_usage, 0))}</span>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
<ImagePreviewModal
open={previewKey !== null}
onOpenChange={(open) => { if (!open) setPreviewKey(null); }}
src={previewKey && previewKey.startsWith('material-') ? (materials[Number(previewKey.replace('material-', ''))]?.photo_url ?? null) : null}
title={previewKey && previewKey.startsWith('material-') ? `${materials[Number(previewKey.replace('material-', ''))]?.material_name}${materials[Number(previewKey.replace('material-', ''))]?.variant}` : undefined}
sources={materials.filter((m) => m.photo_url).map((m) => m.photo_url!)}
/>
{comboDialogOpen && (
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => setComboDialogOpen(false)} />
)}
<Dialog open={comboDialogOpen} onOpenChange={setComboDialogOpen} modal={false}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Tambah Kombinasi</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid gap-2">
<Label>
Nama Bahan Baku <span className="text-destructive">*</span>
</Label>
<Combobox
items={rawMaterials}
itemToStringLabel={(m) => m.name}
value={comboMaterial}
onValueChange={(value) => setComboMaterialName(value?.name ?? '')}
>
<ComboboxInput placeholder="Cari bahan baku..." className="w-full" />
<ComboboxContent>
<ComboboxEmpty>Tidak ada bahan baku ditemukan.</ComboboxEmpty>
<ComboboxList>
{(m) => (
<ComboboxItem key={m.id} value={m}>
{m.name} ({m.unit})
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
{comboSelectedPriceIds.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Varian Dipilih</Label>
<div className="space-y-1">
{comboSelectedPriceIds.map((priceId) => {
let variantName = '';
let materialName = '';
let photoUrl: string | null = null;
for (const rm of rawMaterials) {
const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
if (p) {
variantName = p.variant;
materialName = rm.name;
photoUrl = p.photo_url;
break;
}
}
return (
<div key={priceId} className="flex items-center justify-between gap-2 rounded-md border border-primary bg-primary/5 px-3 py-2">
<div className="flex min-w-0 items-center gap-3">
{photoUrl ? (
<img src={photoUrl} alt={variantName} className="h-8 w-8 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}
<div className="min-w-0">
<p className="text-sm font-medium truncate">{variantName}</p>
<p className="text-xs text-muted-foreground">{materialName}</p>
</div>
</div>
<Button type="button" variant="ghost" size="icon-sm" onClick={() => toggleComboPrice(priceId)}>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
);
})}
</div>
</div>
)}
{comboMaterial && comboMaterial.raw_material_prices.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Pilih Varian</Label>
<div className="space-y-2">
{comboMaterial.raw_material_prices.map((price) => {
const isSelected = comboSelectedPriceIds.includes(price.id);
return (
<div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}>
<div className="flex min-w-0 items-center gap-3">
{price.photo_url ? (
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}
<div className="min-w-0">
<p className="truncate font-medium">{price.variant}</p>
<p className="text-xs text-muted-foreground">Stok: {formatNumber(Number(price.stock))} {comboMaterial.unit}</p>
</div>
</div>
<Button type="button" variant={isSelected ? 'default' : 'outline'} size="sm" onClick={() => toggleComboPrice(price.id)}>
{isSelected ? <><Check className="h-4 w-4" /> Dipilih</> : 'Pilih'}
</Button>
</div>
);
})}
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setComboDialogOpen(false)}>Batal</Button>
<Button type="button" onClick={confirmCombo} disabled={comboSelectedPriceIds.length < 2}>
Konfirmasi
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog open={deleteConfirmOpen} onOpenChange={(open) => { if (!open) { setDeleteConfirmOpen(false); setDeleteMaterialIndex(null); } }} title="Hapus Bahan Baku" description="Apakah Anda yakin ingin menghapus bahan baku ini?" confirmLabel="Hapus" onConfirm={() => { if (deleteMaterialIndex !== null) setMaterials((prev) => prev.filter((_, i) => i !== deleteMaterialIndex)); setDeleteConfirmOpen(false); setDeleteMaterialIndex(null); }} />
<ConfirmDialog open={cartDeleteConfirmOpen} onOpenChange={(open) => { if (!open) { setCartDeleteConfirmOpen(false); setCartDeleteIndex(null); } }} title="Hapus dari Keranjang" description="Apakah Anda yakin ingin menghapus item ini dari keranjang?" confirmLabel="Hapus" variant="destructive" onConfirm={() => { if (cartDeleteIndex !== null) setMaterials((prev) => prev.filter((_, i) => i !== cartDeleteIndex)); setCartDeleteConfirmOpen(false); setCartDeleteIndex(null); }} />
<ConfirmDialog open={comboDeleteConfirmOpen} onOpenChange={(open) => { if (!open) { setComboDeleteConfirmOpen(false); setComboDeleteIndex(null); } }} title="Hapus Kombinasi" description="Apakah Anda yakin ingin menghapus kombinasi ini beserta semua bahannya?" confirmLabel="Hapus" variant="destructive" onConfirm={() => { if (comboDeleteIndex !== null) { setCombinations((prev) => prev.filter((_, i) => i !== comboDeleteIndex)); setMaterials((prev) => prev.filter((m) => m.combination_id !== comboDeleteIndex)); } setComboDeleteConfirmOpen(false); setComboDeleteIndex(null); }} />
</div>
</>
);
}

View File

@ -0,0 +1,146 @@
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
import { ImagePreviewButton } from '@/components/image-preview-button';
import { RowActions } from '@/components/row-actions';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { formatDateTime, formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
import type { Cutting } from './columns';
export type CuttingCardRowParams = {
cutting: Cutting;
index: number;
isExpanded: boolean;
onToggleExpand: () => void;
onEdit: (cutting: Cutting) => void;
onDelete: (cutting: Cutting) => void;
};
export function CuttingCardRow({
cutting,
index,
isExpanded,
onToggleExpand,
onEdit,
onDelete,
}: CuttingCardRowParams) {
const items = cutting.cutting_materials ?? [];
const result = cutting.cutting_results?.[0];
const materialCount = items.length;
const productName = result?.product_name ?? '-';
const totalUsage = items.reduce(
(sum, item) => sum + Number(item.material_usage),
0,
);
return (
<>
<Card className="overflow-hidden">
<CardContent className="p-0">
<div className="flex items-start gap-3 p-4">
<Button
variant="ghost"
size="icon"
className="mt-0.5 h-6 w-6 shrink-0"
onClick={onToggleExpand}
>
<ChevronDown
className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
/>
</Button>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">
{index}.
</span>
<h3 className="truncate font-medium">
{productName}
</h3>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{materialCount > 0 && (
<span>
{materialCount} bahan baku
</span>
)}
{cutting.description && (
<span className="ml-2">
· {cutting.description}
</span>
)}
</div>
<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)}
</span>
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
{cutting.status === 'completed'
? 'Selesai'
: cutting.status === 'cancelled'
? 'Dibatalkan'
: 'Dikerjakan'}
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
<span>
<span className="text-muted-foreground">
Pemakaian:{' '}
</span>
{formatNumber(totalUsage)}
</span>
{result?.cutting_result && (
<span>
<span className="text-muted-foreground">
Hasil:{' '}
</span>
{formatNumber(result.cutting_result)}
</span>
)}
{cutting.cost_per_unit && (
<span className="font-semibold">
<span className="font-normal text-muted-foreground">
Per Unit:{' '}
</span>
{formatCurrency(cutting.cost_per_unit)}
</span>
)}
</div>
{cutting.photo_url && (
<div className="mt-2">
<ImagePreviewButton
srcs={[cutting.photo_url]}
title="Foto Cutting"
className="h-16 w-16"
/>
</div>
)}
</div>
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => onEdit(cutting),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => onDelete(cutting),
},
]}
wrapperClassName="flex shrink-0 items-center gap-1"
/>
</div>
</CardContent>
</Card>
</>
);
}

View File

@ -0,0 +1,131 @@
import { ImagePreviewButton } from '@/components/image-preview-button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatNumber } from '@/lib/format';
import type { Cutting } from './columns';
export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
const items = cutting.cutting_materials ?? [];
const combinations = cutting.cutting_material_combinations ?? [];
const groupedMaterials = items.reduce(
(acc, item) => {
const key = item.combination_id ?? `single-${item.id}`;
if (!acc[key]) {
acc[key] = {
combination: item.combination_id
? (combinations.find((c) => c.id === item.combination_id) ?? null)
: null,
materials: [],
};
}
acc[key].materials.push(item);
return acc;
},
{} as Record<
string,
{
combination: { id: number; material_result: number | null } | null;
materials: typeof items;
}
>,
);
return (
<div className="space-y-4 overflow-x-auto">
{Object.entries(groupedMaterials).map(([key, group]) => (
<div key={key} className="space-y-2">
{group.combination && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
Kombinasi
</span>
{group.combination.material_result !== null && (
<span>
Hasil: {formatNumber(group.combination.material_result)}
</span>
)}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px] text-center">
No
</TableHead>
<TableHead className="w-[60px]">Foto</TableHead>
<TableHead>Bahan Baku</TableHead>
<TableHead>Varian</TableHead>
<TableHead className="text-right">Pemakaian</TableHead>
<TableHead className="text-right">Hasil</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.materials.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
Tidak ada item.
</TableCell>
</TableRow>
) : (
group.materials.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">
{index + 1}
</TableCell>
<TableCell>
{item.raw_material_price?.photo_url ? (
<ImagePreviewButton
srcs={[
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?.raw_material?.name ?? '-'}
</TableCell>
<TableCell>
{item.raw_material_price?.variant ?? '-'}
</TableCell>
<TableCell className="text-right">
{formatNumber(item.material_usage)}
</TableCell>
<TableCell className="text-right">
{group.combination
? '-'
: (item.material_result !== null
? formatNumber(item.material_result)
: '-')}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,675 @@
'use no memo';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUpload } from '@/components/file-upload';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import InputError from '@/components/input-error';
import { NumberInput } from '@/components/number-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from '@/components/ui/combobox';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils';
import { index as cuttingIndex, update } from '@/routes/admin/manage/cuttings';
import { Form, Head } from '@inertiajs/react';
import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import type { CuttingCreateData, CuttingForEdit } from './columns';
type MaterialState = {
id?: number;
raw_material_price_id: number;
material_usage: number;
material_result: number;
combination_id: number | null;
variant: string;
material_name: string;
unit: string;
photo_url: string | null;
};
type CombinationState = {
id?: number;
material_result: number;
};
type Props = {
cutting: CuttingForEdit;
data: CuttingCreateData;
};
export default function CuttingEdit({ cutting, data }: Props) {
const { rawMaterials } = data;
const [materials, setMaterials] = useState<MaterialState[]>(() => {
const comboIdToIndex = new Map<number, number>();
cutting.combinations.forEach((c, i) => comboIdToIndex.set(c.id, i));
return cutting.materials.map((m) => {
const rawMaterial = rawMaterials.find((rm) =>
rm.raw_material_prices.some((p) => p.id === m.raw_material_price_id),
);
const price = rawMaterial?.raw_material_prices.find(
(p) => p.id === m.raw_material_price_id,
);
return {
id: m.id,
raw_material_price_id: m.raw_material_price_id,
material_usage: m.material_usage,
material_result: m.material_result ?? 0,
combination_id:
m.combination_id !== null
? (comboIdToIndex.get(m.combination_id) ?? null)
: null,
variant: price?.variant ?? m.variant ?? '',
material_name: rawMaterial?.name ?? '',
unit: rawMaterial?.unit ?? '',
photo_url: price?.photo_url ?? m.photo_url ?? null,
};
});
});
const [combinations, setCombinations] = useState<CombinationState[]>(
() =>
cutting.combinations.map((c) => ({
id: c.id,
material_result: c.material_result ?? 0,
})),
);
const [selectedMaterialName, setSelectedMaterialName] = useState('');
const [productName, setProductName] = useState(cutting.product_name);
const [sample, setSample] = useState(cutting.sample);
const [originalOutsideSample, setOriginalOutsideSample] = useState(cutting.original_outside_sample);
const cuttingResult = sample + originalOutsideSample;
const [notes, setNotes] = useState(cutting.description ?? '');
const [photo, setPhoto] = useState<string | null>(cutting.photo_key);
const [photoUrl, setPhotoUrl] = useState<string | null>(cutting.photo_url);
const [uploading, setUploading] = useState(false);
const submittingRef = useRef(false);
const [cartOpen, setCartOpen] = useState(false);
const [previewKey, setPreviewKey] = useState<string | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteMaterialIndex, setDeleteMaterialIndex] = useState<number | null>(null);
const [cartDeleteConfirmOpen, setCartDeleteConfirmOpen] = useState(false);
const [cartDeleteIndex, setCartDeleteIndex] = useState<number | null>(null);
const [comboDeleteConfirmOpen, setComboDeleteConfirmOpen] = useState(false);
const [comboDeleteIndex, setComboDeleteIndex] = useState<number | null>(null);
const [comboDialogOpen, setComboDialogOpen] = useState(false);
const [comboMaterialName, setComboMaterialName] = useState('');
const [comboSelectedPriceIds, setComboSelectedPriceIds] = useState<number[]>([]);
const [comboResult, setComboResult] = useState(0);
const comboMaterial = useMemo(
() => rawMaterials.find((m) => m.name === comboMaterialName) ?? null,
[rawMaterials, comboMaterialName],
);
const materialsRef = useRef(materials);
materialsRef.current = materials;
const priceMap = useMemo(
() => new Map(rawMaterials.flatMap((m) => m.raw_material_prices.map((p) => [p.id, p]))),
[rawMaterials],
);
const selectedMaterial = useMemo(
() => rawMaterials.find((m) => m.name === selectedMaterialName) ?? null,
[rawMaterials, selectedMaterialName],
);
const addVariant = useCallback(
(priceId: number) => {
if (!selectedMaterial) return;
const price = selectedMaterial.raw_material_prices.find((p) => p.id === priceId);
if (!price) return;
setMaterials((prev) => {
if (prev.some((m) => m.raw_material_price_id === priceId && m.combination_id === null)) return prev;
return [
...prev,
{
raw_material_price_id: price.id,
material_usage: 0,
material_result: 0,
combination_id: null,
variant: price.variant,
material_name: selectedMaterial.name,
unit: selectedMaterial.unit,
photo_url: price.photo_url,
},
];
});
},
[selectedMaterial],
);
const openComboDialog = useCallback((materialName: string, preSelectPriceId?: number) => {
setComboMaterialName(materialName);
setComboSelectedPriceIds(preSelectPriceId ? [preSelectPriceId] : []);
setComboResult(0);
setComboDialogOpen(true);
}, []);
const toggleComboPrice = useCallback((priceId: number) => {
setComboSelectedPriceIds((prev) =>
prev.includes(priceId) ? prev.filter((id) => id !== priceId) : [...prev, priceId],
);
}, []);
const confirmCombo = useCallback(() => {
if (comboSelectedPriceIds.length < 2) return;
const comboIndex = combinations.length;
const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => {
let foundMaterial: (typeof rawMaterials)[number] | undefined;
let foundPrice: (typeof rawMaterials)[number]['raw_material_prices'][number] | undefined;
for (const rm of rawMaterials) {
const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
if (p) {
foundMaterial = rm;
foundPrice = p;
break;
}
}
return {
raw_material_price_id: priceId,
material_usage: 0,
material_result: 0,
combination_id: comboIndex,
variant: foundPrice?.variant ?? '',
material_name: foundMaterial?.name ?? '',
unit: foundMaterial?.unit ?? '',
photo_url: foundPrice?.photo_url ?? null,
};
});
setMaterials((prev) => [...prev, ...newMaterials]);
setCombinations((prev) => [
...prev,
{
material_result: comboResult,
},
]);
setComboDialogOpen(false);
setComboMaterialName('');
setComboSelectedPriceIds([]);
setComboResult(0);
}, [comboSelectedPriceIds, comboResult, rawMaterials, combinations.length]);
const updateMaterial = useCallback(
(index: number, field: keyof MaterialState, value: unknown) => {
setMaterials((prev) => {
const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value;
return updated;
});
},
[],
);
const updateCombinationResult = useCallback((comboIndex: number, value: number) => {
setCombinations((prev) => prev.map((c, i) => (i === comboIndex ? { ...c, material_result: value } : c)));
}, []);
const totalMaterialCost = useMemo(() => {
return materials.reduce((sum, m) => {
const price = priceMap.get(m.raw_material_price_id);
return sum + (price ? price.price * m.material_usage : 0);
}, 0);
}, [materials, priceMap]);
const totalCost = totalMaterialCost;
const costPerUnit = cuttingResult > 0 ? Math.floor(totalCost / cuttingResult) : 0;
function formatQuantity(value: number): string {
return formatNumber(value, { maximumFractionDigits: 4 });
}
function getPayload() {
return {
description: notes || null,
product_name: productName || null,
sample: sample || null,
original_outside_sample: originalOutsideSample || null,
cutting_result: cuttingResult || null,
materials: materialsRef.current.map((m) => ({
raw_material_price_id: m.raw_material_price_id,
material_usage: m.material_usage,
material_result: m.material_result,
combination_index: m.combination_id,
})),
combinations: combinations.map((c) => ({
material_result: c.material_result,
})),
photo_key: photo,
};
}
return (
<>
<Head title="Edit Cutting" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Edit Cutting</h2>
<Button asChild variant="outline">
<a href={cuttingIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</a>
</Button>
</div>
<Form action={update(cutting.id)} transform={(data) => ({ ...data, ...getPayload() })} onSubmit={() => { submittingRef.current = true; }}>
{({ errors, processing }) => (
<div className="grid gap-6 md:grid-cols-3">
<div className="space-y-6 md:col-span-2">
<Card>
<CardHeader>
<CardTitle>Pilih Bahan Baku</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<Label>
Nama Bahan Baku <span className="text-destructive">*</span>
</Label>
<Combobox
items={rawMaterials}
itemToStringLabel={(m) => m.name}
value={selectedMaterial}
onValueChange={(value) => setSelectedMaterialName(value?.name ?? '')}
>
<ComboboxInput placeholder="Cari bahan baku..." className="w-full" />
<ComboboxContent>
<ComboboxEmpty>Tidak ada bahan baku ditemukan.</ComboboxEmpty>
<ComboboxList>
{(m) => (
<ComboboxItem key={m.id} value={m}>
{m.name} ({m.unit})
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
{selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Pilih Varian</Label>
<div className="space-y-2">
{selectedMaterial.raw_material_prices.map((price) => {
const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
return (
<div key={price.id} className={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}>
<div className="flex min-w-0 items-center gap-3">
{price.photo_url ? (
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}
<div className="min-w-0">
<p className="truncate font-medium">{price.variant}</p>
<p className="text-xs text-muted-foreground">
Stok: {formatNumber(Number(price.stock))} {selectedMaterial.unit} · {formatCurrency(price.price)}
{addedCount > 0 && ` · ×${addedCount}`}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(price.id)}>
<Plus className="h-4 w-4" />
Tambah
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => openComboDialog(selectedMaterial.name, price.id)}>
<Layers className="h-4 w-4" />
Kombinasi
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-6 md:col-span-1">
<Card className="sticky top-6">
<CardHeader>
<CardTitle>Ringkasan</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="product_name">
Nama Produk <span className="text-destructive">*</span>
</Label>
<Input id="product_name" name="product_name" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Masukkan nama produk" />
<InputError message={errors.product_name} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="sample">
Sample <span className="text-destructive">*</span>
</Label>
<NumberInput id="sample" value={sample} onValueChange={setSample} />
<InputError message={errors.sample} />
</div>
<div className="grid gap-2">
<Label htmlFor="original_outside_sample">
Diluar Sample <span className="text-destructive">*</span>
</Label>
<NumberInput id="original_outside_sample" value={originalOutsideSample} onValueChange={setOriginalOutsideSample} />
<InputError message={errors.original_outside_sample} />
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="cutting_result">Hasil</Label>
<NumberInput id="cutting_result" value={cuttingResult} disabled />
<InputError message={errors.cutting_result} />
</div>
<div className="space-y-2">
<div className="border-t pt-2">
<div className="flex items-center justify-between text-sm font-semibold">
<span>Total</span>
<span>{formatCurrency(totalCost)}</span>
</div>
</div>
{cuttingResult > 0 && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Biaya Per Unit</span>
<span className="font-medium">{formatCurrency(costPerUnit)}</span>
</div>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Keterangan</Label>
<Textarea id="notes" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Masukkan keterangan" maxLength={100} />
<InputError message={errors.description} />
</div>
<div className="grid gap-2">
<Label>Foto</Label>
<FileUpload value={photo} onChange={(key) => { setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null); }} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} />
<InputError message={errors.photo_key} />
</div>
<Button type="submit" className="w-full" disabled={processing || submittingRef.current || uploading || materials.length === 0 || !productName || !sample}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</CardContent>
</Card>
</div>
</div>
)}
</Form>
<Button type="button" onClick={() => setCartOpen(true)} className="fixed top-1/2 right-4 z-50 h-14 w-14 -translate-y-1/2 rounded-full shadow-lg" size="icon" aria-label="Buka keranjang cutting">
<ShoppingCart className="h-5 w-5" />
{materials.length > 0 && (
<span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs font-semibold text-white">
{materials.length}
</span>
)}
</Button>
<Sheet open={cartOpen} onOpenChange={setCartOpen}>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>Keranjang Cutting</SheetTitle>
</SheetHeader>
<div className="flex-1 space-y-3 overflow-y-auto px-6 pb-6">
{materials.length === 0 ? (
<p className="text-sm text-muted-foreground">Keranjang kosong.</p>
) : (
(() => {
const groups: { comboIndex: number | null; items: { m: MaterialState; index: number }[] }[] = [];
const comboMap = new Map<number, { m: MaterialState; index: number }[]>();
const singleItems: { m: MaterialState; index: number }[] = [];
materials.forEach((m, i) => {
if (m.combination_id !== null) {
if (!comboMap.has(m.combination_id)) comboMap.set(m.combination_id, []);
comboMap.get(m.combination_id)!.push({ m, index: i });
} else {
singleItems.push({ m, index: i });
}
});
comboMap.forEach((items, comboIdx) => groups.push({ comboIndex: comboIdx, items }));
singleItems.forEach((item) => groups.push({ comboIndex: null, items: [item] }));
return groups.map((group, gi) => (
<div key={gi} className="space-y-2 rounded-lg border p-3">
{group.comboIndex !== null && (
<div className="flex items-center justify-between border-b pb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground">Kombinasi {group.comboIndex + 1}</span>
<span className="text-xs text-muted-foreground">·</span>
<Label className="text-xs whitespace-nowrap">Hasil <span className="text-destructive">*</span></Label>
<NumberInput
className="w-20"
value={combinations[group.comboIndex]?.material_result ?? 0}
onValueChange={(val) => updateCombinationResult(group.comboIndex!, val)}
/>
</div>
<Button type="button" variant="ghost" size="icon-sm" onClick={() => {
setComboDeleteIndex(group.comboIndex);
setComboDeleteConfirmOpen(true);
}}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
)}
{group.items.map(({ m, index }) => {
const price = priceMap.get(m.raw_material_price_id);
const cartKey = `material-${index}`;
return (
<div key={cartKey} className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-3">
{m.photo_url ? (
<button type="button" onClick={() => setPreviewKey(cartKey)} className="block h-8 w-8 shrink-0 overflow-hidden rounded-md border transition-opacity hover:opacity-80">
<img src={m.photo_url} alt={m.variant} className="h-full w-full object-cover" />
</button>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}
<div>
<p className="text-sm font-medium">{m.material_name}</p>
<p className="text-xs text-muted-foreground">{m.variant}{price ? ` · ${formatNumber(Number(price.stock))} ${m.unit}` : ''}</p>
</div>
</div>
{group.comboIndex === null && (
<Button type="button" variant="ghost" size="icon-sm" onClick={() => {
setCartDeleteIndex(index);
setCartDeleteConfirmOpen(true);
}}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">Pemakaian <span className="text-destructive">*</span></span>
<NumberInput
className="flex-1"
value={m.material_usage}
onValueChange={(val) => updateMaterial(index, 'material_usage', val)}
/>
</div>
{group.comboIndex === null && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">Hasil <span className="text-destructive">*</span></span>
<NumberInput
className="flex-1"
value={m.material_result}
onValueChange={(val) => updateMaterial(index, 'material_result', val)}
/>
</div>
)}
</div>
);
})}
</div>
));
})()
)}
</div>
<SheetFooter>
<div className="flex items-center justify-between border-t pt-4">
<span className="text-sm">Total Pemakaian</span>
<span className="text-sm font-semibold">{formatQuantity(materials.reduce((sum, m) => sum + m.material_usage, 0))}</span>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
<ImagePreviewModal
open={previewKey !== null}
onOpenChange={(open) => { if (!open) setPreviewKey(null); }}
src={previewKey && previewKey.startsWith('material-') ? (materials[Number(previewKey.replace('material-', ''))]?.photo_url ?? null) : null}
title={previewKey && previewKey.startsWith('material-') ? `${materials[Number(previewKey.replace('material-', ''))]?.material_name}${materials[Number(previewKey.replace('material-', ''))]?.variant}` : undefined}
sources={materials.filter((m) => m.photo_url).map((m) => m.photo_url!)}
/>
{comboDialogOpen && (
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => setComboDialogOpen(false)} />
)}
<Dialog open={comboDialogOpen} onOpenChange={setComboDialogOpen} modal={false}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Tambah Kombinasi</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid gap-2">
<Label>
Nama Bahan Baku <span className="text-destructive">*</span>
</Label>
<Combobox
items={rawMaterials}
itemToStringLabel={(m) => m.name}
value={comboMaterial}
onValueChange={(value) => setComboMaterialName(value?.name ?? '')}
>
<ComboboxInput placeholder="Cari bahan baku..." className="w-full" />
<ComboboxContent>
<ComboboxEmpty>Tidak ada bahan baku ditemukan.</ComboboxEmpty>
<ComboboxList>
{(m) => (
<ComboboxItem key={m.id} value={m}>
{m.name} ({m.unit})
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
{comboSelectedPriceIds.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Varian Dipilih</Label>
<div className="space-y-1">
{comboSelectedPriceIds.map((priceId) => {
let variantName = '';
let materialName = '';
let photoUrl: string | null = null;
for (const rm of rawMaterials) {
const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
if (p) {
variantName = p.variant;
materialName = rm.name;
photoUrl = p.photo_url;
break;
}
}
return (
<div key={priceId} className="flex items-center justify-between gap-2 rounded-md border border-primary bg-primary/5 px-3 py-2">
<div className="flex min-w-0 items-center gap-3">
{photoUrl ? (
<img src={photoUrl} alt={variantName} className="h-8 w-8 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}
<div className="min-w-0">
<p className="text-sm font-medium truncate">{variantName}</p>
<p className="text-xs text-muted-foreground">{materialName}</p>
</div>
</div>
<Button type="button" variant="ghost" size="icon-sm" onClick={() => toggleComboPrice(priceId)}>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
);
})}
</div>
</div>
)}
{comboMaterial && comboMaterial.raw_material_prices.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Pilih Varian</Label>
<div className="space-y-2">
{comboMaterial.raw_material_prices.map((price) => {
const isSelected = comboSelectedPriceIds.includes(price.id);
return (
<div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}>
<div className="flex min-w-0 items-center gap-3">
{price.photo_url ? (
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}
<div className="min-w-0">
<p className="truncate font-medium">{price.variant}</p>
<p className="text-xs text-muted-foreground">Stok: {formatNumber(Number(price.stock))} {comboMaterial.unit}</p>
</div>
</div>
<Button type="button" variant={isSelected ? 'default' : 'outline'} size="sm" onClick={() => toggleComboPrice(price.id)}>
{isSelected ? <><Check className="h-4 w-4" /> Dipilih</> : 'Pilih'}
</Button>
</div>
);
})}
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setComboDialogOpen(false)}>Batal</Button>
<Button type="button" onClick={confirmCombo} disabled={comboSelectedPriceIds.length < 2}>
Konfirmasi
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog open={deleteConfirmOpen} onOpenChange={(open) => { if (!open) { setDeleteConfirmOpen(false); setDeleteMaterialIndex(null); } }} title="Hapus Bahan Baku" description="Apakah Anda yakin ingin menghapus bahan baku ini?" confirmLabel="Hapus" onConfirm={() => { if (deleteMaterialIndex !== null) setMaterials((prev) => prev.filter((_, i) => i !== deleteMaterialIndex)); setDeleteConfirmOpen(false); setDeleteMaterialIndex(null); }} />
<ConfirmDialog open={cartDeleteConfirmOpen} onOpenChange={(open) => { if (!open) { setCartDeleteConfirmOpen(false); setCartDeleteIndex(null); } }} title="Hapus dari Keranjang" description="Apakah Anda yakin ingin menghapus item ini dari keranjang?" confirmLabel="Hapus" variant="destructive" onConfirm={() => { if (cartDeleteIndex !== null) setMaterials((prev) => prev.filter((_, i) => i !== cartDeleteIndex)); setCartDeleteConfirmOpen(false); setCartDeleteIndex(null); }} />
<ConfirmDialog open={comboDeleteConfirmOpen} onOpenChange={(open) => { if (!open) { setComboDeleteConfirmOpen(false); setComboDeleteIndex(null); } }} title="Hapus Kombinasi" description="Apakah Anda yakin ingin menghapus kombinasi ini beserta semua bahannya?" confirmLabel="Hapus" variant="destructive" onConfirm={() => { if (comboDeleteIndex !== null) { setCombinations((prev) => prev.filter((_, i) => i !== comboDeleteIndex)); setMaterials((prev) => prev.filter((m) => m.combination_id !== comboDeleteIndex)); } setComboDeleteConfirmOpen(false); setComboDeleteIndex(null); }} />
</div>
</>
);
}

View File

@ -0,0 +1,132 @@
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { CardTable } from '@/components/card-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { useServerTable } from '@/hooks/use-server-table';
import {
destroy,
create as cuttingCreate,
index as cuttingIndex,
edit as cuttingEdit,
} from '@/routes/admin/manage/cuttings';
import type { Cutting } from './columns';
import { CuttingCardRow } from './cutting-card';
import { CuttingItemSubRow } from './cutting-sub-row';
type Props = {
cuttings: {
data: Cutting[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
};
export default function CuttingIndex({ cuttings }: Props) {
const [deleting, setDeleting] = useState<Cutting | null>(null);
const expand = useCardTableExpand(true);
const pagination = {
current_page: cuttings.current_page,
last_page: cuttings.last_page,
per_page: cuttings.per_page,
total: cuttings.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => cuttingIndex.url(),
pagination,
});
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy.url(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
return (
<>
<Head title="Cutting" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Cutting"
actions={
<Button asChild>
<a href={cuttingCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
}
/>
<CardTable
data={cuttings.data}
getItemKey={(c) => c.id}
expandedKeys={expand.expandedKeys}
onToggleExpand={expand.toggleExpand}
searchValue={search}
onSearchChange={handleSearchChange}
searchPlaceholder="Cari berdasarkan nama produk..."
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
renderCard={({
item,
index,
isExpanded,
onToggleExpand,
}) => (
<CuttingCardRow
cutting={item}
index={
(pagination.current_page - 1) *
pagination.per_page +
index +
1
}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
onEdit={(c) => {
window.location.href = cuttingEdit.url(c.id);
}}
onDelete={(c) => setDeleting(c)}
/>
)}
renderSubContent={(cutting) => (
<CuttingItemSubRow cutting={cutting} />
)}
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Cutting"
description={(cutting) =>
`Apakah Anda yakin ingin menghapus cutting "${cutting.cutting_results?.[0]?.product_name ?? '-'}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
</div>
</>
);
}

View File

@ -1,3 +1,5 @@
import { Form, Head } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -5,8 +7,6 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { index as rolesIndex, store } from '@/routes/admin/settings/roles';
import { Form, Head } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
type PermissionsByModule = Record<string, string[]>;

View File

@ -1,3 +1,5 @@
import { Form, Head } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -5,8 +7,6 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { index as rolesIndex, update } from '@/routes/admin/settings/roles';
import { Form, Head } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
type PermissionsByModule = Record<string, string[]>;

View File

@ -1,3 +1,6 @@
import { Form, Head } from '@inertiajs/react';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { FileUpload } from '@/components/file-upload';
import InputError from '@/components/input-error';
import { PhoneNumberInput } from '@/components/phone-number-input';
@ -18,9 +21,6 @@ import {
updateSocialMedia,
updateSystem,
} from '@/routes/admin/settings';
import { Form, Head } from '@inertiajs/react';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
type MarketplaceFeeRule = {
base: string;

View File

@ -1,3 +1,4 @@
import { Form, Head } from '@inertiajs/react';
import InputError from '@/components/input-error';
import PasswordInput from '@/components/password-input';
import { Button } from '@/components/ui/button';
@ -6,7 +7,6 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { store } from '@/routes/login';
import { Form, Head } from '@inertiajs/react';
export default function Login() {
return (

View File

@ -1,6 +1,6 @@
import { Head } from '@inertiajs/react';
import AppearanceTabs from '@/components/appearance-tabs';
import { edit as editAppearance } from '@/routes/appearance';
import { Head } from '@inertiajs/react';
export default function Appearance() {
return (

View File

@ -146,7 +146,7 @@ export default function Permissions() {
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- permission check on mount is safe
checkCameraPermission();
// eslint-disable-next-line react-hooks/set-state-in-effect -- permission check on mount is safe
checkLocationPermission();
}, [checkCameraPermission, checkLocationPermission]);

View File

@ -1,3 +1,5 @@
import { Form, Head } from '@inertiajs/react';
import { useState } from 'react';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error';
@ -9,8 +11,6 @@ import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea';
import { edit } from '@/routes/profile';
import { Form, Head } from '@inertiajs/react';
import { useState } from 'react';
type UserData = {
id: number;

View File

@ -1,3 +1,5 @@
import { Form, Head } from '@inertiajs/react';
import { useRef } from 'react';
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
import InputError from '@/components/input-error';
import PasswordInput from '@/components/password-input';
@ -5,8 +7,6 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { edit } from '@/routes/security';
import { Form, Head } from '@inertiajs/react';
import { useRef } from 'react';
type Props = {
passwordRules: string;

View File

@ -1,6 +1,8 @@
<?php
use App\Http\Controllers\Admin\AdminSettingsController;
use App\Http\Controllers\Admin\AnalysisController;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\Finance\CashAccountController;
use App\Http\Controllers\Admin\Finance\EmployeeAdvanceController;
use App\Http\Controllers\Admin\Finance\ExpenseController;
@ -10,6 +12,7 @@
use App\Http\Controllers\Admin\HR\AttendanceController;
use App\Http\Controllers\Admin\HR\EmployeeController;
use App\Http\Controllers\Admin\HR\LeaveRequestController;
use App\Http\Controllers\Admin\Manage\CuttingController;
use App\Http\Controllers\Admin\Manage\PurchaseController;
use App\Http\Controllers\Admin\Manage\RestockController;
use App\Http\Controllers\Admin\Master\CategoryController;
@ -23,7 +26,7 @@
use App\Http\Controllers\Admin\RoleController;
use Illuminate\Support\Facades\Route;
Route::get('/', fn () => inertia('welcome', [
Route::get('/', fn() => inertia('welcome', [
'seo' => [
'title' => 'DST Collection - DST Punya Gaya',
'description' => 'DST Collection - DST Punya Gaya. Toko fashion terpercaya dengan koleksi terlengkap.',
@ -113,8 +116,9 @@
Route::prefix('admin/manage')->name('admin.manage.')->group(function () {
Route::resource('purchases', PurchaseController::class)->except(['show'])->middleware('permission:purchase.view|purchase.create|purchase.update|purchase.delete');
Route::resource('cuttings', CuttingController::class)->except(['show'])->middleware('permission:cutting.view|cutting.create|cutting.update|cutting.delete');
Route::resource('restocks', RestockController::class)->except(['show'])->middleware('permission:restock.view|restock.create|restock.update|restock.delete');
});
});
require __DIR__.'/settings.php';
require __DIR__ . '/settings.php';

View File

@ -0,0 +1,747 @@
<?php
use App\Models\Cutting;
use App\Models\CuttingMaterial;
use App\Models\CuttingMaterialCombination;
use App\Models\CuttingResult;
use App\Models\RawMaterialPrice;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| HELPERS
|--------------------------------------------------------------------------
*/
function giveCuttingPermissions(User $user): void
{
$seeder = new RolePermissionSeeder;
$seeder->run();
$user->givePermissionTo([
'cutting.view',
'cutting.create',
'cutting.update',
'cutting.delete',
]);
}
function makeValidCuttingPayload(array $overrides = []): array
{
$price = RawMaterialPrice::factory()->create(['stock' => 100]);
return array_merge([
'product_name' => 'Produk Test',
'sample' => 5,
'original_outside_sample' => 10,
'cutting_result' => 15,
'description' => 'Keterangan test',
'materials' => [
[
'raw_material_price_id' => $price->id,
'material_usage' => 20,
'material_result' => 15,
'combination_index' => null,
],
],
'combinations' => [],
'photo_key' => null,
], $overrides);
}
/*
|--------------------------------------------------------------------------
| AUTHENTICATION
|--------------------------------------------------------------------------
*/
test('guests are redirected to the login page', function () {
$response = $this->get(route('admin.manage.cuttings.index'));
$response->assertRedirect(route('login'));
});
test('guests are redirected when visiting create page', function () {
$response = $this->get(route('admin.manage.cuttings.create'));
$response->assertRedirect(route('login'));
});
test('guests are redirected when visiting edit page', function () {
$cutting = Cutting::factory()->create();
$response = $this->get(route('admin.manage.cuttings.edit', $cutting));
$response->assertRedirect(route('login'));
});
test('guest cannot create cutting', function () {
$response = $this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload());
$response->assertRedirect(route('login'));
$this->assertDatabaseCount('cuttings', 0);
});
test('guest cannot update cutting', function () {
$cutting = Cutting::factory()->create();
$response = $this->put(route('admin.manage.cuttings.update', $cutting), makeValidCuttingPayload());
$response->assertRedirect(route('login'));
});
test('guest cannot delete cutting', function () {
$cutting = Cutting::factory()->create();
$response = $this->delete(route('admin.manage.cuttings.destroy', $cutting));
$response->assertRedirect(route('login'));
$this->assertDatabaseHas('cuttings', ['id' => $cutting->id, 'deleted_at' => null]);
});
/*
|--------------------------------------------------------------------------
| AUTHORIZATION
|--------------------------------------------------------------------------
*/
test('user without permission cannot view cuttings', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->get(route('admin.manage.cuttings.index'))->assertForbidden();
});
test('user without permission cannot visit create page', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->get(route('admin.manage.cuttings.create'))->assertForbidden();
});
test('user without permission cannot store cutting', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload())->assertForbidden();
$this->assertDatabaseCount('cuttings', 0);
});
test('user without permission cannot visit edit page', function () {
$user = User::factory()->create();
$this->actingAs($user);
$cutting = Cutting::factory()->create();
$this->get(route('admin.manage.cuttings.edit', $cutting))->assertForbidden();
});
test('user without permission cannot update cutting', function () {
$user = User::factory()->create();
$this->actingAs($user);
$cutting = Cutting::factory()->create();
$this->put(route('admin.manage.cuttings.update', $cutting), makeValidCuttingPayload())->assertForbidden();
});
test('user without permission cannot delete cutting', function () {
$user = User::factory()->create();
$this->actingAs($user);
$cutting = Cutting::factory()->create();
$this->delete(route('admin.manage.cuttings.destroy', $cutting))->assertForbidden();
$this->assertDatabaseHas('cuttings', ['id' => $cutting->id, 'deleted_at' => null]);
});
/*
|--------------------------------------------------------------------------
| INDEX
|--------------------------------------------------------------------------
*/
test('user with permission can view cuttings', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
Cutting::factory()->count(3)->create(['created_by_id' => $user->id]);
$this->get(route('admin.manage.cuttings.index'))->assertOk();
});
/*
|--------------------------------------------------------------------------
| CREATE
|--------------------------------------------------------------------------
*/
test('user with permission can visit create page', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$this->get(route('admin.manage.cuttings.create'))->assertOk();
});
/*
|--------------------------------------------------------------------------
| STORE - VALIDATION
|--------------------------------------------------------------------------
*/
test('store requires product_name', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$payload = makeValidCuttingPayload();
unset($payload['product_name']);
$this->post(route('admin.manage.cuttings.store'), $payload)
->assertSessionHasErrors('product_name');
});
test('store requires sample', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$payload = makeValidCuttingPayload();
unset($payload['sample']);
$this->post(route('admin.manage.cuttings.store'), $payload)
->assertSessionHasErrors('sample');
});
test('store requires original_outside_sample', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$payload = makeValidCuttingPayload();
unset($payload['original_outside_sample']);
$this->post(route('admin.manage.cuttings.store'), $payload)
->assertSessionHasErrors('original_outside_sample');
});
test('store requires cutting_result', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$payload = makeValidCuttingPayload();
unset($payload['cutting_result']);
$this->post(route('admin.manage.cuttings.store'), $payload)
->assertSessionHasErrors('cutting_result');
});
test('store requires at least one material', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$payload = makeValidCuttingPayload(['materials' => []]);
$this->post(route('admin.manage.cuttings.store'), $payload)
->assertSessionHasErrors('materials');
});
test('store requires materials.raw_material_price_id', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$payload = makeValidCuttingPayload();
unset($payload['materials'][0]['raw_material_price_id']);
$this->post(route('admin.manage.cuttings.store'), $payload)
->assertSessionHasErrors('materials.0.raw_material_price_id');
});
test('store requires materials.material_usage', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$payload = makeValidCuttingPayload();
unset($payload['materials'][0]['material_usage']);
$this->post(route('admin.manage.cuttings.store'), $payload)
->assertSessionHasErrors('materials.0.material_usage');
});
test('store requires materials.material_result', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$payload = makeValidCuttingPayload();
unset($payload['materials'][0]['material_result']);
$this->post(route('admin.manage.cuttings.store'), $payload)
->assertSessionHasErrors('materials.0.material_result');
});
/*
|--------------------------------------------------------------------------
| STORE - SUCCESS
|--------------------------------------------------------------------------
*/
test('user with permission can store cutting', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload())
->assertRedirect(route('admin.manage.cuttings.index'));
$this->assertDatabaseHas('cuttings', ['created_by_id' => $user->id]);
$this->assertDatabaseHas('cutting_results', ['product_name' => 'Produk Test']);
});
test('store creates cutting material', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$price = RawMaterialPrice::factory()->create(['stock' => 100]);
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload([
'materials' => [
[
'raw_material_price_id' => $price->id,
'material_usage' => 20,
'material_result' => 15,
'combination_index' => null,
],
],
]))->assertRedirect();
$cutting = Cutting::latest()->first();
$this->assertDatabaseHas('cutting_materials', [
'cutting_id' => $cutting->id,
'raw_material_price_id' => $price->id,
'material_usage' => 20,
'material_result' => 15,
]);
});
test('store deducts stock from raw_material_price', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$price = RawMaterialPrice::factory()->create(['stock' => 100]);
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload([
'materials' => [
[
'raw_material_price_id' => $price->id,
'material_usage' => 30,
'material_result' => 25,
'combination_index' => null,
],
],
]))->assertRedirect();
$price->refresh();
$this->assertEquals(70, $price->stock);
});
test('store with combination creates combination record', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$price1 = RawMaterialPrice::factory()->create(['stock' => 100]);
$price2 = RawMaterialPrice::factory()->create(['stock' => 50]);
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload([
'materials' => [
[
'raw_material_price_id' => $price1->id,
'material_usage' => 15,
'material_result' => 12,
'combination_index' => 0,
],
[
'raw_material_price_id' => $price2->id,
'material_usage' => 10,
'material_result' => 8,
'combination_index' => 0,
],
],
'combinations' => [
['material_result' => 20],
],
]))->assertRedirect();
$cutting = Cutting::latest()->first();
$this->assertDatabaseHas('cutting_material_combinations', [
'cutting_id' => $cutting->id,
'material_result' => 20,
]);
$combination = CuttingMaterialCombination::where('cutting_id', $cutting->id)->first();
$this->assertDatabaseHas('cutting_materials', [
'cutting_id' => $cutting->id,
'raw_material_price_id' => $price1->id,
'combination_id' => $combination->id,
]);
$this->assertDatabaseHas('cutting_materials', [
'cutting_id' => $cutting->id,
'raw_material_price_id' => $price2->id,
'combination_id' => $combination->id,
]);
});
test('store deducts stock for multiple materials', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$price1 = RawMaterialPrice::factory()->create(['stock' => 100]);
$price2 = RawMaterialPrice::factory()->create(['stock' => 50]);
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload([
'materials' => [
[
'raw_material_price_id' => $price1->id,
'material_usage' => 25,
'material_result' => 20,
'combination_index' => null,
],
[
'raw_material_price_id' => $price2->id,
'material_usage' => 15,
'material_result' => 12,
'combination_index' => 0,
],
],
'combinations' => [
['material_result' => 18],
],
]))->assertRedirect();
$price1->refresh();
$price2->refresh();
$this->assertEquals(75, $price1->stock);
$this->assertEquals(35, $price2->stock);
});
test('store with photo_key registers media', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload([
'photo_key' => 'cuttings/test-photo.jpg',
]))->assertRedirect();
$cutting = Cutting::latest()->first();
$this->assertNotNull($cutting->getFirstMedia('photos'));
});
/*
|--------------------------------------------------------------------------
| STORE - STOCK VALIDATION
|--------------------------------------------------------------------------
*/
test('store fails when stock is insufficient', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$price = RawMaterialPrice::factory()->create(['stock' => 10]);
$response = $this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload([
'materials' => [
[
'raw_material_price_id' => $price->id,
'material_usage' => 20,
'material_result' => 15,
'combination_index' => null,
],
],
]));
$response->assertRedirect();
$this->assertDatabaseCount('cuttings', 0);
$price->refresh();
$this->assertEquals(10, $price->stock);
});
/*
|--------------------------------------------------------------------------
| EDIT
|--------------------------------------------------------------------------
*/
test('user with permission can visit edit page', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
$this->get(route('admin.manage.cuttings.edit', $cutting))->assertOk();
});
/*
|--------------------------------------------------------------------------
| UPDATE - VALIDATION
|--------------------------------------------------------------------------
*/
test('update requires product_name', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
$payload = makeValidCuttingPayload();
unset($payload['product_name']);
$this->put(route('admin.manage.cuttings.update', $cutting), $payload)
->assertSessionHasErrors('product_name');
});
test('update requires sample', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
$payload = makeValidCuttingPayload();
unset($payload['sample']);
$this->put(route('admin.manage.cuttings.update', $cutting), $payload)
->assertSessionHasErrors('sample');
});
test('update requires original_outside_sample', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
$payload = makeValidCuttingPayload();
unset($payload['original_outside_sample']);
$this->put(route('admin.manage.cuttings.update', $cutting), $payload)
->assertSessionHasErrors('original_outside_sample');
});
test('update requires cutting_result', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
$payload = makeValidCuttingPayload();
unset($payload['cutting_result']);
$this->put(route('admin.manage.cuttings.update', $cutting), $payload)
->assertSessionHasErrors('cutting_result');
});
/*
|--------------------------------------------------------------------------
| UPDATE - SUCCESS
|--------------------------------------------------------------------------
*/
test('user with permission can update cutting', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$price = RawMaterialPrice::factory()->create(['stock' => 100]);
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
$this->put(route('admin.manage.cuttings.update', $cutting), makeValidCuttingPayload([
'product_name' => 'Updated Product',
'materials' => [
[
'raw_material_price_id' => $price->id,
'material_usage' => 25,
'material_result' => 20,
'combination_index' => null,
],
],
]))->assertRedirect(route('admin.manage.cuttings.index'));
$this->assertDatabaseHas('cutting_results', [
'cutting_id' => $cutting->id,
'product_name' => 'Updated Product',
]);
});
test('update adjusts stock correctly', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$price = RawMaterialPrice::factory()->create(['stock' => 100]);
// First store with usage 20
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload([
'materials' => [
[
'raw_material_price_id' => $price->id,
'material_usage' => 20,
'material_result' => 15,
'combination_index' => null,
],
],
]));
$price->refresh();
$this->assertEquals(80, $price->stock);
$cutting = Cutting::latest()->first();
// Update: restore old (20), then deduct new (30) -> 80 + 20 - 30 = 70
$this->put(route('admin.manage.cuttings.update', $cutting), makeValidCuttingPayload([
'materials' => [
[
'raw_material_price_id' => $price->id,
'material_usage' => 30,
'material_result' => 25,
'combination_index' => null,
],
],
]));
$price->refresh();
$this->assertEquals(70, $price->stock);
});
test('update fails when stock is insufficient after restore', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$price = RawMaterialPrice::factory()->create(['stock' => 5]);
// Store with usage 3
$this->post(route('admin.manage.cuttings.store'), makeValidCuttingPayload([
'materials' => [
[
'raw_material_price_id' => $price->id,
'material_usage' => 3,
'material_result' => 2,
'combination_index' => null,
],
],
]));
$price->refresh();
$this->assertEquals(2, $price->stock);
$cutting = Cutting::latest()->first();
// Update: restore old (3) -> 2 + 3 = 5, then try to deduct 10 -> fail
// Stock remains at 5 because restore happens before validation
$this->put(route('admin.manage.cuttings.update', $cutting), makeValidCuttingPayload([
'materials' => [
[
'raw_material_price_id' => $price->id,
'material_usage' => 10,
'material_result' => 8,
'combination_index' => null,
],
],
]))->assertRedirect();
$price->refresh();
$this->assertEquals(5, $price->stock);
});
/*
|--------------------------------------------------------------------------
| DESTROY - SUCCESS
|--------------------------------------------------------------------------
*/
test('user with permission can delete cutting', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
$this->delete(route('admin.manage.cuttings.destroy', $cutting))
->assertRedirect(route('admin.manage.cuttings.index'));
$this->assertSoftDeleted('cuttings', ['id' => $cutting->id]);
});
test('delete restores stock from raw_material_prices', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$price = RawMaterialPrice::factory()->create(['stock' => 100]);
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
CuttingMaterial::create([
'cutting_id' => $cutting->id,
'raw_material_price_id' => $price->id,
'material_usage' => 30,
'material_result' => 25,
]);
$this->delete(route('admin.manage.cuttings.destroy', $cutting))
->assertRedirect(route('admin.manage.cuttings.index'));
$price->refresh();
$this->assertEquals(130, $price->stock);
});
test('delete restores stock for multiple materials', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$price1 = RawMaterialPrice::factory()->create(['stock' => 50]);
$price2 = RawMaterialPrice::factory()->create(['stock' => 30]);
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
CuttingMaterial::create([
'cutting_id' => $cutting->id,
'raw_material_price_id' => $price1->id,
'material_usage' => 20,
'material_result' => 15,
]);
CuttingMaterial::create([
'cutting_id' => $cutting->id,
'raw_material_price_id' => $price2->id,
'material_usage' => 10,
'material_result' => 8,
]);
$this->delete(route('admin.manage.cuttings.destroy', $cutting))
->assertRedirect(route('admin.manage.cuttings.index'));
$price1->refresh();
$price2->refresh();
$this->assertEquals(70, $price1->stock);
$this->assertEquals(40, $price2->stock);
});
test('delete removes cutting_material_combinations', function () {
$user = User::factory()->create();
giveCuttingPermissions($user);
$this->actingAs($user);
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
$combination = CuttingMaterialCombination::create([
'cutting_id' => $cutting->id,
'material_result' => 15,
]);
$this->delete(route('admin.manage.cuttings.destroy', $cutting))
->assertRedirect(route('admin.manage.cuttings.index'));
$this->assertSoftDeleted('cutting_material_combinations', ['id' => $combination->id]);
});