Compare commits

...

10 Commits

Author SHA1 Message Date
Yoga Pangestu
71a7863244 feat: remove submittedCuttings relation and related attributes from Cutting and User models; update cuttings migration to remove submitted_by_id 2026-08-13 10:49:06 +07:00
Yoga Pangestu
a778e2f499 feat: add cutting detail view and enhance cutting data handling in various components 2026-08-13 10:35:04 +07:00
Yoga Pangestu
5182dcc012 feat: enhance search functionality in paginated methods to include related product and raw material variants 2026-08-13 08:48:44 +07:00
Yoga Pangestu
5a89a0d1e3 feat: update attribute labels in various request files to use lowercase Indonesian terms for consistency 2026-08-13 08:39:32 +07:00
Yoga Pangestu
9f46b6d6be Add localization files for authentication, pagination, passwords, and validation in English and Indonesian
- Created English language files for authentication, pagination, passwords, and validation with default messages.
- Added Indonesian language files for authentication, pagination, passwords, and validation with translated messages.
- Included necessary comments for clarity and customization in language files.
2026-08-13 08:35:18 +07:00
Yoga Pangestu
1f41f04199 feat: remove unused media collection clearing in destroy methods and add CleanupOrphanedMediaJob for orphaned media management 2026-08-13 00:39:46 +07:00
Yoga Pangestu
e1a0d25c3c feat: update relationships in models to include trashed records for CuttingMaterial, OrderItem, ProductVariant, and RestockItem 2026-08-13 00:28:10 +07:00
Yoga Pangestu
1e1f195f2d feat: update main labels in analysis statistics for clarity 2026-08-13 00:27:54 +07:00
Yoga Pangestu
ed93fd3eac feat: enhance cutting management with product name and status filters in the index view 2026-08-13 00:09:33 +07:00
Yoga Pangestu
6c1c14c588 feat: enhance image preview components with additional item handling and navigation features 2026-08-12 23:58:00 +07:00
62 changed files with 2432 additions and 303 deletions

View File

@ -439,7 +439,6 @@ ### Relationship Naming
// Custom FK → pastikan ada parameter // Custom FK → pastikan ada parameter
createdCuttings() // → HasMany Cutting, 'created_by_id' createdCuttings() // → HasMany Cutting, 'created_by_id'
submittedCuttings() // → HasMany Cutting, 'submitted_by_id'
``` ```
### Eloquent Select & Eager Loading — Selalu Select yang Dibutuhkan ### Eloquent Select & Eager Loading — Selalu Select yang Dibutuhkan

View File

@ -12,7 +12,7 @@ ### `users` → User
- Implements: HasMedia (Spatie Media Library) - Implements: HasMedia (Spatie Media Library)
- Media Collections: photos (single photo, with thumb conversion) - Media Collections: photos (single photo, with thumb conversion)
- Scopes: active() - Scopes: active()
- Relations: userProfile(HasOne→UserProfile), employee(HasOne→Employee), attendances(HasMany→Attendance), cashAccounts(HasMany→CashAccount,created_by_id), cashTransactions(HasMany→CashTransaction,created_by_id), createdCuttings(HasMany→Cutting,created_by_id), submittedCuttings(HasMany→Cutting,submitted_by_id), createdExpenses(HasMany→Expense,created_by_id), createdOrders(HasMany→Order,created_by_id), marketingOrders(HasMany→Order,marketing_id), orderItems(HasMany→OrderItem), createdPurchases(HasMany→Purchase,created_by_id), createdRestocks(HasMany→Restock,created_by_id), stokOpnamesCreated(HasMany→StokOpname,created_by_id), stokOpnamesVerified(HasMany→StokOpname,verified_by_id), employeeAdvancesPaid(HasMany→EmployeeAdvance,paid_by_id), employeeAdvancesVerified(HasMany→EmployeeAdvance,verified_by_id), paidPayrolls(HasMany→Payroll,paid_by_id), payrollPeriodsClosed(HasMany→PayrollPeriod,closed_by_id), notifications(HasMany→AppNotification), rejections(HasMany→Rejection,rejected_by_id), pushSubscriptions(HasMany→PushSubscription,morph) - Relations: userProfile(HasOne→UserProfile), employee(HasOne→Employee), attendances(HasMany→Attendance), cashAccounts(HasMany→CashAccount,created_by_id), cashTransactions(HasMany→CashTransaction,created_by_id), createdCuttings(HasMany→Cutting,created_by_id), createdExpenses(HasMany→Expense,created_by_id), createdOrders(HasMany→Order,created_by_id), marketingOrders(HasMany→Order,marketing_id), orderItems(HasMany→OrderItem), createdPurchases(HasMany→Purchase,created_by_id), createdRestocks(HasMany→Restock,created_by_id), stokOpnamesCreated(HasMany→StokOpname,created_by_id), stokOpnamesVerified(HasMany→StokOpname,verified_by_id), employeeAdvancesPaid(HasMany→EmployeeAdvance,paid_by_id), employeeAdvancesVerified(HasMany→EmployeeAdvance,verified_by_id), paidPayrolls(HasMany→Payroll,paid_by_id), payrollPeriodsClosed(HasMany→PayrollPeriod,closed_by_id), notifications(HasMany→AppNotification), rejections(HasMany→Rejection,rejected_by_id), pushSubscriptions(HasMany→PushSubscription,morph)
- Accessor: avatar → temporary S3 URL dari media 'photos' (atau null) - Accessor: avatar → temporary S3 URL dari media 'photos' (atau null)
### `user_profiles` → UserProfile ### `user_profiles` → UserProfile
@ -187,10 +187,10 @@ ### `rejections` → Rejection
## Production ## Production
### `cuttings` → Cutting ### `cuttings` → Cutting
`id` `created_by_id`(FK→users) `submitted_by_id`(FK→users,null) `status`(enum,default:in_progress) `description`(100,null) `total_material_cost`(ubig,null) `sewing_cost`(ubig,default:0) `other_cost`(ubig,default:0) `cost_per_unit`(ubig,null) `created_at` `updated_at` `deleted_at` `id` `created_by_id`(FK→users) `status`(enum,default:in_progress) `description`(100,null) `total_material_cost`(ubig,null) `cost_per_unit`(ubig,null) `created_at` `updated_at` `deleted_at`
- Casts: status(CuttingStatus), total_material_cost(int), cost_per_unit(int), sewing_cost(int), other_cost(int) - Casts: status(CuttingStatus), total_material_cost(int), cost_per_unit(int)
- Scopes: cancelled(), completed(), inProgress() - Scopes: cancelled(), completed(), inProgress()
- Relations: createdBy(BelongsTo→User), submittedBy(BelongsTo→User), cuttingMaterialCombinations(HasMany→CuttingMaterialCombination), cuttingMaterials(HasMany→CuttingMaterial), cuttingResults(HasMany→CuttingResult) - Relations: createdBy(BelongsTo→User), cuttingMaterialCombinations(HasMany→CuttingMaterialCombination), cuttingMaterials(HasMany→CuttingMaterial), cuttingResults(HasMany→CuttingResult)
### `cutting_material_combinations` → CuttingMaterialCombination ### `cutting_material_combinations` → CuttingMaterialCombination
`id` `user_id`(FK→users,null) `cutting_id`(FK→cuttings,null) `material_result`(int,null) `created_at` `updated_at` `deleted_at` `id` `user_id`(FK→users,null) `cutting_id`(FK→cuttings,null) `material_result`(int,null) `created_at` `updated_at` `deleted_at`

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin\Manage; namespace App\Http\Controllers\Admin\Manage;
use App\Enums\CuttingStatus;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\CuttingRequest; use App\Http\Requests\Admin\Manage\CuttingRequest;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
@ -24,7 +25,13 @@ public function index(PaginatedRequest $request): Response
return Inertia::render('admin/manage/cutting/index', [ return Inertia::render('admin/manage/cutting/index', [
'cuttings' => $this->service->paginated( 'cuttings' => $this->service->paginated(
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
filters: $request->only(['product_name', 'status']),
), ),
'filters' => $request->only(['product_name', 'status']),
'filterOptions' => [
'statusOptions' => CuttingStatus::toSelect(),
'productNames' => $this->service->getProductNames(),
],
]); ]);
} }
@ -35,6 +42,13 @@ public function create(): Response
]); ]);
} }
public function show(Cutting $cutting): Response
{
return Inertia::render('admin/manage/cutting/show', [
'cutting' => $this->service->getForShow($cutting),
]);
}
public function store(CuttingRequest $request): RedirectResponse public function store(CuttingRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(

View File

@ -64,18 +64,18 @@ public function rules(): array
public function attributes(): array public function attributes(): array
{ {
return [ return [
'email' => 'Email', 'email' => 'email',
'username' => 'Username', 'username' => 'username',
'role' => 'Role', 'role' => 'role',
'full_name' => 'Nama Lengkap', 'full_name' => 'nama lengkap',
'phone_number' => 'No. Telepon', 'phone_number' => 'no. telepon',
'gender' => 'Jenis Kelamin', 'gender' => 'jenis kelamin',
'birth_date' => 'Tanggal Lahir', 'birth_date' => 'tanggal lahir',
'address' => 'Alamat', 'address' => 'alamat',
'join_date' => 'Tanggal Masuk', 'join_date' => 'tanggal masuk',
'resign_date' => 'Tanggal Keluar', 'resign_date' => 'tanggal keluar',
'employment_status' => 'Status Kepegawaian', 'employment_status' => 'status kepegawaian',
'base_salary' => 'Gaji Pokok', 'base_salary' => 'gaji pokok',
]; ];
} }
} }

View File

@ -35,19 +35,19 @@ public function rules(): array
public function attributes(): array public function attributes(): array
{ {
return [ return [
'description' => 'Keterangan', 'description' => 'keterangan',
'product_name' => 'Nama Produk', 'product_name' => 'nama produk',
'sample' => 'Sample', 'sample' => 'sample',
'original_outside_sample' => 'Diluar Sample', 'original_outside_sample' => 'diluar sample',
'cutting_result' => 'Hasil', 'cutting_result' => 'hasil',
'materials' => 'Bahan Baku', 'materials' => 'bahan baku',
'materials.*.raw_material_price_id' => 'Varian Bahan Baku', 'materials.*.raw_material_price_id' => 'varian bahan baku',
'materials.*.material_usage' => 'Pemakaian', 'materials.*.material_usage' => 'pemakaian',
'materials.*.material_result' => 'Hasil Material', 'materials.*.material_result' => 'hasil material',
'materials.*.combination_index' => 'Indeks Kombinasi', 'materials.*.combination_index' => 'indeks kombinasi',
'combinations' => 'Kombinasi', 'combinations' => 'kombinasi',
'combinations.*.material_result' => 'Hasil Kombinasi', 'combinations.*.material_result' => 'hasil kombinasi',
'photo_key' => 'Foto', 'photo_key' => 'foto',
]; ];
} }
} }

View File

@ -48,22 +48,22 @@ public function rules(): array
public function attributes(): array public function attributes(): array
{ {
return [ return [
'name' => 'Nama Bahan Baku', 'name' => 'nama bahan baku',
'unit' => 'Satuan', 'unit' => 'satuan',
'variants' => 'Varian', 'variants' => 'varian',
'variants.*.variant' => 'Nama Varian', 'variants.*.variant' => 'nama varian',
'variants.*.price' => 'Harga', 'variants.*.price' => 'harga',
'variants.*.stock' => 'Stok', 'variants.*.stock' => 'stok',
'variants.*.photo_key' => 'Foto Varian', 'variants.*.photo_key' => 'foto varian',
'existing_items' => 'Item Bahan Baku', 'existing_items' => 'item bahan baku',
'existing_items.*.raw_material_price_id' => 'Varian Bahan Baku', 'existing_items.*.raw_material_price_id' => 'varian bahan baku',
'existing_items.*.quantity' => 'Jumlah', 'existing_items.*.quantity' => 'jumlah',
'existing_items.*.unit_price' => 'Harga Beli', 'existing_items.*.unit_price' => 'harga beli',
'supplier_id' => 'Supplier', 'supplier_id' => 'supplier',
'discount' => 'Diskon', 'discount' => 'diskon',
'shipping_cost' => 'Ongkir', 'shipping_cost' => 'ongkir',
'notes' => 'Keterangan', 'notes' => 'keterangan',
'photo_key' => 'Foto', 'photo_key' => 'foto',
]; ];
} }
} }

View File

@ -29,12 +29,12 @@ public function rules(): array
public function attributes(): array public function attributes(): array
{ {
return [ return [
'stock_type' => 'Jenis Stok', 'stock_type' => 'jenis stok',
'items' => 'Item Produk', 'items' => 'item produk',
'items.*.product_variant_id' => 'Varian Produk', 'items.*.product_variant_id' => 'varian produk',
'items.*.quantity' => 'Jumlah', 'items.*.quantity' => 'jumlah',
'notes' => 'Keterangan', 'notes' => 'keterangan',
'photo_key' => 'Foto', 'photo_key' => 'foto',
]; ];
} }
} }

View File

@ -57,23 +57,23 @@ public function rules(): array
public function attributes(): array public function attributes(): array
{ {
return [ return [
'stock_type' => 'Tipe Stok', 'stock_type' => 'tipe stok',
'channel' => 'Channel', 'channel' => 'channel',
'price_type' => 'Tipe Harga', 'price_type' => 'tipe harga',
'payment_type' => 'Tipe Pembayaran', 'payment_type' => 'tipe pembayaran',
'customer_id' => 'Pelanggan', 'customer_id' => 'pelanggan',
'marketing_id' => 'Marketing', 'marketing_id' => 'marketing',
'discount' => 'Diskon', 'discount' => 'diskon',
'nego_price' => 'Harga Nego', 'nego_price' => 'harga nego',
'is_completed' => 'Pesanan Selesai', 'is_completed' => 'pesanan selesai',
'is_affiliate' => 'Affiliasi', 'is_affiliate' => 'affiliasi',
'tiktok_order_id' => 'ID Pesanan TikTok', 'tiktok_order_id' => 'id pesanan tiktok',
'shopee_order_id' => 'ID Pesanan Shopee', 'shopee_order_id' => 'id pesanan shopee',
'items' => 'Item Produk', 'items' => 'item produk',
'items.*.product_variant_id' => 'Varian Produk', 'items.*.product_variant_id' => 'varian produk',
'items.*.quantity' => 'Jumlah', 'items.*.quantity' => 'jumlah',
'notes' => 'Keterangan', 'notes' => 'keterangan',
'photo_key' => 'Foto', 'photo_key' => 'foto',
]; ];
} }
} }

View File

@ -61,20 +61,20 @@ public function rules(): array
public function attributes(): array public function attributes(): array
{ {
return [ return [
'name' => 'Nama Produk', 'name' => 'nama produk',
'description' => 'Deskripsi', 'description' => 'deskripsi',
'status' => 'Status', 'status' => 'status',
'category_ids' => 'Kategori', 'category_ids' => 'kategori',
'variants' => 'Varian', 'variants' => 'varian',
'variants.*.name' => 'Nama Varian', 'variants.*.name' => 'nama varian',
'variants.*.stock' => 'Stok', 'variants.*.stock' => 'stok',
'variants.*.reject_stock' => 'Stok Reject', 'variants.*.reject_stock' => 'stok reject',
'variants.*.retail_stock' => 'Stok Retail', 'variants.*.retail_stock' => 'stok retail',
'variants.*.photo_keys' => 'Foto', 'variants.*.photo_keys' => 'foto',
'variants.*.photo_keys.*' => 'Foto', 'variants.*.photo_keys.*' => 'foto',
'variants.*.prices' => 'Harga', 'variants.*.prices' => 'harga',
'variants.*.prices.*.type' => 'Tipe Harga', 'variants.*.prices.*.type' => 'tipe harga',
'variants.*.prices.*.price' => 'Harga', 'variants.*.prices.*.price' => 'harga',
]; ];
} }
} }

View File

@ -40,15 +40,15 @@ public function rules(): array
public function attributes(): array public function attributes(): array
{ {
return [ return [
'name' => 'Nama Varian', 'name' => 'nama varian',
'stock' => 'Stok Bagus', 'stock' => 'stok bagus',
'reject_stock' => 'Stok Reject', 'reject_stock' => 'stok reject',
'retail_stock' => 'Stok Ecer', 'retail_stock' => 'stok ecer',
'photo_keys' => 'Foto', 'photo_keys' => 'foto',
'photo_keys.*' => 'Foto', 'photo_keys.*' => 'foto',
'prices' => 'Harga', 'prices' => 'harga',
'prices.*.type' => 'Tipe Harga', 'prices.*.type' => 'tipe harga',
'prices.*.price' => 'Harga', 'prices.*.price' => 'harga',
]; ];
} }
} }

View File

@ -22,8 +22,8 @@ public function rules(): array
public function attributes(): array public function attributes(): array
{ {
return [ return [
'quantity' => 'Jumlah Transfer', 'quantity' => 'jumlah transfer',
'description' => 'Keterangan', 'description' => 'keterangan',
]; ];
} }
} }

View File

@ -40,14 +40,14 @@ public function rules(): array
public function attributes(): array public function attributes(): array
{ {
return [ return [
'name' => 'Nama Bahan Baku', 'name' => 'nama bahan baku',
'unit' => 'Satuan', 'unit' => 'satuan',
'is_active' => 'Status', 'is_active' => 'status',
'variants' => 'Varian', 'variants' => 'varian',
'variants.*.variant' => 'Nama Varian', 'variants.*.variant' => 'nama varian',
'variants.*.price' => 'Harga', 'variants.*.price' => 'harga',
'variants.*.stock' => 'Stok', 'variants.*.stock' => 'stok',
'variants.*.photo_key' => 'Foto Varian', 'variants.*.photo_key' => 'foto varian',
]; ];
} }
} }

View File

@ -33,10 +33,10 @@ public function rules(): array
public function attributes(): array public function attributes(): array
{ {
return [ return [
'variant' => 'Nama Varian', 'variant' => 'nama varian',
'price' => 'Harga', 'price' => 'harga',
'stock' => 'Stok', 'stock' => 'stok',
'photo_key' => 'Foto', 'photo_key' => 'foto',
]; ];
} }
} }

View File

@ -32,8 +32,8 @@ public function rules(): array
public function attributes(): array public function attributes(): array
{ {
return [ return [
'name' => 'Nama Role', 'name' => 'nama role',
'permissions' => 'Permission', 'permissions' => 'permission',
]; ];
} }
} }

View File

@ -73,9 +73,9 @@ public function attributes(): array
'tiktok_shop_affiliate.base' => 'dasar affiliate tiktok shop', 'tiktok_shop_affiliate.base' => 'dasar affiliate tiktok shop',
'tiktok_shop_affiliate.type' => 'tipe affiliate tiktok shop', 'tiktok_shop_affiliate.type' => 'tipe affiliate tiktok shop',
'tiktok_shop_affiliate.value' => 'nilai affiliate tiktok shop', 'tiktok_shop_affiliate.value' => 'nilai affiliate tiktok shop',
'tiktok_shop_pre_order_service_fee.base' => 'dasar layanan PO tiktok shop', 'tiktok_shop_pre_order_service_fee.base' => 'dasar layanan po tiktok shop',
'tiktok_shop_pre_order_service_fee.type' => 'tipe layanan PO tiktok shop', 'tiktok_shop_pre_order_service_fee.type' => 'tipe layanan po tiktok shop',
'tiktok_shop_pre_order_service_fee.value' => 'nilai layanan PO tiktok shop', 'tiktok_shop_pre_order_service_fee.value' => 'nilai layanan po tiktok shop',
'shopee_admin_fee.base' => 'dasar biaya administrasi shopee', 'shopee_admin_fee.base' => 'dasar biaya administrasi shopee',
'shopee_admin_fee.type' => 'tipe biaya administrasi shopee', 'shopee_admin_fee.type' => 'tipe biaya administrasi shopee',
'shopee_admin_fee.value' => 'nilai biaya administrasi shopee', 'shopee_admin_fee.value' => 'nilai biaya administrasi shopee',
@ -94,12 +94,12 @@ public function attributes(): array
'shopee_order_processing_fee.base' => 'dasar biaya proses pesanan shopee', 'shopee_order_processing_fee.base' => 'dasar biaya proses pesanan shopee',
'shopee_order_processing_fee.type' => 'tipe biaya proses pesanan shopee', 'shopee_order_processing_fee.type' => 'tipe biaya proses pesanan shopee',
'shopee_order_processing_fee.value' => 'nilai biaya proses pesanan shopee', 'shopee_order_processing_fee.value' => 'nilai biaya proses pesanan shopee',
'shopee_ams_commission_fee.base' => 'dasar biaya komisi AMS shopee', 'shopee_ams_commission_fee.base' => 'dasar biaya komisi ams shopee',
'shopee_ams_commission_fee.type' => 'tipe biaya komisi AMS shopee', 'shopee_ams_commission_fee.type' => 'tipe biaya komisi ams shopee',
'shopee_ams_commission_fee.value' => 'nilai biaya komisi AMS shopee', 'shopee_ams_commission_fee.value' => 'nilai biaya komisi ams shopee',
'shopee_pre_order.base' => 'dasar PO shopee', 'shopee_pre_order.base' => 'dasar po shopee',
'shopee_pre_order.type' => 'tipe PO shopee', 'shopee_pre_order.type' => 'tipe po shopee',
'shopee_pre_order.value' => 'nilai PO shopee', 'shopee_pre_order.value' => 'nilai po shopee',
'shopee_live_extra.base' => 'dasar live extra shopee', 'shopee_live_extra.base' => 'dasar live extra shopee',
'shopee_live_extra.type' => 'tipe live extra shopee', 'shopee_live_extra.type' => 'tipe live extra shopee',
'shopee_live_extra.value' => 'nilai live extra shopee', 'shopee_live_extra.value' => 'nilai live extra shopee',

View File

@ -0,0 +1,96 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class CleanupOrphanedMediaJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $chunkSize = 100;
public function handle(): void
{
$orphanedMedia = $this->getOrphanedMedia();
if ($orphanedMedia->isEmpty()) {
Log::info('CleanupOrphanedMediaJob: Tidak ada media orphaned.');
return;
}
$totalDeleted = 0;
$orphanedMedia->each(function (Media $media) use (&$totalDeleted) {
$this->deleteMedia($media);
$totalDeleted++;
});
Log::info("CleanupOrphanedMediaJob: Berhasil hapus {$totalDeleted} media orphaned.");
}
private function getOrphanedMedia()
{
$allMedia = Media::select(['id', 'model_type', 'model_id', 'file_name', 'custom_properties', 'disk'])
->get()
->groupBy(fn (Media $m) => $m->model_type.'|'.$m->model_id);
$orphanedIds = [];
foreach ($allMedia as $key => $mediaItems) {
[$modelType, $modelId] = explode('|', $key);
if ($this->isOrphaned($modelType, (int) $modelId)) {
foreach ($mediaItems as $media) {
$orphanedIds[] = $media->id;
}
}
}
if (empty($orphanedIds)) {
return collect();
}
return Media::whereIn('id', $orphanedIds)->get();
}
private function isOrphaned(string $modelType, int $modelId): bool
{
if (! class_exists($modelType)) {
return true;
}
$model = $modelType::withTrashed()->find($modelId);
if (! $model) {
return true;
}
if (method_exists($model, 'trashed') && $model->trashed()) {
return $model->deleted_at->lt(now()->subMonth());
}
return false;
}
private function deleteMedia(Media $media): void
{
$s3Key = $media->getCustomProperty('s3_key');
if ($s3Key && Storage::disk($media->disk)->exists($s3Key)) {
Storage::disk($media->disk)->delete($s3Key);
}
$media->forceDelete();
}
}

View File

@ -18,7 +18,7 @@
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media; use Spatie\MediaLibrary\MediaCollections\Models\Media;
#[Appends(['formatted_cost_per_unit', 'formatted_other_cost', 'formatted_sewing_cost', 'status_label', 'formatted_total_material_cost'])] #[Appends(['formatted_cost_per_unit', 'status_label', 'formatted_total_material_cost', 'formatted_created_at'])]
#[Guarded(['id'])] #[Guarded(['id'])]
class Cutting extends Model implements HasMedia class Cutting extends Model implements HasMedia
{ {
@ -30,8 +30,6 @@ protected function casts(): array
'status' => CuttingStatus::class, 'status' => CuttingStatus::class,
'total_material_cost' => 'integer', 'total_material_cost' => 'integer',
'cost_per_unit' => 'integer', 'cost_per_unit' => 'integer',
'sewing_cost' => 'integer',
'other_cost' => 'integer',
]; ];
} }
@ -42,20 +40,6 @@ protected function formattedCostPerUnit(): Attribute
); );
} }
protected function formattedOtherCost(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->other_cost, 0, ',', '.'),
);
}
protected function formattedSewingCost(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->sewing_cost, 0, ',', '.'),
);
}
protected function statusLabel(): Attribute protected function statusLabel(): Attribute
{ {
return Attribute::make( return Attribute::make(
@ -70,6 +54,13 @@ protected function formattedTotalMaterialCost(): Attribute
); );
} }
protected function formattedCreatedAt(): Attribute
{
return Attribute::make(
get: fn () => $this->created_at?->translatedFormat('l, d F Y'),
);
}
#[Scope] #[Scope]
protected function cancelled(Builder $query): void protected function cancelled(Builder $query): void
{ {
@ -108,11 +99,6 @@ public function cuttingResults(): HasMany
return $this->hasMany(CuttingResult::class); return $this->hasMany(CuttingResult::class);
} }
public function submittedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'submitted_by_id');
}
public function registerMediaCollections(): void public function registerMediaCollections(): void
{ {
$this->addMediaCollection('photos'); $this->addMediaCollection('photos');

View File

@ -50,7 +50,7 @@ public function cutting(): BelongsTo
public function rawMaterialPrice(): BelongsTo public function rawMaterialPrice(): BelongsTo
{ {
return $this->belongsTo(RawMaterialPrice::class); return $this->belongsTo(RawMaterialPrice::class)->withTrashed();
} }
public function user(): BelongsTo public function user(): BelongsTo

View File

@ -69,7 +69,7 @@ public function order(): BelongsTo
public function productVariant(): BelongsTo public function productVariant(): BelongsTo
{ {
return $this->belongsTo(ProductVariant::class); return $this->belongsTo(ProductVariant::class)->withTrashed();
} }
public function user(): BelongsTo public function user(): BelongsTo

View File

@ -68,7 +68,7 @@ public function orderItems(): HasMany
public function product(): BelongsTo public function product(): BelongsTo
{ {
return $this->belongsTo(Product::class); return $this->belongsTo(Product::class)->withTrashed();
} }
public function productPrices(): HasMany public function productPrices(): HasMany

View File

@ -41,7 +41,7 @@ protected function formattedUnitPrice(): Attribute
public function productVariant(): BelongsTo public function productVariant(): BelongsTo
{ {
return $this->belongsTo(ProductVariant::class); return $this->belongsTo(ProductVariant::class)->withTrashed();
} }
public function restock(): BelongsTo public function restock(): BelongsTo

View File

@ -215,11 +215,6 @@ public function stokOpnamesVerified(): HasMany
return $this->hasMany(StokOpname::class, 'verified_by_id'); return $this->hasMany(StokOpname::class, 'verified_by_id');
} }
public function submittedCuttings(): HasMany
{
return $this->hasMany(Cutting::class, 'submitted_by_id');
}
public function userProfile(): HasOne public function userProfile(): HasOne
{ {
return $this->hasOne(UserProfile::class); return $this->hasOne(UserProfile::class);

View File

@ -13,7 +13,7 @@ public function migrate(): array
{ {
$results = []; $results = [];
$results['cuttings'] = $this->migrateTable('cuttings'); $results['cuttings'] = $this->migrateTableWithoutColumns('cuttings', ['other_cost', 'sewing_cost', 'submitted_by_id']);
$results['cutting_material_combinations'] = $this->migrateTable('cutting_material_combinations'); $results['cutting_material_combinations'] = $this->migrateTable('cutting_material_combinations');
$results['cutting_materials'] = $this->migrateTable('cutting_materials', function ($row) { $results['cutting_materials'] = $this->migrateTable('cutting_materials', function ($row) {
if (isset($row['material_usage']) && is_string($row['material_usage'])) { if (isset($row['material_usage']) && is_string($row['material_usage'])) {

View File

@ -168,8 +168,6 @@ public function destroy(CashTransaction $transaction): bool
Cache::forget("cash_transaction_receipt_{$media->id}"); Cache::forget("cash_transaction_receipt_{$media->id}");
} }
$transaction->clearMediaCollection('photos');
$deleted = $transaction->delete(); $deleted = $transaction->delete();
if ($deleted) { if ($deleted) {

View File

@ -130,8 +130,6 @@ public function destroy(Expense $expense): bool
Cache::forget("expense_receipt_{$media->id}"); Cache::forget("expense_receipt_{$media->id}");
} }
$expense->clearMediaCollection('photos');
$deleted = $expense->delete(); $deleted = $expense->delete();
if ($deleted) { if ($deleted) {

View File

@ -10,6 +10,7 @@
use App\Services\Concerns\RegistersMedia; use App\Services\Concerns\RegistersMedia;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -21,16 +22,16 @@ public function __construct(
private S3PresignedService $s3Service, private S3PresignedService $s3Service,
) {} ) {}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
$paginator = Cutting::query() $paginator = Cutting::query()
->select(['id', 'created_by_id', 'status', 'description', 'total_material_cost', 'cost_per_unit', 'created_at']) ->select(['id', 'created_by_id', 'status', 'description', 'total_material_cost', 'cost_per_unit', 'created_at'])
->with([ ->with([
'createdBy:id', 'createdBy:id',
'createdBy.userProfile:id,user_id,full_name', 'createdBy.userProfile:id,user_id,full_name',
'cuttingResults:id,cutting_id,product_name,cutting_result,sample,original_outside_sample', 'cuttingResults:id,cutting_id,product_name,cutting_result',
'cuttingMaterials:id,cutting_id,raw_material_price_id,material_usage,material_result,combination_id', 'cuttingMaterials:id,cutting_id,raw_material_price_id,material_usage,material_result,combination_id',
'cuttingMaterials.rawMaterialPrice:id,raw_material_id,variant,price,stock', 'cuttingMaterials.rawMaterialPrice:id,raw_material_id,variant',
'cuttingMaterials.rawMaterialPrice.rawMaterial:id,name,unit', 'cuttingMaterials.rawMaterialPrice.rawMaterial:id,name,unit',
'cuttingMaterialCombinations:id,cutting_id,material_result', 'cuttingMaterialCombinations:id,cutting_id,material_result',
]) ])
@ -38,6 +39,8 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
$q->whereHas('cuttingResults', fn ($rq) => $rq->where('product_name', 'like', "%{$search}%")) $q->whereHas('cuttingResults', fn ($rq) => $rq->where('product_name', 'like', "%{$search}%"))
->orWhere('description', 'like', "%{$search}%"); ->orWhere('description', 'like', "%{$search}%");
}) })
->when($filters['product_name'] ?? null, fn ($q, $productName) => $q->whereHas('cuttingResults', fn ($rq) => $rq->where('product_name', $productName)))
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->orderBy($sort, $direction) ->orderBy($sort, $direction)
->paginate($perPage); ->paginate($perPage);
@ -66,6 +69,100 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator; return $paginator;
} }
public function getProductNames(): Collection
{
return CuttingResult::query()
->select('product_name')
->whereNotNull('product_name')
->where('product_name', '!=', '')
->distinct()
->orderBy('product_name')
->get();
}
public function getForShow(Cutting $cutting): array
{
$cutting->load([
'createdBy:id',
'createdBy.userProfile:id,user_id,full_name',
'cuttingResults:id,cutting_id,product_name,cutting_result,sample,original_outside_sample',
'cuttingMaterials:id,cutting_id,raw_material_price_id,material_usage,material_result,combination_id',
'cuttingMaterials.rawMaterialPrice:id,raw_material_id,variant',
'cuttingMaterials.rawMaterialPrice.rawMaterial:id,name,unit',
'cuttingMaterialCombinations:id,cutting_id,material_result',
]);
$cuttingMedia = $cutting->getFirstMedia('images');
$cutting->photo_url = $cuttingMedia
? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath())
: null;
$cutting->photo_conversion_url = $cuttingMedia
? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath('thumb'))
: null;
$cutting->cuttingMaterials->each(function (CuttingMaterial $material) {
$media = $material->rawMaterialPrice?->getFirstMedia('images');
if ($material->rawMaterialPrice) {
$material->rawMaterialPrice->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
$material->rawMaterialPrice->photo_conversion_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: null;
}
});
return [
'id' => $cutting->id,
'status' => $cutting->status->value,
'status_label' => $cutting->status_label,
'description' => $cutting->description,
'total_material_cost' => $cutting->total_material_cost,
'formatted_total_material_cost' => $cutting->formatted_total_material_cost,
'cost_per_unit' => $cutting->cost_per_unit,
'formatted_cost_per_unit' => $cutting->formatted_cost_per_unit,
'created_at' => $cutting->created_at,
'formatted_created_at' => $cutting->formatted_created_at,
'photo_url' => $cutting->photo_url,
'photo_conversion_url' => $cutting->photo_conversion_url,
'created_by' => [
'id' => $cutting->createdBy->id,
'user_profile' => [
'full_name' => $cutting->createdBy->userProfile->full_name ?? '-',
],
],
'cutting_results' => $cutting->cuttingResults->map(fn ($r) => [
'id' => $r->id,
'product_name' => $r->product_name,
'cutting_result' => $r->cutting_result,
'sample' => $r->sample,
'original_outside_sample' => $r->original_outside_sample,
]),
'cutting_materials' => $cutting->cuttingMaterials->map(fn ($m) => [
'id' => $m->id,
'material_usage' => $m->material_usage,
'material_result' => $m->material_result,
'combination_id' => $m->combination_id,
'raw_material_price' => [
'id' => $m->rawMaterialPrice->id,
'variant' => $m->rawMaterialPrice->variant,
'price' => $m->rawMaterialPrice->price,
'photo_url' => $m->rawMaterialPrice->photo_url,
'photo_conversion_url' => $m->rawMaterialPrice->photo_conversion_url,
'raw_material' => [
'id' => $m->rawMaterialPrice->rawMaterial->id,
'name' => $m->rawMaterialPrice->rawMaterial->name,
'unit' => $m->rawMaterialPrice->rawMaterial->unit,
],
],
]),
'cutting_material_combinations' => $cutting->cuttingMaterialCombinations->map(fn ($c) => [
'id' => $c->id,
'material_result' => $c->material_result,
]),
];
}
public function getForEdit(Cutting $cutting): array public function getForEdit(Cutting $cutting): array
{ {
$cutting->load([ $cutting->load([
@ -343,7 +440,6 @@ public function destroy(Cutting $cutting): bool
} }
} }
$cutting->clearMediaCollection('images');
$cutting->cuttingResults()->delete(); $cutting->cuttingResults()->delete();
$cutting->cuttingMaterials()->delete(); $cutting->cuttingMaterials()->delete();
$cutting->cuttingMaterialCombinations()->delete(); $cutting->cuttingMaterialCombinations()->delete();

View File

@ -480,7 +480,6 @@ public function destroy(Purchase $purchase): bool
} }
}); });
$purchase->clearMediaCollection('photos');
$purchase->purchaseItems()->delete(); $purchase->purchaseItems()->delete();
$purchase->delete(); $purchase->delete();

View File

@ -144,7 +144,6 @@ public function destroy(Restock $restock): bool
}); });
$restock->restockItems()->delete(); $restock->restockItems()->delete();
$restock->clearMediaCollection('photos');
$restock->delete(); $restock->delete();
return true; return true;

View File

@ -299,7 +299,6 @@ public function destroy(Order $order): bool
}); });
$order->orderItems()->delete(); $order->orderItems()->delete();
$order->clearMediaCollection('photos');
$order->delete(); $order->delete();
return true; return true;

View File

@ -41,7 +41,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
'productVariants:id,product_id,name,stock,reject_stock,retail_stock', 'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
'productVariants.productPrices:id,variant_id,type,price', 'productVariants.productPrices:id,variant_id,type,price',
]) ])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")
->orWhereHas('productVariants', fn ($vq) => $vq->where('name', 'like', "%{$search}%"))
)
->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%")) ->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status)) ->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) { ->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
@ -404,7 +406,6 @@ public function destroy(Product $product): bool
$result = DB::transaction(function () use ($product) { $result = DB::transaction(function () use ($product) {
$product->productVariants->each(function (ProductVariant $variant) { $product->productVariants->each(function (ProductVariant $variant) {
$variant->productPrices()->delete(); $variant->productPrices()->delete();
$variant->clearMediaCollection('images');
$variant->delete(); $variant->delete();
}); });

View File

@ -156,7 +156,6 @@ public function destroy(Product $product, ProductVariant $variant): bool
$result = DB::transaction(function () use ($variant) { $result = DB::transaction(function () use ($variant) {
$variant->productPrices()->delete(); $variant->productPrices()->delete();
$variant->clearMediaCollection('images');
return $variant->delete(); return $variant->delete();
}); });

View File

@ -32,7 +32,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->with([ ->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock', 'rawMaterialPrices:id,raw_material_id,variant,price,stock',
]) ])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")
->orWhereHas('rawMaterialPrices', fn ($vq) => $vq->where('variant', 'like', "%{$search}%"))
)
->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%")) ->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
->when(($filters['is_active'] ?? null) !== null && ($filters['is_active'] ?? null) !== '', function ($q) use ($filters) { ->when(($filters['is_active'] ?? null) !== null && ($filters['is_active'] ?? null) !== '', function ($q) use ($filters) {
$q->where('is_active', $filters['is_active'] === 'true'); $q->where('is_active', $filters['is_active'] === 'true');
@ -213,7 +215,6 @@ public function destroy(RawMaterial $rawMaterial): bool
{ {
return DB::transaction(function () use ($rawMaterial) { return DB::transaction(function () use ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) { $rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$price->clearMediaCollection('images');
$price->delete(); $price->delete();
}); });

View File

@ -93,8 +93,6 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool
{ {
$result = DB::transaction(function () use ($variant) { $result = DB::transaction(function () use ($variant) {
$variant->clearMediaCollection('images');
return $variant->delete(); return $variant->delete();
}); });

View File

@ -27,6 +27,7 @@
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.24", "fakerphp/faker": "^1.24",
"larastan/larastan": "^3.9", "larastan/larastan": "^3.9",
"laravel-lang/lang": "^15.34",
"laravel/pail": "^1.2.5", "laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6", "laravel/pao": "^1.0.6",
"laravel/pint": "^1.27", "laravel/pint": "^1.27",

915
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "b8312d5622d94884c0607ebccdfd2ed8", "content-hash": "c8587897f88cd85097351d4f9acdd0bd",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@ -9212,6 +9212,59 @@
} }
], ],
"packages-dev": [ "packages-dev": [
{
"name": "archtechx/enums",
"version": "v1.1.2",
"source": {
"type": "git",
"url": "https://github.com/archtechx/enums.git",
"reference": "81375b71c176f680880a95e7448d84258cfb5c72"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/archtechx/enums/zipball/81375b71c176f680880a95e7448d84258cfb5c72",
"reference": "81375b71c176f680880a95e7448d84258cfb5c72",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"larastan/larastan": "^2.4",
"orchestra/testbench": "^8.0 || ^9.0",
"pestphp/pest": "^2.0",
"pestphp/pest-plugin-laravel": "^2.0"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
}
},
"autoload": {
"psr-4": {
"ArchTech\\Enums\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Samuel Štancl",
"email": "samuel@archte.ch"
}
],
"description": "Helpers for making PHP enums more lovable.",
"support": {
"issues": "https://github.com/archtechx/enums/issues",
"source": "https://github.com/archtechx/enums/tree/v1.1.2"
},
"time": "2025-06-06T23:15:09+00:00"
},
{ {
"name": "brianium/paratest", "name": "brianium/paratest",
"version": "v7.20.0", "version": "v7.20.0",
@ -9447,6 +9500,233 @@
], ],
"time": "2024-05-06T16:37:16+00:00" "time": "2024-05-06T16:37:16+00:00"
}, },
{
"name": "dragon-code/contracts",
"version": "2.25.0",
"source": {
"type": "git",
"url": "https://github.com/TheDragonCode/contracts.git",
"reference": "13d1254801026be5ba33cf1309a414953869175f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/TheDragonCode/contracts/zipball/13d1254801026be5ba33cf1309a414953869175f",
"reference": "13d1254801026be5ba33cf1309a414953869175f",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"psr/http-message": "^1.0.1 || ^2.0",
"symfony/http-kernel": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0",
"symfony/polyfill-php80": "^1.23"
},
"conflict": {
"andrey-helldar/contracts": "*"
},
"require-dev": {
"illuminate/database": "^10.0 || ^11.0 || ^12.0 || ^13.0",
"phpdocumentor/reflection-docblock": "^5.0 || ^6.0"
},
"type": "library",
"autoload": {
"psr-4": {
"DragonCode\\Contracts\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Andrey Helldar",
"email": "helldar@dragon-code.pro",
"homepage": "https://dragon-code.pro"
}
],
"description": "A set of contracts for any project",
"keywords": [
"contracts",
"interfaces"
],
"support": {
"source": "https://github.com/TheDragonCode/contracts"
},
"funding": [
{
"url": "https://boosty.to/dragon-code",
"type": "boosty"
},
{
"url": "https://yoomoney.ru/to/410012608840929",
"type": "yoomoney"
}
],
"time": "2026-03-17T21:50:20+00:00"
},
{
"name": "dragon-code/pretty-array",
"version": "4.2.0",
"source": {
"type": "git",
"url": "https://github.com/TheDragonCode/pretty-array.git",
"reference": "b94034d92172a5d14a578822d68b2a8f8b5388e0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/TheDragonCode/pretty-array/zipball/b94034d92172a5d14a578822d68b2a8f8b5388e0",
"reference": "b94034d92172a5d14a578822d68b2a8f8b5388e0",
"shasum": ""
},
"require": {
"dragon-code/contracts": "^2.20",
"dragon-code/support": "^6.11.2",
"ext-dom": "*",
"ext-mbstring": "*",
"php": "^8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^10.0 || ^11.0 || ^12.0"
},
"suggest": {
"symfony/thanks": "Give thanks (in the form of a GitHub) to your fellow PHP package maintainers"
},
"type": "library",
"autoload": {
"psr-4": {
"DragonCode\\PrettyArray\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Andrey Helldar",
"email": "helldar@dragon-code.pro",
"homepage": "https://dragon-code.pro"
}
],
"description": "Simple conversion of an array to a pretty view",
"keywords": [
"andrey helldar",
"array",
"dragon",
"dragon code",
"pretty",
"pretty array"
],
"support": {
"issues": "https://github.com/TheDragonCode/pretty-array/issues",
"source": "https://github.com/TheDragonCode/pretty-array"
},
"funding": [
{
"url": "https://boosty.to/dragon-code",
"type": "boosty"
},
{
"url": "https://yoomoney.ru/to/410012608840929",
"type": "yoomoney"
}
],
"time": "2025-02-24T15:35:24+00:00"
},
{
"name": "dragon-code/support",
"version": "6.17.1",
"source": {
"type": "git",
"url": "https://github.com/TheDragonCode/support.git",
"reference": "82a465953267989883d64b921e9725600a5073b5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/TheDragonCode/support/zipball/82a465953267989883d64b921e9725600a5073b5",
"reference": "82a465953267989883d64b921e9725600a5073b5",
"shasum": ""
},
"require": {
"dragon-code/contracts": "^2.22.0",
"ext-bcmath": "*",
"ext-ctype": "*",
"ext-dom": "*",
"ext-json": "*",
"ext-mbstring": "*",
"php": "^8.1",
"psr/http-message": "^1.0.1 || ^2.0",
"voku/portable-ascii": "^1.4.8 || ^2.0.1"
},
"conflict": {
"andrey-helldar/support": "*"
},
"require-dev": {
"illuminate/contracts": "^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0",
"phpunit/phpunit": "^9.6 || ^11.0 || ^12.0",
"symfony/var-dumper": "^6.0 || ^7.0"
},
"suggest": {
"dragon-code/laravel-support": "Various helper files for the Laravel and Lumen frameworks",
"symfony/thanks": "Give thanks (in the form of a GitHub) to your fellow PHP package maintainers"
},
"type": "library",
"extra": {
"dragon-code": {
"docs-generator": {
"preview": {
"brand": "php",
"vendor": "The Dragon Code"
}
}
}
},
"autoload": {
"psr-4": {
"DragonCode\\Support\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Andrey Helldar",
"email": "helldar@dragon-code.pro",
"homepage": "https://dragon-code.pro"
}
],
"description": "Support package is a collection of helpers and tools for any project.",
"keywords": [
"dragon",
"dragon-code",
"framework",
"helper",
"helpers",
"laravel",
"php",
"support",
"symfony",
"yii",
"yii2"
],
"support": {
"issues": "https://github.com/TheDragonCode/support/issues",
"source": "https://github.com/TheDragonCode/support"
},
"funding": [
{
"url": "https://boosty.to/dragon-code",
"type": "boosty"
},
{
"url": "https://yoomoney.ru/to/410012608840929",
"type": "yoomoney"
}
],
"time": "2026-04-03T15:06:29+00:00"
},
{ {
"name": "fakerphp/faker", "name": "fakerphp/faker",
"version": "v1.24.1", "version": "v1.24.1",
@ -9884,6 +10164,639 @@
], ],
"time": "2026-05-28T08:00:58+00:00" "time": "2026-05-28T08:00:58+00:00"
}, },
{
"name": "laravel-lang/config",
"version": "1.17.0",
"source": {
"type": "git",
"url": "https://github.com/Laravel-Lang/config.git",
"reference": "77ad089234aa74961ca30c7e6d13db9a62654c87"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Laravel-Lang/config/zipball/77ad089234aa74961ca30c7e6d13db9a62654c87",
"reference": "77ad089234aa74961ca30c7e6d13db9a62654c87",
"shasum": ""
},
"require": {
"archtechx/enums": "^1.0",
"illuminate/config": "^10.0 || ^11.0 || ^12.0 || ^13.0",
"illuminate/support": "^10.0 || ^11.0 || ^12.0 || ^13.0",
"laravel-lang/locale-list": "^1.6",
"php": "^8.1"
},
"require-dev": {
"orchestra/testbench": "^8.23 || ^9.1 || ^10.0 || ^11.0",
"pestphp/pest": "^2.34 || ^3.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"LaravelLang\\Config\\ServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"LaravelLang\\Config\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Andrey Helldar",
"email": "helldar@dragon-code.pro",
"homepage": "https://dragon-code.pro"
},
{
"name": "Laravel-Lang Team",
"homepage": "https://laravel-lang.com"
}
],
"description": "The Laravel-Lang config package",
"keywords": [
"Laravel-lang",
"Settings",
"config",
"lang",
"languages",
"laravel",
"locale",
"locales",
"localization",
"localizations",
"translation",
"translations"
],
"support": {
"issues": "https://github.com/Laravel-Lang/config/issues",
"source": "https://github.com/Laravel-Lang/config/tree/1.17.0"
},
"time": "2026-03-17T21:23:36+00:00"
},
{
"name": "laravel-lang/lang",
"version": "15.34.2",
"source": {
"type": "git",
"url": "https://github.com/Laravel-Lang/lang.git",
"reference": "3149a2cea69aa16469ebf44bcb58f3d25920a620"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Laravel-Lang/lang/zipball/3149a2cea69aa16469ebf44bcb58f3d25920a620",
"reference": "3149a2cea69aa16469ebf44bcb58f3d25920a620",
"shasum": ""
},
"require": {
"ext-json": "*",
"laravel-lang/publisher": "^16.0",
"php": "^8.2"
},
"conflict": {
"laravel/framework": "<11.0.7"
},
"require-dev": {
"dragon-code/codestyler": "^6.0",
"laravel-lang/status-generator": "^2.11",
"phpunit/phpunit": "^11.0 || ^12.0",
"symfony/var-dumper": "^7.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"LaravelLang\\Lang\\ServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"LaravelLang\\Lang\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Laravel-Lang Team",
"homepage": "https://github.com/Laravel-Lang"
}
],
"description": "List of 126 languages for Laravel Framework, Laravel Jetstream, Laravel Fortify, Laravel Breeze, Laravel Cashier, Laravel Nova, Laravel Spark and Laravel UI",
"keywords": [
"lang",
"languages",
"laravel",
"lpm"
],
"support": {
"issues": "https://github.com/Laravel-Lang/lang/issues",
"source": "https://github.com/Laravel-Lang/lang"
},
"funding": [
{
"url": "https://boosty.to/dragon-code",
"type": "boosty"
},
{
"url": "https://yoomoney.ru/to/410012608840929",
"type": "yoomoney"
}
],
"time": "2026-08-11T18:30:36+00:00"
},
{
"name": "laravel-lang/locale-list",
"version": "1.7.0",
"source": {
"type": "git",
"url": "https://github.com/Laravel-Lang/locale-list.git",
"reference": "48e61c7f0a957420d4aaf5d35653889c25c4e2d4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Laravel-Lang/locale-list/zipball/48e61c7f0a957420d4aaf5d35653889c25c4e2d4",
"reference": "48e61c7f0a957420d4aaf5d35653889c25c4e2d4",
"shasum": ""
},
"require": {
"archtechx/enums": "^0.3.2 || ^1.0",
"php": "^8.1"
},
"type": "library",
"autoload": {
"psr-4": {
"LaravelLang\\LocaleList\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Andrey Helldar",
"email": "helldar@dragon-code.pro",
"homepage": "https://dragon-code.pro"
},
{
"name": "Laravel-Lang Team",
"homepage": "https://laravel-lang.com"
}
],
"description": "List of localizations available in Laravel Lang projects",
"keywords": [
"Laravel-lang",
"lang",
"languages",
"laravel",
"locale",
"locales",
"localization",
"translation",
"translations"
],
"support": {
"issues": "https://github.com/Laravel-Lang/locale-list/issues",
"source": "https://github.com/Laravel-Lang/locale-list"
},
"funding": [
{
"url": "https://boosty.to/dragon-code",
"type": "boosty"
},
{
"url": "https://yoomoney.ru/to/410012608840929",
"type": "yoomoney"
}
],
"time": "2026-01-20T08:17:15+00:00"
},
{
"name": "laravel-lang/locales",
"version": "2.11.0",
"source": {
"type": "git",
"url": "https://github.com/Laravel-Lang/locales.git",
"reference": "761aa3cfbc5bbe29eb958c9839e7dd3806193bac"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Laravel-Lang/locales/zipball/761aa3cfbc5bbe29eb958c9839e7dd3806193bac",
"reference": "761aa3cfbc5bbe29eb958c9839e7dd3806193bac",
"shasum": ""
},
"require": {
"archtechx/enums": "^0.3.2 || ^1.0",
"dragon-code/support": "^6.11.3",
"ext-json": "*",
"illuminate/collections": "^10.0 || ^11.0 || ^12.0 || ^13.0",
"laravel-lang/config": "^1.12",
"laravel-lang/locale-list": "^1.5",
"laravel-lang/native-country-names": "^1.5",
"laravel-lang/native-currency-names": "^1.6",
"laravel-lang/native-locale-names": "^2.5",
"php": "^8.1"
},
"require-dev": {
"orchestra/testbench": "^8.0 || ^9.0 || ^10.0 || ^11.0",
"pestphp/pest": "^2.24.1 || ^3.0 || ^4.0",
"symfony/var-dumper": "^6.0 || ^7.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"LaravelLang\\Locales\\ServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"LaravelLang\\Locales\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Andrey Helldar",
"email": "helldar@dragon-code.pro"
},
{
"name": "Laravel-Lang Team",
"homepage": "https://laravel-lang.com"
}
],
"description": "Basic functionality for working with localizations",
"keywords": [
"laravel",
"locale",
"locales",
"localization",
"translation",
"translations"
],
"support": {
"issues": "https://github.com/Laravel-Lang/locales/issues",
"source": "https://github.com/Laravel-Lang/locales"
},
"time": "2026-03-17T22:40:41+00:00"
},
{
"name": "laravel-lang/native-country-names",
"version": "1.8.0",
"source": {
"type": "git",
"url": "https://github.com/Laravel-Lang/native-country-names.git",
"reference": "1d293138e34eb9e914bc4568cdebac2cb0a2eb0e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Laravel-Lang/native-country-names/zipball/1d293138e34eb9e914bc4568cdebac2cb0a2eb0e",
"reference": "1d293138e34eb9e914bc4568cdebac2cb0a2eb0e",
"shasum": ""
},
"require": {
"dragon-code/support": "^6.11",
"ext-json": "*",
"illuminate/collections": "^10.0 || ^11.0 || ^12.0 || ^13.0",
"php": "^8.1"
},
"require-dev": {
"illuminate/support": "^10.0 || ^11.0 || ^12.0 || ^13.0",
"laravel-lang/locale-list": "^1.5",
"pestphp/pest": "^2.0 || ^3.0 || ^4.0",
"punic/punic": "^3.8",
"symfony/console": "^6.0 || ^7.0 || ^8.0",
"symfony/process": "^6.0 || ^7.0 || ^8.0",
"symfony/var-dumper": "^6.0 || ^7.0 || ^8.0",
"vlucas/phpdotenv": "^5.6"
},
"type": "library",
"autoload": {
"psr-4": {
"LaravelLang\\NativeCountryNames\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Andrey Helldar",
"email": "helldar@dragon-code.pro"
},
{
"name": "Laravel-Lang Team",
"homepage": "https://laravel-lang.com"
}
],
"description": "The project contains native translations of country names",
"keywords": [
"Laravel-lang",
"countries",
"country",
"lang",
"languages",
"laravel",
"locale",
"locales",
"localization",
"territories",
"territory",
"translation",
"translations"
],
"support": {
"issues": "https://github.com/Laravel-Lang/native-country-names/issues",
"source": "https://github.com/Laravel-Lang/native-country-names"
},
"funding": [
{
"url": "https://boosty.to/dragon-code",
"type": "boosty"
},
{
"url": "https://yoomoney.ru/to/410012608840929",
"type": "yoomoney"
}
],
"time": "2026-03-17T22:01:15+00:00"
},
{
"name": "laravel-lang/native-currency-names",
"version": "1.10.0",
"source": {
"type": "git",
"url": "https://github.com/Laravel-Lang/native-currency-names.git",
"reference": "7ebe95a9942bebf6afd61986fa18c82dade3d583"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Laravel-Lang/native-currency-names/zipball/7ebe95a9942bebf6afd61986fa18c82dade3d583",
"reference": "7ebe95a9942bebf6afd61986fa18c82dade3d583",
"shasum": ""
},
"require": {
"dragon-code/support": "^6.11",
"ext-json": "*",
"illuminate/collections": "^10.0 || ^11.0 || ^12.0 || ^13.0",
"php": "^8.1"
},
"require-dev": {
"illuminate/support": "^10.0 || ^11.0 || ^12.0 || ^13.0",
"laravel-lang/locale-list": "^1.2",
"pestphp/pest": "^2.0 || ^3.0 || ^4.0",
"punic/punic": "^3.8",
"symfony/console": "^6.0 || ^7.0",
"symfony/process": "^6.0 || ^7.0",
"symfony/var-dumper": "^6.0 || ^7.0",
"vlucas/phpdotenv": "^5.6"
},
"type": "library",
"autoload": {
"psr-4": {
"LaravelLang\\NativeCurrencyNames\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Andrey Helldar",
"email": "helldar@dragon-code.pro"
},
{
"name": "Laravel-Lang Team",
"homepage": "https://laravel-lang.com"
}
],
"description": "The project contains native translations of currency names",
"keywords": [
"Laravel-lang",
"currency",
"lang",
"languages",
"laravel",
"locale",
"locales",
"localization",
"translation",
"translations"
],
"support": {
"issues": "https://github.com/Laravel-Lang/native-currency-names/issues",
"source": "https://github.com/Laravel-Lang/native-currency-names"
},
"funding": [
{
"url": "https://boosty.to/dragon-code",
"type": "boosty"
},
{
"url": "https://yoomoney.ru/to/410012608840929",
"type": "yoomoney"
}
],
"time": "2026-06-20T16:50:37+00:00"
},
{
"name": "laravel-lang/native-locale-names",
"version": "2.9.0",
"source": {
"type": "git",
"url": "https://github.com/Laravel-Lang/native-locale-names.git",
"reference": "e5925182bad34654203a5d93544357ca00a6d9c0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Laravel-Lang/native-locale-names/zipball/e5925182bad34654203a5d93544357ca00a6d9c0",
"reference": "e5925182bad34654203a5d93544357ca00a6d9c0",
"shasum": ""
},
"require": {
"dragon-code/support": "^6.11",
"ext-json": "*",
"php": "^8.1"
},
"require-dev": {
"illuminate/support": "^10.0 || ^11.0 || ^12.0 || ^13.0",
"laravel-lang/locale-list": "^1.2",
"pestphp/pest": "^2.24.3 || ^3.0 || ^4.0",
"punic/punic": "^3.8",
"symfony/console": "^6.0 || ^7.0",
"symfony/process": "^6.0 || ^7.0",
"symfony/var-dumper": "^6.0 || ^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"LaravelLang\\NativeLocaleNames\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Andrey Helldar",
"email": "helldar@dragon-code.pro"
},
{
"name": "Laravel-Lang Team",
"homepage": "https://laravel-lang.com"
}
],
"description": "The project contains native translations of locale names",
"keywords": [
"Laravel-lang",
"lang",
"languages",
"laravel",
"locale",
"locales",
"localization",
"translation",
"translations"
],
"support": {
"issues": "https://github.com/Laravel-Lang/native-locale-names/issues",
"source": "https://github.com/Laravel-Lang/native-locale-names"
},
"funding": [
{
"url": "https://boosty.to/dragon-code",
"type": "boosty"
},
{
"url": "https://yoomoney.ru/to/410012608840929",
"type": "yoomoney"
}
],
"time": "2026-06-20T16:52:11+00:00"
},
{
"name": "laravel-lang/publisher",
"version": "16.8.0",
"source": {
"type": "git",
"url": "https://github.com/Laravel-Lang/publisher.git",
"reference": "e5d3383f5385c2102f8a0d3dbe488ed86cd0250f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Laravel-Lang/publisher/zipball/e5d3383f5385c2102f8a0d3dbe488ed86cd0250f",
"reference": "e5d3383f5385c2102f8a0d3dbe488ed86cd0250f",
"shasum": ""
},
"require": {
"composer/semver": "^3.4",
"dragon-code/pretty-array": "^4.1",
"dragon-code/support": "^6.11.3",
"ext-json": "*",
"illuminate/collections": "^10.0 || ^11.0 || ^12.0 || ^13.0",
"illuminate/console": "^10.0 || ^11.0 || ^12.0 || ^13.0",
"illuminate/support": "^10.0 || ^11.0 || ^12.0 || ^13.0",
"laravel-lang/config": "^1.12",
"laravel-lang/locales": "^2.10",
"league/commonmark": "^2.4.1",
"league/config": "^1.2",
"php": "^8.1"
},
"conflict": {
"laravel-lang/attributes": "<2.0",
"laravel-lang/http-statuses": "<3.0",
"laravel-lang/lang": "<11.0"
},
"require-dev": {
"laravel-lang/json-fallback": "^2.2",
"orchestra/testbench": "^8.14 || ^9.0 || ^10.0 || ^11.0",
"phpunit/phpunit": "^10.4.2 || ^11.0 || ^12.0",
"symfony/var-dumper": "^6.3.6 || ^7.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"LaravelLang\\Publisher\\ServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"LaravelLang\\Publisher\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Andrey Helldar",
"email": "helldar@dragon-code.pro"
},
{
"name": "Laravel-Lang Team",
"homepage": "https://laravel-lang.com"
}
],
"description": "Localization publisher for your Laravel application",
"keywords": [
"Laravel-lang",
"breeze",
"cashier",
"fortify",
"framework",
"i18n",
"jetstream",
"lang",
"languages",
"laravel",
"locale",
"locales",
"localization",
"localizations",
"lpm",
"lumen",
"nova",
"publisher",
"spark",
"trans",
"translation",
"translations",
"validations"
],
"support": {
"issues": "https://github.com/Laravel-Lang/publisher/issues",
"source": "https://github.com/Laravel-Lang/publisher"
},
"funding": [
{
"url": "https://boosty.to/dragon-code",
"type": "boosty"
},
{
"url": "https://yoomoney.ru/to/410012608840929",
"type": "yoomoney"
}
],
"time": "2026-03-17T22:56:20+00:00"
},
{ {
"name": "laravel/agent-detector", "name": "laravel/agent-detector",
"version": "v2.0.2", "version": "v2.0.2",

View File

@ -13,14 +13,11 @@ public function up(): void
$table->id(); $table->id();
$table->foreignId('created_by_id')->constrained('users')->restrictOnDelete(); $table->foreignId('created_by_id')->constrained('users')->restrictOnDelete();
$table->foreignId('submitted_by_id')->nullable()->constrained('users')->nullOnDelete();
$table->enum('status', CuttingStatus::values())->default(CuttingStatus::IN_PROGRESS->value); $table->enum('status', CuttingStatus::values())->default(CuttingStatus::IN_PROGRESS->value);
$table->string('description', 100)->nullable(); $table->string('description', 100)->nullable();
$table->unsignedBigInteger('total_material_cost')->nullable(); $table->unsignedBigInteger('total_material_cost')->nullable();
$table->unsignedBigInteger('sewing_cost')->default(0);
$table->unsignedBigInteger('other_cost')->default(0);
$table->unsignedBigInteger('cost_per_unit')->nullable(); $table->unsignedBigInteger('cost_per_unit')->nullable();
$table->timestamp('created_at')->useCurrent(); $table->timestamp('created_at')->useCurrent();

69
lang/en.json Normal file
View File

@ -0,0 +1,69 @@
{
"(and :count more error)": "(and :count more error)",
"(and :count more errors)": "(and :count more error)|(and :count more errors)|(and :count more errors)",
"A decryption key is required.": "A decryption key is required.",
"All rights reserved.": "All rights reserved.",
"Encrypted environment file already exists.": "Encrypted environment file already exists.",
"Encrypted environment file not found.": "Encrypted environment file not found.",
"Environment file already exists.": "Environment file already exists.",
"Environment file not found.": "Environment file not found.",
"errors": "errors",
"Forbidden": "Forbidden",
"Go to page :page": "Go to page :page",
"Hello!": "Hello!",
"If you did not create an account, no further action is required.": "If you did not create an account, no further action is required.",
"If you did not request a password reset, no further action is required.": "If you did not request a password reset, no further action is required.",
"If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:",
"Invalid credential format.": "Invalid credential format.",
"Invalid filename.": "Invalid filename.",
"Invalid JSON was returned from the route.": "Invalid JSON was returned from the route.",
"Location": "Location",
"Login": "Login",
"Logout": "Logout",
"Not Found": "Not Found",
"of": "of",
"Page Expired": "Page Expired",
"Pagination Navigation": "Pagination Navigation",
"Passkey not recognized. It may have been removed from your account.": "Passkey not recognized. It may have been removed from your account.",
"Passkey registration session expired. Please try again.": "Passkey registration session expired. Please try again.",
"Passkey verification session expired. Please try again.": "Passkey verification session expired. Please try again.",
"Payment Required": "Payment Required",
"Please click the button below to verify your email address.": "Please click the button below to verify your email address.",
"Regards,": "Regards,",
"Register": "Register",
"Reset Password": "Reset Password",
"Reset your password": "Reset your password",
"results": "results",
"Server Error": "Server Error",
"Service Unavailable": "Service Unavailable",
"Showing": "Showing",
"The :attribute must be at least :length characters and contain at least one number.": "The :attribute must be at least :length characters and contain at least one number.",
"The :attribute must be at least :length characters and contain at least one special character and one number.": "The :attribute must be at least :length characters and contain at least one special character and one number.",
"The :attribute must be at least :length characters and contain at least one special character.": "The :attribute must be at least :length characters and contain at least one special character.",
"The :attribute must be at least :length characters and contain at least one uppercase character and one number.": "The :attribute must be at least :length characters and contain at least one uppercase character and one number.",
"The :attribute must be at least :length characters and contain at least one uppercase character and one special character.": "The :attribute must be at least :length characters and contain at least one uppercase character and one special character.",
"The :attribute must be at least :length characters and contain at least one uppercase character, one number, and one special character.": "The :attribute must be at least :length characters and contain at least one uppercase character, one number, and one special character.",
"The :attribute must be at least :length characters and contain at least one uppercase character.": "The :attribute must be at least :length characters and contain at least one uppercase character.",
"The :attribute must be at least :length characters.": "The :attribute must be at least :length characters.",
"The given data was invalid.": "The given data was invalid.",
"The provided password does not match your current password.": "The provided password does not match your current password.",
"The provided password was incorrect.": "The provided password was incorrect.",
"The provided two factor authentication code was invalid.": "The provided two factor authentication code was invalid.",
"The provided two factor recovery code was invalid.": "The provided two factor recovery code was invalid.",
"The response is not a streamed response.": "The response is not a streamed response.",
"The response is not a view.": "The response is not a view.",
"This action is unauthorized.": "This action is unauthorized.",
"This password reset link will expire in :count minutes.": "This password reset link will expire in :count minutes.",
"to": "to",
"Toggle navigation": "Toggle navigation",
"Too Many Requests": "Too Many Requests",
"Unable to register passkey. Please try again.": "Unable to register passkey. Please try again.",
"Unable to register this passkey.": "Unable to register this passkey.",
"Unable to sign in with this account.": "Unable to sign in with this account.",
"Unable to verify passkey. Please try again.": "Unable to verify passkey. Please try again.",
"Unauthorized": "Unauthorized",
"Verify Email Address": "Verify Email Address",
"Verify your email address": "Verify your email address",
"Whoops!": "Whoops!",
"You are receiving this email because we received a password reset request for your account.": "You are receiving this email because we received a password reset request for your account."
}

9
lang/en/auth.php Normal file
View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
return [
'failed' => 'These credentials do not match our records.',
'password' => 'The provided password is incorrect.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
];

8
lang/en/pagination.php Normal file
View File

@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
return [
'next' => 'Next &raquo;',
'previous' => '&laquo; Previous',
];

11
lang/en/passwords.php Normal file
View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
return [
'reset' => 'Your password has been reset.',
'sent' => 'We have emailed your password reset link.',
'throttled' => 'Please wait before retrying.',
'token' => 'This password reset token is invalid.',
'user' => 'We can\'t find a user with that email address.',
];

161
lang/en/validation.php Normal file
View File

@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
return [
'accepted' => 'The :attribute field must be accepted.',
'accepted_if' => 'The :attribute field must be accepted when :other is :value.',
'active_url' => 'The :attribute field must be a valid URL.',
'after' => 'The :attribute field must be a date after :date.',
'after_or_equal' => 'The :attribute field must be a date after or equal to :date.',
'alpha' => 'The :attribute field must only contain letters.',
'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.',
'alpha_num' => 'The :attribute field must only contain letters and numbers.',
'any_of' => 'The :attribute field is invalid.',
'array' => 'The :attribute field must be an array.',
'array_keys' => 'The :attribute field must only contain the following keys: :values.',
'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.',
'base64' => 'The :attribute field must be a valid Base64 string.',
'before' => 'The :attribute field must be a date before :date.',
'before_or_equal' => 'The :attribute field must be a date before or equal to :date.',
'between' => [
'array' => 'The :attribute field must have between :min and :max items.',
'file' => 'The :attribute field must be between :min and :max kilobytes.',
'numeric' => 'The :attribute field must be between :min and :max.',
'string' => 'The :attribute field must be between :min and :max characters.',
],
'boolean' => 'The :attribute field must be true or false.',
'can' => 'The :attribute field contains an unauthorized value.',
'confirmed' => 'The :attribute field confirmation does not match.',
'contains' => 'The :attribute field is missing a required value.',
'current_password' => 'The password is incorrect.',
'date' => 'The :attribute field must be a valid date.',
'date_equals' => 'The :attribute field must be a date equal to :date.',
'date_format' => 'The :attribute field must match the format :format.',
'decimal' => 'The :attribute field must have :decimal decimal places.',
'declined' => 'The :attribute field must be declined.',
'declined_if' => 'The :attribute field must be declined when :other is :value.',
'different' => 'The :attribute field and :other must be different.',
'digits' => 'The :attribute field must be :digits digits.',
'digits_between' => 'The :attribute field must be between :min and :max digits.',
'dimensions' => 'The :attribute field has invalid image dimensions.',
'distinct' => 'The :attribute field has a duplicate value.',
'doesnt_contain' => 'The :attribute field must not contain any of the following: :values.',
'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.',
'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.',
'email' => 'The :attribute field must be a valid email address.',
'encoding' => 'The :attribute field must be encoded in :encoding.',
'ends_with' => 'The :attribute field must end with one of the following: :values.',
'enum' => 'The selected :attribute is invalid.',
'exists' => 'The selected :attribute is invalid.',
'extensions' => 'The :attribute field must have one of the following extensions: :values.',
'file' => 'The :attribute field must be a file.',
'filled' => 'The :attribute field must have a value.',
'gt' => [
'array' => 'The :attribute field must have more than :value items.',
'file' => 'The :attribute field must be greater than :value kilobytes.',
'numeric' => 'The :attribute field must be greater than :value.',
'string' => 'The :attribute field must be greater than :value characters.',
],
'gte' => [
'array' => 'The :attribute field must have :value items or more.',
'file' => 'The :attribute field must be greater than or equal to :value kilobytes.',
'numeric' => 'The :attribute field must be greater than or equal to :value.',
'string' => 'The :attribute field must be greater than or equal to :value characters.',
],
'hex_color' => 'The :attribute field must be a valid hexadecimal color.',
'image' => 'The :attribute field must be an image.',
'in' => 'The selected :attribute is invalid.',
'in_array' => 'The :attribute field must exist in :other.',
'in_array_keys' => 'The :attribute field must contain at least one of the following keys: :values.',
'integer' => 'The :attribute field must be an integer.',
'ip' => 'The :attribute field must be a valid IP address.',
'ipv4' => 'The :attribute field must be a valid IPv4 address.',
'ipv6' => 'The :attribute field must be a valid IPv6 address.',
'json' => 'The :attribute field must be a valid JSON string.',
'list' => 'The :attribute field must be a list.',
'lowercase' => 'The :attribute field must be lowercase.',
'lt' => [
'array' => 'The :attribute field must have less than :value items.',
'file' => 'The :attribute field must be less than :value kilobytes.',
'numeric' => 'The :attribute field must be less than :value.',
'string' => 'The :attribute field must be less than :value characters.',
],
'lte' => [
'array' => 'The :attribute field must not have more than :value items.',
'file' => 'The :attribute field must be less than or equal to :value kilobytes.',
'numeric' => 'The :attribute field must be less than or equal to :value.',
'string' => 'The :attribute field must be less than or equal to :value characters.',
],
'mac_address' => 'The :attribute field must be a valid MAC address.',
'max' => [
'array' => 'The :attribute field must not have more than :max items.',
'file' => 'The :attribute field must not be greater than :max kilobytes.',
'numeric' => 'The :attribute field must not be greater than :max.',
'string' => 'The :attribute field must not be greater than :max characters.',
],
'max_digits' => 'The :attribute field must not have more than :max digits.',
'mimes' => 'The :attribute field must be a file of type: :values.',
'mimetypes' => 'The :attribute field must be a file of type: :values.',
'min' => [
'array' => 'The :attribute field must have at least :min items.',
'file' => 'The :attribute field must be at least :min kilobytes.',
'numeric' => 'The :attribute field must be at least :min.',
'string' => 'The :attribute field must be at least :min characters.',
],
'min_digits' => 'The :attribute field must have at least :min digits.',
'missing' => 'The :attribute field must be missing.',
'missing_if' => 'The :attribute field must be missing when :other is :value.',
'missing_unless' => 'The :attribute field must be missing unless :other is :value.',
'missing_with' => 'The :attribute field must be missing when :values is present.',
'missing_with_all' => 'The :attribute field must be missing when :values are present.',
'multiple_of' => 'The :attribute field must be a multiple of :value.',
'not_in' => 'The selected :attribute is invalid.',
'not_regex' => 'The :attribute field format is invalid.',
'numeric' => 'The :attribute field must be a number.',
'password' => [
'letters' => 'The :attribute field must contain at least one letter.',
'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.',
'numbers' => 'The :attribute field must contain at least one number.',
'symbols' => 'The :attribute field must contain at least one symbol.',
'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.',
],
'present' => 'The :attribute field must be present.',
'present_if' => 'The :attribute field must be present when :other is :value.',
'present_unless' => 'The :attribute field must be present unless :other is :value.',
'present_with' => 'The :attribute field must be present when :values is present.',
'present_with_all' => 'The :attribute field must be present when :values are present.',
'prohibited' => 'The :attribute field is prohibited.',
'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',
'prohibited_if_accepted' => 'The :attribute field is prohibited when :other is accepted.',
'prohibited_if_declined' => 'The :attribute field is prohibited when :other is declined.',
'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',
'prohibits' => 'The :attribute field prohibits :other from being present.',
'regex' => 'The :attribute field format is invalid.',
'required' => 'The :attribute field is required.',
'required_array_keys' => 'The :attribute field must contain entries for: :values.',
'required_if' => 'The :attribute field is required when :other is :value.',
'required_if_accepted' => 'The :attribute field is required when :other is accepted.',
'required_if_declined' => 'The :attribute field is required when :other is declined.',
'required_unless' => 'The :attribute field is required unless :other is in :values.',
'required_with' => 'The :attribute field is required when :values is present.',
'required_with_all' => 'The :attribute field is required when :values are present.',
'required_without' => 'The :attribute field is required when :values is not present.',
'required_without_all' => 'The :attribute field is required when none of :values are present.',
'same' => 'The :attribute field must match :other.',
'size' => [
'array' => 'The :attribute field must contain :size items.',
'file' => 'The :attribute field must be :size kilobytes.',
'numeric' => 'The :attribute field must be :size.',
'string' => 'The :attribute field must be :size characters.',
],
'starts_with' => 'The :attribute field must start with one of the following: :values.',
'string' => 'The :attribute field must be a string.',
'timezone' => 'The :attribute field must be a valid timezone.',
'ulid' => 'The :attribute field must be a valid ULID.',
'unique' => 'The :attribute has already been taken.',
'uploaded' => 'The :attribute failed to upload.',
'uppercase' => 'The :attribute field must be uppercase.',
'url' => 'The :attribute field must be a valid URL.',
'uuid' => 'The :attribute field must be a valid UUID.',
];

69
lang/id.json Normal file
View File

@ -0,0 +1,69 @@
{
"(and :count more error)": "(dan :count kesalahan lainnya)",
"(and :count more errors)": "(dan :count kesalahan lainnya)|(dan :count kesalahan lainnya)|(dan :count kesalahan lainnya)",
"A decryption key is required.": "Kunci dekripsi diperlukan.",
"All rights reserved.": "Hak Cipta Dilindungi.",
"Encrypted environment file already exists.": "Enkripsi file environment sudah ada.",
"Encrypted environment file not found.": "Enkripsi file environment tidak ditemukan.",
"Environment file already exists.": "File environment sudah ada.",
"Environment file not found.": "file environment tidak ditemukan.",
"errors": "kesalahan",
"Forbidden": "Dilarang",
"Go to page :page": "Ke halaman :page",
"Hello!": "Halo!",
"If you did not create an account, no further action is required.": "Jika Anda tidak membuat akun, Anda tidak perlu melakukan apapun.",
"If you did not request a password reset, no further action is required.": "Jika Anda tidak meminta pengaturan ulang kata sandi, Anda tidak perlu melakukan apapun.",
"If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Jika Anda mengalami kesulitan mengklik tombol \":actionText\", salin dan tempel URL di bawah\nke browser web Anda:",
"Invalid credential format.": "Invalid credential format.",
"Invalid filename.": "Nama file tidak valid.",
"Invalid JSON was returned from the route.": "JSON yang tidak valid dikembalikan dari rute.",
"Location": "Lokasi",
"Login": "Masuk",
"Logout": "Keluar",
"Not Found": "Tidak ditemukan",
"of": "dari",
"Page Expired": "Halaman Kadaluwarsa",
"Pagination Navigation": "Navigasi Paginasi",
"Passkey not recognized. It may have been removed from your account.": "Passkey not recognized. It may have been removed from your account.",
"Passkey registration session expired. Please try again.": "Passkey registration session expired. Please try again.",
"Passkey verification session expired. Please try again.": "Passkey verification session expired. Please try again.",
"Payment Required": "Pembayaran Diperlukan",
"Please click the button below to verify your email address.": "Silakan klik tombol di bawah untuk memverifikasi alamat surel Anda.",
"Regards,": "Salam,",
"Register": "Daftar",
"Reset Password": "Atur Ulang Kata Sandi",
"Reset your password": "Setel ulang kata sandi Anda",
"results": "hasil",
"Server Error": "Terjadi Kesalahan Server",
"Service Unavailable": "Layanan Tidak Tersedia",
"Showing": "Menampilkan",
"The :attribute must be at least :length characters and contain at least one number.": ":Attribute minimal berisi :length karakter dan mengandung setidaknya satu angka.",
"The :attribute must be at least :length characters and contain at least one special character and one number.": ":Attribute minimal berisi :length karakter dan mengandung setidaknya satu karakter spesial dan satu angka.",
"The :attribute must be at least :length characters and contain at least one special character.": ":Attribute minimal berisi :length karakter dan mengandung setidaknya satu karakter spesial.",
"The :attribute must be at least :length characters and contain at least one uppercase character and one number.": ":Attribute minimal berisi :length karakter dan mengandung setidaknya satu huruf besar dan satu angka.",
"The :attribute must be at least :length characters and contain at least one uppercase character and one special character.": ":Attribute minimal berisi :length karakter dan mengandung setidaknya satu huruf besar dan satu karakter spesial.",
"The :attribute must be at least :length characters and contain at least one uppercase character, one number, and one special character.": ":Attribute minimal berisi :length karakter dan mengandung setidaknya satu huruf besar, satu angka, dan satu karakter spesial.",
"The :attribute must be at least :length characters and contain at least one uppercase character.": ":Attribute minimal berisi :length karakter dan mengandung setidaknya satu huruf besar.",
"The :attribute must be at least :length characters.": ":Attribute minimal berisi :length karakter.",
"The given data was invalid.": "Data yang diberikan tidak valid.",
"The provided password does not match your current password.": "Kata sandi yang dimasukkan tidak cocok dengan kata sandi saat ini.",
"The provided password was incorrect.": "Kata sandi yang dimasukkan salah.",
"The provided two factor authentication code was invalid.": "Kode autentikasi dua faktor yang dimasukkan tidak valid.",
"The provided two factor recovery code was invalid.": "Kode pemulihan dua faktor yang diberikan tidak valid.",
"The response is not a streamed response.": "Responsnya bukan respons yang dialirkan.",
"The response is not a view.": "Responsnya bukanlah pandangan.",
"This action is unauthorized.": "Tindakan ini tidak sah.",
"This password reset link will expire in :count minutes.": "Tautan pengaturan ulang kata sandi ini akan kedaluwarsa dalam :count menit.",
"to": "kepada",
"Toggle navigation": "Alihkan navigasi",
"Too Many Requests": "Terlalu Banyak Permintaan",
"Unable to register passkey. Please try again.": "Unable to register passkey. Please try again.",
"Unable to register this passkey.": "Unable to register this passkey.",
"Unable to sign in with this account.": "Unable to sign in with this account.",
"Unable to verify passkey. Please try again.": "Unable to verify passkey. Please try again.",
"Unauthorized": "Tidak Diizinkan",
"Verify Email Address": "Verifikasi Alamat Surel",
"Verify your email address": "Verifikasi alamat email Anda",
"Whoops!": "Aduh!",
"You are receiving this email because we received a password reset request for your account.": "Anda menerima surel ini karena kami menerima permintaan pengaturan ulang kata sandi untuk akun anda."
}

9
lang/id/auth.php Normal file
View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
return [
'failed' => 'Identitas tersebut tidak cocok dengan data kami.',
'password' => 'Kata sandi salah.',
'throttle' => 'Terlalu banyak upaya masuk. Silahkan coba lagi dalam :seconds detik.',
];

8
lang/id/pagination.php Normal file
View File

@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
return [
'next' => 'Berikutnya &raquo;',
'previous' => '&laquo; Sebelumnya',
];

11
lang/id/passwords.php Normal file
View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
return [
'reset' => 'Kata sandi Anda sudah direset!',
'sent' => 'Kami sudah mengirim surel yang berisi tautan untuk mereset kata sandi Anda!',
'throttled' => 'Harap tunggu sebelum mencoba lagi.',
'token' => 'Token pengaturan ulang kata sandi tidak sah.',
'user' => 'Kami tidak dapat menemukan pengguna dengan alamat surel tersebut.',
];

161
lang/id/validation.php Normal file
View File

@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
return [
'accepted' => ':Attribute harus diterima.',
'accepted_if' => ':Attribute harus diterima ketika :other berisi :value.',
'active_url' => ':Attribute bukan URL yang valid.',
'after' => ':Attribute harus berisi tanggal setelah :date.',
'after_or_equal' => ':Attribute harus berisi tanggal setelah atau sama dengan :date.',
'alpha' => ':Attribute hanya boleh berisi huruf.',
'alpha_dash' => ':Attribute hanya boleh berisi huruf, angka, strip, dan garis bawah.',
'alpha_num' => ':Attribute hanya boleh berisi huruf dan angka.',
'any_of' => 'Bidang :attribute tidak valid.',
'array' => ':Attribute harus berisi sebuah array.',
'array_keys' => 'The :attribute field must only contain the following keys: :values.',
'ascii' => ':Attribute hanya boleh berisi karakter dan simbol alfanumerik single-byte.',
'base64' => 'The :attribute field must be a valid Base64 string.',
'before' => ':Attribute harus berisi tanggal sebelum :date.',
'before_or_equal' => ':Attribute harus berisi tanggal sebelum atau sama dengan :date.',
'between' => [
'array' => ':Attribute harus memiliki :min sampai :max anggota.',
'file' => ':Attribute harus berukuran antara :min sampai :max kilobita.',
'numeric' => ':Attribute harus bernilai antara :min sampai :max.',
'string' => ':Attribute harus berisi antara :min sampai :max karakter.',
],
'boolean' => ':Attribute harus bernilai true atau false',
'can' => 'Bidang :attribute berisi nilai yang tidak sah.',
'confirmed' => 'Konfirmasi :attribute tidak cocok.',
'contains' => 'Bidang :attribute tidak memiliki nilai yang diperlukan.',
'current_password' => 'Kata sandi salah.',
'date' => ':Attribute bukan tanggal yang valid.',
'date_equals' => ':Attribute harus berisi tanggal yang sama dengan :date.',
'date_format' => ':Attribute tidak cocok dengan format :format.',
'decimal' => ':Attribute harus memiliki :decimal tempat desimal.',
'declined' => ':Attribute ini harus ditolak.',
'declined_if' => ':Attribute ini harus ditolak ketika :other bernilai :value.',
'different' => ':Attribute dan :other harus berbeda.',
'digits' => ':Attribute harus terdiri dari :digits angka.',
'digits_between' => ':Attribute harus terdiri dari :min sampai :max angka.',
'dimensions' => ':Attribute tidak memiliki dimensi gambar yang valid.',
'distinct' => ':Attribute memiliki nilai yang duplikat.',
'doesnt_contain' => 'Bidang :attribute tidak boleh berisi salah satu dari yang berikut: :values.',
'doesnt_end_with' => ':Attribute tidak boleh diakhiri dengan salah satu dari berikut ini: :values.',
'doesnt_start_with' => ':Attribute tidak boleh dimulai dengan salah satu dari berikut ini: :values.',
'email' => ':Attribute harus berupa alamat surel yang valid.',
'encoding' => 'Bidang :attribute harus dikodekan dalam :encoding.',
'ends_with' => ':Attribute harus diakhiri salah satu dari berikut: :values',
'enum' => ':Attribute yang dipilih tidak valid.',
'exists' => ':Attribute yang dipilih tidak valid.',
'extensions' => 'Bidang :attribute harus memiliki salah satu ekstensi berikut: :values.',
'file' => ':Attribute harus berupa sebuah berkas.',
'filled' => ':Attribute harus memiliki nilai.',
'gt' => [
'array' => ':Attribute harus memiliki lebih dari :value anggota.',
'file' => ':Attribute harus berukuran lebih besar dari :value kilobita.',
'numeric' => ':Attribute harus bernilai lebih besar dari :value.',
'string' => ':Attribute harus berisi lebih besar dari :value karakter.',
],
'gte' => [
'array' => ':Attribute harus terdiri dari :value anggota atau lebih.',
'file' => ':Attribute harus berukuran lebih besar dari atau sama dengan :value kilobita.',
'numeric' => ':Attribute harus bernilai lebih besar dari atau sama dengan :value.',
'string' => ':Attribute harus berisi lebih besar dari atau sama dengan :value karakter.',
],
'hex_color' => 'Bidang :attribute harus berupa warna heksadesimal yang valid.',
'image' => ':Attribute harus berupa gambar.',
'in' => ':Attribute yang dipilih tidak valid.',
'in_array' => ':Attribute tidak ada di dalam :other.',
'in_array_keys' => ':attribute bidang harus berisi setidaknya satu dari tombol berikut: :values.',
'integer' => ':Attribute harus berupa bilangan bulat.',
'ip' => ':Attribute harus berupa alamat IP yang valid.',
'ipv4' => ':Attribute harus berupa alamat IPv4 yang valid.',
'ipv6' => ':Attribute harus berupa alamat IPv6 yang valid.',
'json' => ':Attribute harus berupa JSON string yang valid.',
'list' => 'Bidang :attribute harus berupa daftar.',
'lowercase' => ':Attribute harus berupa huruf kecil.',
'lt' => [
'array' => ':Attribute harus memiliki kurang dari :value anggota.',
'file' => ':Attribute harus berukuran kurang dari :value kilobita.',
'numeric' => ':Attribute harus bernilai kurang dari :value.',
'string' => ':Attribute harus berisi kurang dari :value karakter.',
],
'lte' => [
'array' => ':Attribute harus tidak lebih dari :value anggota.',
'file' => ':Attribute harus berukuran kurang dari atau sama dengan :value kilobita.',
'numeric' => ':Attribute harus bernilai kurang dari atau sama dengan :value.',
'string' => ':Attribute harus berisi kurang dari atau sama dengan :value karakter.',
],
'mac_address' => ':Attribute harus berupa alamat MAC yang valid.',
'max' => [
'array' => ':Attribute maksimal terdiri dari :max anggota.',
'file' => ':Attribute maksimal berukuran :max kilobita.',
'numeric' => ':Attribute maksimal bernilai :max.',
'string' => ':Attribute maksimal berisi :max karakter.',
],
'max_digits' => ':Attribute tidak boleh memiliki lebih dari :max digit.',
'mimes' => ':Attribute harus berupa berkas berjenis: :values.',
'mimetypes' => ':Attribute harus berupa berkas berjenis: :values.',
'min' => [
'array' => ':Attribute minimal terdiri dari :min anggota.',
'file' => ':Attribute minimal berukuran :min kilobita.',
'numeric' => ':Attribute minimal bernilai :min.',
'string' => ':Attribute minimal berisi :min karakter.',
],
'min_digits' => ':Attribute tidak boleh memiliki kurang dari :min digit.',
'missing' => 'Bidang :attribute harus hilang.',
'missing_if' => 'Bidang :attribute harus hilang ketika :other adalah :value.',
'missing_unless' => 'Bidang :attribute harus hilang kecuali :other adalah :value.',
'missing_with' => 'Kolom :attribute harus hilang saat ada :values.',
'missing_with_all' => 'Kolom :attribute harus hilang jika ada :values.',
'multiple_of' => ':Attribute harus merupakan kelipatan dari :value',
'not_in' => ':Attribute yang dipilih tidak valid.',
'not_regex' => 'Format :attribute tidak valid.',
'numeric' => ':Attribute harus berupa angka.',
'password' => [
'letters' => ':Attribute ini harus memiliki setidaknya satu karakter.',
'mixed' => ':Attribute ini harus memiliki setidaknya satu huruf kapital dan satu huruf kecil.',
'numbers' => ':Attribute ini harus memiliki setidaknya satu angka.',
'symbols' => ':Attribute ini harus memiliki setidaknya satu simbol.',
'uncompromised' => ':Attribute ini telah muncul di kebocoran data. Silahkan memilih :attribute yang berbeda.',
],
'present' => ':Attribute wajib ada.',
'present_if' => 'Bidang :attribute harus ada ketika :other adalah :value.',
'present_unless' => 'Bidang :attribute harus ada kecuali :other adalah :value.',
'present_with' => 'Bidang :attribute harus ada bila ada :values.',
'present_with_all' => 'Bidang :attribute harus ada ketika ada :values.',
'prohibited' => ':Attribute tidak boleh ada.',
'prohibited_if' => ':Attribute tidak boleh ada bila :other adalah :value.',
'prohibited_if_accepted' => ':attribute bidang dilarang ketika :other diterima.',
'prohibited_if_declined' => ':attribute bidang dilarang ketika :other ditolak.',
'prohibited_unless' => ':Attribute tidak boleh ada kecuali :other memiliki nilai :values.',
'prohibits' => ':Attribute melarang isian :other untuk ditampilkan.',
'regex' => 'Format :attribute tidak valid.',
'required' => ':Attribute wajib diisi.',
'required_array_keys' => ':Attribute wajib berisi entri untuk: :values.',
'required_if' => ':Attribute wajib diisi bila :other adalah :value.',
'required_if_accepted' => ':Attribute wajib diisi bila :other sesuai.',
'required_if_declined' => 'Bidang :attribute wajib diisi bila :other ditolak.',
'required_unless' => ':Attribute wajib diisi kecuali :other memiliki nilai :values.',
'required_with' => ':Attribute wajib diisi bila terdapat :values.',
'required_with_all' => ':Attribute wajib diisi bila terdapat :values.',
'required_without' => ':Attribute wajib diisi bila tidak terdapat :values.',
'required_without_all' => ':Attribute wajib diisi bila sama sekali tidak terdapat :values.',
'same' => ':Attribute dan :other harus sama.',
'size' => [
'array' => ':Attribute harus mengandung :size anggota.',
'file' => ':Attribute harus berukuran :size kilobyte.',
'numeric' => ':Attribute harus berukuran :size.',
'string' => ':Attribute harus berukuran :size karakter.',
],
'starts_with' => ':Attribute harus diawali salah satu dari berikut: :values',
'string' => ':Attribute harus berupa string.',
'timezone' => ':Attribute harus berisi zona waktu yang valid.',
'ulid' => ':Attribute harus merupakan ULID yang valid.',
'unique' => ':Attribute sudah ada sebelumnya.',
'uploaded' => ':Attribute gagal diunggah.',
'uppercase' => ':Attribute harus berupa huruf kapital.',
'url' => 'Format :attribute tidak valid.',
'uuid' => ':Attribute harus merupakan UUID yang valid.',
];

View File

@ -24,7 +24,7 @@ createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName), title: (title) => (title ? `${title} - ${appName}` : appName),
layout: (name) => { layout: (name) => {
switch (true) { switch (true) {
case name === 'welcome': case name === 'admin/manage/cutting/show':
return null; return null;
case name.startsWith('auth/'): case name.startsWith('auth/'):
return AuthLayout; return AuthLayout;

View File

@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { ImagePreviewModal } from '@/components/dialogs'; import { ImagePreviewModal } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs/image-preview-modal';
type ImagePreviewButtonProps = { type ImagePreviewButtonProps = {
srcs: string[]; srcs: string[];
@ -9,6 +10,8 @@ type ImagePreviewButtonProps = {
description?: string; description?: string;
alt?: string; alt?: string;
className?: string; className?: string;
items?: ImagePreviewItem[];
startIndex?: number;
}; };
export function ImagePreviewButton({ export function ImagePreviewButton({
@ -19,6 +22,8 @@ export function ImagePreviewButton({
description, description,
alt, alt,
className = 'h-10 w-10', className = 'h-10 w-10',
items,
startIndex = 0,
}: ImagePreviewButtonProps) { }: ImagePreviewButtonProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -49,6 +54,8 @@ export function ImagePreviewButton({
sources={modalSources ?? srcs} sources={modalSources ?? srcs}
title={title} title={title}
description={description} description={description}
items={items}
startIndex={startIndex}
/> />
</> </>
); );

View File

@ -1,13 +1,22 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useEffect, useState } from 'react';
export type ImagePreviewItem = {
id?: string | number;
src: string | null;
sources?: string[];
title?: string;
description?: string;
};
type ImagePreviewModalProps = { type ImagePreviewModalProps = {
open: boolean; open: boolean;
@ -17,6 +26,8 @@ type ImagePreviewModalProps = {
description?: string; description?: string;
alt?: string; alt?: string;
sources?: string[]; sources?: string[];
items?: ImagePreviewItem[];
startIndex?: number;
}; };
export function ImagePreviewModal({ export function ImagePreviewModal({
@ -27,23 +38,49 @@ export function ImagePreviewModal({
description, description,
alt = 'Preview', alt = 'Preview',
sources, sources,
items,
startIndex = 0,
}: ImagePreviewModalProps) { }: ImagePreviewModalProps) {
const allImages = sources && sources.length > 0 ? sources : src ? [src] : []; const [itemIndex, setItemIndex] = useState(startIndex);
const [currentIndex, setCurrentIndex] = useState(0); const [imageIndex, setImageIndex] = useState(0);
const currentSrc = allImages[currentIndex] ?? src; useEffect(() => {
const hasMultiple = allImages.length > 1; if (open) {
setItemIndex(startIndex);
setImageIndex(0);
}
}, [open, startIndex]);
function handlePrev() { useEffect(() => {
setCurrentIndex((prev) => setImageIndex(0);
prev === 0 ? allImages.length - 1 : prev - 1, }, [itemIndex]);
);
const isMultiItem = items && items.length > 1;
const currentItem = isMultiItem ? items[itemIndex] : null;
const allImages = currentItem
? (currentItem.sources?.length ? currentItem.sources : currentItem.src ? [currentItem.src] : [])
: (sources?.length ? sources : src ? [src] : []);
const currentSrc = allImages[imageIndex] ?? allImages[0] ?? currentItem?.src ?? src;
const currentTitle = currentItem?.title ?? title;
const currentDescription = currentItem?.description ?? description;
const hasMultipleImages = allImages.length > 1;
function handleImagePrev() {
setImageIndex((prev) => prev === 0 ? allImages.length - 1 : prev - 1);
} }
function handleNext() { function handleImageNext() {
setCurrentIndex((prev) => setImageIndex((prev) => prev === allImages.length - 1 ? 0 : prev + 1);
prev === allImages.length - 1 ? 0 : prev + 1, }
);
function handleItemPrev() {
setItemIndex((prev) => prev === 0 ? items.length - 1 : prev - 1);
}
function handleItemNext() {
setItemIndex((prev) => prev === items.length - 1 ? 0 : prev + 1);
} }
return ( return (
@ -51,17 +88,17 @@ export function ImagePreviewModal({
open={open} open={open}
onOpenChange={(v) => { onOpenChange={(v) => {
if (!v) { if (!v) {
setCurrentIndex(0); setItemIndex(startIndex);
} setImageIndex(0);
}
onOpenChange(v); onOpenChange(v);
}} }}
> >
<DialogContent showCloseButton> <DialogContent showCloseButton>
{(title || description) && ( {(currentTitle || currentDescription) && (
<DialogHeader> <DialogHeader>
{title && <DialogTitle>{title}</DialogTitle>} {currentTitle && <DialogTitle>{currentTitle}</DialogTitle>}
{description && <DialogDescription>{description}</DialogDescription>} {currentDescription && <DialogDescription>{currentDescription}</DialogDescription>}
</DialogHeader> </DialogHeader>
)} )}
{currentSrc && ( {currentSrc && (
@ -71,13 +108,13 @@ setCurrentIndex(0);
alt={alt} alt={alt}
className="max-h-[80vh] w-full rounded-lg object-contain" className="max-h-[80vh] w-full rounded-lg object-contain"
/> />
{hasMultiple && ( {hasMultipleImages && (
<> <>
<Button <Button
variant="secondary" variant="secondary"
size="icon" size="icon"
className="absolute left-2 top-1/2 h-8 w-8 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70" className="absolute left-2 top-1/2 h-8 w-8 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
onClick={handlePrev} onClick={handleImagePrev}
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
@ -85,17 +122,29 @@ setCurrentIndex(0);
variant="secondary" variant="secondary"
size="icon" size="icon"
className="absolute right-2 top-1/2 h-8 w-8 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70" className="absolute right-2 top-1/2 h-8 w-8 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
onClick={handleNext} onClick={handleImageNext}
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2 py-1 text-xs text-white"> <div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2 py-1 text-xs text-white">
{currentIndex + 1} / {allImages.length} {imageIndex + 1} / {allImages.length}
</div> </div>
</> </>
)} )}
</div> </div>
)} )}
{isMultiItem && (
<DialogFooter>
<Button variant="outline" onClick={handleItemPrev}>
<ChevronLeft className="h-4 w-4 mr-1" />
Sebelumnya
</Button>
<Button variant="outline" onClick={handleItemNext}>
Selanjutnya
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</DialogFooter>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -3,3 +3,4 @@ export { DeleteConfirmDialog } from './delete-confirm-dialog';
export { FormDialog } from './form-dialog'; export { FormDialog } from './form-dialog';
export { ImagePreviewButton } from './image-preview-button'; export { ImagePreviewButton } from './image-preview-button';
export { ImagePreviewModal } from './image-preview-modal'; export { ImagePreviewModal } from './image-preview-modal';
export type { ImagePreviewItem } from './image-preview-modal';

View File

@ -599,7 +599,7 @@ export default function Analysis({
<StatCard <StatCard
title="Bahan Baku" title="Bahan Baku"
icon={Package} icon={Package}
mainLabel="Total Belanja" mainLabel="Total Stok"
mainValue={rawMaterialStock.total_stock.toLocaleString('id-ID')} mainValue={rawMaterialStock.total_stock.toLocaleString('id-ID')}
subLabel={`Rp${formatRupiah(rawMaterialStock.total_value)}`} subLabel={`Rp${formatRupiah(rawMaterialStock.total_value)}`}
description="Tidak terpengaruh filter tanggal" description="Tidak terpengaruh filter tanggal"
@ -615,7 +615,7 @@ export default function Analysis({
<StatCard <StatCard
title="Stok Produk" title="Stok Produk"
icon={ShoppingCart} icon={ShoppingCart}
mainLabel="Total Restock" mainLabel="Total Stok"
mainValue={productStock.total_stock.toLocaleString('id-ID')} mainValue={productStock.total_stock.toLocaleString('id-ID')}
subLabel={`Rp${formatRupiah(productStock.total_value)}`} subLabel={`Rp${formatRupiah(productStock.total_value)}`}
description="Tidak terpengaruh filter tanggal" description="Tidak terpengaruh filter tanggal"

View File

@ -26,10 +26,13 @@ export type Cutting = {
status: string; status: string;
description: string | null; description: string | null;
total_material_cost: number | null; total_material_cost: number | null;
formatted_total_material_cost: string | null;
cost_per_unit: number | null; cost_per_unit: number | null;
formatted_cost_per_unit: string | null;
photo_url: string | null; photo_url: string | null;
photo_conversion_url: string | null; photo_conversion_url: string | null;
created_at: string; created_at: string;
formatted_created_at: string;
created_by: { created_by: {
id: number; id: number;
user_profile: { user_profile: {
@ -46,8 +49,6 @@ export type Cutting = {
raw_material_price: { raw_material_price: {
id: number; id: number;
variant: string; variant: string;
price: number;
stock: number;
photo_url: string | null; photo_url: string | null;
photo_conversion_url: string | null; photo_conversion_url: string | null;
raw_material: { raw_material: {

View File

@ -1,13 +1,90 @@
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
import { ImagePreviewButton } from '@/components/dialogs';
import { RowActions } from '@/components/data-display'; import { RowActions } from '@/components/data-display';
import { ImagePreviewButton } from '@/components/dialogs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useCan } from '@/hooks/use-can'; import { useCan } from '@/hooks/use-can';
import { formatDateTime, formatNumber } from '@/lib/format'; import { formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils'; import { show as cuttingShow } from '@/routes/admin/manage/cuttings';
import { Briefcase, ChevronDown, Copy, MessageCircle, Pencil, Share2, Trash2 } from 'lucide-react';
import { useState } from 'react';
import type { Cutting } from './columns'; import type { Cutting } from './columns';
function generateShareLink(cuttingId: number): string {
return window.location.origin + cuttingShow.url(cuttingId);
}
function generateWhatsappText(cutting: Cutting): string {
const result = cutting.cutting_results?.[0];
const items = cutting.cutting_materials ?? [];
const shareLink = generateShareLink(cutting.id);
let text = `*Cutting #${cutting.id}*\n`;
text += `Status: ${cutting.status === 'completed' ? 'Selesai' : cutting.status === 'cancelled' ? 'Dibatalkan' : 'Dikerjakan'}\n`;
text += `Tanggal: ${cutting.formatted_created_at}\n`;
text += `Oleh: ${cutting.created_by?.user_profile?.full_name ?? '-'}\n`;
if (cutting.description) {
text += `Deskripsi: ${cutting.description}\n`;
}
const productNames = new Set<string>();
(cutting.cutting_results ?? []).forEach((r) => {
if (r.product_name) {
productNames.add(r.product_name);
}
});
if (productNames.size > 0) {
text += `\nNama Produk: ${Array.from(productNames).join(', ')}\n`;
}
const totalUsage = items.reduce(
(sum, item) => sum + Number(item.material_usage),
0,
);
text += `Total Pemakaian: ${formatNumber(totalUsage)}\n`;
if (result?.cutting_result) {
text += `Total Hasil Cutting: ${formatNumber(result.cutting_result)} pcs\n`;
}
text += `\nLihat detail lengkap:\n${shareLink}`;
return text;
}
function openWAWeb(text: string) {
const encoded = encodeURIComponent(text);
window.open(`https://wa.me/?text=${encoded}`, '_blank');
}
function openWAAndroid(text: string, pkg: string) {
const encoded = encodeURIComponent(text);
window.open(`intent://send?text=${encoded}#Intent;scheme=whatsapp;package=${pkg};end`, '_blank');
}
function shareViaWhatsAppMessenger(text: string) {
if (/android/i.test(navigator.userAgent)) {
openWAAndroid(text, 'com.whatsapp');
} else {
openWAWeb(text);
}
}
function shareViaWhatsAppBusiness(text: string) {
if (/android/i.test(navigator.userAgent)) {
openWAAndroid(text, 'com.whatsapp.w4b');
} else {
openWAWeb(text);
}
}
export type CuttingCardRowParams = { export type CuttingCardRowParams = {
cutting: Cutting; cutting: Cutting;
index: number; index: number;
@ -26,6 +103,8 @@ export function CuttingCardRow({
onDelete, onDelete,
}: CuttingCardRowParams) { }: CuttingCardRowParams) {
const { can } = useCan(); const { can } = useCan();
const [shareOpen, setShareOpen] = useState(false);
const [copied, setCopied] = useState(false);
const items = cutting.cutting_materials ?? []; const items = cutting.cutting_materials ?? [];
const result = cutting.cutting_results?.[0]; const result = cutting.cutting_results?.[0];
const singleCount = items.filter( const singleCount = items.filter(
@ -43,6 +122,18 @@ export function CuttingCardRow({
0, 0,
); );
const shareText = generateWhatsappText(cutting);
function handleShareWAMessenger() {
shareViaWhatsAppMessenger(shareText);
setShareOpen(false);
}
function handleShareWABusiness() {
shareViaWhatsAppBusiness(shareText);
setShareOpen(false);
}
return ( return (
<> <>
<Card className="overflow-hidden"> <Card className="overflow-hidden">
@ -84,7 +175,7 @@ export function CuttingCardRow({
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-muted-foreground"> <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"> <span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
{formatDateTime(cutting.created_at)} {cutting.formatted_created_at}
</span> </span>
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground"> <span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
{cutting.status === 'completed' {cutting.status === 'completed'
@ -110,20 +201,20 @@ export function CuttingCardRow({
{formatNumber(result.cutting_result)} {formatNumber(result.cutting_result)}
</span> </span>
)} )}
{cutting.cost_per_unit && ( {cutting.formatted_cost_per_unit && (
<span className="font-semibold"> <span className="font-semibold">
<span className="font-normal text-muted-foreground"> <span className="font-normal text-muted-foreground">
Per Produk:{' '} Per Produk:{' '}
</span> </span>
{formatCurrency(cutting.cost_per_unit)} {cutting.formatted_cost_per_unit}
</span> </span>
)} )}
{cutting.total_material_cost && ( {cutting.formatted_total_material_cost && (
<span className="font-semibold"> <span className="font-semibold">
<span className="font-normal text-muted-foreground"> <span className="font-normal text-muted-foreground">
Biaya Keseluruhan:{' '} Biaya Keseluruhan:{' '}
</span> </span>
{formatCurrency(cutting.total_material_cost)} {cutting.formatted_total_material_cost}
</span> </span>
)} )}
</div> </div>
@ -134,13 +225,44 @@ export function CuttingCardRow({
srcs={[cutting.photo_conversion_url ?? cutting.photo_url]} srcs={[cutting.photo_conversion_url ?? cutting.photo_url]}
modalSrc={cutting.photo_url} modalSrc={cutting.photo_url}
title={productName} title={productName}
description={cutting.description ?? formatDateTime(cutting.created_at)} description={cutting.description ?? cutting.formatted_created_at}
className="h-16 w-16" className="h-16 w-16"
/> />
</div> </div>
)} )}
</div> </div>
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<Popover open={shareOpen} onOpenChange={setShareOpen}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Share2 className="h-4 w-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Bagikan</TooltipContent>
<PopoverContent align="end" className="w-48 space-y-1 p-2">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2"
onClick={handleShareWAMessenger}
>
WhatsApp Messenger
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2"
onClick={handleShareWABusiness}
>
WhatsApp Business
</Button>
</PopoverContent>
</Popover>
</Tooltip>
{(can('cuttings.update') || can('cuttings.delete')) && ( {(can('cuttings.update') || can('cuttings.delete')) && (
<RowActions <RowActions
actions={[ actions={[
@ -163,6 +285,7 @@ export function CuttingCardRow({
/> />
)} )}
</div> </div>
</div>
</CardContent> </CardContent>
</Card> </Card>
</> </>

View File

@ -1,4 +1,3 @@
import { Fragment } from 'react';
import { ImagePreviewButton } from '@/components/dialogs'; import { ImagePreviewButton } from '@/components/dialogs';
import { import {
Table, Table,
@ -9,6 +8,7 @@ import {
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { formatNumber } from '@/lib/format'; import { formatNumber } from '@/lib/format';
import { Fragment } from 'react';
import type { Cutting } from './columns'; import type { Cutting } from './columns';
export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) { export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
@ -23,8 +23,8 @@ export function CuttingItemSubRow({ cutting }: { cutting: Cutting }) {
const comboId = item.combination_id!; const comboId = item.combination_id!;
if (!comboGroups[comboId]) { if (!comboGroups[comboId]) {
comboGroups[comboId] = []; comboGroups[comboId] = [];
} }
comboGroups[comboId].push(item); comboGroups[comboId].push(item);
}); });
@ -35,8 +35,8 @@ comboGroups[comboId] = [];
item.raw_material_price?.raw_material?.name ?? 'BING'; item.raw_material_price?.raw_material?.name ?? 'BING';
if (!acc[name]) { if (!acc[name]) {
acc[name] = []; acc[name] = [];
} }
acc[name].push(item); acc[name].push(item);
@ -51,7 +51,7 @@ acc[name] = [];
let counter = 0; let counter = 0;
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>

View File

@ -1,11 +1,27 @@
import { Head, Link, router } from '@inertiajs/react'; import { Head, Link, router } from '@inertiajs/react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { CardTable } from '@/components/data-display'; import { CardTable } from '@/components/data-display';
import { DeleteConfirmDialog } from '@/components/dialogs'; import { DeleteConfirmDialog } from '@/components/dialogs';
import { FilterPopover } from '@/components/data-display';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand'; import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/layout'; import { PageHeader } from '@/components/layout';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useCan } from '@/hooks/use-can'; import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { import {
@ -26,9 +42,21 @@ type Props = {
per_page: number; per_page: number;
total: number; total: number;
}; };
filters: {
product_name?: string;
status?: string;
};
filterOptions: {
statusOptions: Array<{ value: string; label: string }>;
productNames: Array<{ product_name: string }>;
};
}; };
export default function CuttingIndex({ cuttings }: Props) { export default function CuttingIndex({
cuttings,
filters,
filterOptions,
}: Props) {
const { can } = useCan(); const { can } = useCan();
const [deleting, setDeleting] = useState<Cutting | null>(null); const [deleting, setDeleting] = useState<Cutting | null>(null);
const expand = useCardTableExpand(true); const expand = useCardTableExpand(true);
@ -42,14 +70,22 @@ export default function CuttingIndex({ cuttings }: Props) {
const { const {
search, search,
filterOpen,
setFilterOpen,
handlePageChange, handlePageChange,
handlePerPageChange, handlePerPageChange,
handleSearchChange, handleSearchChange,
applyFilter,
clearFilters,
} = useServerTable({ } = useServerTable({
route: () => cuttingIndex.url(), route: () => cuttingIndex.url(),
pagination, pagination,
filters,
filterWithParams: false,
}); });
const hasActiveFilters = Boolean(filters.product_name || filters.status);
function handleDelete() { function handleDelete() {
if (!deleting) { if (!deleting) {
return; return;
@ -60,6 +96,80 @@ export default function CuttingIndex({ cuttings }: Props) {
}); });
} }
const selectedProductName = useMemo(
() =>
filterOptions.productNames.find(
(p) => p.product_name === filters.product_name,
) ?? null,
[filterOptions.productNames, filters.product_name],
);
const filterToolbar = (
<FilterPopover
open={filterOpen}
onOpenChange={setFilterOpen}
filters={filters}
hasActiveFilters={hasActiveFilters}
onClear={clearFilters}
>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Nama Produk
</label>
<Combobox
items={filterOptions.productNames}
itemToStringLabel={(product) => product.product_name}
value={selectedProductName}
onValueChange={(value) =>
applyFilter(
'product_name',
value ? value.product_name : '',
)
}
>
<ComboboxInput
placeholder="Pilih nama produk..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada produk ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(product) => (
<ComboboxItem value={product}>
{product.product_name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Status
</label>
<Select
value={filters.status ?? ''}
onValueChange={(value) =>
applyFilter('status', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua status" />
</SelectTrigger>
<SelectContent>
{filterOptions.statusOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</FilterPopover>
);
return ( return (
<> <>
<Head title="Cutting" /> <Head title="Cutting" />
@ -87,6 +197,7 @@ export default function CuttingIndex({ cuttings }: Props) {
searchValue={search} searchValue={search}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
toolbar={filterToolbar}
pagination={pagination} pagination={pagination}
onPageChange={handlePageChange} onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange} onPerPageChange={handlePerPageChange}

View File

@ -0,0 +1,183 @@
import { Head, Link } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
import { ImagePreviewButton } from '@/components/dialogs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatNumber } from '@/lib/format';
import { index as cuttingIndex } from '@/routes/admin/manage/cuttings';
import type { Cutting } from './columns';
import { CuttingItemSubRow } from './cutting-sub-row';
type Props = {
cutting: Cutting;
};
const STATUS_BADGE_CLASSES: Record<string, string> = {
completed: 'bg-green-100 text-green-800 hover:bg-green-100',
cancelled: 'bg-red-100 text-red-800 hover:bg-red-100',
in_progress: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100',
};
export default function CuttingShow({ cutting }: Props) {
const items = cutting.cutting_materials ?? [];
const result = cutting.cutting_results?.[0];
const totalUsage = items.reduce(
(sum, item) => sum + Number(item.material_usage),
0,
);
const statusBadgeClass =
STATUS_BADGE_CLASSES[cutting.status] ?? STATUS_BADGE_CLASSES.in_progress;
return (
<>
<Head title={`Cutting #${cutting.id}`} />
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 text-center">
<h1 className="text-2xl font-bold tracking-tight">
Detail Cutting
</h1>
<p className="mt-1 text-muted-foreground">
Rincian data proses cutting
</p>
</div>
<div className="overflow-hidden rounded-lg border bg-card shadow-sm">
<div className="border-b bg-muted/30 p-6">
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-xl font-semibold">
Cutting #{cutting.id}
</h2>
<Badge variant="secondary" className={statusBadgeClass}>
{cutting.status === 'completed'
? 'Selesai'
: cutting.status === 'cancelled'
? 'Dibatalkan'
: 'Dikerjakan'}
</Badge>
</div>
<div className="mt-3 space-y-1 text-sm text-muted-foreground">
<p>{cutting.formatted_created_at}</p>
<p>
Oleh{' '}
<span className="font-medium text-foreground">
{cutting.created_by?.user_profile?.full_name ?? '-'}
</span>
</p>
</div>
{cutting.description && (
<p className="mt-3 text-sm">{cutting.description}</p>
)}
{cutting.photo_url && (
<div className="mt-4">
<ImagePreviewButton
srcs={[cutting.photo_conversion_url ?? cutting.photo_url]}
modalSrc={cutting.photo_url}
title={result?.product_name ?? `Cutting #${cutting.id}`}
description={cutting.description ?? ''}
/>
</div>
)}
</div>
{result && (
<div className="border-b p-6">
<h3 className="mb-3 text-sm font-semibold">Hasil Produk</h3>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Produk</TableHead>
<TableHead className="text-right">Hasil</TableHead>
<TableHead className="text-right">Sample</TableHead>
<TableHead className="text-right">Diluar Sample</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>{result.product_name ?? '-'}</TableCell>
<TableCell className="text-right tabular-nums">
{result.cutting_result ?? '-'} pcs
</TableCell>
<TableCell className="text-right tabular-nums">
{result.sample ?? '-'} pcs
</TableCell>
<TableCell className="text-right tabular-nums">
{result.original_outside_sample ?? '-'} pcs
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
)}
{items.length > 0 && (
<div className="border-b p-6">
<h3 className="mb-3 text-sm font-semibold">Bahan Baku</h3>
<div className="pb-2">
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Total Pemakaian</p>
<p className="mt-1 text-lg font-semibold text-primary">
{formatNumber(totalUsage)}
</p>
</div>
{result?.cutting_result && (
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Total Hasil Cutting</p>
<p className="mt-1 text-lg font-semibold text-primary">
{formatNumber(result.cutting_result)} pcs
</p>
</div>
)}
{cutting.formatted_cost_per_unit && (
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Biaya Per Produk</p>
<p className="mt-1 text-lg font-semibold text-primary">
{cutting.formatted_cost_per_unit}
</p>
</div>
)}
{cutting.formatted_total_material_cost && (
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Total Biaya Bahan</p>
<p className="mt-1 text-lg font-semibold text-primary">
{cutting.formatted_total_material_cost}
</p>
</div>
)}
</div>
</div>
<i className="text-xs md:hidden">Geser kesamping untuk melihat lebih banyak</i>
<CuttingItemSubRow cutting={cutting} />
</div>
)}
</div>
<div className="mt-6 flex items-center justify-between">
<Button variant="outline" asChild>
<Link href={cuttingIndex.url()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Kembali
</Link>
</Button>
<p className="text-xs text-muted-foreground">
Data ini dibagikan dari sistem DST Collection
</p>
</div>
</div>
</>
);
}

View File

@ -4,6 +4,7 @@ import { Plus } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { CardTable } from '@/components/data-display'; import { CardTable } from '@/components/data-display';
import { DeleteConfirmDialog } from '@/components/dialogs'; import { DeleteConfirmDialog } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs';
import { FilterPopover } from '@/components/data-display'; import { FilterPopover } from '@/components/data-display';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand'; import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/layout'; import { PageHeader } from '@/components/layout';
@ -103,6 +104,22 @@ export default function ProductIndex({ products, categories, productNames, filte
[productNames], [productNames],
); );
const allPreviewItems: ImagePreviewItem[] = useMemo(
() =>
products.data.flatMap((product) =>
(product.product_variants ?? [])
.filter((v) => v.photo_urls?.length > 0)
.map((v) => ({
id: `${product.id}-${v.id}`,
src: v.photo_urls[0],
sources: v.photo_urls,
title: v.name,
description: `Stok Bagus: ${v.formatted_stock} | Stok Reject: ${v.formatted_reject_stock} | Stok Ecer: ${v.formatted_retail_stock}`,
}))
),
[products.data],
);
const selectedCategory = useMemo( const selectedCategory = useMemo(
() => categories.find((c) => String(c.id) === filters.category) ?? null, () => categories.find((c) => String(c.id) === filters.category) ?? null,
[categories, filters.category], [categories, filters.category],
@ -311,6 +328,7 @@ export default function ProductIndex({ products, categories, productNames, filte
renderSubContent={(product) => ( renderSubContent={(product) => (
<VariantSubRow <VariantSubRow
product={product} product={product}
allPreviewItems={allPreviewItems}
onEditVariant={(p, v) => { onEditVariant={(p, v) => {
router.visit( router.visit(
variantEdit.url({ variantEdit.url({

View File

@ -2,6 +2,7 @@ import { router } from '@inertiajs/react';
import { ArrowRightLeft, Pencil, ScrollText, Trash2 } from 'lucide-react'; import { ArrowRightLeft, Pencil, ScrollText, Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { ImagePreviewButton } from '@/components/dialogs'; import { ImagePreviewButton } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs';
import { RowActions } from '@/components/data-display'; import { RowActions } from '@/components/data-display';
import { import {
Table, Table,
@ -20,10 +21,12 @@ import { TransferStockDialog } from './transfer-stock-dialog';
export function VariantSubRow({ export function VariantSubRow({
product, product,
allPreviewItems,
onEditVariant, onEditVariant,
onDeleteVariantClick, onDeleteVariantClick,
}: { }: {
product: Product; product: Product;
allPreviewItems: ImagePreviewItem[];
onEditVariant: (product: Product, variant: ProductVariant) => void; onEditVariant: (product: Product, variant: ProductVariant) => void;
onDeleteVariantClick: (product: Product, variant: ProductVariant) => void; onDeleteVariantClick: (product: Product, variant: ProductVariant) => void;
}) { }) {
@ -94,6 +97,8 @@ export function VariantSubRow({
modalSrcs={variant.photo_urls} modalSrcs={variant.photo_urls}
title={variant.name} title={variant.name}
description={`Stok Bagus: ${variant.formatted_stock} | Stok Reject: ${variant.formatted_reject_stock} | Stok Ecer: ${variant.formatted_retail_stock}`} description={`Stok Bagus: ${variant.formatted_stock} | Stok Reject: ${variant.formatted_reject_stock} | Stok Ecer: ${variant.formatted_retail_stock}`}
items={allPreviewItems}
startIndex={allPreviewItems.findIndex((item) => item.id === `${product.id}-${variant.id}`)}
/> />
) : ( ) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground"> <div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">

View File

@ -3,6 +3,7 @@ import { Plus } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { CardTable } from '@/components/data-display'; import { CardTable } from '@/components/data-display';
import { DeleteConfirmDialog } from '@/components/dialogs'; import { DeleteConfirmDialog } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs';
import { FilterPopover } from '@/components/data-display'; import { FilterPopover } from '@/components/data-display';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand'; import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/layout'; import { PageHeader } from '@/components/layout';
@ -91,6 +92,21 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
[rawMaterialNames], [rawMaterialNames],
); );
const allPreviewItems: ImagePreviewItem[] = useMemo(
() =>
rawMaterials.data.flatMap((rm) =>
(rm.raw_material_prices ?? [])
.filter((v) => v.photo_url)
.map((v) => ({
id: `${rm.id}-${v.id}`,
src: v.photo_url,
title: v.variant,
description: `Stok: ${v.formatted_stock}`,
}))
),
[rawMaterials.data],
);
function handleDelete() { function handleDelete() {
if (!deleting) { if (!deleting) {
return; return;
@ -246,7 +262,10 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
/> />
)} )}
renderSubContent={(rawMaterial) => ( renderSubContent={(rawMaterial) => (
<RawMaterialVariantSubRow rawMaterial={rawMaterial} /> <RawMaterialVariantSubRow
rawMaterial={rawMaterial}
allPreviewItems={allPreviewItems}
/>
)} )}
/> />

View File

@ -3,6 +3,7 @@ import { Pencil, Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { ConfirmDialog } from '@/components/dialogs'; import { ConfirmDialog } from '@/components/dialogs';
import { ImagePreviewButton } from '@/components/dialogs'; import { ImagePreviewButton } from '@/components/dialogs';
import type { ImagePreviewItem } from '@/components/dialogs';
import { RowActions } from '@/components/data-display'; import { RowActions } from '@/components/data-display';
import { import {
Table, Table,
@ -23,8 +24,10 @@ import type { RawMaterial, RawMaterialVariant } from '../columns';
export function RawMaterialVariantSubRow({ export function RawMaterialVariantSubRow({
rawMaterial, rawMaterial,
allPreviewItems,
}: { }: {
rawMaterial: RawMaterial; rawMaterial: RawMaterial;
allPreviewItems: ImagePreviewItem[];
}) { }) {
const { can } = useCan(); const { can } = useCan();
const variants = rawMaterial.raw_material_prices ?? []; const variants = rawMaterial.raw_material_prices ?? [];
@ -91,6 +94,8 @@ export function RawMaterialVariantSubRow({
modalSrc={variant.photo_url} modalSrc={variant.photo_url}
title={variant.variant} title={variant.variant}
description={`Stok: ${variant.formatted_stock}`} description={`Stok: ${variant.formatted_stock}`}
items={allPreviewItems}
startIndex={allPreviewItems.findIndex((item) => item.id === `${rawMaterial.id}-${variant.id}`)}
/> />
) : ( ) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground"> <div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">

View File

@ -2,7 +2,9 @@
use App\Console\Commands\GeneratePayrollCommand; use App\Console\Commands\GeneratePayrollCommand;
use App\Jobs\CheckAttendancePenaltiesJob; use App\Jobs\CheckAttendancePenaltiesJob;
use App\Jobs\CleanupOrphanedMediaJob;
use Illuminate\Support\Facades\Schedule; use Illuminate\Support\Facades\Schedule;
Schedule::command(GeneratePayrollCommand::class)->monthlyOn(1, '00:00'); Schedule::command(GeneratePayrollCommand::class)->monthlyOn(1, '00:00');
Schedule::job(new CheckAttendancePenaltiesJob)->dailyAt('00:00'); Schedule::job(new CheckAttendancePenaltiesJob)->dailyAt('00:00');
Schedule::job(new CleanupOrphanedMediaJob)->monthlyOn(1, '01:00');

View File

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