feat(raw-materials): add inline price editing with keyboard shortcuts and partial update support
This commit is contained in:
1 parent
a5584d0d46
commit
b030c08f68
2 files changed
+128
-10
No files matched your search
@@ -10,6 +10,10 @@ import {
|
|||||||
Boxes,
|
Boxes,
|
||||||
Layers,
|
Layers,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { API_BASE, type RawMaterial } from '@homaroma/types';
|
import { API_BASE, type RawMaterial } from '@homaroma/types';
|
||||||
import RawMaterialBulkJsonModal from '../components/RawMaterialBulkJsonModal';
|
import RawMaterialBulkJsonModal from '../components/RawMaterialBulkJsonModal';
|
||||||
@@ -21,6 +25,56 @@ export default function RawMaterialsPage() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [isBulkModalOpen, setIsBulkModalOpen] = useState(false);
|
const [isBulkModalOpen, setIsBulkModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Inline Price Editing State
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editingPrice, setEditingPrice] = useState<string>('');
|
||||||
|
const [savingId, setSavingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const startEditing = (item: RawMaterial) => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
setEditingPrice(String(item.pricePerUnit ?? ''));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditing = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditingPrice('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePrice = async (item: RawMaterial) => {
|
||||||
|
const val = parseFloat(editingPrice);
|
||||||
|
if (isNaN(val) || val < 0) {
|
||||||
|
alert('กรุณาระบุราคาต้นทุนให้ถูกต้อง (ต้องเป็นตัวเลขมากกว่าหรือเท่ากับ 0)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingId(item.id);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/raw-materials/${item.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
pricePerUnit: val,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || 'บันทึกราคาไม่สำเร็จ');
|
||||||
|
}
|
||||||
|
|
||||||
|
setMaterials((prev) =>
|
||||||
|
prev.map((m) => (m.id === item.id ? { ...m, pricePerUnit: val } : m))
|
||||||
|
);
|
||||||
|
setEditingId(null);
|
||||||
|
setEditingPrice('');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
alert(err.message || 'เกิดข้อผิดพลาดในการบันทึกราคา');
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchMaterials = async () => {
|
const fetchMaterials = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -240,10 +294,64 @@ export default function RawMaterialsPage() {
|
|||||||
{item.unit}
|
{item.unit}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-3">
|
||||||
<p className="text-sm font-bold text-emerald-400 font-mono">
|
{editingId === item.id ? (
|
||||||
฿{Number(item.pricePerUnit).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||||
</p>
|
<div className="relative">
|
||||||
|
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-slate-500 font-mono font-bold">
|
||||||
|
฿
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
autoFocus
|
||||||
|
value={editingPrice}
|
||||||
|
onChange={(e) => setEditingPrice(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSavePrice(item);
|
||||||
|
if (e.key === 'Escape') cancelEditing();
|
||||||
|
}}
|
||||||
|
disabled={savingId === item.id}
|
||||||
|
className="w-28 pl-6 pr-2 py-1 text-sm font-bold font-mono text-emerald-400 bg-black/80 border border-cyan-500/60 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500/40 shadow-inner"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={savingId === item.id}
|
||||||
|
onClick={() => handleSavePrice(item)}
|
||||||
|
className="p-1.5 rounded-lg bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40 transition-all disabled:opacity-50"
|
||||||
|
title="บันทึกราคา (Enter)"
|
||||||
|
>
|
||||||
|
{savingId === item.id ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={savingId === item.id}
|
||||||
|
onClick={cancelEditing}
|
||||||
|
className="p-1.5 rounded-lg bg-white/5 hover:bg-rose-500/20 text-slate-400 hover:text-rose-300 border border-white/10 transition-all disabled:opacity-50"
|
||||||
|
title="ยกเลิก (Esc)"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => startEditing(item)}
|
||||||
|
className="group/price inline-flex items-center gap-2 cursor-pointer px-2.5 py-1 -ml-2.5 rounded-lg hover:bg-white/5 border border-transparent hover:border-white/10 transition-all"
|
||||||
|
title="คลิกที่นี่เพื่อแก้ไขราคาต่อหน่วย (Inline Edit)"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-bold text-emerald-400 font-mono">
|
||||||
|
฿{Number(item.pricePerUnit).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
<Pencil className="w-3 h-3 text-slate-500 opacity-0 group-hover/price:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
|||||||
@@ -137,20 +137,30 @@ export const rawMaterialsRoute = new Elysia({ prefix: "/raw-materials" })
|
|||||||
if (isNaN(id)) return status(400, { message: "Invalid ID" });
|
if (isNaN(id)) return status(400, { message: "Invalid ID" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const updateData: any = {};
|
||||||
|
if (body.name !== undefined) updateData.name = String(body.name).trim();
|
||||||
|
if (body.unit !== undefined) updateData.unit = String(body.unit).trim();
|
||||||
|
if (body.pricePerUnit !== undefined) {
|
||||||
|
updateData.pricePerUnit =
|
||||||
|
typeof body.pricePerUnit === "string"
|
||||||
|
? parseFloat(body.pricePerUnit) || 0
|
||||||
|
: Number(body.pricePerUnit) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
const rawMaterial = await prisma.rawMaterial.update({
|
const rawMaterial = await prisma.rawMaterial.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: body,
|
data: updateData,
|
||||||
});
|
});
|
||||||
return rawMaterial;
|
return rawMaterial;
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
return status(500, { message: "Failed to update raw material" });
|
return status(500, { message: "Failed to update raw material", error: err.message });
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
name: t.String(),
|
name: t.Optional(t.String()),
|
||||||
unit: t.String(),
|
unit: t.Optional(t.String()),
|
||||||
pricePerUnit: t.Number(),
|
pricePerUnit: t.Optional(t.Union([t.Number(), t.String()])),
|
||||||
}),
|
}),
|
||||||
detail: { tags: ["raw-materials"] }
|
detail: { tags: ["raw-materials"] }
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in new issue
Block a user