feat(sales): replace import page with dialog UI and support template download
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 50s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

This commit is contained in:
2026-02-09 17:16:00 +08:00
parent 613eb555ba
commit 7cf640b2f4
6 changed files with 193 additions and 99 deletions

View File

@@ -30,9 +30,9 @@ class SalesImportController extends Controller
]); ]);
} }
public function create() public function template()
{ {
return Inertia::render('Sales/Import/Create'); return Excel::download(new \App\Modules\Sales\Exports\SalesImportTemplateExport, 'sales_import_template.xlsx');
} }
public function store(Request $request) public function store(Request $request)

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Modules\Sales\Exports;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithTitle;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class SalesImportTemplateExport implements WithHeadings, WithTitle, WithStyles
{
public function headings(): array
{
return [
'機台編號',
'交易時間',
'商品代號',
'數量',
'單價',
'總金額',
'貨道', // 新增欄位
'狀態', // 原始狀態
];
}
public function title(): string
{
return '銷售單匯入範本';
}
public function styles(Worksheet $sheet)
{
return [
1 => ['font' => ['bold' => true]],
];
}
}

View File

@@ -10,7 +10,7 @@ Route::middleware(['auth', 'verified'])->prefix('sales')->name('sales-imports.')
}); });
Route::post('/imports', [SalesImportController::class, 'store'])->middleware('permission:sales_imports.create')->name('store'); Route::post('/imports', [SalesImportController::class, 'store'])->middleware('permission:sales_imports.create')->name('store');
Route::get('/imports/create', [SalesImportController::class, 'create'])->middleware('permission:sales_imports.create')->name('create'); Route::get('/imports/template', [SalesImportController::class, 'template'])->middleware('permission:sales_imports.create')->name('template');
Route::post('/imports/{import}/confirm', [SalesImportController::class, 'confirm'])->middleware('permission:sales_imports.confirm')->name('confirm'); Route::post('/imports/{import}/confirm', [SalesImportController::class, 'confirm'])->middleware('permission:sales_imports.confirm')->name('confirm');
Route::delete('/imports/{import}', [SalesImportController::class, 'destroy'])->middleware('permission:sales_imports.delete')->name('destroy'); Route::delete('/imports/{import}', [SalesImportController::class, 'destroy'])->middleware('permission:sales_imports.delete')->name('destroy');

View File

@@ -0,0 +1,139 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Upload, AlertCircle, Info, FileSpreadsheet } from "lucide-react";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/Components/ui/accordion";
import { useForm } from "@inertiajs/react";
import { Alert, AlertDescription } from "@/Components/ui/alert";
import React from "react";
interface SalesImportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function SalesImportDialog({ open, onOpenChange }: SalesImportDialogProps) {
const { data, setData, post, processing, errors, reset, clearErrors } = useForm<{
file: File | null;
}>({
file: null,
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setData("file", e.target.files[0]);
clearErrors("file");
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
post(route("sales-imports.store"), {
onSuccess: () => {
reset();
onOpenChange(false);
},
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
Excel
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
{/* 下載範本區域 */}
<div className="space-y-2 p-4 bg-gray-50 rounded-lg border border-gray-100">
<Label className="font-medium flex items-center gap-2">
<FileSpreadsheet className="w-4 h-4 text-green-600" />
1 Excel
</Label>
<div className="text-sm text-gray-500 mb-2">
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => window.location.href = route('sales-imports.template')}
className="w-full sm:w-auto button-outlined-primary"
>
<Upload className="w-4 h-4 mr-2 rotate-180" />
(.xlsx)
</Button>
</div>
{/* 上傳檔案區域 */}
<div className="space-y-2">
<Label className="font-medium flex items-center gap-2">
<Upload className="w-4 h-4 text-blue-600" />
2
</Label>
<div className="grid w-full max-w-sm items-center gap-1.5">
<Input
id="file"
type="file"
accept=".xlsx, .xls, .csv"
onChange={handleFileChange}
className="cursor-pointer"
/>
</div>
{errors.file && (
<Alert variant="destructive" className="mt-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="whitespace-pre-wrap">
{errors.file}
</AlertDescription>
</Alert>
)}
<p className="text-xs text-gray-500">.xlsx, .xls, .csv</p>
</div>
{/* 匯入說明 - 使用 Accordion 收合 */}
<Accordion type="single" collapsible className="w-full border rounded-lg px-2">
<AccordionItem value="item-1" className="border-b-0">
<AccordionTrigger className="text-sm text-gray-500 hover:no-underline py-3">
<div className="flex items-center gap-2">
<Info className="h-4 w-4" />
</div>
</AccordionTrigger>
<AccordionContent>
<div className="text-sm text-gray-600 space-y-2 pb-2 pl-6">
<ul className="list-disc space-y-1">
<li><span className="font-medium text-gray-700"></span>使 Excel </li>
<li><span className="font-medium text-gray-700"></span></li>
<li><span className="font-medium text-gray-700"></span></li>
</ul>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={processing}
className="button-outlined-primary"
>
</Button>
<Button type="submit" disabled={!data.file || processing} className="button-filled-primary">
{processing ? "匯入中..." : "開始匯入"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,90 +0,0 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm, Link } from '@inertiajs/react';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
import { Upload, ArrowLeft, FileSpreadsheet } from 'lucide-react';
import React from 'react';
export default function SalesImportCreate() {
const { data, setData, post, processing, errors } = useForm({
file: null as File | null,
});
const submit = (e: React.FormEvent) => {
e.preventDefault();
post(route('sales-imports.store'));
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '銷售管理', href: '#' },
{ label: '銷售單匯入', href: route('sales-imports.index') },
{ label: '新增匯入', href: route('sales-imports.create'), isPage: true },
]}
>
<Head title="新增銷售匯入" />
<div className="container mx-auto p-6 max-w-3xl">
<div className="mb-6">
<Link href={route('sales-imports.index')}>
<Button variant="outline" type="button" className="gap-2 mb-4">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<FileSpreadsheet className="h-6 w-6 text-primary-main" />
</h1>
</div>
<div className="bg-white rounded-lg border shadow-sm p-8">
<form onSubmit={submit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="file" className="text-lg font-medium"> Excel </Label>
<div className="border-2 border-dashed border-gray-300 rounded-xl p-10 flex flex-col items-center justify-center bg-gray-50 hover:bg-gray-100 transition-colors cursor-pointer relative">
<Input
id="file"
type="file"
accept=".xlsx,.xls,.csv"
onChange={(e) => setData('file', e.target.files ? e.target.files[0] : null)}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Upload className="h-10 w-10 text-gray-400 mb-4" />
<div className="text-center">
<p className="text-sm font-medium text-gray-700">
{data.file ? data.file.name : '點擊或拖曳檔案至此'}
</p>
<p className="text-xs text-gray-500 mt-1"> .xlsx, .xls, .csv</p>
</div>
</div>
{errors.file && <p className="text-red-500 text-sm">{errors.file}</p>}
</div>
<div className="flex justify-end pt-4">
<Button
type="submit"
size="lg"
className="button-filled-primary w-full md:w-auto px-8"
disabled={processing}
>
{processing ? '處理中...' : '開始匯入'}
</Button>
</div>
</form>
</div>
<div className="mt-8 bg-blue-50 p-6 rounded-lg border border-blue-100">
<h3 className="font-bold text-blue-800 mb-2"></h3>
<ul className="list-disc list-inside text-sm text-blue-700 space-y-1">
<li>使 Excel </li>
<li></li>
<li></li>
</ul>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -28,6 +28,7 @@ import Pagination from "@/Components/shared/Pagination";
import { SearchableSelect } from "@/Components/ui/searchable-select"; import { SearchableSelect } from "@/Components/ui/searchable-select";
import { router } from "@inertiajs/react"; import { router } from "@inertiajs/react";
import { usePermission } from "@/hooks/usePermission"; import { usePermission } from "@/hooks/usePermission";
import SalesImportDialog from "@/Components/Sales/SalesImportDialog";
interface ImportBatch { interface ImportBatch {
id: number; id: number;
@@ -54,6 +55,7 @@ interface Props {
export default function SalesImportIndex({ batches, filters = {} }: Props) { export default function SalesImportIndex({ batches, filters = {} }: Props) {
const { can } = usePermission(); const { can } = usePermission();
const [perPage, setPerPage] = useState(filters?.per_page?.toString() || "10"); const [perPage, setPerPage] = useState(filters?.per_page?.toString() || "10");
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (filters?.per_page) { if (filters?.per_page) {
@@ -91,15 +93,21 @@ export default function SalesImportIndex({ batches, filters = {} }: Props) {
</p> </p>
</div> </div>
{can('sales_imports.create') && ( {can('sales_imports.create') && (
<Link href={route('sales-imports.create')}> <Button
<Button className="button-filled-primary gap-2"> className="button-filled-primary gap-2"
<Plus className="h-4 w-4" /> onClick={() => setIsImportDialogOpen(true)}
>
</Button> <Plus className="h-4 w-4" />
</Link>
</Button>
)} )}
</div> </div>
<SalesImportDialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
/>
<div className="bg-white rounded-lg border shadow-sm overflow-hidden"> <div className="bg-white rounded-lg border shadow-sm overflow-hidden">
<Table> <Table>
<TableHeader className="bg-gray-50"> <TableHeader className="bg-gray-50">