Files
star-erp/resources/js/Pages/Admin/ActivityLog/Index.tsx
sky121113 18edb3cb69
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 45s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
feat: 優化操作紀錄顯示與邏輯 (恢復描述欄位、支援來源標記、改進快照)
2026-01-19 11:47:10 +08:00

298 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, router } from '@inertiajs/react';
import { PageProps } from '@/types/global';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import Pagination from '@/Components/shared/Pagination';
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { FileText, Eye, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import { Button } from '@/Components/ui/button';
import ActivityDetailDialog from './ActivityDetailDialog';
interface Activity {
id: number;
description: string;
subject_type: string;
event: string;
causer: string;
created_at: string;
properties: any;
}
interface PaginationLinks {
url: string | null;
label: string;
active: boolean;
}
interface Props extends PageProps {
activities: {
data: Activity[];
links: PaginationLinks[];
current_page: number;
last_page: number;
total: number;
from: number;
};
filters: {
per_page?: string;
sort_by?: string;
sort_order?: 'asc' | 'desc';
};
}
export default function ActivityLogIndex({ activities, filters }: Props) {
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const getEventBadgeClass = (event: string) => {
switch (event) {
case 'created': return 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100';
case 'updated': return 'bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100';
case 'deleted': return 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100';
default: return 'bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100';
}
};
const getEventLabel = (event: string) => {
switch (event) {
case 'created': return '新增';
case 'updated': return '更新';
case 'deleted': return '刪除';
default: return event;
}
};
const getDescription = (activity: Activity) => {
const props = activity.properties || {};
const attrs = props.attributes || {};
const old = props.old || {};
// Try to find a name in attributes or old values
// Priority: specific name fields > generic name > code > ID
const nameParams = ['product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'name', 'code', 'title'];
let subjectName = '';
// Special handling for Inventory: show "Warehouse - Product"
if (attrs.warehouse_name && attrs.product_name) {
subjectName = `${attrs.warehouse_name} - ${attrs.product_name}`;
} else if (old.warehouse_name && old.product_name) {
subjectName = `${old.warehouse_name} - ${old.product_name}`;
} else {
// Default fallback
for (const param of nameParams) {
if (attrs[param]) {
subjectName = attrs[param];
break;
}
if (old[param]) {
subjectName = old[param];
break;
}
}
}
// If no name found, try ID but format it nicely if possible, or just don't show it if it's redundant with subject_type
if (!subjectName && (attrs.id || old.id)) {
subjectName = `#${attrs.id || old.id}`;
}
// Combine parts: [Causer] [Action] [Name] [Subject]
// Example: Admin 新增 可樂 商品
// Example: Admin 更新 台北倉 - 可樂 庫存
return (
<span className="flex items-center gap-1.5 flex-wrap">
<span className="font-medium text-gray-900">{activity.causer}</span>
<span className="text-gray-500">{getEventLabel(activity.event)}</span>
{subjectName && (
<span className="font-medium text-primary-600 bg-primary-50 px-1.5 py-0.5 rounded text-xs">
{subjectName}
</span>
)}
<span className="text-gray-700">{activity.subject_type}</span>
{/* Display reason/source if available (e.g., from Replenishment) */}
{(attrs._reason || old._reason) && (
<span className="text-gray-500 text-xs">
( {attrs._reason || old._reason})
</span>
)}
</span>
);
};
const handleViewDetail = (activity: Activity) => {
setSelectedActivity(activity);
setDetailOpen(true);
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(
route('activity-logs.index'),
{ ...filters, per_page: value },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
const handleSort = (field: string) => {
let newSortBy: string | undefined = field;
let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
if (filters.sort_by === field) {
if (filters.sort_order === 'asc') {
newSortOrder = 'desc';
} else {
newSortBy = undefined;
newSortOrder = undefined;
}
}
router.get(
route('activity-logs.index'),
{ ...filters, sort_by: newSortBy, sort_order: newSortOrder },
{ preserveState: true, replace: true }
);
};
const SortIcon = ({ field }: { field: string }) => {
if (filters.sort_by !== field) {
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
}
if (filters.sort_order === "asc") {
return <ArrowUp className="h-4 w-4 text-primary-main ml-1" />;
}
return <ArrowDown className="h-4 w-4 text-primary-main ml-1" />;
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '系統管理', href: '#' },
{ label: '操作紀錄', href: route('activity-logs.index'), isPage: true },
]}
>
<Head title="操作紀錄" />
<div className="container mx-auto p-6 max-w-7xl">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<FileText className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead className="w-[180px]">
<button
onClick={() => handleSort('created_at')}
className="flex items-center gap-1 hover:text-gray-900 transition-colors"
>
<SortIcon field="created_at" />
</button>
</TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activities.data.length > 0 ? (
activities.data.map((activity, index) => (
<TableRow key={activity.id}>
<TableCell className="text-gray-500 font-medium text-center">
{activities.from + index}
</TableCell>
<TableCell className="text-gray-500 font-medium whitespace-nowrap">
{activity.created_at}
</TableCell>
<TableCell>
<span className="font-medium text-gray-900">{activity.causer}</span>
</TableCell>
<TableCell>
{getDescription(activity)}
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className={getEventBadgeClass(activity.event)}>
{getEventLabel(activity.event)}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className="bg-slate-50 text-slate-600 border-slate-200">
{activity.subject_type}
</Badge>
</TableCell>
<TableCell className="text-center">
<Button
variant="outline"
size="sm"
onClick={() => handleViewDetail(activity)}
className="button-outlined-primary"
title="檢視詳情"
>
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span></span>
<SearchableSelect
value={perPage}
onValueChange={handlePerPageChange}
options={[
{ label: "10", value: "10" },
{ label: "20", value: "20" },
{ label: "50", value: "50" },
{ label: "100", value: "100" }
]}
className="w-[80px] h-8"
showSearch={false}
/>
<span></span>
</div>
<Pagination links={activities.links} />
</div>
</div>
<ActivityDetailDialog
open={detailOpen}
onOpenChange={setDetailOpen}
activity={selectedActivity}
/>
</AuthenticatedLayout>
);
}