๐ŸŽฏ ๋ชฉํ‘œ ๋‹ฌ์„ฑ ์ง„ํ–‰ ๋ฐ” - ์‹œ๊ฐ์  ๋™๊ธฐ๋ถ€์—ฌ ํŠธ๋ž˜์ปค - ๋ฐ”์ด๋ธŒ์ฝ”๋”ฉ ๋ ˆ์‹œํ”ผ
์ž…๋ฌธ PHP g5se AI: Claude

๐ŸŽฏ ๋ชฉํ‘œ ๋‹ฌ์„ฑ ์ง„ํ–‰ ๋ฐ” - ์‹œ๊ฐ์  ๋™๊ธฐ๋ถ€์—ฌ ํŠธ๋ž˜์ปค

๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋ช… visual-goal-progress-tracker
์กฐํšŒ์ˆ˜ 77 0๊ฑด ์ œ์ถœ

๋ฌธ์ œ ์„ค๋ช…

๐Ÿ“Š ๋ชฉํ‘œ ๋‹ฌ์„ฑ ์ง„ํ–‰ ๋ฐ” - ์‹œ๊ฐ์  ๋™๊ธฐ๋ถ€์—ฌ ํŠธ๋ž˜์ปค

๋ชฉํ‘œ๋ฅผ ์„ค์ •ํ•˜๊ณ  ์ง„ํ–‰ ์ƒํ™ฉ์„ ์‹œ๊ฐ์ ์œผ๋กœ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ์ง„ํ–‰ ๋ฐ” ํŠธ๋ž˜์ปค์ž…๋‹ˆ๋‹ค. ์ฑ… ์ฝ๊ธฐ, ์šด๋™ ํšŸ์ˆ˜, ์ €์ถ• ๊ธˆ์•ก ๋“ฑ ๋‹ค์–‘ํ•œ ๋ชฉํ‘œ๋ฅผ ๋“ฑ๋กํ•˜๊ณ  ๋‹ฌ์„ฑ๋ฅ ์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ํ™•์ธํ•˜์„ธ์š”.

โœจ ํ•ต์‹ฌ ๊ธฐ๋Šฅ

  • ๋ชฉํ‘œ ๋“ฑ๋ก: ๋ชฉํ‘œ๋ช…, ๋ชฉํ‘œ ์ˆ˜์น˜, ํ˜„์žฌ ์ง„ํ–‰ ์ˆ˜์น˜ ์ž…๋ ฅ
  • ์ง„ํ–‰ ๋ฐ” ์‹œ๊ฐํ™”: ํผ์„ผํŠธ ๊ธฐ๋ฐ˜ ํ”„๋กœ๊ทธ๋ ˆ์Šค ๋ฐ”๋กœ ๋‹ฌ์„ฑ๋ฅ  ํ‘œ์‹œ
  • ์ƒ‰์ƒ ๋ณ€ํ™”: ๋‹ฌ์„ฑ๋ฅ ์— ๋”ฐ๋ผ ์ง„ํ–‰ ๋ฐ” ์ƒ‰์ƒ ์ž๋™ ๋ณ€๊ฒฝ (0-30% ๋นจ๊ฐ•, 31-70% ์ฃผํ™ฉ, 71-99% ๋…ธ๋ž‘, 100% ์ดˆ๋ก)
  • ๋ชฉํ‘œ ์ˆ˜์ •: ์ง„ํ–‰ ์ˆ˜์น˜ ์—…๋ฐ์ดํŠธ (+ ๋ฒ„ํŠผ์œผ๋กœ ๊ฐ„ํŽธ ์ฆ๊ฐ€)
  • ๋ชฉํ‘œ ์‚ญ์ œ: ์™„๋ฃŒ๋˜๊ฑฐ๋‚˜ ๋ถˆํ•„์š”ํ•œ ๋ชฉํ‘œ ์ œ๊ฑฐ
  • ๋‹คํฌ๋ชจ๋“œ ์ž๋™ ๋Œ€์‘: modern shell์˜ ๋””์ž์ธ ํ† ํฐ ํ™œ์šฉ

๐ŸŽ ๋ณด๋„ˆ์Šค ๊ธฐ๋Šฅ (์„ ํƒ)

  • ๋ชฉํ‘œ๋ณ„ ์•„์ด์ฝ˜ ์„ ํƒ ๊ธฐ๋Šฅ (๐Ÿ“š์ฑ…, ๐Ÿ’ช์šด๋™, ๐Ÿ’ฐ์ €์ถ• ๋“ฑ)
  • ๋ชฉํ‘œ ๋‹ฌ์„ฑ ์‹œ ์ถ•ํ•˜ ์• ๋‹ˆ๋ฉ”์ด์…˜ ํšจ๊ณผ
  • ๋ชฉํ‘œ ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ๊ทธ๋ฃนํ™” (๊ฑด๊ฐ•, ์žฌ์ •, ํ•™์Šต ๋“ฑ)
  • ์ผ์ผ ์ง„ํ–‰ ๊ธฐ๋ก ์ฐจํŠธ (๊ฐ„๋‹จํ•œ ๋ง‰๋Œ€ ๊ทธ๋ž˜ํ”„)

๐Ÿ›  ๊ธฐ์ˆ  ์Šคํƒ

  • ๋ฐฑ์—”๋“œ: PHP (๊ทธ๋ˆ„๋ณด๋“œ5 SE) + SQLite
  • ํ”„๋ก ํŠธ์—”๋“œ: HTML5 + CSS3 (๋””์ž์ธ ํ† ํฐ) + Vanilla JavaScript
  • ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค: SQLite (๋ชจ๋“ˆ ์ „์šฉ DB ํŒŒ์ผ)

๐Ÿ“ ๋ฐ์ดํ„ฐ ๊ตฌ์กฐ

goals ํ…Œ์ด๋ธ”:

  • id: ์ž๋™ ์ฆ๊ฐ€ ๊ธฐ๋ณธํ‚ค
  • title: ๋ชฉํ‘œ๋ช… (์˜ˆ: "๋…์„œ 100๊ถŒ ์ฝ๊ธฐ")
  • target: ๋ชฉํ‘œ ์ˆ˜์น˜ (์˜ˆ: 100)
  • current: ํ˜„์žฌ ์ง„ํ–‰ ์ˆ˜์น˜ (์˜ˆ: 37)
  • icon: ์•„์ด์ฝ˜ ์ด๋ชจ์ง€ (์„ ํƒ, ๊ธฐ๋ณธ๊ฐ’ ๐ŸŽฏ)
  • created_at: ์ƒ์„ฑ ์‹œ๊ฐ
  • updated_at: ์ˆ˜์ • ์‹œ๊ฐ

๐ŸŽจ UI/UX ํŠน์ง•

  • ๋ชฉํ‘œ ์นด๋“œ ๋ ˆ์ด์•„์›ƒ (๊ทธ๋ฆฌ๋“œ ๋ฐฉ์‹)
  • ํฐ ์ง„ํ–‰ ๋ฐ”์™€ ํผ์„ผํŠธ ํ‘œ์‹œ๋กœ ํ•œ๋ˆˆ์— ๋‹ฌ์„ฑ๋ฅ  ํ™•์ธ
  • +1, +5, +10 ๋ฒ„ํŠผ์œผ๋กœ ๋น ๋ฅธ ์ง„ํ–‰ ์—…๋ฐ์ดํŠธ
  • ๋ถ€๋“œ๋Ÿฌ์šด ์• ๋‹ˆ๋ฉ”์ด์…˜ (์ง„ํ–‰ ๋ฐ” ์ฆ๊ฐ€ ์‹œ transition ํšจ๊ณผ)
  • ๋ฐ˜์‘ํ˜• ๋””์ž์ธ (๋ชจ๋ฐ”์ผ ์ตœ์ ํ™”)

์ œ์•ฝ ์กฐ๊ฑด

  • โ€ข ๊ทธ๋ˆ„๋ณด๋“œ5 SE ์„ค์น˜๋ณธ ์œ„์—์„œ ์ž‘๋™ (https://github.com/gnuboard/g5se main ๋ธŒ๋žœ์น˜)
  • โ€ข ํด๋” ๊ธฐ๋ฐ˜ ์ž๋™ ๋ผ์šฐํŒ… ์‚ฌ์šฉ (app/modules/visual-goal-progress-tracker/index.php โ†’ /visual-goal-progress-tracker)
  • โ€ข modern shell ํ•„์ˆ˜ ์ ์šฉ (_head.inc.php + m-shell + _nav.inc.php + m-container + _footer.inc.php + tail.sub.php)
  • โ€ข ๋””์ž์ธ ํ† ํฐ ์‚ฌ์šฉ (--m-surface, --m-text, --m-text-muted, --m-shadow, --m-radius-lg ๋“ฑ)
  • โ€ข ์ž์ฒด SQLite DB ์‚ฌ์šฉ (G5_DATA_PATH.'/modules/visual-goal-progress-tracker.sqlite')
  • โ€ข prepared statement๋กœ SQL ์ธ์ ์…˜ ๋ฐฉ์ง€
  • โ€ข htmlspecialchars()๋กœ XSS ๋ฐฉ์ง€
  • โ€ข ์™ธ๋ถ€ ๋นŒ๋“œ ๋„๊ตฌ ์‚ฌ์šฉ ๊ธˆ์ง€ (์ˆœ์ˆ˜ PHP + HTML + CSS + JS)
  • โ€ข CDN ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ ๊ธˆ์ง€ (Vanilla JS๋กœ ๊ตฌํ˜„)

ํ”„๋กฌํ”„ํŠธ ๋ ˆ์‹œํ”ผ

์•„๋ž˜ ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ˆœ์„œ๋Œ€๋กœ AI์—๊ฒŒ ๋ณด๋‚ด๋ฉด ๋ฉ๋‹ˆ๋‹ค. ๋ณต์‚ฌ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๋ฐ”๋กœ ์‚ฌ์šฉํ•˜์„ธ์š”.

1
Step 1
g5se๊ฐ€ ์„ค์น˜๋œ ๋””๋ ‰ํ„ฐ๋ฆฌ(๋ฃจํŠธ์— `index.php`ยท`app/`์ด ์žˆ๋Š” ๊ณณ)์—์„œ ์ž‘์—…์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. (g5se ์„ค์น˜๋Š” https://github.com/gnuboard/g5se ์ฐธ๊ณ )

๋ชฉํ‘œ ๋‹ฌ์„ฑ ์ง„ํ–‰ ๋ฐ” ํŠธ๋ž˜์ปค์˜ ๊ธฐ๋ณธ ๊ณจ๊ฒฉ์„ ๋งŒ๋“ค์–ด์ฃผ์„ธ์š”.

**์ž‘์—… ๋‚ด์šฉ:**

1. `app/modules/visual-goal-progress-tracker/index.php` ํŒŒ์ผ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

2. modern shell ๊ณจ๊ฒฉ์„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค:
```php
<?php
include_once('./_common.php');

// SQLite DB ์—ฐ๊ฒฐ (๋ชจ๋“ˆ ์ „์šฉ DB ํŒŒ์ผ)
$db_dir = G5_DATA_PATH.'/modules';
@mkdir($db_dir, 0707, true);
$db_path = $db_dir.'/visual-goal-progress-tracker.sqlite';
$pdo = new PDO('sqlite:'.$db_path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

// ํ…Œ์ด๋ธ” ์ƒ์„ฑ (์ฒซ ์ ‘๊ทผ ์‹œ)
$pdo->exec("
    CREATE TABLE IF NOT EXISTS goals (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        target INTEGER NOT NULL,
        current INTEGER DEFAULT 0,
        icon TEXT DEFAULT '๐ŸŽฏ',
        created_at TEXT DEFAULT CURRENT_TIMESTAMP,
        updated_at TEXT DEFAULT CURRENT_TIMESTAMP
    )
");

// AJAX ์ฒ˜๋ฆฌ (๋ชฉํ‘œ ์ถ”๊ฐ€/์ˆ˜์ •/์‚ญ์ œ)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    header('Content-Type: application/json');
    $action = $_POST['action'] ?? '';
    
    if ($action === 'add') {
        $title = trim($_POST['title'] ?? '');
        $target = intval($_POST['target'] ?? 0);
        $icon = trim($_POST['icon'] ?? '๐ŸŽฏ');
        
        if ($title && $target > 0) {
            $stmt = $pdo->prepare("INSERT INTO goals (title, target, icon) VALUES (?, ?, ?)");
            $stmt->execute([$title, $target, $icon]);
            echo json_encode(['success' => true, 'id' => $pdo->lastInsertId()]);
        } else {
            echo json_encode(['success' => false, 'error' => '์ œ๋ชฉ๊ณผ ๋ชฉํ‘œ ์ˆ˜์น˜๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”']);
        }
        exit;
    }
    
    if ($action === 'update') {
        $id = intval($_POST['id'] ?? 0);
        $current = intval($_POST['current'] ?? 0);
        
        if ($id > 0 && $current >= 0) {
            $stmt = $pdo->prepare("UPDATE goals SET current = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
            $stmt->execute([$current, $id]);
            echo json_encode(['success' => true]);
        } else {
            echo json_encode(['success' => false, 'error' => '์ž˜๋ชป๋œ ์š”์ฒญ']);
        }
        exit;
    }
    
    if ($action === 'delete') {
        $id = intval($_POST['id'] ?? 0);
        
        if ($id > 0) {
            $stmt = $pdo->prepare("DELETE FROM goals WHERE id = ?");
            $stmt->execute([$id]);
            echo json_encode(['success' => true]);
        } else {
            echo json_encode(['success' => false, 'error' => '์ž˜๋ชป๋œ ์š”์ฒญ']);
        }
        exit;
    }
}

// ๋ชฉํ‘œ ๋ชฉ๋ก ์กฐํšŒ
$stmt = $pdo->query("SELECT * FROM goals ORDER BY created_at DESC");
$goals = $stmt->fetchAll(PDO::FETCH_ASSOC);

require_once(G5_THEME_PATH.'/modern/_head.inc.php');
include_once('./head.sub.php');
?>
<div class="m-shell">
    <?php require G5_THEME_PATH.'/modern/_nav.inc.php'; ?>
    <main class="m-container" style="padding: 32px 20px 48px;">
        <div style="max-width: 900px; margin: 0 auto;">
            <h1 style="font-size: 28px; font-weight: 700; margin-bottom: 8px; color: var(--m-text);">๐ŸŽฏ ๋ชฉํ‘œ ๋‹ฌ์„ฑ ์ง„ํ–‰ ๋ฐ”</h1>
            <p style="font-size: 15px; color: var(--m-text-muted); margin-bottom: 32px;">๋ชฉํ‘œ๋ฅผ ์„ค์ •ํ•˜๊ณ  ์ง„ํ–‰ ์ƒํ™ฉ์„ ์‹œ๊ฐ์ ์œผ๋กœ ํ™•์ธํ•˜์„ธ์š”</p>
            
            <!-- ๋ชฉํ‘œ ์ถ”๊ฐ€ ํผ -->
            <div style="background: var(--m-surface); padding: 24px; border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow); margin-bottom: 32px;">
                <h2 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: var(--m-text);">์ƒˆ ๋ชฉํ‘œ ์ถ”๊ฐ€</h2>
                <form id="addGoalForm" style="display: flex; flex-direction: column; gap: 12px;">
                    <input type="text" id="goalTitle" placeholder="๋ชฉํ‘œ ์ œ๋ชฉ (์˜ˆ: ๋…์„œ 100๊ถŒ ์ฝ๊ธฐ)" style="padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; background: var(--m-surface); color: var(--m-text);" required>
                    <div style="display: flex; gap: 12px;">
                        <input type="number" id="goalTarget" placeholder="๋ชฉํ‘œ ์ˆ˜์น˜" min="1" style="flex: 1; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; background: var(--m-surface); color: var(--m-text);" required>
                        <input type="text" id="goalIcon" placeholder="์•„์ด์ฝ˜ (์˜ˆ: ๐Ÿ“š)" maxlength="2" style="width: 80px; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; text-align: center; background: var(--m-surface); color: var(--m-text);">
                    </div>
                    <button type="submit" style="padding: 12px; background: #4F46E5; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: background 0.2s;">๋ชฉํ‘œ ์ถ”๊ฐ€</button>
                </form>
            </div>
            
            <!-- ๋ชฉํ‘œ ๋ชฉ๋ก -->
            <div id="goalsList" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px;">
                <?php if (empty($goals)): ?>
                    <p style="grid-column: 1 / -1; text-align: center; color: var(--m-text-muted); padding: 40px;">์•„์ง ๋“ฑ๋ก๋œ ๋ชฉํ‘œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์œ„์—์„œ ์ƒˆ ๋ชฉํ‘œ๋ฅผ ์ถ”๊ฐ€ํ•ด๋ณด์„ธ์š”!</p>
                <?php else: ?>
                    <?php foreach ($goals as $goal): ?>
                        <?php 
                            $percent = $goal['target'] > 0 ? min(100, round(($goal['current'] / $goal['target']) * 100)) : 0;
                            $barColor = $percent >= 100 ? '#10B981' : ($percent >= 71 ? '#F59E0B' : ($percent >= 31 ? '#F97316' : '#EF4444'));
                        ?>
                        <div class="goal-card" data-id="<?= $goal['id'] ?>" style="background: var(--m-surface); padding: 20px; border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow);">
                            <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 12px;">
                                <div style="display: flex; align-items: center; gap: 8px;">
                                    <span style="font-size: 24px;"><?= htmlspecialchars($goal['icon']) ?></span>
                                    <h3 style="font-size: 16px; font-weight: 600; color: var(--m-text);"><?= htmlspecialchars($goal['title']) ?></h3>
                                </div>
                                <button class="delete-btn" data-id="<?= $goal['id'] ?>" style="background: none; border: none; color: #EF4444; cursor: pointer; font-size: 18px;">๐Ÿ—‘๏ธ</button>
                            </div>
                            
                            <div style="margin-bottom: 12px;">
                                <div style="display: flex; justify-content: space-between; margin-bottom: 6px;">
                                    <span style="font-size: 14px; color: var(--m-text-muted);"><?= $goal['current'] ?> / <?= $goal['target'] ?></span>
                                    <span style="font-size: 14px; font-weight: 600; color: var(--m-text);"><?= $percent ?>%</span>
                                </div>
                                <div style="width: 100%; height: 12px; background: #E5E7EB; border-radius: 6px; overflow: hidden;">
                                    <div class="progress-bar" style="width: <?= $percent ?>%; height: 100%; background: <?= $barColor ?>; transition: width 0.5s ease;"></div>
                                </div>
                            </div>
                            
                            <div style="display: flex; gap: 8px;">
                                <button class="update-btn" data-id="<?= $goal['id'] ?>" data-increment="1" style="flex: 1; padding: 8px; background: #E0E7FF; color: #4F46E5; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">+1</button>
                                <button class="update-btn" data-id="<?= $goal['id'] ?>" data-increment="5" style="flex: 1; padding: 8px; background: #DBEAFE; color: #2563EB; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">+5</button>
                                <button class="update-btn" data-id="<?= $goal['id'] ?>" data-increment="10" style="flex: 1; padding: 8px; background: #D1FAE5; color: #059669; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">+10</button>
                            </div>
                        </div>
                    <?php endforeach; ?>
                <?php endif; ?>
            </div>
        </div>
    </main>
    <?php require G5_THEME_PATH.'/modern/_footer.inc.php'; ?>
</div>
<?php include_once('./tail.sub.php'); ?>
```

3. `/visual-goal-progress-tracker` URL๋กœ ์ ‘์†ํ•˜๋ฉด ๋ชฉํ‘œ ํŠธ๋ž˜์ปค ํŽ˜์ด์ง€๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค (ํด๋” ๊ธฐ๋ฐ˜ ์ž๋™ ๋ผ์šฐํŒ…).

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: modern shell์ด ์ ์šฉ๋œ ๋ชฉํ‘œ ํŠธ๋ž˜์ปค ํŽ˜์ด์ง€๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค. ๋ชฉํ‘œ ์ถ”๊ฐ€ ํผ๊ณผ ๋ชฉํ‘œ ๋ชฉ๋ก์ด ์นด๋“œ ๋ ˆ์ด์•„์›ƒ์œผ๋กœ ๋ณด์ด๋ฉฐ, ๊ฐ ๋ชฉํ‘œ ์นด๋“œ์—๋Š” ์ง„ํ–‰ ๋ฐ”์™€ ํผ์„ผํŠธ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค. ์•„์ง JavaScript๊ฐ€ ์—†์–ด ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ ์•„๋ฌด ๋™์ž‘๋„ ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

2
Step 2
๋ชฉํ‘œ ์ถ”๊ฐ€/์ˆ˜์ •/์‚ญ์ œ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•˜๋Š” JavaScript๋ฅผ ์ถ”๊ฐ€ํ•ด์ฃผ์„ธ์š”.

**์ž‘์—… ๋‚ด์šฉ:**

1. `app/modules/visual-goal-progress-tracker/index.php` ํŒŒ์ผ์˜ `<?php include_once('./tail.sub.php'); ?>` ๋ฐ”๋กœ ์œ„์— ๋‹ค์Œ ์Šคํฌ๋ฆฝํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

```javascript
<script>
// ๋ชฉํ‘œ ์ถ”๊ฐ€
document.getElementById('addGoalForm').addEventListener('submit', async (e) => {
    e.preventDefault();
    
    const title = document.getElementById('goalTitle').value.trim();
    const target = parseInt(document.getElementById('goalTarget').value);
    const icon = document.getElementById('goalIcon').value.trim() || '๐ŸŽฏ';
    
    if (!title || target <= 0) {
        alert('์ œ๋ชฉ๊ณผ ๋ชฉํ‘œ ์ˆ˜์น˜๋ฅผ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ž…๋ ฅํ•˜์„ธ์š”');
        return;
    }
    
    const formData = new FormData();
    formData.append('action', 'add');
    formData.append('title', title);
    formData.append('target', target);
    formData.append('icon', icon);
    
    const response = await fetch('/visual-goal-progress-tracker', {
        method: 'POST',
        body: formData
    });
    
    const result = await response.json();
    
    if (result.success) {
        location.reload();
    } else {
        alert(result.error || '๋ชฉํ‘œ ์ถ”๊ฐ€์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค');
    }
});

// ๋ชฉํ‘œ ์ง„ํ–‰ ์—…๋ฐ์ดํŠธ
document.querySelectorAll('.update-btn').forEach(btn => {
    btn.addEventListener('click', async () => {
        const id = btn.dataset.id;
        const increment = parseInt(btn.dataset.increment);
        const card = document.querySelector(`.goal-card[data-id="${id}"]`);
        const progressText = card.querySelector('.progress-bar').parentElement.previousElementSibling.querySelector('span:first-child').textContent;
        const [current, target] = progressText.split(' / ').map(n => parseInt(n.trim()));
        
        const newCurrent = Math.min(current + increment, target);
        
        const formData = new FormData();
        formData.append('action', 'update');
        formData.append('id', id);
        formData.append('current', newCurrent);
        
        const response = await fetch('/visual-goal-progress-tracker', {
            method: 'POST',
            body: formData
        });
        
        const result = await response.json();
        
        if (result.success) {
            location.reload();
        } else {
            alert(result.error || '์—…๋ฐ์ดํŠธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค');
        }
    });
});

// ๋ชฉํ‘œ ์‚ญ์ œ
document.querySelectorAll('.delete-btn').forEach(btn => {
    btn.addEventListener('click', async () => {
        if (!confirm('์ด ๋ชฉํ‘œ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) return;
        
        const id = btn.dataset.id;
        
        const formData = new FormData();
        formData.append('action', 'delete');
        formData.append('id', id);
        
        const response = await fetch('/visual-goal-progress-tracker', {
            method: 'POST',
            body: formData
        });
        
        const result = await response.json();
        
        if (result.success) {
            location.reload();
        } else {
            alert(result.error || '์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค');
        }
    });
});
</script>
```

2. ์ด์ œ ๋ชฉํ‘œ ์ถ”๊ฐ€, ์ง„ํ–‰ ์—…๋ฐ์ดํŠธ, ์‚ญ์ œ๊ฐ€ ๋ชจ๋‘ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ๋ชฉํ‘œ ์ถ”๊ฐ€ ํผ์—์„œ ์ œ๋ชฉ๊ณผ ๋ชฉํ‘œ ์ˆ˜์น˜๋ฅผ ์ž…๋ ฅํ•˜๊ณ  ์ œ์ถœํ•˜๋ฉด ์ƒˆ ๋ชฉํ‘œ ์นด๋“œ๊ฐ€ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค. ๊ฐ ๋ชฉํ‘œ ์นด๋“œ์˜ +1, +5, +10 ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜๋ฉด ์ง„ํ–‰ ์ˆ˜์น˜๊ฐ€ ์ฆ๊ฐ€ํ•˜๊ณ  ์ง„ํ–‰ ๋ฐ”๊ฐ€ ๋Š˜์–ด๋‚ฉ๋‹ˆ๋‹ค. ๐Ÿ—‘๏ธ ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜๋ฉด ํ™•์ธ ํ›„ ๋ชฉํ‘œ๊ฐ€ ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค. ๋‹ฌ์„ฑ๋ฅ ์— ๋”ฐ๋ผ ์ง„ํ–‰ ๋ฐ” ์ƒ‰์ƒ์ด ๋นจ๊ฐ•โ†’์ฃผํ™ฉโ†’๋…ธ๋ž‘โ†’์ดˆ๋ก์œผ๋กœ ๋ณ€ํ•ฉ๋‹ˆ๋‹ค.

3
Step 3
๋ชฉํ‘œ ๋‹ฌ์„ฑ ์‹œ ์ถ•ํ•˜ ์• ๋‹ˆ๋ฉ”์ด์…˜๊ณผ ๋ฐ˜์‘ํ˜• ๋””์ž์ธ์„ ๊ฐœ์„ ํ•ด์ฃผ์„ธ์š”.

**์ž‘์—… ๋‚ด์šฉ:**

1. `app/modules/visual-goal-progress-tracker/index.php` ํŒŒ์ผ์˜ `<main>` ํƒœ๊ทธ ๋ฐ”๋กœ ์•„๋ž˜์— ๋‹ค์Œ ์Šคํƒ€์ผ์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:

```html
<style>
@keyframes celebrate {
    0%, 100% { transform: scale(1); }
    25% { transform: scale(1.1) rotate(-5deg); }
    50% { transform: scale(1.15); }
    75% { transform: scale(1.1) rotate(5deg); }
}

.goal-completed {
    animation: celebrate 0.6s ease;
    border: 2px solid #10B981 !important;
}

.confetti {
    position: fixed;
    width: 10px;
    height: 10px;
    background: #F59E0B;
    animation: confetti-fall 2s linear forwards;
    pointer-events: none;
    z-index: 9999;
}

@keyframes confetti-fall {
    to {
        transform: translateY(100vh) rotate(360deg);
        opacity: 0;
    }
}

@media (max-width: 768px) {
    #goalsList {
        grid-template-columns: 1fr !important;
    }
}

.update-btn:hover {
    opacity: 0.8;
    transform: scale(1.05);
    transition: all 0.2s;
}

.delete-btn:hover {
    transform: scale(1.2);
    transition: transform 0.2s;
}

#addGoalForm button[type="submit"]:hover {
    background: #4338CA;
}
</style>
```

2. JavaScript ๋ถ€๋ถ„์˜ '๋ชฉํ‘œ ์ง„ํ–‰ ์—…๋ฐ์ดํŠธ' ์„น์…˜์„ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค (confetti ํšจ๊ณผ ์ถ”๊ฐ€):

```javascript
// ๋ชฉํ‘œ ์ง„ํ–‰ ์—…๋ฐ์ดํŠธ
document.querySelectorAll('.update-btn').forEach(btn => {
    btn.addEventListener('click', async () => {
        const id = btn.dataset.id;
        const increment = parseInt(btn.dataset.increment);
        const card = document.querySelector(`.goal-card[data-id="${id}"]`);
        const progressText = card.querySelector('.progress-bar').parentElement.previousElementSibling.querySelector('span:first-child').textContent;
        const [current, target] = progressText.split(' / ').map(n => parseInt(n.trim()));
        
        const newCurrent = Math.min(current + increment, target);
        const wasCompleted = current >= target;
        const isNowCompleted = newCurrent >= target;
        
        const formData = new FormData();
        formData.append('action', 'update');
        formData.append('id', id);
        formData.append('current', newCurrent);
        
        const response = await fetch('/visual-goal-progress-tracker', {
            method: 'POST',
            body: formData
        });
        
        const result = await response.json();
        
        if (result.success) {
            // ๋ชฉํ‘œ ๋‹ฌ์„ฑ ์ˆœ๊ฐ„ ์ถ•ํ•˜ ํšจ๊ณผ
            if (!wasCompleted && isNowCompleted) {
                card.classList.add('goal-completed');
                createConfetti();
                setTimeout(() => {
                    location.reload();
                }, 1500);
            } else {
                location.reload();
            }
        } else {
            alert(result.error || '์—…๋ฐ์ดํŠธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค');
        }
    });
});

// Confetti ์ƒ์„ฑ ํ•จ์ˆ˜
function createConfetti() {
    const colors = ['#F59E0B', '#10B981', '#3B82F6', '#EF4444', '#8B5CF6'];
    for (let i = 0; i < 30; i++) {
        setTimeout(() => {
            const confetti = document.createElement('div');
            confetti.className = 'confetti';
            confetti.style.left = Math.random() * 100 + 'vw';
            confetti.style.top = '-10px';
            confetti.style.background = colors[Math.floor(Math.random() * colors.length)];
            confetti.style.animationDelay = Math.random() * 0.5 + 's';
            document.body.appendChild(confetti);
            
            setTimeout(() => confetti.remove(), 2000);
        }, i * 50);
    }
}
```

3. ์ด์ œ ๋ชฉํ‘œ๋ฅผ 100% ๋‹ฌ์„ฑํ•˜๋ฉด ์นด๋“œ์— ์ถ•ํ•˜ ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ์žฌ์ƒ๋˜๊ณ  ์ƒ‰์ƒ‰์˜ confetti๊ฐ€ ๋–จ์–ด์ง‘๋‹ˆ๋‹ค.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ๋ชฉํ‘œ๋ฅผ 100% ๋‹ฌ์„ฑํ•˜๋Š” ์ˆœ๊ฐ„ ์นด๋“œ๊ฐ€ ํ™•๋Œ€/ํšŒ์ „ํ•˜๋Š” ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ์žฌ์ƒ๋˜๊ณ , ํ™”๋ฉด์— ์ƒ‰์ƒ‰์˜ confetti๊ฐ€ ๋–จ์–ด์ง‘๋‹ˆ๋‹ค. ๋ชจ๋ฐ”์ผ์—์„œ๋Š” ๋ชฉํ‘œ ์นด๋“œ๊ฐ€ 1์—ด๋กœ ์ •๋ ฌ๋˜๋ฉฐ, ๋ฒ„ํŠผ ํ˜ธ๋ฒ„ ์‹œ ๋ถ€๋“œ๋Ÿฌ์šด ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ์ ์šฉ๋ฉ๋‹ˆ๋‹ค. ๋‹คํฌ๋ชจ๋“œ์—์„œ๋„ ๋””์ž์ธ ํ† ํฐ ๋•๋ถ„์— ๋ชจ๋“  ์š”์†Œ๊ฐ€ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

์ œ์ถœ๋œ ์ž‘ํ’ˆ (0)

๋กœ๊ทธ์ธ ํ›„ ์ œ์ถœ

์•„์ง ์ œ์ถœ๋œ ์ž‘ํ’ˆ์ด ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ๋„์ „์ž๊ฐ€ ๋˜์–ด๋ณด์„ธ์š”!

๋Œ“๊ธ€ (0)

๋กœ๊ทธ์ธ ํ›„ ๋Œ“๊ธ€์„ ๋‚จ๊ธธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ๋Œ“๊ธ€์„ ๋‚จ๊ฒจ๋ณด์„ธ์š”!