feat: 統一庫存管理分頁 UI 與寬度規範,並更新 SKILL 規範文件

This commit is contained in:
2026-02-03 17:24:34 +08:00
parent 15aaa039e4
commit bd999c7bb6
17 changed files with 357 additions and 205 deletions

View File

@@ -1,5 +1,6 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm, router, Link } from '@inertiajs/react';
import { usePermission } from '@/hooks/usePermission';
import {
Table,
TableBody,
@@ -71,7 +72,7 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(filters.search || '');
const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || '');
const [perPage, setPerPage] = useState(filters.per_page || '15');
const [perPage, setPerPage] = useState(filters.per_page || '10');
const [deleteId, setDeleteId] = useState<string | null>(null);
// For Count Doc Selection
@@ -115,9 +116,12 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
debouncedFilter({ search: searchQuery, warehouse_id: val, per_page: perPage });
};
const handlePerPageChange = (val: string) => {
setPerPage(val);
debouncedFilter({ search: searchQuery, warehouse_id: warehouseId, per_page: val });
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(route('inventory.adjust.index'),
{ ...filters, search: searchQuery, warehouse_id: warehouseId, per_page: value, page: 1 },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
const confirmDelete = (id: string, e: React.MouseEvent) => {
@@ -134,6 +138,8 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
}
};
const { can } = usePermission();
const { data, setData, post, processing, reset } = useForm({
count_doc_id: null as string | null,
warehouse_id: '',
@@ -229,7 +235,7 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
{/* Action Buttons */}
<div className="flex gap-2 w-full md:w-auto">
<Can permission="inventory.adjust">
<Can permission="inventory_adjust.create">
<Button
className="flex-1 md:flex-none button-filled-primary h-9"
onClick={() => setIsDialogOpen(true)}
@@ -286,30 +292,55 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
<TableCell className="text-gray-500 text-sm">{doc.posted_at || '-'}</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2" onClick={(e) => e.stopPropagation()}>
<Link href={route('inventory.adjust.show', [doc.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title={doc.status === 'posted' ? '查閱' : '編輯'}
>
{doc.status === 'posted' ? (
<Eye className="w-4 h-4" />
) : (
<Pencil className="w-4 h-4" />
)}
</Button>
</Link>
{(() => {
const isDraft = doc.status === 'draft';
const canEdit = can('inventory_adjust.edit');
const canView = can('inventory_adjust.view');
if (isDraft && canEdit) {
return (
<Link href={route('inventory.adjust.show', [doc.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="w-4 h-4 ml-0.5" />
</Button>
</Link>
);
}
if (canView) {
return (
<Link href={route('inventory.adjust.show', [doc.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="查閱"
>
<Eye className="w-4 h-4 ml-0.5" />
</Button>
</Link>
);
}
return null;
})()}
{doc.status === 'draft' && (
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={(e) => confirmDelete(doc.id, e)}
>
<Trash2 className="w-4 h-4" />
</Button>
<Can permission="inventory_adjust.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={(e) => confirmDelete(doc.id, e)}
>
<Trash2 className="w-4 h-4 ml-0.5" />
</Button>
</Can>
)}
</div>
</TableCell>
@@ -328,12 +359,12 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
value={perPage}
onValueChange={handlePerPageChange}
options={[
{ label: "15", value: "15" },
{ label: "30", value: "30" },
{ label: "10", value: "10" },
{ label: "20", value: "20" },
{ label: "50", value: "50" },
{ label: "100", value: "100" }
]}
className="w-[100px] h-8"
className="w-[90px] h-8"
showSearch={false}
/>
<span></span>

View File

@@ -1,5 +1,6 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, useForm, router } from '@inertiajs/react';
import { usePermission } from '@/hooks/usePermission';
import {
Table,
TableBody,
@@ -65,7 +66,10 @@ interface AdjDoc {
}
export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
const { can } = usePermission();
const isDraft = doc.status === 'draft';
const canEdit = can('inventory_adjust.edit');
const isReadOnly = !isDraft || !canEdit;
// Main Form using Inertia useForm
const { data, setData, put, delete: destroy, processing } = useForm({
@@ -183,7 +187,6 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
setData('action', 'save');
put(route('inventory.adjust.update', [doc.id]), {
preserveScroll: true,
onSuccess: () => toast.success("草稿儲存成功"),
});
};
@@ -199,15 +202,12 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
} as any, {
onSuccess: () => {
setIsPostDialogOpen(false);
toast.success("盤調單過帳成功");
}
});
};
const handleDelete = () => {
destroy(route('inventory.adjust.destroy', [doc.id]), {
onSuccess: () => toast.success("盤調單已刪除"),
});
destroy(route('inventory.adjust.destroy', [doc.id]));
};
return (
@@ -263,65 +263,69 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
</p>
</div>
<div className="flex items-center gap-2">
{isDraft && (
<Can permission="inventory.adjust">
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={processing} className="button-outlined-error">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>調</AlertDialogTitle>
<AlertDialogDescription>
稿
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="button-filled-error"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{!isReadOnly && (
<>
<Can permission="inventory_adjust.delete">
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={processing} className="button-outlined-error">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>調</AlertDialogTitle>
<AlertDialogDescription>
稿
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="button-filled-error"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
onClick={handleSave}
disabled={processing}
>
<Save className="w-4 h-4 mr-2" />
</Button>
<Can permission="inventory_adjust.edit">
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
onClick={handleSave}
disabled={processing}
>
<Save className="w-4 h-4 mr-2" />
</Button>
<AlertDialog open={isPostDialogOpen} onOpenChange={setIsPostDialogOpen}>
<AlertDialogTrigger asChild>
<Button
size="sm"
className="button-filled-primary"
disabled={processing || data.items.length === 0}
>
<CheckCircle className="w-4 h-4 mr-2" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
調調
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handlePost} className="button-filled-primary"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
<AlertDialog open={isPostDialogOpen} onOpenChange={setIsPostDialogOpen}>
<AlertDialogTrigger asChild>
<Button
size="sm"
className="button-filled-primary"
disabled={processing || data.items.length === 0}
>
<CheckCircle className="w-4 h-4 mr-2" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
調調
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handlePost} className="button-filled-primary"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
</>
)}
</div>
</div>
@@ -332,7 +336,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-2">
<div className="space-y-1">
<Label className="text-xs font-bold text-grey-500 uppercase tracking-wider font-semibold">調</Label>
{isDraft ? (
{!isReadOnly ? (
<Input
value={data.reason}
onChange={e => setData('reason', e.target.value)}
@@ -345,7 +349,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
</div>
<div className="space-y-1">
<Label className="text-xs font-bold text-grey-500 uppercase tracking-wider font-semibold"></Label>
{isDraft ? (
{!isReadOnly ? (
<Input
value={data.remarks}
onChange={e => setData('remarks', e.target.value)}
@@ -367,7 +371,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
</p>
</div>
{isDraft && !doc.count_doc_id && (
{!isReadOnly && !doc.count_doc_id && (
<Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="button-outlined-primary">
@@ -505,13 +509,13 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
<TableHead className="w-32 text-right font-medium text-grey-600">調</TableHead>
<TableHead className="w-40 text-right font-medium text-grey-600">調 (+/-)</TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
{isDraft && <TableHead className="w-[50px]"></TableHead>}
{!isReadOnly && <TableHead className="w-[50px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={isDraft ? 8 : 7} className="h-32 text-center text-grey-400">
<TableCell colSpan={!isReadOnly ? 8 : 7} className="h-32 text-center text-grey-400">
</TableCell>
</TableRow>
@@ -534,7 +538,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
{item.qty_before}
</TableCell>
<TableCell className="text-right">
{isDraft ? (
{!isReadOnly ? (
<div className="flex justify-end pr-2">
<Input
type="number"
@@ -550,7 +554,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
)}
</TableCell>
<TableCell>
{isDraft ? (
{!isReadOnly ? (
<Input
className="h-9 text-sm"
value={item.notes || ''}
@@ -561,7 +565,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
<span className="text-grey-600 text-sm">{item.notes || '-'}</span>
)}
</TableCell>
{isDraft && !doc.count_doc_id && (
{!isReadOnly && !doc.count_doc_id && (
<TableCell className="text-center">
<Button
variant="ghost"

View File

@@ -1,6 +1,7 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, useForm, router } from '@inertiajs/react';
import { Head, Link, useForm, router, usePage } from '@inertiajs/react';
import { useState, useCallback, useEffect } from 'react';
import { usePermission } from '@/hooks/usePermission';
import { debounce } from "lodash";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
@@ -54,6 +55,8 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
remarks: '',
});
const { can } = usePermission();
const [searchTerm, setSearchTerm] = useState(filters.search || "");
const [warehouseFilter, setWarehouseFilter] = useState(filters.warehouse_id || "all");
const [perPage, setPerPage] = useState(filters.per_page || "10");
@@ -207,7 +210,7 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
{/* Action Buttons */}
<div className="flex gap-2 w-full md:w-auto">
<Can permission="inventory.view">
<Can permission="inventory_count.create">
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button className="flex-1 md:flex-none button-filled-primary">
@@ -302,22 +305,46 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
<TableCell className="text-sm">{doc.created_by}</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<Can permission="inventory.view">
<Link href={route('inventory.count.show', [doc.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title={['completed', 'adjusted'].includes(doc.status) ? '查閱' : '盤點'}
>
{['completed', 'adjusted'].includes(doc.status) ? (
<Eye className="w-4 h-4 ml-0.5" />
) : (
<Pencil className="w-4 h-4 ml-0.5" />
)}
</Button>
</Link>
{!['completed', 'adjusted'].includes(doc.status) && (
{/* Action Button Logic: Prefer Edit if allowed and status is active, otherwise fallback to View if allowed */}
{(() => {
const isEditable = !['completed', 'adjusted'].includes(doc.status);
const canEdit = can('inventory_count.edit');
const canView = can('inventory_count.view');
if (isEditable && canEdit) {
return (
<Link href={route('inventory.count.show', [doc.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="盤點"
>
<Pencil className="w-4 h-4 ml-0.5" />
</Button>
</Link>
);
}
if (canView) {
return (
<Link href={route('inventory.count.show', [doc.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="查閱"
>
<Eye className="w-4 h-4 ml-0.5" />
</Button>
</Link>
);
}
return null;
})()}
{!['completed', 'adjusted'].includes(doc.status) && (
<Can permission="inventory_count.delete">
<Button
variant="outline"
size="sm"
@@ -327,8 +354,8 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
>
<Trash2 className="w-4 h-4 ml-0.5" />
</Button>
)}
</Can>
</Can>
)}
</div>
</TableCell>
</TableRow>
@@ -351,7 +378,7 @@ export default function Index({ auth, docs, warehouses, filters }: any) {
{ label: "50", value: "50" },
{ label: "100", value: "100" }
]}
className="w-[100px] h-8"
className="w-[90px] h-8"
showSearch={false}
/>
<span></span>

View File

@@ -1,5 +1,6 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm, Link, router } from '@inertiajs/react'; // Added Link
import { Head, useForm, Link, router } from '@inertiajs/react';
import { usePermission } from '@/hooks/usePermission'; // Added Link
import {
Table,
TableBody,
@@ -61,7 +62,10 @@ export default function Show({ doc }: any) {
});
}
const { can } = usePermission();
const isCompleted = ['completed', 'adjusted'].includes(doc.status);
const canEdit = can('inventory_count.edit');
const isReadOnly = isCompleted || !canEdit;
// Calculate progress
const totalItems = doc.items.length;
@@ -155,7 +159,7 @@ export default function Show({ doc }: any) {
{!isCompleted && (
<div className="flex items-center gap-2">
<Can permission="inventory.view">
<Can permission="inventory_count.delete">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={processing} className="button-outlined-error">
@@ -176,7 +180,9 @@ export default function Show({ doc }: any) {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
<Can permission="inventory_count.edit">
<Button
variant="outline"
size="sm"
@@ -261,7 +267,7 @@ export default function Show({ doc }: any) {
<TableCell className="text-sm font-mono">{item.batch_number || '-'}</TableCell>
<TableCell className="text-right font-medium">{item.system_qty.toFixed(0)}</TableCell>
<TableCell className="text-right px-1 py-3">
{isCompleted ? (
{isReadOnly ? (
<span className="font-semibold mr-2">{item.counted_qty}</span>
) : (
<Input
@@ -290,7 +296,7 @@ export default function Show({ doc }: any) {
</TableCell>
<TableCell className="text-sm text-gray-500">{item.unit || item.unit_name}</TableCell>
<TableCell className="px-1">
{isCompleted ? (
{isReadOnly ? (
<span className="text-sm text-gray-600">{item.notes}</span>
) : (
<Input

View File

@@ -226,7 +226,7 @@ export default function Index({ warehouses, orders, filters }: any) {
{/* Action Buttons */}
<div className="flex gap-2 w-full md:w-auto">
<Can permission="inventory.view">
<Can permission="inventory_transfer.create">
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button className="flex-1 md:flex-none button-filled-primary">
@@ -320,7 +320,7 @@ export default function Index({ warehouses, orders, filters }: any) {
<TableCell className="text-sm">{order.created_by}</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2" onClick={(e) => e.stopPropagation()}>
<Can permission="inventory.view">
<Can permission="inventory_transfer.view">
<Link href={route('inventory.transfer.show', [order.id])}>
<Button
variant="outline"
@@ -336,15 +336,17 @@ export default function Index({ warehouses, orders, filters }: any) {
</Button>
</Link>
{order.status === 'draft' && (
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={() => confirmDelete(order.id)}
>
<Trash2 className="w-4 h-4 ml-0.5" />
</Button>
<Can permission="inventory_transfer.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={() => confirmDelete(order.id)}
>
<Trash2 className="w-4 h-4 ml-0.5" />
</Button>
</Can>
)}
</Can>
</div>
@@ -369,7 +371,7 @@ export default function Index({ warehouses, orders, filters }: any) {
{ label: "50", value: "50" },
{ label: "100", value: "100" }
]}
className="w-[100px] h-8"
className="w-[90px] h-8"
showSearch={false}
/>
<span></span>