refactor: clean up imports and improve code readability across multiple components

This commit is contained in:
Yoga Pangestu 2026-08-06 09:55:11 +07:00
parent bcf5842c4a
commit 62341ac50a
13 changed files with 256 additions and 81 deletions

View File

@ -5,7 +5,6 @@
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Guarded(['id'])] #[Guarded(['id'])]

View File

@ -10,7 +10,6 @@
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;

View File

@ -1,3 +1,29 @@
import { Link, router } from '@inertiajs/react';
import type { LucideIcon } from 'lucide-react';
import {
Activity,
ArrowUpFromLine,
BarChart3,
Boxes,
CalendarCheck,
CalendarDays,
ClipboardCheck,
DollarSign,
HandCoins,
LayoutGrid,
Package,
RefreshCw,
Scissors,
Settings,
Shield,
ShoppingCart,
Tags,
Truck,
UserCircle,
Users,
Wallet,
} from 'lucide-react';
import React from 'react';
import AppLogo from '@/components/app-logo'; import AppLogo from '@/components/app-logo';
import { import {
Sidebar, Sidebar,
@ -30,32 +56,6 @@ import { index as productsIndex } from '@/routes/admin/master/products';
import { index as rawMaterialsIndex } from '@/routes/admin/master/raw-materials'; import { index as rawMaterialsIndex } from '@/routes/admin/master/raw-materials';
import { index as suppliersIndex } from '@/routes/admin/master/suppliers'; import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
import { index as rolesIndex } from '@/routes/admin/settings/roles'; import { index as rolesIndex } from '@/routes/admin/settings/roles';
import { Link, router } from '@inertiajs/react';
import type { LucideIcon } from 'lucide-react';
import {
Activity,
ArrowUpFromLine,
BarChart3,
Boxes,
CalendarCheck,
CalendarDays,
ClipboardCheck,
DollarSign,
HandCoins,
LayoutGrid,
Package,
RefreshCw,
Scissors,
Settings,
Shield,
ShoppingCart,
Tags,
Truck,
UserCircle,
Users,
Wallet,
} from 'lucide-react';
import React from 'react';
type NavMenuItem = { title: string; href: string; icon: LucideIcon; permission?: string | string[] }; type NavMenuItem = { title: string; href: string; icon: LucideIcon; permission?: string | string[] };
@ -111,12 +111,20 @@ function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) {
const { can, canAny } = useCan(); const { can, canAny } = useCan();
const filtered = items.filter((item) => { const filtered = items.filter((item) => {
if (!item.permission) return true; if (!item.permission) {
if (Array.isArray(item.permission)) return canAny(...item.permission); return true;
}
if (Array.isArray(item.permission)) {
return canAny(...item.permission);
}
return can(item.permission); return can(item.permission);
}); });
if (filtered.length === 0) return null; if (filtered.length === 0) {
return null;
}
return ( return (
<SidebarGroup> <SidebarGroup>

View File

@ -1,6 +1,6 @@
import { Clock, LogIn, LogOut } from 'lucide-react'; import { Clock, LogIn, LogOut } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
type TodayAttendance = { type TodayAttendance = {
id: number; id: number;
@ -29,8 +29,12 @@ export function AttendanceCard({ todayAttendance, isOnLeave, canCheckIn, onCheck
const hasCheckedOut = !!todayAttendance?.check_out_at; const hasCheckedOut = !!todayAttendance?.check_out_at;
function formatTime(dateStr: string | null): string { function formatTime(dateStr: string | null): string {
if (!dateStr) return '-'; if (!dateStr) {
return '-';
}
const d = new Date(dateStr); const d = new Date(dateStr);
return d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', hour12: false }); return d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', hour12: false });
} }

View File

@ -6,8 +6,14 @@ export function useCardTableExpand(
defaultExpanded: boolean | (number | string)[] = false, defaultExpanded: boolean | (number | string)[] = false,
) { ) {
const [expandedKeys, setExpandedKeys] = useState<ExpandState>(() => { const [expandedKeys, setExpandedKeys] = useState<ExpandState>(() => {
if (defaultExpanded === true) return 'all'; if (defaultExpanded === true) {
if (Array.isArray(defaultExpanded)) return new Set(defaultExpanded); return 'all';
}
if (Array.isArray(defaultExpanded)) {
return new Set(defaultExpanded);
}
return new Set(); return new Set();
}); });
@ -16,12 +22,15 @@ export function useCardTableExpand(
if (prev === 'all') { if (prev === 'all') {
return new Set([key]); return new Set([key]);
} }
const next = new Set(prev); const next = new Set(prev);
if (next.has(key)) { if (next.has(key)) {
next.delete(key); next.delete(key);
} else { } else {
next.add(key); next.add(key);
} }
return next; return next;
}); });
}, []); }, []);
@ -36,7 +45,10 @@ export function useCardTableExpand(
const isExpanded = useCallback( const isExpanded = useCallback(
(key: number | string): boolean => { (key: number | string): boolean => {
if (expandedKeys === 'all') return true; if (expandedKeys === 'all') {
return true;
}
return expandedKeys.has(key); return expandedKeys.has(key);
}, },
[expandedKeys], [expandedKeys],

View File

@ -17,7 +17,10 @@ type PageProps = {
}; };
function extractNames(items?: RoleOrPermission[]): string[] { function extractNames(items?: RoleOrPermission[]): string[] {
if (!items) return []; if (!items) {
return [];
}
return items.map((item) => (typeof item === 'string' ? item : item.name)); return items.map((item) => (typeof item === 'string' ? item : item.name));
} }
@ -29,24 +32,42 @@ export function useCan() {
const permissionNames = extractNames(user?.permissions); const permissionNames = extractNames(user?.permissions);
function can(permission: string): boolean { function can(permission: string): boolean {
if (!user) return false; if (!user) {
if (roleNames.includes('developer') || roleNames.includes('owner')) return true; return false;
}
if (roleNames.includes('developer') || roleNames.includes('owner')) {
return true;
}
return permissionNames.includes(permission); return permissionNames.includes(permission);
} }
function canAny(...permissions: string[]): boolean { function canAny(...permissions: string[]): boolean {
if (!user) return false; if (!user) {
if (roleNames.includes('developer') || roleNames.includes('owner')) return true; return false;
}
if (roleNames.includes('developer') || roleNames.includes('owner')) {
return true;
}
return permissions.some((p) => permissionNames.includes(p)); return permissions.some((p) => permissionNames.includes(p));
} }
function hasRole(role: string): boolean { function hasRole(role: string): boolean {
if (!user) return false; if (!user) {
return false;
}
return roleNames.includes(role); return roleNames.includes(role);
} }
function hasAnyRole(roles: string[]): boolean { function hasAnyRole(roles: string[]): boolean {
if (!user) return false; if (!user) {
return false;
}
return roles.some((role) => roleNames.includes(role)); return roles.some((role) => roleNames.includes(role));
} }

View File

@ -6,12 +6,15 @@ export function formatRupiahShort(value: number): string {
if (value >= 1_000_000_000) { if (value >= 1_000_000_000) {
return (value / 1_000_000_000).toFixed(1).replace('.0', '') + 'jt'; return (value / 1_000_000_000).toFixed(1).replace('.0', '') + 'jt';
} }
if (value >= 1_000_000) { if (value >= 1_000_000) {
return (value / 1_000_000).toFixed(1).replace('.0', '') + 'jt'; return (value / 1_000_000).toFixed(1).replace('.0', '') + 'jt';
} }
if (value >= 1_000) { if (value >= 1_000) {
return (value / 1_000).toFixed(0) + 'rb'; return (value / 1_000).toFixed(0) + 'rb';
} }
return value.toString(); return value.toString();
} }

View File

@ -1,5 +1,8 @@
'use no memo'; 'use no memo';
import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog'; import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUpload } from '@/components/file-upload'; import { FileUpload } from '@/components/file-upload';
import { ImagePreviewModal } from '@/components/image-preview-modal'; import { ImagePreviewModal } from '@/components/image-preview-modal';
@ -19,9 +22,6 @@ import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload'; import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { index as cuttingIndex, store } from '@/routes/admin/manage/cuttings'; import { index as cuttingIndex, store } from '@/routes/admin/manage/cuttings';
import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import type { CuttingCreateData } from './columns'; import type { CuttingCreateData } from './columns';
type MaterialState = { type MaterialState = {
@ -63,6 +63,7 @@ export default function CuttingCreate({ data }: Props) {
photo_url: m.photo_url, photo_url: m.photo_url,
})); }));
} }
return []; return [];
}); });
const [combinations, setCombinations] = useState<CombinationState[]>(() => { const [combinations, setCombinations] = useState<CombinationState[]>(() => {
@ -71,6 +72,7 @@ export default function CuttingCreate({ data }: Props) {
material_result: c.material_result ?? 0, material_result: c.material_result ?? 0,
})); }));
} }
return []; return [];
}); });
const [selectedMaterialName, setSelectedMaterialName] = useState(draft?.selectedMaterialName ?? ''); const [selectedMaterialName, setSelectedMaterialName] = useState(draft?.selectedMaterialName ?? '');
@ -149,12 +151,21 @@ export default function CuttingCreate({ data }: Props) {
const addVariant = useCallback( const addVariant = useCallback(
(priceId: number) => { (priceId: number) => {
if (!selectedMaterial) return; if (!selectedMaterial) {
return;
}
const price = selectedMaterial.raw_material_prices.find((p) => p.id === priceId); const price = selectedMaterial.raw_material_prices.find((p) => p.id === priceId);
if (!price) return;
if (!price) {
return;
}
setMaterials((prev) => { setMaterials((prev) => {
if (prev.some((m) => m.raw_material_price_id === priceId && m.combination_id === null)) return prev; if (prev.some((m) => m.raw_material_price_id === priceId && m.combination_id === null)) {
return prev;
}
return [ return [
...prev, ...prev,
{ {
@ -187,21 +198,26 @@ export default function CuttingCreate({ data }: Props) {
}, []); }, []);
const confirmCombo = useCallback(() => { const confirmCombo = useCallback(() => {
if (comboSelectedPriceIds.length < 2) return; if (comboSelectedPriceIds.length < 2) {
return;
}
const comboIndex = combinations.length; const comboIndex = combinations.length;
const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => { const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => {
let foundMaterial: typeof rawMaterials[number] | undefined; let foundMaterial: typeof rawMaterials[number] | undefined;
let foundPrice: typeof rawMaterials[number]['raw_material_prices'][number] | undefined; let foundPrice: typeof rawMaterials[number]['raw_material_prices'][number] | undefined;
for (const rm of rawMaterials) { for (const rm of rawMaterials) {
const p = rm.raw_material_prices.find((pp) => pp.id === priceId); const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
if (p) { if (p) {
foundMaterial = rm; foundMaterial = rm;
foundPrice = p; foundPrice = p;
break; break;
} }
} }
return { return {
raw_material_price_id: priceId, raw_material_price_id: priceId,
material_usage: 0, material_usage: 0,
@ -238,6 +254,7 @@ export default function CuttingCreate({ data }: Props) {
setMaterials((prev) => { setMaterials((prev) => {
const updated = [...prev]; const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value; (updated[index] as Record<string, unknown>)[field] = value;
return updated; return updated;
}); });
}, },
@ -251,6 +268,7 @@ export default function CuttingCreate({ data }: Props) {
const totalMaterialCost = useMemo(() => { const totalMaterialCost = useMemo(() => {
return materials.reduce((sum, m) => { return materials.reduce((sum, m) => {
const price = priceMap.get(m.raw_material_price_id); const price = priceMap.get(m.raw_material_price_id);
return sum + (price ? price.price * m.material_usage : 0); return sum + (price ? price.price * m.material_usage : 0);
}, 0); }, 0);
}, [materials, priceMap]); }, [materials, priceMap]);
@ -297,7 +315,9 @@ export default function CuttingCreate({ data }: Props) {
</Button> </Button>
</div> </div>
<Form action={store()} transform={(data) => ({ ...data, ...getPayload() })} onSubmit={() => { submittingRef.current = true; }}> <Form action={store()} transform={(data) => ({ ...data, ...getPayload() })} onSubmit={() => {
submittingRef.current = true;
}}>
{({ errors, processing }) => ( {({ errors, processing }) => (
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
<div className="space-y-6 md:col-span-2"> <div className="space-y-6 md:col-span-2">
@ -337,6 +357,7 @@ export default function CuttingCreate({ data }: Props) {
{selectedMaterial.raw_material_prices.map((price) => { {selectedMaterial.raw_material_prices.map((price) => {
const isAdded = materials.some((m) => m.raw_material_price_id === price.id); const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length; const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
return ( return (
<div key={price.id} className={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}> <div key={price.id} className={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}>
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -435,7 +456,9 @@ export default function CuttingCreate({ data }: Props) {
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Foto</Label> <Label>Foto</Label>
<FileUpload value={photo} onChange={(key) => { setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null); }} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} /> <FileUpload value={photo} onChange={(key) => {
setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null);
}} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} />
<InputError message={errors.photo_key} /> <InputError message={errors.photo_key} />
</div> </div>
@ -474,7 +497,10 @@ export default function CuttingCreate({ data }: Props) {
materials.forEach((m, i) => { materials.forEach((m, i) => {
if (m.combination_id !== null) { if (m.combination_id !== null) {
if (!comboMap.has(m.combination_id)) comboMap.set(m.combination_id, []); if (!comboMap.has(m.combination_id)) {
comboMap.set(m.combination_id, []);
}
comboMap.get(m.combination_id)!.push({ m, index: i }); comboMap.get(m.combination_id)!.push({ m, index: i });
} else { } else {
singleItems.push({ m, index: i }); singleItems.push({ m, index: i });
@ -509,6 +535,7 @@ export default function CuttingCreate({ data }: Props) {
{group.items.map(({ m, index }) => { {group.items.map(({ m, index }) => {
const price = priceMap.get(m.raw_material_price_id); const price = priceMap.get(m.raw_material_price_id);
const cartKey = `material-${index}`; const cartKey = `material-${index}`;
return ( return (
<div key={cartKey} className="space-y-2"> <div key={cartKey} className="space-y-2">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
@ -571,7 +598,11 @@ export default function CuttingCreate({ data }: Props) {
<ImagePreviewModal <ImagePreviewModal
open={previewKey !== null} open={previewKey !== null}
onOpenChange={(open) => { if (!open) setPreviewKey(null); }} onOpenChange={(open) => {
if (!open) {
setPreviewKey(null);
}
}}
src={previewKey && previewKey.startsWith('material-') ? (materials[Number(previewKey.replace('material-', ''))]?.photo_url ?? null) : null} src={previewKey && previewKey.startsWith('material-') ? (materials[Number(previewKey.replace('material-', ''))]?.photo_url ?? null) : null}
title={previewKey && previewKey.startsWith('material-') ? `${materials[Number(previewKey.replace('material-', ''))]?.material_name}${materials[Number(previewKey.replace('material-', ''))]?.variant}` : undefined} title={previewKey && previewKey.startsWith('material-') ? `${materials[Number(previewKey.replace('material-', ''))]?.material_name}${materials[Number(previewKey.replace('material-', ''))]?.variant}` : undefined}
sources={materials.filter((m) => m.photo_url).map((m) => m.photo_url!)} sources={materials.filter((m) => m.photo_url).map((m) => m.photo_url!)}
@ -619,8 +650,10 @@ export default function CuttingCreate({ data }: Props) {
let variantName = ''; let variantName = '';
let materialName = ''; let materialName = '';
let photoUrl: string | null = null; let photoUrl: string | null = null;
for (const rm of rawMaterials) { for (const rm of rawMaterials) {
const p = rm.raw_material_prices.find((pp) => pp.id === priceId); const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
if (p) { if (p) {
variantName = p.variant; variantName = p.variant;
materialName = rm.name; materialName = rm.name;
@ -628,6 +661,7 @@ export default function CuttingCreate({ data }: Props) {
break; break;
} }
} }
return ( return (
<div key={priceId} className="flex items-center justify-between gap-2 rounded-md border border-primary bg-primary/5 px-3 py-2"> <div key={priceId} className="flex items-center justify-between gap-2 rounded-md border border-primary bg-primary/5 px-3 py-2">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -657,6 +691,7 @@ export default function CuttingCreate({ data }: Props) {
<div className="space-y-2"> <div className="space-y-2">
{comboMaterial.raw_material_prices.map((price) => { {comboMaterial.raw_material_prices.map((price) => {
const isSelected = comboSelectedPriceIds.includes(price.id); const isSelected = comboSelectedPriceIds.includes(price.id);
return ( return (
<div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}> <div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}>
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -689,11 +724,41 @@ export default function CuttingCreate({ data }: Props) {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<ConfirmDialog open={deleteConfirmOpen} onOpenChange={(open) => { if (!open) { setDeleteConfirmOpen(false); setDeleteMaterialIndex(null); } }} title="Hapus Bahan Baku" description="Apakah Anda yakin ingin menghapus bahan baku ini?" confirmLabel="Hapus" onConfirm={() => { if (deleteMaterialIndex !== null) setMaterials((prev) => prev.filter((_, i) => i !== deleteMaterialIndex)); setDeleteConfirmOpen(false); setDeleteMaterialIndex(null); }} /> <ConfirmDialog open={deleteConfirmOpen} onOpenChange={(open) => {
if (!open) {
setDeleteConfirmOpen(false); setDeleteMaterialIndex(null);
}
}} title="Hapus Bahan Baku" description="Apakah Anda yakin ingin menghapus bahan baku ini?" confirmLabel="Hapus" onConfirm={() => {
if (deleteMaterialIndex !== null) {
setMaterials((prev) => prev.filter((_, i) => i !== deleteMaterialIndex));
}
<ConfirmDialog open={cartDeleteConfirmOpen} onOpenChange={(open) => { if (!open) { setCartDeleteConfirmOpen(false); setCartDeleteIndex(null); } }} title="Hapus dari Keranjang" description="Apakah Anda yakin ingin menghapus item ini dari keranjang?" confirmLabel="Hapus" variant="destructive" onConfirm={() => { if (cartDeleteIndex !== null) setMaterials((prev) => prev.filter((_, i) => i !== cartDeleteIndex)); setCartDeleteConfirmOpen(false); setCartDeleteIndex(null); }} /> setDeleteConfirmOpen(false); setDeleteMaterialIndex(null);
}} />
<ConfirmDialog open={comboDeleteConfirmOpen} onOpenChange={(open) => { if (!open) { setComboDeleteConfirmOpen(false); setComboDeleteIndex(null); } }} title="Hapus Kombinasi" description="Apakah Anda yakin ingin menghapus kombinasi ini beserta semua bahannya?" confirmLabel="Hapus" variant="destructive" onConfirm={() => { if (comboDeleteIndex !== null) { setCombinations((prev) => prev.filter((_, i) => i !== comboDeleteIndex)); setMaterials((prev) => prev.filter((m) => m.combination_id !== comboDeleteIndex)); } setComboDeleteConfirmOpen(false); setComboDeleteIndex(null); }} /> <ConfirmDialog open={cartDeleteConfirmOpen} onOpenChange={(open) => {
if (!open) {
setCartDeleteConfirmOpen(false); setCartDeleteIndex(null);
}
}} title="Hapus dari Keranjang" description="Apakah Anda yakin ingin menghapus item ini dari keranjang?" confirmLabel="Hapus" variant="destructive" onConfirm={() => {
if (cartDeleteIndex !== null) {
setMaterials((prev) => prev.filter((_, i) => i !== cartDeleteIndex));
}
setCartDeleteConfirmOpen(false); setCartDeleteIndex(null);
}} />
<ConfirmDialog open={comboDeleteConfirmOpen} onOpenChange={(open) => {
if (!open) {
setComboDeleteConfirmOpen(false); setComboDeleteIndex(null);
}
}} title="Hapus Kombinasi" description="Apakah Anda yakin ingin menghapus kombinasi ini beserta semua bahannya?" confirmLabel="Hapus" variant="destructive" onConfirm={() => {
if (comboDeleteIndex !== null) {
setCombinations((prev) => prev.filter((_, i) => i !== comboDeleteIndex)); setMaterials((prev) => prev.filter((m) => m.combination_id !== comboDeleteIndex));
}
setComboDeleteConfirmOpen(false); setComboDeleteIndex(null);
}} />
</div> </div>
</> </>
); );

View File

@ -1,5 +1,8 @@
'use no memo'; 'use no memo';
import { Form, Head, Link } from '@inertiajs/react';
import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog'; import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUpload } from '@/components/file-upload'; import { FileUpload } from '@/components/file-upload';
import { ImagePreviewModal } from '@/components/image-preview-modal'; import { ImagePreviewModal } from '@/components/image-preview-modal';
@ -17,9 +20,6 @@ import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload'; import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { index as cuttingIndex, update } from '@/routes/admin/manage/cuttings'; import { index as cuttingIndex, update } from '@/routes/admin/manage/cuttings';
import { Form, Head, Link } from '@inertiajs/react';
import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import type { CuttingCreateData, CuttingForEdit } from './columns'; import type { CuttingCreateData, CuttingForEdit } from './columns';
type MaterialState = { type MaterialState = {
@ -131,12 +131,21 @@ export default function CuttingEdit({ cutting, data }: Props) {
const addVariant = useCallback( const addVariant = useCallback(
(priceId: number) => { (priceId: number) => {
if (!selectedMaterial) return; if (!selectedMaterial) {
return;
}
const price = selectedMaterial.raw_material_prices.find((p) => p.id === priceId); const price = selectedMaterial.raw_material_prices.find((p) => p.id === priceId);
if (!price) return;
if (!price) {
return;
}
setMaterials((prev) => { setMaterials((prev) => {
if (prev.some((m) => m.raw_material_price_id === priceId && m.combination_id === null)) return prev; if (prev.some((m) => m.raw_material_price_id === priceId && m.combination_id === null)) {
return prev;
}
return [ return [
...prev, ...prev,
{ {
@ -169,21 +178,26 @@ export default function CuttingEdit({ cutting, data }: Props) {
}, []); }, []);
const confirmCombo = useCallback(() => { const confirmCombo = useCallback(() => {
if (comboSelectedPriceIds.length < 2) return; if (comboSelectedPriceIds.length < 2) {
return;
}
const comboIndex = combinations.length; const comboIndex = combinations.length;
const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => { const newMaterials: MaterialState[] = comboSelectedPriceIds.map((priceId) => {
let foundMaterial: (typeof rawMaterials)[number] | undefined; let foundMaterial: (typeof rawMaterials)[number] | undefined;
let foundPrice: (typeof rawMaterials)[number]['raw_material_prices'][number] | undefined; let foundPrice: (typeof rawMaterials)[number]['raw_material_prices'][number] | undefined;
for (const rm of rawMaterials) { for (const rm of rawMaterials) {
const p = rm.raw_material_prices.find((pp) => pp.id === priceId); const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
if (p) { if (p) {
foundMaterial = rm; foundMaterial = rm;
foundPrice = p; foundPrice = p;
break; break;
} }
} }
return { return {
raw_material_price_id: priceId, raw_material_price_id: priceId,
material_usage: 0, material_usage: 0,
@ -215,6 +229,7 @@ export default function CuttingEdit({ cutting, data }: Props) {
setMaterials((prev) => { setMaterials((prev) => {
const updated = [...prev]; const updated = [...prev];
(updated[index] as Record<string, unknown>)[field] = value; (updated[index] as Record<string, unknown>)[field] = value;
return updated; return updated;
}); });
}, },
@ -228,6 +243,7 @@ export default function CuttingEdit({ cutting, data }: Props) {
const totalMaterialCost = useMemo(() => { const totalMaterialCost = useMemo(() => {
return materials.reduce((sum, m) => { return materials.reduce((sum, m) => {
const price = priceMap.get(m.raw_material_price_id); const price = priceMap.get(m.raw_material_price_id);
return sum + (price ? price.price * m.material_usage : 0); return sum + (price ? price.price * m.material_usage : 0);
}, 0); }, 0);
}, [materials, priceMap]); }, [materials, priceMap]);
@ -274,7 +290,9 @@ export default function CuttingEdit({ cutting, data }: Props) {
</Button> </Button>
</div> </div>
<Form action={update(cutting.id)} transform={(data) => ({ ...data, ...getPayload() })} onSubmit={() => { submittingRef.current = true; }}> <Form action={update(cutting.id)} transform={(data) => ({ ...data, ...getPayload() })} onSubmit={() => {
submittingRef.current = true;
}}>
{({ errors, processing }) => ( {({ errors, processing }) => (
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
<div className="space-y-6 md:col-span-2"> <div className="space-y-6 md:col-span-2">
@ -314,6 +332,7 @@ export default function CuttingEdit({ cutting, data }: Props) {
{selectedMaterial.raw_material_prices.map((price) => { {selectedMaterial.raw_material_prices.map((price) => {
const isAdded = materials.some((m) => m.raw_material_price_id === price.id); const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length; const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
return ( return (
<div key={price.id} className={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}> <div key={price.id} className={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}>
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -410,7 +429,9 @@ export default function CuttingEdit({ cutting, data }: Props) {
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Foto</Label> <Label>Foto</Label>
<FileUpload value={photo} onChange={(key) => { setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null); }} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} /> <FileUpload value={photo} onChange={(key) => {
setPhoto(key); setPhotoUrl(key ? getTemporaryUrl(key) : null);
}} folder="cutting" existingUrl={photoUrl} onUploadingChange={setUploading} />
<InputError message={errors.photo_key} /> <InputError message={errors.photo_key} />
</div> </div>
@ -449,7 +470,10 @@ export default function CuttingEdit({ cutting, data }: Props) {
materials.forEach((m, i) => { materials.forEach((m, i) => {
if (m.combination_id !== null) { if (m.combination_id !== null) {
if (!comboMap.has(m.combination_id)) comboMap.set(m.combination_id, []); if (!comboMap.has(m.combination_id)) {
comboMap.set(m.combination_id, []);
}
comboMap.get(m.combination_id)!.push({ m, index: i }); comboMap.get(m.combination_id)!.push({ m, index: i });
} else { } else {
singleItems.push({ m, index: i }); singleItems.push({ m, index: i });
@ -484,6 +508,7 @@ export default function CuttingEdit({ cutting, data }: Props) {
{group.items.map(({ m, index }) => { {group.items.map(({ m, index }) => {
const price = priceMap.get(m.raw_material_price_id); const price = priceMap.get(m.raw_material_price_id);
const cartKey = `material-${index}`; const cartKey = `material-${index}`;
return ( return (
<div key={cartKey} className="space-y-2"> <div key={cartKey} className="space-y-2">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
@ -546,7 +571,11 @@ export default function CuttingEdit({ cutting, data }: Props) {
<ImagePreviewModal <ImagePreviewModal
open={previewKey !== null} open={previewKey !== null}
onOpenChange={(open) => { if (!open) setPreviewKey(null); }} onOpenChange={(open) => {
if (!open) {
setPreviewKey(null);
}
}}
src={previewKey && previewKey.startsWith('material-') ? (materials[Number(previewKey.replace('material-', ''))]?.photo_url ?? null) : null} src={previewKey && previewKey.startsWith('material-') ? (materials[Number(previewKey.replace('material-', ''))]?.photo_url ?? null) : null}
title={previewKey && previewKey.startsWith('material-') ? `${materials[Number(previewKey.replace('material-', ''))]?.material_name}${materials[Number(previewKey.replace('material-', ''))]?.variant}` : undefined} title={previewKey && previewKey.startsWith('material-') ? `${materials[Number(previewKey.replace('material-', ''))]?.material_name}${materials[Number(previewKey.replace('material-', ''))]?.variant}` : undefined}
sources={materials.filter((m) => m.photo_url).map((m) => m.photo_url!)} sources={materials.filter((m) => m.photo_url).map((m) => m.photo_url!)}
@ -594,8 +623,10 @@ export default function CuttingEdit({ cutting, data }: Props) {
let variantName = ''; let variantName = '';
let materialName = ''; let materialName = '';
let photoUrl: string | null = null; let photoUrl: string | null = null;
for (const rm of rawMaterials) { for (const rm of rawMaterials) {
const p = rm.raw_material_prices.find((pp) => pp.id === priceId); const p = rm.raw_material_prices.find((pp) => pp.id === priceId);
if (p) { if (p) {
variantName = p.variant; variantName = p.variant;
materialName = rm.name; materialName = rm.name;
@ -603,6 +634,7 @@ export default function CuttingEdit({ cutting, data }: Props) {
break; break;
} }
} }
return ( return (
<div key={priceId} className="flex items-center justify-between gap-2 rounded-md border border-primary bg-primary/5 px-3 py-2"> <div key={priceId} className="flex items-center justify-between gap-2 rounded-md border border-primary bg-primary/5 px-3 py-2">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -632,6 +664,7 @@ export default function CuttingEdit({ cutting, data }: Props) {
<div className="space-y-2"> <div className="space-y-2">
{comboMaterial.raw_material_prices.map((price) => { {comboMaterial.raw_material_prices.map((price) => {
const isSelected = comboSelectedPriceIds.includes(price.id); const isSelected = comboSelectedPriceIds.includes(price.id);
return ( return (
<div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}> <div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}>
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -664,11 +697,41 @@ export default function CuttingEdit({ cutting, data }: Props) {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<ConfirmDialog open={deleteConfirmOpen} onOpenChange={(open) => { if (!open) { setDeleteConfirmOpen(false); setDeleteMaterialIndex(null); } }} title="Hapus Bahan Baku" description="Apakah Anda yakin ingin menghapus bahan baku ini?" confirmLabel="Hapus" onConfirm={() => { if (deleteMaterialIndex !== null) setMaterials((prev) => prev.filter((_, i) => i !== deleteMaterialIndex)); setDeleteConfirmOpen(false); setDeleteMaterialIndex(null); }} /> <ConfirmDialog open={deleteConfirmOpen} onOpenChange={(open) => {
if (!open) {
setDeleteConfirmOpen(false); setDeleteMaterialIndex(null);
}
}} title="Hapus Bahan Baku" description="Apakah Anda yakin ingin menghapus bahan baku ini?" confirmLabel="Hapus" onConfirm={() => {
if (deleteMaterialIndex !== null) {
setMaterials((prev) => prev.filter((_, i) => i !== deleteMaterialIndex));
}
<ConfirmDialog open={cartDeleteConfirmOpen} onOpenChange={(open) => { if (!open) { setCartDeleteConfirmOpen(false); setCartDeleteIndex(null); } }} title="Hapus dari Keranjang" description="Apakah Anda yakin ingin menghapus item ini dari keranjang?" confirmLabel="Hapus" variant="destructive" onConfirm={() => { if (cartDeleteIndex !== null) setMaterials((prev) => prev.filter((_, i) => i !== cartDeleteIndex)); setCartDeleteConfirmOpen(false); setCartDeleteIndex(null); }} /> setDeleteConfirmOpen(false); setDeleteMaterialIndex(null);
}} />
<ConfirmDialog open={comboDeleteConfirmOpen} onOpenChange={(open) => { if (!open) { setComboDeleteConfirmOpen(false); setComboDeleteIndex(null); } }} title="Hapus Kombinasi" description="Apakah Anda yakin ingin menghapus kombinasi ini beserta semua bahannya?" confirmLabel="Hapus" variant="destructive" onConfirm={() => { if (comboDeleteIndex !== null) { setCombinations((prev) => prev.filter((_, i) => i !== comboDeleteIndex)); setMaterials((prev) => prev.filter((m) => m.combination_id !== comboDeleteIndex)); } setComboDeleteConfirmOpen(false); setComboDeleteIndex(null); }} /> <ConfirmDialog open={cartDeleteConfirmOpen} onOpenChange={(open) => {
if (!open) {
setCartDeleteConfirmOpen(false); setCartDeleteIndex(null);
}
}} title="Hapus dari Keranjang" description="Apakah Anda yakin ingin menghapus item ini dari keranjang?" confirmLabel="Hapus" variant="destructive" onConfirm={() => {
if (cartDeleteIndex !== null) {
setMaterials((prev) => prev.filter((_, i) => i !== cartDeleteIndex));
}
setCartDeleteConfirmOpen(false); setCartDeleteIndex(null);
}} />
<ConfirmDialog open={comboDeleteConfirmOpen} onOpenChange={(open) => {
if (!open) {
setComboDeleteConfirmOpen(false); setComboDeleteIndex(null);
}
}} title="Hapus Kombinasi" description="Apakah Anda yakin ingin menghapus kombinasi ini beserta semua bahannya?" confirmLabel="Hapus" variant="destructive" onConfirm={() => {
if (comboDeleteIndex !== null) {
setCombinations((prev) => prev.filter((_, i) => i !== comboDeleteIndex)); setMaterials((prev) => prev.filter((m) => m.combination_id !== comboDeleteIndex));
}
setComboDeleteConfirmOpen(false); setComboDeleteIndex(null);
}} />
</div> </div>
</> </>
); );

View File

@ -74,7 +74,6 @@ export default function PurchaseIndex({
pagination, pagination,
filters, filters,
filterWithParams: false, filterWithParams: false,
reloadOnly: ['purchases', 'filters'],
}); });
const selectedSupplier = useMemo( const selectedSupplier = useMemo(

View File

@ -28,7 +28,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -36,6 +35,7 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useTransactionDraftSave } from '@/hooks/use-transaction-draft'; import { useTransactionDraftSave } from '@/hooks/use-transaction-draft';
import { formatNumber } from '@/lib/format'; import { formatNumber } from '@/lib/format';
@ -182,6 +182,7 @@ export default function TransactionCreate({ data }: Props) {
if (stockType === 'reject') { if (stockType === 'reject') {
return priceTypeOptions.filter((o) => o.value === 'reject'); return priceTypeOptions.filter((o) => o.value === 'reject');
} }
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value)); return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value));
}, [stockType, priceTypeOptions]); }, [stockType, priceTypeOptions]);

View File

@ -28,7 +28,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -36,6 +35,7 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { formatNumber } from '@/lib/format'; import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload'; import { getTemporaryUrl } from '@/lib/upload';
@ -151,6 +151,7 @@ export default function TransactionEdit({ transaction, data }: Props) {
if (stockType === 'reject') { if (stockType === 'reject') {
return priceTypeOptions.filter((o) => o.value === 'reject'); return priceTypeOptions.filter((o) => o.value === 'reject');
} }
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value)); return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value));
}, [stockType, priceTypeOptions]); }, [stockType, priceTypeOptions]);

View File

@ -1,3 +1,6 @@
import { Head, Link, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { CardTable } from '@/components/card-table'; import { CardTable } from '@/components/card-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FilterPopover } from '@/components/filter-popover'; import { FilterPopover } from '@/components/filter-popover';
@ -11,6 +14,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { import {
destroy, destroy,
@ -22,13 +26,9 @@ import {
import { import {
destroy as variantDestroy destroy as variantDestroy
} from '@/routes/admin/master/raw-materials/variants'; } from '@/routes/admin/master/raw-materials/variants';
import { Head, Link, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import type { RawMaterial, RawMaterialVariant } from './columns'; import type { RawMaterial, RawMaterialVariant } from './columns';
import { RawMaterialCardRow } from './raw-material-card'; import { RawMaterialCardRow } from './raw-material-card';
import { RawMaterialVariantSubRow } from './variant/sub-row'; import { RawMaterialVariantSubRow } from './variant/sub-row';
import { useCan } from '@/hooks/use-can';
type Props = { type Props = {
rawMaterials: { rawMaterials: {