1. 現場の「困った」が起点
私は神奈川県の薬局で働く薬剤師です。
日々の業務で最も時間を取られる課題の一つが、 医薬品の出荷状況の確認 でした。
なんと、医薬品安定供給にかかわる業務で、労働時間が1割以上奪われているのです。
医薬品の出荷状況を調べるのに、厚生労働省のウェブサイトから最新のExcelデータをダウンロードし、 17,000品目中3,000品目 にも及ぶ 出荷制限情報 を手動で確認するのは、非常に手間がかかる作業です。
たいていの人は、医薬品卸に何度も電話をかけて出荷状況を確認しています。
この課題を解決するため、まずはノーコードツール 「Make」 と 「LINE Bot」 を組み合わせたプロトタイプ開発に挑戦しました。
2. 最初の壁:ノーコードでも乗り越えられない現実
MakeとLINE Botを使えば、専門知識がなくても手軽にツールを作成できると考えていました。
そして、満足できるレベルのものが完成しました。
しかし、すぐに二つの大きな壁に直面しました。
会社の許可。
LINE Botを業務で利用するには、社用端末でのLINE仕様のための正式な許可が必要だと判明しました。
費用の問題。
Makeの無料枠は1,000オペレーションで、検索回数が増えるとすぐに上限に達し、実用化にはコストがかかってしまうことが分かりました。
私は、外部ツールに依存せず、会社の許可も費用もかからない、 自前で完結するコンパクトなツール を作る必要に迫られました。
3. AIとの出会い:30分で生まれた「魔法」
途方に暮れていた時、AIがHTMLやJavaScriptといったウェブページのコードを生成できることを知りました。
プログラミングの知識は全くありませんでしたが、藁にもすがる思いでChatGPTに指示を出してみたのです。
すると、たった30分で、検索窓とボタンが付いた、 ちゃんと動作するウェブページのプロトタイプ が完成しました。
この瞬間、「これはいける!」と確信し、本格的な開発をAIと進めることを決意しました。
4. ChatGPTとの格闘と挫折
ChatGPTを相棒に、より高度な機能の実装に挑みました。
検索条件の追加。医薬品名だけでなく、成分名やメーカーでも検索できるようにしました。
データのアップロード機能。最新のExcelファイルを簡単にアップロードできるようにしました。
UI/UXの改善。ボタンの位置や色を変えるなど、見やすさを追求しました。
しかし、ここでまた壁にぶつかりました。
ChatGPTは複雑な指示に弱く、一つの問題を修正すると別の場所に不具合が出る 「モグラ叩き」 状態が続きました。
最終的に「半角入力でも検索できるようにする」という簡単な機能さえ解決できず、諦めかけた時、別のAIを試すというアイデアがひらめきました。
5. 救世主Gemini:AIを乗り換えるという選択
私は、不具合を抱えたコードをそのままGeminiに渡し、「このコードの不具合を修正してほしい」と依頼しました。
すると、信じられないことが起きました。
Geminiは、長大なコードを拒否することなく、不具合箇所を瞬時に特定し、修正したコードと丁寧な解説を返してくれたのです。
私には 解説の内容はほとんど意味不明 でしたが、これまでChatGPTで解決できなかった問題が、嘘のように解決しました。
このブレイクスルーによって、私は デバッグ作業から解放 され、余剰時間をより創造的な作業に充てることができました。
視覚的フィードバック の強化。データが正常にアップロードされたらボタンの色を緑に変えるなど、 直感的な操作性 を追求しました。
誰が見ても分かりやすいように、デザインを徹底的に作り込みました。
これは「売れるレベル」じゃないかと自画自賛しています。
6.完成したものがこちら
PCだけでなく、スマホでも使いやすいよう表示を工夫しています。
私にはさっぱりわからないので、ここにコードを埋めておきます。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>医薬品 出荷状況検索</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap">
<style>
body {
font-family: 'Inter', sans-serif;
}
.message-box-center {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 50;
white-space: pre-wrap; /* 複数行のメッセージに対応 */
}
.table-container {
max-height: 60vh; /* 画面の60%を最大高さとする */
overflow-y: auto;
}
/* スマートフォン向けにテーブルのレイアウトを調整 */
@media (max-width: 640px) {
.table-container table {
table-layout: fixed;
width: 100%;
}
.table-container th, .table-container td {
word-wrap: break-word;
white-space: normal;
padding: 0.5rem;
}
/* 行を2段表示にする */
.row-group {
display: flex;
flex-wrap: wrap;
border-bottom: 1px solid #e5e7eb;
}
.cell-group {
display: flex;
flex-direction: column;
padding: 0.5rem;
}
.cell-group:first-child {
width: 60%;
}
.cell-group:last-child {
width: 40%;
}
.cell-label {
font-weight: 600;
color: #4b5563;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
/* PC画面では非表示にする */
.cell-group { display: block; }
.cell-label { display: none; }
.table-container thead { display: table-header-group; }
/* スマホ画面では2段表示に */
.table-container .row-group { display: flex; }
.table-container tbody { display: block; }
.table-container tr { display: flex; flex-direction: column; }
.table-container td { padding: 0.5rem; }
.table-container thead { display: none; }
.table-container td:before {
content: attr(data-label);
font-weight: 600;
color: #4b5563;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
display: block;
margin-bottom: 0.25rem;
}
/* 複数行表示と省略を制御 */
.truncate-lines {
display: -webkit-box;
-webkit-line-clamp: 2; /* 2行に制限 */
-webkit-box-orient: vertical;
overflow: hidden;
}
}
</style>
</head>
<body class="bg-gray-100 p-4 sm:p-8 flex flex-col items-center min-h-screen">
<div class="bg-white p-6 sm:p-8 rounded-xl shadow-lg w-full max-w-4xl relative">
<h2 class="text-2xl sm:text-3xl font-bold text-center text-gray-800 mb-6">医薬品 出荷状況検索</h2>
<!-- メッセージ表示エリア -->
<div id="messageBox" class="text-sm sm:text-base text-center p-3 rounded-lg hidden message-box-center w-3/4"></div>
<!-- ファイルアップロード/ダウンロード欄 -->
<div class="mb-6">
<label class="block text-gray-700 font-medium mb-2">Excelファイルを準備してください</label>
<!-- 修正:スマホでも確実に2列になるように変更 -->
<div class="grid grid-cols-2 gap-4">
<a href="https://www.mhlw.go.jp/stf/seisakunitsuite/bunya/kenkou_iryou/iryou/kouhatu-iyaku/04_00003.html" target="_blank" class="w-full">
<button class="w-full bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-md shadow transition ease-in-out duration-150">厚生労働省のサイトでダウンロード</button>
</a>
<label for="fileInput" class="w-full cursor-pointer block">
<span id="uploadButtonText" class="block bg-rose-500 hover:bg-rose-600 text-white font-bold py-2 px-4 rounded-md shadow text-center transition ease-in-out duration-150">
ファイルをアップロード
</span>
<input type="file" id="fileInput" accept=".xlsx, .xls" class="hidden">
</label>
</div>
</div>
<!-- 検索欄の修正 -->
<!-- 修正: スマホは2列、PCは4列 -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<!-- 医薬品名 -->
<div class="col-span-1">
<label for="drugName" class="block text-gray-700 text-sm font-medium mb-1">医薬品名:</label>
<input type="text" id="drugName" placeholder="例: ロキソニン" class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 transition ease-in-out duration-150 p-2">
</div>
<!-- 成分名 -->
<div class="col-span-1">
<label for="ingredientName" class="block text-gray-700 text-sm font-medium mb-1">成分名:</label>
<input type="text" id="ingredientName" placeholder="例: ロキソプロフェン" class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 transition ease-in-out duration-150 p-2">
</div>
<!-- 規格・剤形・メーカー -->
<div class="col-span-1">
<label for="makerName" class="block text-gray-700 text-sm font-medium mb-1">規格、剤形、メーカー:</label>
<input type="text" id="makerName" placeholder="例: 60mg" class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 transition ease-in-out duration-150 p-2">
</div>
<!-- 検索ボタン -->
<div class="col-span-1 flex items-end">
<button onclick="searchData()" class="w-full bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-md shadow transition ease-in-out duration-150">検索</button>
</div>
</div>
<!-- 結果テーブル -->
<div class="table-container rounded-lg shadow-md border border-gray-200">
<table id="resultTable" class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">品名</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">成分名</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">出荷対応状況</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">限定出荷/供給停止理由</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider whitespace-nowrap">出荷量状況</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200" id="resultTableBody">
<!-- 検索結果がここに追加されます -->
</tbody>
</table>
</div>
<!-- 製作者情報フッター -->
<div class="mt-8 text-center text-gray-500 text-sm">
<p>ご意見・ご要望は製作者までお寄せください。</p>
<p>ツイッター: <a href="https://x.com/oshigoto_twitte" target="_blank" class="text-blue-500 hover:underline">@oshigoto_twitte</a></p>
</div>
</div>
<script>
let excelData = [];
const messageBox = document.getElementById('messageBox');
// メッセージを表示する関数
function showMessage(text, type = 'info') {
messageBox.textContent = text;
messageBox.classList.remove('hidden', 'bg-red-200', 'text-red-800', 'bg-green-200', 'text-green-800', 'bg-blue-200', 'text-blue-800');
messageBox.classList.add('block');
if (type === 'error') {
messageBox.classList.add('bg-red-200', 'text-red-800');
} else if (type === 'success') {
messageBox.classList.add('bg-green-200', 'text-green-800');
} else {
messageBox.classList.add('bg-blue-200', 'text-blue-800');
}
}
// 指定時間後にメッセージを非表示にする関数
function hideMessage(delay) {
setTimeout(() => {
messageBox.classList.add('hidden');
}, delay);
}
// 半角・全角、ひらがな・カタカナ、大文字・小文字を区別せず統一する関数
function normalizeString(str) {
if (!str) return '';
let normalizedStr = String(str);
// 全角カタカナをひらがなに、全角英数字を半角に変換
normalizedStr = normalizedStr.replace(/[ァ-ヶ]/g, s => String.fromCharCode(s.charCodeAt(0) - 0x60));
normalizedStr = normalizedStr.replace(/[A-Za-z0-9]/g, s => String.fromCharCode(s.charCodeAt(0) - 0xFEE0));
return normalizedStr.toLowerCase().replace(/\s/g, ''); // 空白も除去
}
// ファイル読み込みイベントリスナー
document.getElementById('fileInput').addEventListener('change', handleFile, false);
function handleFile(event) {
const file = event.target.files[0];
const uploadButtonSpan = document.getElementById('uploadButtonText');
if (!file) {
showMessage("ファイルが選択されていません。", "error");
hideMessage(3000);
uploadButtonSpan.textContent = 'ファイルをアップロード';
uploadButtonSpan.classList.remove('bg-green-500', 'hover:bg-green-600');
uploadButtonSpan.classList.add('bg-rose-500', 'hover:bg-rose-600');
return;
}
const reader = new FileReader();
reader.onload = function(e) {
const data = new Uint8Array(e.target.result);
processExcelData(data);
uploadButtonSpan.textContent = 'ファイルをアップロード済み';
uploadButtonSpan.classList.remove('bg-rose-500', 'hover:bg-rose-600');
uploadButtonSpan.classList.add('bg-green-500', 'hover:bg-green-600');
};
reader.readAsArrayBuffer(file);
}
// Excelデータを処理する共通関数
function processExcelData(data) {
try {
const workbook = XLSX.read(data, {type: 'array'});
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
excelData = XLSX.utils.sheet_to_json(sheet, {header:1});
showMessage("Excelファイルを読み込みました。", "success");
hideMessage(3000);
} catch (error) {
console.error('Error processing Excel file:', error);
showMessage("Excelファイルの処理中にエラーが発生しました。", "error");
hideMessage(3000);
}
}
// 検索ボタンクリック時の処理
function searchData() {
if (excelData.length === 0) {
showMessage("先にExcelファイルを読み込んでください。", "error");
hideMessage(3000);
return;
}
// 検索キーワードを正規化してトリム
const drug = normalizeString(document.getElementById('drugName').value.trim());
const ingredient = normalizeString(document.getElementById('ingredientName').value.trim());
const maker = normalizeString(document.getElementById('makerName').value.trim());
const resultBody = document.getElementById('resultTableBody');
resultBody.innerHTML = "";
let results = [];
// ヘッダー行をスキップして検索
for (let i = 1; i < excelData.length; i++) {
const row = excelData[i];
if (!row || row.length < 17) continue;
// データの文字列も正規化して比較
const drugName = normalizeString(row[5]); // F列: 品名
const ingredientName = normalizeString(row[2]); // C列: 成分名
// G列(規格・剤形)とJ列(販売会社名)を結合してメーカー検索対象とする
const makerName = normalizeString((row[6] || "") + (row[9] || ""));
// 全てのキーワードが一致するかチェック(AND検索)
const matchDrug = drug === "" || drugName.includes(drug);
const matchIngredient = ingredient === "" || ingredientName.includes(ingredient);
// 品名、規格、剤形、メーカー名に対してmakerNameを検索
const matchMaker = maker === "" || (drugName.includes(maker) || makerName.includes(maker));
if (matchDrug && matchIngredient && matchMaker) {
results.push(row);
}
}
if (results.length === 0) {
const row = resultBody.insertRow();
const cell = row.insertCell(0);
cell.colSpan = 5;
cell.textContent = "該当データがありません";
cell.className = "px-4 py-3 text-sm text-gray-500 text-center italic";
showMessage("検索結果が見つかりませんでした。", "info");
hideMessage(3000);
return;
}
// 結果の表示を最大500件に制限
const displayResults = results.slice(0, 500);
displayResults.forEach(row => {
const newRow = resultBody.insertRow();
newRow.className = "transition-colors duration-150 hover:bg-gray-50";
// スマホ用2段表示のデータラベルを追加
const drugNameCell = newRow.insertCell(0);
drugNameCell.textContent = row[5] || "";
drugNameCell.setAttribute('data-label', '品名');
drugNameCell.classList.add("truncate-lines");
const ingredientNameCell = newRow.insertCell(1);
ingredientNameCell.textContent = row[2] || "";
ingredientNameCell.setAttribute('data-label', '成分名');
ingredientNameCell.classList.add("truncate-lines");
const statusCell = newRow.insertCell(2);
statusCell.textContent = row[11] || "";
statusCell.setAttribute('data-label', '出荷対応状況');
const reasonCell = newRow.insertCell(3);
reasonCell.textContent = row[13] || "";
reasonCell.setAttribute('data-label', '限定出荷/供給停止理由');
const volumeCell = newRow.insertCell(4);
volumeCell.textContent = row[16] || "";
volumeCell.setAttribute('data-label', '出荷量状況');
for (let i = 0; i < newRow.cells.length; i++) {
newRow.cells[i].className = "px-4 py-3 text-sm text-gray-900";
}
});
if (results.length > 500) {
showMessage(`${results.length} 件のデータが見つかりました。\n表示は上位 500 件に制限されています。`, "success");
} else {
showMessage(`${results.length} 件のデータが見つかりました。`, "success");
}
hideMessage(3000);
}
// エンターキーで検索をトリガーするイベントリスナー
document.getElementById('drugName').addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
searchData();
}
});
document.getElementById('ingredientName').addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
searchData();
}
});
document.getElementById('makerName').addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
searchData();
}
});
</script>
</body>
</html>
7. 成果と学び:AIは「万能な相棒」
結果として、プログラミング知識ゼロの私が、たった数日で 「売れるレベル」 (と勝手に思っている)のツールを完成させることができました。
- 検索 :医薬品名、成分名、規格・剤形・メーカーで部分一致検索が可能です。
- 利便性:ひらがな・カタカナ、半角・全角の区別なく検索できます。
- 視覚的:データアップロード状況を色で直感的に把握できます。
- コスト:会社の許可も、継続的な費用も不要なツールが完成しました。
今回の研修を通して、私は 「AIは万能な相棒」 だと強く実感しました。
AIはコーディングという作業を代行するだけでなく、専門知識がなくても実現できる課題の可能性を広げてくれます。