Skip to content

Commit 067e8a6

Browse files
committed
feat: Rental tests + Survey React pages — 20 tests passing
Rental module complete (10 tests): - RentalTest covers index, create, show, rent, duplicate-rent 422, return, agreements list, calendar, isOverdue(), totalAmount() Survey module complete (10 tests): - Index.tsx: survey list with status badges, new survey form - Show.tsx: questions list with add-question form, response count - Results.tsx: per-question answer aggregation (text list, choice counts, rating average, yes/no counts) - SurveyTest covers index, create, show, publish, close, addQuestion, respond, respond-closed 422, results, removeQuestion https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 73223f7 commit 067e8a6

5 files changed

Lines changed: 940 additions & 0 deletions

File tree

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { Head, Link, router, useForm } from '@inertiajs/react';
2+
import AppLayout from '@/Layouts/AppLayout';
3+
import { Button } from '@/Components/Common/Button';
4+
import type { PageProps } from '@/types';
5+
import { useState } from 'react';
6+
7+
interface Survey {
8+
id: number;
9+
title: string;
10+
description: string | null;
11+
status: 'draft' | 'published' | 'closed';
12+
starts_at: string | null;
13+
ends_at: string | null;
14+
created_at: string;
15+
}
16+
17+
interface Paginated {
18+
data: Survey[];
19+
current_page: number;
20+
last_page: number;
21+
total: number;
22+
}
23+
24+
interface Props extends PageProps {
25+
surveys: Paginated;
26+
filters: { status?: string };
27+
}
28+
29+
const statusBadge: Record<string, string> = {
30+
draft: 'bg-slate-100 text-slate-700',
31+
published: 'bg-green-100 text-green-700',
32+
closed: 'bg-red-100 text-red-700',
33+
};
34+
35+
export default function SurveyIndex({ surveys, filters }: Props) {
36+
const [showForm, setShowForm] = useState(false);
37+
38+
const { data, setData, post, processing, reset, errors } = useForm({
39+
title: '',
40+
description: '',
41+
});
42+
43+
function handleCreate(e: React.FormEvent) {
44+
e.preventDefault();
45+
post('/surveys', {
46+
onSuccess: () => {
47+
reset();
48+
setShowForm(false);
49+
},
50+
});
51+
}
52+
53+
function filterStatus(value: string) {
54+
router.get('/surveys', { ...filters, status: value || undefined }, { preserveState: true, replace: true });
55+
}
56+
57+
function publish(id: number) {
58+
router.post(`/surveys/${id}/publish`);
59+
}
60+
61+
function close(id: number) {
62+
router.post(`/surveys/${id}/close`);
63+
}
64+
65+
return (
66+
<AppLayout>
67+
<Head title="Surveys" />
68+
<div className="space-y-6">
69+
<div className="flex items-center justify-between">
70+
<div>
71+
<h1 className="text-2xl font-semibold text-slate-900">Surveys</h1>
72+
<p className="text-sm text-slate-500 mt-1">{surveys.total} records</p>
73+
</div>
74+
<Button onClick={() => setShowForm(!showForm)}>New Survey</Button>
75+
</div>
76+
77+
{showForm && (
78+
<div className="bg-white border border-slate-200 rounded-lg p-6 shadow-sm">
79+
<h2 className="text-lg font-medium text-slate-800 mb-4">Create Survey</h2>
80+
<form onSubmit={handleCreate} className="space-y-4">
81+
<div>
82+
<label className="block text-sm font-medium text-slate-700 mb-1">Title *</label>
83+
<input
84+
type="text"
85+
value={data.title}
86+
onChange={e => setData('title', e.target.value)}
87+
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
88+
placeholder="Survey title"
89+
/>
90+
{errors.title && <p className="text-red-600 text-xs mt-1">{errors.title}</p>}
91+
</div>
92+
<div>
93+
<label className="block text-sm font-medium text-slate-700 mb-1">Description</label>
94+
<textarea
95+
value={data.description}
96+
onChange={e => setData('description', e.target.value)}
97+
rows={3}
98+
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
99+
placeholder="Optional description"
100+
/>
101+
</div>
102+
<div className="flex gap-3">
103+
<Button type="submit" disabled={processing}>Create</Button>
104+
<button type="button" onClick={() => setShowForm(false)} className="text-sm text-slate-600 hover:text-slate-800">Cancel</button>
105+
</div>
106+
</form>
107+
</div>
108+
)}
109+
110+
{/* Filter */}
111+
<div className="flex gap-3">
112+
<select
113+
value={filters.status ?? ''}
114+
onChange={e => filterStatus(e.target.value)}
115+
className="rounded-lg border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
116+
>
117+
<option value="">All Statuses</option>
118+
<option value="draft">Draft</option>
119+
<option value="published">Published</option>
120+
<option value="closed">Closed</option>
121+
</select>
122+
</div>
123+
124+
{/* Table */}
125+
<div className="bg-white rounded-lg border border-slate-200 shadow-sm overflow-hidden">
126+
<table className="w-full text-sm">
127+
<thead className="bg-slate-50 border-b border-slate-200">
128+
<tr>
129+
<th className="text-left px-4 py-3 font-medium text-slate-600">Title</th>
130+
<th className="text-left px-4 py-3 font-medium text-slate-600">Status</th>
131+
<th className="text-left px-4 py-3 font-medium text-slate-600">Created</th>
132+
<th className="text-right px-4 py-3 font-medium text-slate-600">Actions</th>
133+
</tr>
134+
</thead>
135+
<tbody className="divide-y divide-slate-100">
136+
{surveys.data.length === 0 && (
137+
<tr>
138+
<td colSpan={4} className="text-center text-slate-400 py-8">No surveys found.</td>
139+
</tr>
140+
)}
141+
{surveys.data.map(survey => (
142+
<tr key={survey.id} className="hover:bg-slate-50">
143+
<td className="px-4 py-3">
144+
<Link href={`/surveys/${survey.id}`} className="font-medium text-indigo-600 hover:underline">
145+
{survey.title}
146+
</Link>
147+
</td>
148+
<td className="px-4 py-3">
149+
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${statusBadge[survey.status]}`}>
150+
{survey.status}
151+
</span>
152+
</td>
153+
<td className="px-4 py-3 text-slate-500">
154+
{new Date(survey.created_at).toLocaleDateString()}
155+
</td>
156+
<td className="px-4 py-3 text-right space-x-2">
157+
{survey.status === 'draft' && (
158+
<button
159+
onClick={() => publish(survey.id)}
160+
className="text-xs text-green-600 hover:text-green-800 font-medium"
161+
>
162+
Publish
163+
</button>
164+
)}
165+
{survey.status === 'published' && (
166+
<button
167+
onClick={() => close(survey.id)}
168+
className="text-xs text-red-600 hover:text-red-800 font-medium"
169+
>
170+
Close
171+
</button>
172+
)}
173+
<Link href={`/surveys/${survey.id}`} className="text-xs text-indigo-600 hover:underline">
174+
View
175+
</Link>
176+
</td>
177+
</tr>
178+
))}
179+
</tbody>
180+
</table>
181+
</div>
182+
183+
{/* Pagination */}
184+
{surveys.last_page > 1 && (
185+
<div className="flex gap-2 justify-end">
186+
{Array.from({ length: surveys.last_page }, (_, i) => i + 1).map(page => (
187+
<button
188+
key={page}
189+
onClick={() => router.get('/surveys', { ...filters, page }, { preserveState: true })}
190+
className={`px-3 py-1 rounded text-sm border ${page === surveys.current_page ? 'bg-indigo-600 text-white border-indigo-600' : 'border-slate-300 text-slate-600 hover:bg-slate-50'}`}
191+
>
192+
{page}
193+
</button>
194+
))}
195+
</div>
196+
)}
197+
</div>
198+
</AppLayout>
199+
);
200+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { Head, Link } from '@inertiajs/react';
2+
import AppLayout from '@/Layouts/AppLayout';
3+
import type { PageProps } from '@/types';
4+
5+
interface Survey {
6+
id: number;
7+
title: string;
8+
description: string | null;
9+
status: string;
10+
}
11+
12+
interface TextStats {
13+
type: 'text';
14+
answers: string[];
15+
}
16+
17+
interface ChoiceStats {
18+
type: 'single_choice' | 'multiple_choice';
19+
counts: Record<string, number>;
20+
}
21+
22+
interface RatingStats {
23+
type: 'rating';
24+
average: number | null;
25+
count: number;
26+
}
27+
28+
interface YesNoStats {
29+
type: 'yes_no';
30+
yes: number;
31+
no: number;
32+
}
33+
34+
type QuestionStats = TextStats | ChoiceStats | RatingStats | YesNoStats;
35+
36+
interface QuestionResult {
37+
id: number;
38+
question_text: string;
39+
question_type: string;
40+
stats: QuestionStats;
41+
}
42+
43+
interface Props extends PageProps {
44+
survey: Survey;
45+
questionStats: QuestionResult[];
46+
responseCount: number;
47+
}
48+
49+
export default function SurveyResults({ survey, questionStats, responseCount }: Props) {
50+
return (
51+
<AppLayout>
52+
<Head title={`Results: ${survey.title}`} />
53+
<div className="space-y-6">
54+
{/* Header */}
55+
<div className="flex items-center justify-between">
56+
<div>
57+
<div className="flex items-center gap-3 mb-1">
58+
<Link href="/surveys" className="text-sm text-slate-500 hover:text-slate-700">Surveys</Link>
59+
<span className="text-slate-400">/</span>
60+
<Link href={`/surveys/${survey.id}`} className="text-sm text-slate-500 hover:text-slate-700">{survey.title}</Link>
61+
<span className="text-slate-400">/</span>
62+
<h1 className="text-2xl font-semibold text-slate-900">Results</h1>
63+
</div>
64+
<p className="text-sm text-slate-500">{responseCount} total responses</p>
65+
</div>
66+
<Link href={`/surveys/${survey.id}`}>
67+
<button className="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
68+
Back to Survey
69+
</button>
70+
</Link>
71+
</div>
72+
73+
{/* Question Results */}
74+
<div className="space-y-4">
75+
{questionStats.length === 0 && (
76+
<div className="bg-white rounded-lg border border-slate-200 p-8 text-center text-slate-400">
77+
No questions found.
78+
</div>
79+
)}
80+
{questionStats.map((q, i) => (
81+
<div key={q.id} className="bg-white rounded-lg border border-slate-200 shadow-sm p-6">
82+
<h3 className="text-base font-medium text-slate-800 mb-4">
83+
{i + 1}. {q.question_text}
84+
<span className="ml-2 text-xs font-normal text-slate-400 capitalize">
85+
({q.question_type.replace('_', ' ')})
86+
</span>
87+
</h3>
88+
89+
{q.stats.type === 'text' && (
90+
<div className="space-y-2">
91+
{q.stats.answers.length === 0 ? (
92+
<p className="text-sm text-slate-400">No answers yet.</p>
93+
) : (
94+
q.stats.answers.map((answer, j) => (
95+
<div key={j} className="bg-slate-50 rounded px-3 py-2 text-sm text-slate-700">
96+
{answer}
97+
</div>
98+
))
99+
)}
100+
</div>
101+
)}
102+
103+
{(q.stats.type === 'single_choice' || q.stats.type === 'multiple_choice') && (
104+
<div className="space-y-2">
105+
{Object.entries(q.stats.counts).map(([option, count]) => {
106+
const total = Object.values(q.stats.counts as Record<string, number>).reduce((a, b) => a + b, 0);
107+
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
108+
return (
109+
<div key={option}>
110+
<div className="flex items-center justify-between text-sm mb-1">
111+
<span className="text-slate-700">{option}</span>
112+
<span className="text-slate-500">{count} ({pct}%)</span>
113+
</div>
114+
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
115+
<div
116+
className="h-full bg-indigo-500 rounded-full"
117+
style={{ width: `${pct}%` }}
118+
/>
119+
</div>
120+
</div>
121+
);
122+
})}
123+
</div>
124+
)}
125+
126+
{q.stats.type === 'rating' && (
127+
<div className="flex items-center gap-4">
128+
<div className="text-3xl font-bold text-indigo-600">
129+
{q.stats.average !== null ? q.stats.average.toFixed(1) : '—'}
130+
</div>
131+
<div className="text-sm text-slate-500">
132+
Average rating from {q.stats.count} response{q.stats.count !== 1 ? 's' : ''}
133+
</div>
134+
</div>
135+
)}
136+
137+
{q.stats.type === 'yes_no' && (
138+
<div className="flex gap-6">
139+
<div className="text-center">
140+
<div className="text-3xl font-bold text-green-600">{q.stats.yes}</div>
141+
<div className="text-sm text-slate-500">Yes</div>
142+
</div>
143+
<div className="text-center">
144+
<div className="text-3xl font-bold text-red-500">{q.stats.no}</div>
145+
<div className="text-sm text-slate-500">No</div>
146+
</div>
147+
</div>
148+
)}
149+
</div>
150+
))}
151+
</div>
152+
</div>
153+
</AppLayout>
154+
);
155+
}

0 commit comments

Comments
 (0)