はじめに
学園祭の模擬店で使うレジ(POS)システムを作りました。
作るにあたって最初に決めたのは、「クラウドと通信しない」 という方針です。
経験上、人がたくさん集まる場所は通信環境が悪く、クラウド連携型のサービスはうまく動きません。会計のたびに通信待ちが発生したり、最悪その場で使えなくなったりします。学園祭当日にレジが止まるのは致命的です。
そこで本プログラムは、外部と一切通信せず、その端末だけで完結して動く ことを目的に、あえてシンプルな構成にしました。
- 🔌 インストール不要 …
index.htmlをブラウザで開くだけ - 📴 オフライン動作 … ネットがなくても会計できる
- 📱 スマホ・タブレット対応 … 画面幅で自動レイアウト切替
- 🗄 サーバー・アカウント不要 … データは端末内に保存
リポジトリはこちらです。
デモ用スクリーンショット
作ったもの
学園祭の模擬店運営に必要な機能を、下部タブ5つに整理しました。
| タブ | 内容 |
|---|---|
| 🧾 レジ | 商品をタッチしてカゴに追加、合計・お預かり・おつりを自動計算 |
| ⚙️ 設定 | 釣銭準備金(初期お釣り)の登録と、商品の登録/編集/削除 |
| 📊 売上 | 総売上・会計件数・客単価、商品別ランキング、時間帯別グラフ |
| 🕑 履歴 | 過去の会計を一覧・取り消し(取消時は在庫を自動復元) |
| 🔒 締め | 交代時に「釣銭準備金+売上」と実際の現金を照合し、過不足を記録 |
技術構成
いわゆる「モダンな構成」は使っていません。依存ライブラリゼロ・ビルドなし・素の HTML / CSS / JavaScript です。
- フロント:1枚の
index.html(HTML + CSS + JS を内包) - データ保存:ブラウザの
localStorage - フレームワーク:なし
- ビルドツール:なし
理由はシンプルで、当日トラブっても中身を全部読める・直せる ことを重視したからです。ファイル1枚なら、USBメモリやLINEで配っても動きますし、ビルド環境がなくても改修できます。
設計のポイント
1. データは端末内に持ち、通信しない
状態はすべて localStorage に JSON で保存します。読み込み時に既定値とマージすることで、機能追加でキーが増えても古いデータが壊れないようにしています。
const KEY = "gakusai_pos_v1";
const DEFAULT = { products:[], sales:[], closings:[], float:0, seq:{p:1,s:1,c:1} };
function load(){
try{
const raw = localStorage.getItem(KEY);
if(!raw) return structuredClone(DEFAULT);
const d = JSON.parse(raw);
// 既定値にマージ → 後から追加したキー(例: float)も安全に埋まる
return Object.assign(structuredClone(DEFAULT), d);
}catch(e){ return structuredClone(DEFAULT); }
}
function save(){
try{ localStorage.setItem(KEY, JSON.stringify(DB)); }
catch(e){ toast("保存に失敗しました"); }
}
2. 在庫は「取り消し」で元に戻す
会計を取り消したら在庫も自動で戻します。学園祭のレジは操作ミスが起きやすいので、「取消したら売った分の在庫が復活する」という当たり前の挙動を保証しました。
function voidSale(id){
const s = DB.sales.find(x=>x.id===id);
if(!s || s.voided) return;
s.voided = true;
// 売った分の在庫を戻す
s.items.forEach(i=>{
const p = findProduct(i.productId);
if(p && p.stock!=null){ p.sold = Math.max(0, p.sold - i.qty); }
});
save();
}
3. 「締め」でレジの現金を照合する
一番こだわったのが締め(レジ点検)機能です。交代時などに、開店時にレジへ入れた釣銭準備金(初期お釣り)+その間の売上 と、実際に数えた現金が合うかを照合します。
// 想定レジ現金 = 釣銭準備金 + 前回の締め以降の売上
const drawer = float + expected;
// 過不足 = 数えた現金 − 想定レジ現金
const diff = counted - drawer;
過不足がひと目で分かるので、「誰かの回のときに合わなかった」を後から追えます。売上だけでなく釣銭準備金を計算に含めているのがポイントです。
4. スマホもタブレットも1つのHTMLで
CSSのメディアクエリだけでレイアウトを出し分けています。
- スマホ:縦1列(商品グリッド → カート → 会計ボタン)
- タブレット・横長画面:2カラム(左に商品、右にカートと会計を固定表示)
/* タブレット以上は2カラム。カートは画面に追従(sticky) */
@media (min-width:760px){
#view-register{
display:grid;
grid-template-columns:1fr minmax(300px,340px);
gap:18px;
align-items:start;
}
.reg-side{ position:sticky; top:60px; }
}
あえて割り切った点
オフライン単独動作を優先した結果、割り切った部分もあります。
- 複数端末の売上を自動合算する機能はない … レジを分けるなら端末ごとに集計し、締めで合算する運用にします
- データは端末依存 … 同じ端末・同じブラウザで使い続ける必要があります(サイトデータ削除で消える)
これらは「通信に依存しない」ことの裏返しであり、学園祭という用途ではメリットの方が大きいと判断しました。
まとめ
- 人が集まる場所=通信が不安定、という前提でオフライン単独動作を選んだ
- 1枚のHTML+localStorageという枯れた構成で、当日でも直せる安心感を優先した
- レジ/在庫/売上集計/締め(現金照合)まで、模擬店運営に必要な機能をカバーした
「学園祭のレジ、どうしよう?」と悩んでいる方の参考になれば嬉しいです。
タグ候補:
JavaScriptHTMLCSS個人開発localStorage